From a6d576b594cd66dd0830d854bb4828d6b0abab95 Mon Sep 17 00:00:00 2001 From: Him188 Date: Thu, 19 Sep 2024 23:48:03 +0100 Subject: [PATCH 01/11] Add SelectorMediaSource --- .../source/media/fetch/MediaSourceManager.kt | 2 + .../media/resolver/WebViewVideoExtractor.kt | 17 ++ .../media/source/web/SelectorMediaSource.kt | 187 ++++++++++++ .../source/web/SelectorMediaSourceEngine.kt | 273 ++++++++++++++++++ .../media/source/web/SelectorSearchConfig.kt | 115 ++++++++ .../media/source/web/WebSearchSubjectInfo.kt | 44 +++ .../web/format/SelectorChannelFormat.kt | 172 +++++++++++ .../media/source/web/format/SelectorFormat.kt | 63 ++++ .../web/format/SelectorSubjectFormat.kt | 74 +++++ 9 files changed, 947 insertions(+) create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt create mode 100644 app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt index 75c9a969bc..58c0d1e531 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt @@ -34,6 +34,7 @@ import me.him188.ani.app.data.source.media.cache.MediaCacheManager.Companion.LOC import me.him188.ani.app.data.source.media.instance.MediaSourceInstance import me.him188.ani.app.data.source.media.instance.MediaSourceSave import me.him188.ani.app.data.source.media.source.RssMediaSource +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSource import me.him188.ani.app.platform.getAniUserAgent import me.him188.ani.app.tools.ServiceLoader import me.him188.ani.datasources.api.matcher.MediaSourceWebVideoMatcherLoader @@ -193,6 +194,7 @@ class MediaSourceManagerImpl( add(MikanMediaSource.Factory()) // Kotlin bug, MPP 加载不了 resources add(MikanCNMediaSource.Factory()) add(RssMediaSource.Factory()) + add(SelectorMediaSource.Factory()) }.toList() private val additionalSources by lazy { diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt index cd7eddecc2..a24c4d1417 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt @@ -12,6 +12,7 @@ package me.him188.ani.app.data.source.media.resolver import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.data.models.preference.VideoResolverSettings import me.him188.ani.app.platform.Context +import me.him188.ani.utils.platform.annotations.TestOnly interface WebViewVideoExtractor { suspend fun getVideoResourceUrl( @@ -25,3 +26,19 @@ expect fun WebViewVideoExtractor( proxyConfig: ProxyConfig?, videoResolverSettings: VideoResolverSettings, ): WebViewVideoExtractor + +@TestOnly +class TestWebViewVideoExtractor( + private val urls: (pageUrl: String) -> List, +) : WebViewVideoExtractor { + override suspend fun getVideoResourceUrl( + context: Context, + pageUrl: String, + resourceMatcher: (String) -> R? + ): R { + urls(pageUrl).forEach { + resourceMatcher(it)?.let { return it } + } + throw IllegalStateException("No match found") + } +} 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 new file mode 100644 index 0000000000..6d5214ec8c --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt @@ -0,0 +1,187 @@ +/* + * 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.data.source.media.source.web + +import io.ktor.client.request.get +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.serialization.Serializable +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.fold +import me.him188.ani.app.data.models.map +import me.him188.ani.app.data.models.runApiRequest +import me.him188.ani.datasources.api.DefaultMedia +import me.him188.ani.datasources.api.matcher.WebVideoMatcher +import me.him188.ani.datasources.api.matcher.WebVideoMatcherProvider +import me.him188.ani.datasources.api.paging.SinglePagePagedSource +import me.him188.ani.datasources.api.paging.SizedSource +import me.him188.ani.datasources.api.paging.map +import me.him188.ani.datasources.api.paging.merge +import me.him188.ani.datasources.api.source.ConnectionStatus +import me.him188.ani.datasources.api.source.FactoryId +import me.him188.ani.datasources.api.source.HttpMediaSource +import me.him188.ani.datasources.api.source.MatchKind +import me.him188.ani.datasources.api.source.MediaFetchRequest +import me.him188.ani.datasources.api.source.MediaMatch +import me.him188.ani.datasources.api.source.MediaSource +import me.him188.ani.datasources.api.source.MediaSourceConfig +import me.him188.ani.datasources.api.source.MediaSourceFactory +import me.him188.ani.datasources.api.source.MediaSourceInfo +import me.him188.ani.datasources.api.source.MediaSourceKind +import me.him188.ani.datasources.api.source.MediaSourceLocation +import me.him188.ani.datasources.api.source.deserializeArgumentsOrNull +import me.him188.ani.datasources.api.source.useHttpClient + +@Suppress("unused") // bug +private typealias ArgumentType = SelectorMediaSourceArguments +private typealias EngineType = DefaultSelectorMediaSourceEngine + +/** + * [SelectorMediaSource] 的用户侧配置, 用于创建 [SelectorMediaSource] 实例. + * + * @since 3.10 + */ +@Serializable +data class SelectorMediaSourceArguments( + val name: String, + val description: String, + val iconUrl: String, + val searchConfig: SelectorSearchConfig = SelectorSearchConfig.Empty, +) { + companion object { + val Default = SelectorMediaSourceArguments( + name = "Selector", + description = "", + iconUrl = "", + searchConfig = SelectorSearchConfig.Empty, + ) + } +} + +/** + * @since 3.10 + */ +class SelectorMediaSource( + override val mediaSourceId: String, + config: MediaSourceConfig, + override val kind: MediaSourceKind = MediaSourceKind.WEB, +) : HttpMediaSource(), WebVideoMatcherProvider { + companion object { + val FactoryId = FactoryId("web-selector") + } + + private val arguments = + config.deserializeArgumentsOrNull(ArgumentType.serializer()) + ?: ArgumentType.Default + private val searchConfig = arguments.searchConfig + + private val client by lazy { useHttpClient(config) } + private val engine by lazy { EngineType(flowOf(client)) } + + override val location: MediaSourceLocation get() = MediaSourceLocation.Online + + class Factory : MediaSourceFactory { + override val factoryId: FactoryId get() = FactoryId + + override val info: MediaSourceInfo = MediaSourceInfo( + displayName = "Selector", + description = "通用 CSS Selector 数据源", + iconUrl = "", + ) + + override val allowMultipleInstances: Boolean get() = true + override fun create(mediaSourceId: String, config: MediaSourceConfig): MediaSource = + SelectorMediaSource(mediaSourceId, config) + } + + override suspend fun checkConnection(): ConnectionStatus { + return kotlin.runCatching { + runApiRequest { + client.get(searchConfig.searchUrl) // 提交一个请求, 只要它不是因为网络错误就行 + }.fold( + onSuccess = { ConnectionStatus.SUCCESS }, + onKnownFailure = { + when (it) { + ApiFailure.NetworkError -> ConnectionStatus.FAILED + ApiFailure.ServiceUnavailable -> ConnectionStatus.FAILED + ApiFailure.Unauthorized -> ConnectionStatus.SUCCESS + } + }, + ) + }.recover { + // 只要不是网络错误就行 + ConnectionStatus.SUCCESS + }.getOrThrow() + } + + override val info: MediaSourceInfo = MediaSourceInfo( + displayName = arguments.name, + description = arguments.description, + websiteUrl = searchConfig.searchUrl, + iconUrl = arguments.iconUrl, + ) + + // all-in-one search + private suspend fun EngineType.search( + searchConfig: SelectorSearchConfig, + query: SelectorSearchQuery, + mediaSourceId: String, + ): ApiResponse> { + return searchSubjects(searchConfig.searchUrl, query.subjectName).map { (_, document) -> + document ?: return@map emptyList() + val episodes = selectSubjects(document, searchConfig) + .orEmpty() + .let { originalList -> + val filters = searchConfig.createFiltersForSubject() + with(query.toFilterContext()) { + originalList.filter { + filters.applyOn(it.asCandidate()) + } + } + } + .mapNotNull { subject -> + doHttpGet(subject.subjectDetailsPageUrl) + .getOrNull() + } + .asSequence() + .mapNotNull { subjectDetails -> + selectEpisodes(subjectDetails, searchConfig) // null if invalid config + } + + selectMedia(episodes.flatMap { it.episodes }, searchConfig, query, mediaSourceId).filteredList + } + } + + override suspend fun fetch(query: MediaFetchRequest): SizedSource { + return query.subjectNames + .map { name -> + SinglePagePagedSource { + engine.search( + searchConfig, + SelectorSearchQuery( + subjectName = name, + episodeSort = query.episodeSort, + ), + mediaSourceId, + ).getOrThrow().asFlow() + }.map { + MediaMatch(it, MatchKind.FUZZY) + } + }.merge() + } + + override val matcher: WebVideoMatcher by lazy(LazyThreadSafetyMode.NONE) { + WebVideoMatcher { url, _ -> + engine.matchWebVideo(url, arguments.searchConfig.matchVideo) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..36b45d988f --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt @@ -0,0 +1,273 @@ +/* + * 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.data.source.media.source.web + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.runApiRequest +import me.him188.ani.app.data.source.media.source.MediaListFilter +import me.him188.ani.app.data.source.media.source.MediaListFilterContext +import me.him188.ani.app.data.source.media.source.MediaListFilters +import me.him188.ani.app.data.source.media.source.MediaSourceEngineHelpers +import me.him188.ani.app.data.source.media.source.asCandidate +import me.him188.ani.app.data.source.media.source.web.format.SelectedChannelEpisodes +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat.Companion.isPossiblyMovie +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatConfig +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormat +import me.him188.ani.datasources.api.DefaultMedia +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.source.MediaSourceKind +import me.him188.ani.datasources.api.source.MediaSourceLocation +import me.him188.ani.datasources.api.topic.EpisodeRange +import me.him188.ani.datasources.api.topic.FileSize +import me.him188.ani.datasources.api.topic.ResourceLocation +import me.him188.ani.datasources.api.topic.SubtitleLanguage +import me.him188.ani.utils.ktor.toSource +import me.him188.ani.utils.xml.Document +import me.him188.ani.utils.xml.Element +import me.him188.ani.utils.xml.Xml + +data class SelectorSearchQuery( + val subjectName: String, + val episodeSort: EpisodeSort, +) + +fun SelectorSearchQuery.toFilterContext() = MediaListFilterContext( + subjectNames = setOf(subjectName), + episodeSort = episodeSort, +) + +/** + * 解析流程: + * + * [SelectorMediaSourceEngine.searchSubjects] + * -> [SelectorMediaSourceEngine.selectSubjects] + * -> [SelectorMediaSourceEngine.searchEpisodes] + * -> [SelectorMediaSourceEngine.selectEpisodes] + */ +abstract class SelectorMediaSourceEngine { + companion object { + const val CURRENT_VERSION: UInt = 1u + } + + data class SearchSubjectResult( + val url: Url, + /** + * `null` means 404 + */ + val document: Document?, + ) + + suspend fun searchSubjects( + searchUrl: String, + subjectName: String, + ): ApiResponse { + val encodedUrl = MediaSourceEngineHelpers.encodeUrlSegment(subjectName) + + val finalUrl = Url( + searchUrl.replace("{keyword}", encodedUrl), + ) + + return searchImpl(finalUrl) + } + + protected abstract suspend fun searchImpl( + finalUrl: Url, + ): ApiResponse + + /** + * @return `null` if config is invalid + */ + open fun selectSubjects( + document: Element, + config: SelectorSearchConfig, + ): List? { + val subjectFormat = SelectorSubjectFormat.findById(config.subjectFormatId) + ?: throw UnsupportedOperationException("Unsupported subject format: ${config.subjectFormatId}") + + @Suppress("UNCHECKED_CAST") + subjectFormat as SelectorSubjectFormat + + val formatConfig = config.getFormatConfig(subjectFormat) + if (!formatConfig.isValid()) { + return null + } + val originalList = subjectFormat.select(document, config.baseUrl, formatConfig) + + return originalList + } + + suspend fun searchEpisodes( + subjectDetailsPageUrl: String, + ): ApiResponse = doHttpGet(subjectDetailsPageUrl) + + /** + * @return `null` if config is invalid + */ + fun selectEpisodes( + subjectDetailsPage: Element, + config: SelectorSearchConfig, + ): SelectedChannelEpisodes? { + val channelFormat = SelectorChannelFormat.findById(config.channelFormatId) + ?: throw UnsupportedOperationException("Unsupported channel format: ${config.channelFormatId}") + + @Suppress("UNCHECKED_CAST") + channelFormat as SelectorChannelFormat + val formatConfig = config.getFormatConfig(channelFormat) + if (!formatConfig.isValid()) { + return null + } + return channelFormat.select( + subjectDetailsPage, + config.baseUrl, + formatConfig, + ) + } + + data class SelectMediaResult( + val originalList: List, + val filteredList: List, + ) + + fun selectMedia( + episodes: Sequence, + config: SelectorSearchConfig, + query: SelectorSearchQuery, + mediaSourceId: String, + ): SelectMediaResult { + val originalMediaList = episodes.mapNotNull { info -> + info.episodeSort ?: return@mapNotNull null + DefaultMedia( + mediaId = "$mediaSourceId.${info.name}-${info.episodeSort}", + mediaSourceId = mediaSourceId, + originalUrl = info.playUrl, + download = ResourceLocation.WebVideo(info.playUrl), + originalTitle = info.name, + publishedTime = 0L, + properties = MediaProperties( + subtitleLanguageIds = listOf(SubtitleLanguage.ChineseSimplified.id), + resolution = "1080P", + alliance = info.channel ?: mediaSourceId, + size = FileSize.Unspecified, + subtitleKind = SubtitleKind.EMBEDDED, + ), + episodeRange = EpisodeRange.single( + if (isPossiblyMovie(info.name) && info.episodeSort is EpisodeSort.Special) { + EpisodeSort(1) // 电影总是 01 + } else { + info.episodeSort + }, + ), + location = MediaSourceLocation.Online, + kind = MediaSourceKind.WEB, + ) + }.toList() + + return with(query.toFilterContext()) { + val filters = config.createFiltersForEpisode() + val filteredList = originalMediaList.filter { + filters.applyOn(it.asCandidate()) + } + SelectMediaResult(originalMediaList, filteredList) + } + } + + fun matchWebVideo(url: String, searchConfig: SelectorSearchConfig.MatchVideoConfig): WebVideo? { + val result = searchConfig.matchVideoUrlRegex?.find(url) ?: return null + val videoUrl = result.groups["v"]?.value ?: result.value + 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", + ), + ) + } + + protected abstract suspend fun doHttpGet(uri: String): ApiResponse +} + +// TODO: require MediaListFilterContext when context parameters +fun WebSearchSubjectInfo.asCandidate(): MediaListFilter.Candidate { + val info = this + return object : MediaListFilter.Candidate { + override val originalTitle: String get() = info.name + override val episodeRange: EpisodeRange? get() = null + } +} + +/** + * If you change, you also need to change + */ +internal fun SelectorSearchConfig.createFiltersForSubject() = buildList { + if (filterBySubjectName) add(MediaListFilters.ContainsSubjectName) +} + +internal fun SelectorSearchConfig.createFiltersForEpisode() = buildList { + addAll(createFiltersForSubject()) + if (filterByEpisodeSort) add(MediaListFilters.ContainsEpisodeSort) +} + +class DefaultSelectorMediaSourceEngine( + /** + * Engine 自己不会 cache 实例, 每次都调用 `.first()`. + */ + private val client: Flow, +) : SelectorMediaSourceEngine() { + override suspend fun searchImpl( + finalUrl: Url, + ): ApiResponse = runApiRequest { + val document = try { + client.first().get(finalUrl).let { resp -> + Xml.parse(resp.bodyAsChannel().toSource()) + } + } catch (e: ClientRequestException) { + if (e.response.status == HttpStatusCode.NotFound) { + // 404 Not Found + return@runApiRequest SearchSubjectResult( + finalUrl, + document = null, + ) + } + throw e + } + + + SearchSubjectResult( + finalUrl, + document, + ) + } + + + public override suspend fun doHttpGet(uri: String): ApiResponse = + runApiRequest { + client.first().get(uri) { + }.let { resp -> + Xml.parse(resp.bodyAsChannel().toSource()) + } + } +} 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 new file mode 100644 index 0000000000..764a6442e3 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt @@ -0,0 +1,115 @@ +/* + * 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.data.source.media.source.web + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +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 +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatConfig +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 + +@Immutable +@Serializable +data class SelectorSearchConfig( + // Phase 1, search + val searchUrl: String = "", // required + // Phase 2, for search result, select subjects + val subjectFormatId: SelectorFormatId = SelectorSubjectFormatA.id, + val selectorSubjectFormatA: SelectorSubjectFormatA.Config = SelectorSubjectFormatA.Config(), + // Phase 3, for each subject, select channels + val channelFormatId: SelectorFormatId = SelectorChannelFormatNoChannel.id, + val selectorChannelFormatFlattened: SelectorChannelFormatFlattened.Config = SelectorChannelFormatFlattened.Config(), + val selectorChannelFormatNoChannel: SelectorChannelFormatNoChannel.Config = SelectorChannelFormatNoChannel.Config(), +// /** +// * Regex. Group names: +// * - ``: channel name +// * - ``: episode name +// * +// * E.g. 用于匹配 "线路1 第1集": +// * ```regex +// * (?.+)\s*第(?\d+)集 +// * ``` +// * +// * 匹配方式为 find 而不是 matchEntire. +// * @see SelectorChannelFormat.FLATTENED +// */ +// val matchChannelFromEpisodeText: String = "", +// val selectNameFromEpisode: String = "", +// val selectPlayUrlFromEpisode: String = "", + + + // Search done. Now we should have Medias. + val filterByEpisodeSort: Boolean = true, + val filterBySubjectName: Boolean = true, + + // When playing a media: + val matchVideo: MatchVideoConfig = MatchVideoConfig(), +) { // TODO: add Engine version capabilities + val baseUrl by lazy(LazyThreadSafetyMode.PUBLICATION) { + searchUrl.substringBeforeLast("/") + } + + @Serializable + data class MatchVideoConfig( + @Suppress("RegExpRedundantEscape") + val matchVideoUrl: String = """^(?http(s)?:\/\/(?!.*http(s)?:\/\/).+((\.mp4)|(\.mkv)|(\.m3u8)))""", + val addHeadersToVideo: VideoHeaders = VideoHeaders(), + ) { + val matchVideoUrlRegex by lazy { + Regex.parseOrNull(matchVideoUrl) + } + } + + @Serializable + data class VideoHeaders( + val referer: String = "", + val userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + ) + + +// val matchChannelFromEpisodeRegex by lazy(LazyThreadSafetyMode.PUBLICATION) { +// matchChannelFromEpisodeText.toRegex() +// } + + // These classes are nested to limit namespace + + @Stable + companion object { + @Stable + val Empty = SelectorSearchConfig() + } +} + +/** + * 获取该 [SelectorSubjectFormat] 的配置 [C]. + */ +fun SelectorSearchConfig.getFormatConfig(format: SelectorSubjectFormat): C { + @Suppress("UNCHECKED_CAST") + return when (format) { + SelectorSubjectFormatA -> selectorSubjectFormatA as C + } +} + +/** + * 获取该 [SelectorChannelFormat] 的配置 [C]. + */ +fun SelectorSearchConfig.getFormatConfig(format: SelectorChannelFormat): C { + @Suppress("UNCHECKED_CAST") + return when (format) { + SelectorChannelFormatFlattened -> selectorChannelFormatFlattened as C + SelectorChannelFormatNoChannel -> selectorChannelFormatNoChannel as C + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt new file mode 100644 index 0000000000..07abfdb31b --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt @@ -0,0 +1,44 @@ +/* + * 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.data.source.media.source.web + +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.xml.Element + +data class WebSearchSubjectInfo( + val internalId: String, + val name: String, + val subjectDetailsPageUrl: String, + val origin: Element?, +) + +class WebSearchChannelInfo( + val name: String, + val content: Element, +) + +data class WebSearchEpisodeInfo( + /** + * 播放线路 + */ + val channel: String?, + /** + * "第x集" 等原名. + */ + val name: String, + /** + * 解析成功的 [EpisodeSort], 未解析成功则为 `null`. + */ + val episodeSort: EpisodeSort?, + /** + * 播放地址 + */ + val playUrl: String +) 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 new file mode 100644 index 0000000000..8779837906 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt @@ -0,0 +1,172 @@ +/* + * 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.data.source.media.source.web.format + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.WebSearchEpisodeInfo +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 + +/** + * 决定如何匹配线路和剧集 + * @see SelectorMediaSourceEngine + */ +sealed class SelectorChannelFormat(override val id: SelectorFormatId) : + SelectorFormat { + /** + * @return `null` for invalid config + * @see baseUrl must not end with `/` + */ + abstract fun select( + page: Element, + baseUrl: String, + config: Config, + ): SelectedChannelEpisodes? + + companion object { + val entries by lazy { // 必须 lazy, 否则可能获取到 null + listOf(SelectorChannelFormatNoChannel, SelectorChannelFormatFlattened) + } + + fun findById(id: SelectorFormatId): SelectorChannelFormat<*>? { + return entries.find { it.id == id } + } + + const val DEFAULT_MATCH_EPISODE_SORT_FROM_NAME = "第(?.+)(话|集)" + + fun isPossiblyMovie(title: String): Boolean { + return ("简" in title || "繁" in title) + && ("2160P" in title || "1440P" in title || "2K" in title || "4K" in title || "1080P" in title || "720P" in title) + } + } +} + +data class SelectedChannelEpisodes( + /** + * `null` 表示该 format 不支持 (不考虑) channels + */ + val channels: List?, + val episodes: List, +) + + +/** + * 每个剧集标题内包含了线路名称. 例如 "主线第1集" + * + * 解析方式: + */ +data object SelectorChannelFormatFlattened : + SelectorChannelFormat(SelectorFormatId("flattened")) { + @Immutable + @Serializable + data class Config( + 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", + val selectLists: String = "body > div.box-width.cor5 > div.anthology.wow.fadeInUp.animated > a", + val selectElements: String = "a", + val matchEpisodeSortFromName: String = DEFAULT_MATCH_EPISODE_SORT_FROM_NAME, + ) : SelectorFormatConfig { + override fun isValid(): Boolean { + return selectChannels.isNotBlank() && selectLists.isNotBlank() && selectElements.isNotBlank() + } + } + + override fun select( + page: Element, + baseUrl: String, + config: Config, + ): SelectedChannelEpisodes? { + val selectChannels = QueryParser.parseSelectorOrNull(config.selectChannels) ?: return null + val selectElements = QueryParser.parseSelectorOrNull(config.selectElements) ?: return null + val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return null + val matchEpisodeSortFromNameRegex = Regex.parseOrNull(config.matchEpisodeSortFromName) ?: return null + + val channels = page.select(selectChannels) + .map { e -> e.text().trim() } + + fun parseEps(ep: Element, channel: String?): List { + return ep.select(selectElements).mapNotNull { a -> + val text = a.text() + if (text in channels) return@mapNotNull null + + val href = a.attr("title").takeIf { it.isNotBlank() } ?: a.attr("href") + WebSearchEpisodeInfo( + channel = channel, + name = text, + episodeSort = matchEpisodeSortFromNameRegex.find(text)?.groups?.get("ep")?.value + ?.let { EpisodeSort(it) } + ?: EpisodeSort(text), + playUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href), + ) + } + } + + return SelectedChannelEpisodes( + channels, + page.select(selectLists) + .flatMapIndexed { i, e -> + val channel = channels.getOrNull(i) + parseEps(e, channel) + }, + ) + } +} + +/** + * 没有线路, 或者相当于只有一个线路. 只有一个 list of episodes + */ +data object SelectorChannelFormatNoChannel : + SelectorChannelFormat(SelectorFormatId("no-channel")) { + @Immutable + @Serializable + data class Config( + val selectEpisodes: String = "", + val matchEpisodeSortFromName: String = DEFAULT_MATCH_EPISODE_SORT_FROM_NAME, + ) : SelectorFormatConfig { + val matchEpisodeSortFromNameRegex by lazy(LazyThreadSafetyMode.PUBLICATION) { + try { + matchEpisodeSortFromName.toRegex() + } catch (e: Exception) { + null + } + } + + override fun isValid(): Boolean { + return selectEpisodes.isNotBlank() && matchEpisodeSortFromName.isNotBlank() + } + } + + override fun select( + page: Element, + baseUrl: String, + config: Config, + ): SelectedChannelEpisodes? { + val regex = config.matchEpisodeSortFromNameRegex ?: return null + val selectEpisodes = QueryParser.parseSelectorOrNull(config.selectEpisodes) ?: return null + return SelectedChannelEpisodes( + null, + page.select(selectEpisodes).map { a -> + val text = a.text() + val href = a.attr("href") + WebSearchEpisodeInfo( + channel = null, + name = text, + episodeSort = regex.find(text)?.groups?.get("ep")?.value + ?.let { EpisodeSort(it) } + ?: EpisodeSort(text), + playUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href), + ) + }, + ) + } +} 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 new file mode 100644 index 0000000000..a8b460eea5 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt @@ -0,0 +1,63 @@ +/* + * 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.data.source.media.source.web.format + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +/** + * @see SelectorChannelFormat + * @see SelectorSubjectFormat + */ +interface SelectorFormat { + /** + * 永久唯一的命名. 用于序列化和反序列化. + */ + val id: SelectorFormatId +} + +@Serializable +@Immutable +@JvmInline +value class SelectorFormatId( + // in case we want to change type + val value: String, +) + +interface SelectorFormatConfig { + /** + * 注意, 这只做最简单的检测, 例如是否为空. 不会检查是否合法. + */ + fun isValid(): Boolean +} + +fun Regex.Companion.parseOrNull(regex: String): Regex? { + return try { + regex.toRegex() + } catch (e: Exception) { + null + } +} + +object SelectorHelpers { + fun computeAbsoluteUrl(baseUrl: String, relativeUrl: String): String { + @Suppress("NAME_SHADOWING") + var baseUrl = baseUrl + if (baseUrl.endsWith('/')) { + baseUrl = baseUrl.dropLast(1) + } + return when { + relativeUrl.startsWith("http") -> relativeUrl + relativeUrl.startsWith('/') -> baseUrl + relativeUrl + else -> "$baseUrl/$relativeUrl" + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5e28e65a09 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt @@ -0,0 +1,74 @@ +/* + * 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.data.source.media.source.web.format + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +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 + +/** + * 决定如何匹配条目 + */ +sealed class SelectorSubjectFormat(override val id: SelectorFormatId) : + SelectorFormat { // 方便改名 + abstract fun select( + document: Element, + baseUrl: String, + config: Config, + ): List + + companion object { + val entries by lazy { // 必须 lazy, 否则可能获取到 null + listOf(SelectorSubjectFormatA) + } + + fun findById(id: SelectorFormatId): SelectorSubjectFormat<*>? { + // reflection is not supported in Kotlin/Native + return entries.find { it.id == id } + } + } +} + +/** + * Select 出一些 ``, text 作为 name, `href` 作为 url + */ +data object SelectorSubjectFormatA : SelectorSubjectFormat(SelectorFormatId("a")) { + @Immutable + @Serializable + data class Config( + val selectLists: String = "", + ) : SelectorFormatConfig { + override fun isValid(): Boolean { + return selectLists.isNotBlank() + } + } + + override fun select( + document: Element, + baseUrl: String, + config: Config, + ): List { + val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return emptyList() + return document.select(selectLists).map { a -> + val name = a.attr("title").takeIf { it.isNotBlank() } ?: a.text() + val href = a.attr("href") + val id = href.substringBeforeLast(".html").substringAfterLast("/") + WebSearchSubjectInfo( + internalId = id, + name = name, + subjectDetailsPageUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href), + origin = a, + ) + } + } +} From b01557e02b5a82add9d3ff57a3afa40283f52ec1 Mon Sep 17 00:00:00 2001 From: Him188 Date: Thu, 19 Sep 2024 23:50:14 +0100 Subject: [PATCH 02/11] Add settings ui for SelectorMediaSource --- .../kotlin/ui/main/AniAppContentPortrait.kt | 14 + ...ectorChannelConfigurationColumn.android.kt | 62 +++ .../selector/edit/SelectorEditPane.android.kt | 38 ++ .../test/SelectorEpisodePane.android.kt | 117 ++++ .../selector/test/SelectorTestPane.android.kt | 75 +++ .../test/SubjectResultLazyRow.android.kt | 100 ++++ .../selector/EditSelectorMediaSourcePage.kt | 166 ++++++ .../SelectorMediaSourceConfigurationPage.kt | 137 +++++ .../SelectorChannelConfigurationColumn.kt | 127 +++++ .../selector/edit/SelectorEditPane.kt | 515 ++++++++++++++++++ .../selector/test/SelectTestEpisodeResult.kt | 91 ++++ .../selector/test/SelectorEpisodePane.kt | 276 ++++++++++ .../selector/test/SelectorTestEpisodeList.kt | 93 ++++ .../selector/test/SelectorTestPane.kt | 395 ++++++++++++++ .../test/SelectorTestResultLazyRow.kt | 77 +++ .../test/SelectorTestSearchSubjectResult.kt | 88 +++ .../selector/test/SubjectResultLazyRow.kt | 135 +++++ .../tabs/media/source/MediaSourceGroup.kt | 13 + 18 files changed, 2519 insertions(+) create mode 100644 app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt create mode 100644 app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt create mode 100644 app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt create mode 100644 app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt create mode 100644 app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt diff --git a/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt b/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt index c8b77a5697..8f2b3ce890 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt @@ -34,6 +34,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import me.him188.ani.app.data.source.media.source.RssMediaSource +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSource import me.him188.ani.app.navigation.AniNavigator import me.him188.ani.app.navigation.LocalNavigator import me.him188.ani.app.platform.LocalContext @@ -56,6 +57,8 @@ import me.him188.ani.app.ui.settings.SettingsTab 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.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 @@ -305,6 +308,17 @@ fun AniAppContentPortrait( windowInsets, ) + SelectorMediaSource.FactoryId -> { + val context = LocalContext.current + EditSelectorMediaSourcePage( + viewModel(key = mediaSourceInstanceId) { + SelectorMediaSourceConfigurationViewModel(mediaSourceInstanceId, context) + }, + Modifier, + windowInsets = windowInsets, + ) + } + else -> error("Unknown factoryId: $factoryId") } } 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 new file mode 100644 index 0000000000..27bd35a0fd --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt @@ -0,0 +1,62 @@ +/* + * 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 + */ + +@file:OptIn(TestOnly::class) + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.settings.mediasource.rss.createTestSaveableStorage +import me.him188.ani.utils.platform.annotations.TestOnly + +@Composable +@TestOnly +fun rememberTestSelectorConfigurationState( + arguments: SelectorMediaSourceArguments = SelectorMediaSourceArguments.Default +): SelectorConfigurationState { + return remember { + SelectorConfigurationState( + createTestSaveableStorage( + arguments, + ), + ) + } +} + +@Composable +@Preview +private fun PreviewSelectorChannelConfigurationColumnNotFound() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorChannelConfigurationColumn(SelectorFormatId("dummy"), rememberTestSelectorConfigurationState()) + } +} + +@Composable +@Preview +private fun PreviewSelectorChannelConfigurationColumnFlattened() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorChannelConfigurationColumn(SelectorChannelFormatFlattened.id, rememberTestSelectorConfigurationState()) + } +} + +@Composable +@Preview +private fun PreviewSelectorChannelConfigurationColumnNoChannel() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorChannelConfigurationColumn(SelectorChannelFormatNoChannel.id, rememberTestSelectorConfigurationState()) + } +} 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 new file mode 100644 index 0000000000..cd4c2c4123 --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt @@ -0,0 +1,38 @@ +/* + * 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.edit + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.foundation.preview.PHONE_LANDSCAPE +import me.him188.ani.app.ui.settings.mediasource.rss.createTestSaveableStorage +import me.him188.ani.utils.platform.annotations.TestOnly + +@OptIn(TestOnly::class) +@Composable +@Preview(PHONE_LANDSCAPE) +fun PreviewSelectorConfigurationPane() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorConfigurationPane( + remember { + SelectorConfigurationState( + createTestSaveableStorage( + SelectorMediaSourceArguments.Default, + ), + ) + }, + ) + } +} + diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt new file mode 100644 index 0000000000..e0adacf7de --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt @@ -0,0 +1,117 @@ +/* + * 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.test + +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 +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.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.test.buildMatchTags +import me.him188.ani.app.ui.settings.mediasource.selector.edit.rememberTestSelectorConfigurationState +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 { + Surface { + SelectorEpisodePane( + state = rememberTestSelectorEpisodeState( + TestSelectorTestEpisodePresentations[0], + SelectorSearchConfig.MatchVideoConfig(), + ), + layout = SelectorEpisodePaneLayout.WithBottomSheet, + configurationContent = configurationContent, + ) + } +} + +@OptIn(TestOnly::class) +@Composable +@Preview +fun PreviewSelectorEpisodePaneListOnly() { + ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorEpisodePane( + state = rememberTestSelectorEpisodeState(), + layout = SelectorEpisodePaneLayout.ListOnly, + configurationContent = configurationContent, + ) + } + } +} + +@TestOnly +@Stable +internal val TestSelectorTestEpisodePresentations + get() = listOf( + SelectorTestEpisodePresentation( + name = "Test Episode 2", + episodeSort = EpisodeSort(2), + playUrl = "https://example.com", + tags = buildMatchTags { + emit("EP: 02", isMatch = true) + emit("https://example.com", isMatch = true) + }, + origin = null, + ), + SelectorTestEpisodePresentation( + name = "Test Episode Unknown", + episodeSort = null, + playUrl = "https://example.com", + tags = buildMatchTags { + emit("缺失 EP", isMissing = true) + }, + origin = null, + ), + ) + +@TestOnly +@Composable +internal fun rememberTestSelectorEpisodeState( + item: SelectorTestEpisodePresentation? = TestSelectorTestEpisodePresentations[0], + config: SelectorSearchConfig.MatchVideoConfig = SelectorSearchConfig.MatchVideoConfig(), + urls: (pageUrl: String) -> List = { + listOf("https://example.com/a.mkv") + }, +): SelectorEpisodeState { + val scope = rememberCoroutineScope() + val context = LocalContext.current + return remember { + SelectorEpisodeState( + itemState = stateOf(item), + matchVideoConfigState = stateOf(config), + webViewVideoExtractor = stateOf(TestWebViewVideoExtractor(urls)), + engine = TestSelectorMediaSourceEngine(), + backgroundScope = scope, + flowDispatcher = EmptyCoroutineContext, + context = context, + ) + } +} 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 new file mode 100644 index 0000000000..898cb19c75 --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt @@ -0,0 +1,75 @@ +/* + * 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 + */ + +@file:OptIn(TestOnly::class) + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.tooling.preview.Preview +import io.ktor.http.Url +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.networkError +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.data.source.media.source.web.WebSearchSubjectInfo +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.utils.platform.annotations.TestOnly +import me.him188.ani.utils.xml.Document +import me.him188.ani.utils.xml.Element + +@Composable +@Preview +fun PreviewSelectorTestPane() = ProvideFoundationCompositionLocalsForPreview { + val scope = rememberCoroutineScope() + Surface { + SelectorTestPane( + remember { + SelectorTestState( + searchConfigState = mutableStateOf(SelectorSearchConfig.Empty), + engine = TestSelectorMediaSourceEngine(), + scope, + ).apply { + subjectSearcher.restartCurrentSearch() + } + }, + ) + } +} + +@TestOnly +class TestSelectorMediaSourceEngine : SelectorMediaSourceEngine() { + override suspend fun searchImpl( + finalUrl: Url + ): ApiResponse { + return ApiResponse.success( + SearchSubjectResult( + Url("https://example.com"), + null, + ), + ) + } + + override fun selectSubjects(document: Element, config: SelectorSearchConfig): List { + return listOf( + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + ) + } + + override suspend fun doHttpGet(uri: String): ApiResponse { + return ApiResponse.networkError() + } +} diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt new file mode 100644 index 0000000000..c3e1f378fb --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt @@ -0,0 +1,100 @@ +/* + * 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.test + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.settings.mediasource.rss.test.MatchTag +import me.him188.ani.utils.platform.annotations.TestOnly +import me.him188.ani.utils.xml.Element +import kotlin.random.Random +import kotlin.random.nextInt + +@TestOnly +fun createTestSelectorTestSubjectPresentation( + name: String, + subjectDetailsPageUrl: String = "", + origin: Element? = null, + tags: List = emptyList(), +): SelectorTestSubjectPresentation { + return SelectorTestSubjectPresentation( + name, subjectDetailsPageUrl, origin, tags, + ) +} + +@TestOnly +val TestSelectorTestSubjectPresentations + get() = listOf( + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + tags = listOf(MatchTag("标题", isMatch = false), MatchTag("字幕", isMatch = true)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + ) + +@OptIn(TestOnly::class) +@Composable +@Preview +private fun PreviewSubjectResultLazyRow() = ProvideFoundationCompositionLocalsForPreview { + Surface { + Column { + var selectedIndex by remember { mutableIntStateOf(1) } + SelectorTestSubjectResultLazyRow( + TestSelectorTestSubjectPresentations, + selectedItemIndex = selectedIndex, + { _, _ -> }, + ) + + Row { + Text("Selected index: $selectedIndex") + Button({ selectedIndex = Random.nextInt(TestSelectorTestSubjectPresentations.indices) }) { + Text("Select random") + } + } + } + } +} \ 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 new file mode 100644 index 0000000000..0c7ee340c7 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt @@ -0,0 +1,166 @@ +/* + * 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 + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +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.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.CoroutineDispatcher +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.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.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.SelectorConfigurationPane +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationState +import me.him188.ani.app.ui.settings.mediasource.selector.test.ConfigurationContent +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodePane +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodePaneDefaults +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodePaneLayout +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodeState +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestPane +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestState + +class EditSelectorMediaSourceState( + argumentsStorage: SaveableStorage, + engine: SelectorMediaSourceEngine, + webViewVideoExtractor: State, + backgroundScope: CoroutineScope, + context: Context, + flowDispatcher: CoroutineDispatcher = Dispatchers.Default, +) { + internal val configurationState: SelectorConfigurationState = SelectorConfigurationState(argumentsStorage) + + internal val testState: SelectorTestState = + SelectorTestState(configurationState.searchConfigState, engine, backgroundScope) + + internal val episodeState: SelectorEpisodeState = SelectorEpisodeState( + itemState = derivedStateOf { testState.viewingItem }, + matchVideoConfigState = derivedStateOf { configurationState.searchConfigState.value?.matchVideo }, + webViewVideoExtractor = webViewVideoExtractor, + engine = engine, + backgroundScope = backgroundScope, + context = context, + flowDispatcher = flowDispatcher, + ) +} + +@Composable +fun EditSelectorMediaSourcePage( + vm: SelectorMediaSourceConfigurationViewModel, + modifier: Modifier = Modifier, + navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), + windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, +) { + val state by vm.state.collectAsStateWithLifecycle(null) + state?.let { + EditSelectorMediaSourcePage(it, modifier, navigator, windowInsets) + } +} + +@Composable +fun EditSelectorMediaSourcePage( + state: EditSelectorMediaSourceState, + modifier: Modifier = Modifier, + navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), + windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, +) { + 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), + ) + }, + contentWindowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom), + ) { paddingValues -> + BackHandler(navigator.canNavigateBack()) { + navigator.navigateBack() + } + ListDetailPaneScaffold( + navigator.scaffoldDirective, + navigator.scaffoldValue, + listPane = { + AnimatedPane1(Modifier.preferredWidth(480.dp)) { + SelectorConfigurationPane( + state = state.configurationState, + Modifier.fillMaxSize().consumeWindowInsets(paddingValues), + contentPadding = paddingValues, + ) + } + }, + detailPane = { + AnimatedPane1 { + SelectorTestPane( + state.testState, + onViewEpisode = { + state.testState.viewEpisode(it) + navigator.navigateTo(ListDetailPaneScaffoldRole.Extra) + }, + Modifier.fillMaxSize().consumeWindowInsets(paddingValues), + paddingValues, + ) + } + }, + 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 new file mode 100644 index 0000000000..ad3bb9cc21 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt @@ -0,0 +1,137 @@ +/* + * 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 + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import io.ktor.client.plugins.BrowserUserAgent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.him188.ani.app.data.repository.SettingsRepository +import me.him188.ani.app.data.source.media.fetch.MediaSourceManager +import me.him188.ani.app.data.source.media.fetch.toClientProxyConfig +import me.him188.ani.app.data.source.media.fetch.updateMediaSourceArguments +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.DefaultSelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.platform.Context +import me.him188.ani.app.tools.MonoTasker +import me.him188.ani.app.ui.foundation.AbstractViewModel +import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.datasources.api.source.HttpMediaSource +import me.him188.ani.datasources.api.source.createHttpClient +import me.him188.ani.datasources.api.source.deserializeArgumentsOrNull +import me.him188.ani.utils.coroutines.onReplacement +import me.him188.ani.utils.ktor.proxy +import me.him188.ani.utils.ktor.registerLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +private typealias ArgumentsType = SelectorMediaSourceArguments + +@Stable +class SelectorMediaSourceConfigurationViewModel( + initialInstanceId: String, + context: Context, +) : AbstractViewModel(), KoinComponent { + private val mediaSourceManager: MediaSourceManager by inject() + private val settingsRepository: SettingsRepository by inject() + + private val instanceId: MutableStateFlow = MutableStateFlow(initialInstanceId) + + private val arguments = this.instanceId.flatMapLatest { instanceId -> + mediaSourceManager.instanceConfigFlow(instanceId).map { + it?.deserializeArgumentsOrNull( + ArgumentsType.serializer(), + ) ?: ArgumentsType.Default + } + } + + val state: Flow = this.instanceId.transformLatest { instanceId -> + coroutineScope { + val saveTasker = MonoTasker(this) + val arguments = mutableStateOf(null) + launch { + val persisted = mediaSourceManager.instanceConfigFlow(instanceId).first() + ?.deserializeArgumentsOrNull(ArgumentsType.serializer()) + ?: ArgumentsType.Default + withContext(Dispatchers.Main) { + arguments.value = persisted + } + } + emit( + EditSelectorMediaSourceState( + argumentsStorage = SaveableStorage( + arguments, + onSave = { + arguments.value = it + saveTasker.launch { + delay(500) + mediaSourceManager.updateMediaSourceArguments( + instanceId, + ArgumentsType.serializer(), + it, + ) + } + }, + isSavingState = derivedStateOf { saveTasker.isRunning }, + ), + engine = DefaultSelectorMediaSourceEngine(client), + webViewVideoExtractor = combine( + settingsRepository.proxySettings.flow.map { it.default.config }.distinctUntilChanged(), + settingsRepository.videoResolverSettings.flow.distinctUntilChanged(), + ) { proxySettings, videoResolverSettings -> + WebViewVideoExtractor(proxySettings, videoResolverSettings) + }.produceState(null), + backgroundScope = this, + context, + flowDispatcher = Dispatchers.Default, + ), + ) + } + }.flowOn(Dispatchers.Default) + + private val client = settingsRepository.proxySettings.flow.map { + HttpMediaSource.createHttpClient { + proxy(it.default.toClientProxyConfig()) + BrowserUserAgent() + }.apply { + registerLogging(logger) + } + }.onReplacement { + it.close() + }.shareInBackground(started = SharingStarted.Lazily) + +// val testState: RssTestPaneState = RssTestPaneState( +// // 这里用的是序列化之后的配置, 也就是只有保存成功之后, 才会更新测试 (和触发重新查询) +// searchConfigState = arguments.map { it.searchConfig }.produceState(RssSearchConfig.Empty), +// engine = DefaultRssMediaSourceEngine(client, parser = RssParser(includeOrigin = true)), +// backgroundScope, +// ) + + override fun onCleared() { + super.onCleared() + client.replayCache.firstOrNull()?.close() + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..74390405d8 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt @@ -0,0 +1,127 @@ +/* + * 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.edit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +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.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor +import me.him188.ani.app.ui.settings.mediasource.MediaSourceConfigurationDefaults + +@Composable +internal fun SelectorChannelConfigurationColumn( + formatId: SelectorFormatId, + state: SelectorConfigurationState, + modifier: Modifier = Modifier, + textFieldShape: Shape = MediaSourceConfigurationDefaults.outlinedTextFieldShape, +) { + Column(modifier) { + when (SelectorChannelFormat.findById(formatId)) { + SelectorChannelFormatFlattened -> Column( + verticalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding), + ) { + val conf = state.channelFormatIndexed + OutlinedTextField( + conf.selectChannels, { conf.selectChannels = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取线路名称列表") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("CSS Selector 表达式。将会读取结果的 text") }, + isError = conf.selectChannelsIsError, + ) + OutlinedTextField( + conf.selectLists, { conf.selectLists = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取所有线路的剧集列表") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个剧集,将会读取其 href 属性和 text") }, + isError = conf.selectListsIsError, + ) + OutlinedTextField( + conf.matchEpisodeSortFromName, { conf.matchEpisodeSortFromName = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("从剧集名称中匹配序号") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("正则表达式查找。期望名为 ep 的分组") }, + isError = conf.matchEpisodeSortFromNameIsError, + ) + } + + SelectorChannelFormatNoChannel -> Column( + verticalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding), + ) { + val conf = state.channelFormatNoChannel + OutlinedTextField( + conf.selectEpisodes, { conf.selectEpisodes = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取剧集列表") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个剧集,将会读取其 href 属性和 text") }, + isError = conf.selectEpisodesIsError, + ) + OutlinedTextField( + conf.matchEpisodeSortFromName, { conf.matchEpisodeSortFromName = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("从剧集名称中匹配序号") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("正则表达式查找。期望名为 ep 的分组") }, + isError = conf.matchEpisodeSortFromNameIsError, + ) + } + + null -> { + Column( + Modifier.align(Alignment.CenterHorizontally), + ) { + ProvideTextStyleContentColor(MaterialTheme.typography.bodyLarge, MaterialTheme.colorScheme.error) { + Icon( + Icons.Rounded.Error, null, + Modifier.align(Alignment.CenterHorizontally).size(48.dp), + ) + Text( + "当前版本不支持该配置类型:${formatId.value}\n\n这可能是导入了一个在更高版本编辑的配置导致的\n可升级 Ani 或切换到其他配置类型", + Modifier.padding(top = 24.dp), + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} + diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.kt new file mode 100644 index 0000000000..78319d53ae --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.kt @@ -0,0 +1,515 @@ +/* + * 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.edit + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA +import me.him188.ani.app.ui.foundation.animation.StandardEasing +import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor +import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.theme.EasingDurations +import me.him188.ani.app.ui.settings.danmaku.isValidRegex +import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.app.ui.settings.mediasource.rss.edit.MediaSourceHeadline +import me.him188.ani.utils.xml.QueryParser +import me.him188.ani.utils.xml.parseSelectorOrNull + +/** + * 编辑配置 + */ +@Stable +class SelectorConfigurationState( + private val argumentsStorage: SaveableStorage, +) { + private val arguments by argumentsStorage.containerState + val isLoading by derivedStateOf { arguments == null } + val isSaving by argumentsStorage.isSavingState + + var displayName by argumentsStorage.prop( + { it.name }, { copy(name = it) }, + "", + ) + + val displayNameIsError by derivedStateOf { displayName.isBlank() } + + var iconUrl by argumentsStorage.prop( + { it.iconUrl }, { copy(iconUrl = it) }, + "", + ) + + var searchUrl by argumentsStorage.prop( + { it.searchConfig.searchUrl }, { copy(searchConfig = searchConfig.copy(searchUrl = it)) }, + "", + ) + val searchUrlIsError by derivedStateOf { searchUrl.isBlank() } + + // region SubjectFormat + + val subjectFormatA = SubjectFormatAConfig() + + @Stable + inner class SubjectFormatAConfig { + private fun prop( + get: (SelectorSubjectFormatA.Config) -> T, + set: SelectorSubjectFormatA.Config.(T) -> SelectorSubjectFormatA.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorSubjectFormatA.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorSubjectFormatA = searchConfig.selectorSubjectFormatA.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorSubjectFormatA.let(get), + ) + + var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) + val selectListsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectLists) == null + } + } + + // endregion + + // region ChannelFormat + + var channelFormatId by argumentsStorage.prop( + { it.searchConfig.channelFormatId }, { copy(searchConfig = searchConfig.copy(channelFormatId = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.channelFormatId, + ) + val allChannelFormats get() = SelectorChannelFormat.entries + + val channelFormatIndexed = ChannelFormatIndexedConfig() + + @Stable + inner class ChannelFormatIndexedConfig { + private fun prop( + get: (SelectorChannelFormatFlattened.Config) -> T, + set: SelectorChannelFormatFlattened.Config.(T) -> SelectorChannelFormatFlattened.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorChannelFormatFlattened.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorChannelFormatFlattened = searchConfig.selectorChannelFormatFlattened.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatFlattened.let(get), + ) + + var selectChannels by prop({ it.selectChannels }, { copy(selectChannels = it) }) + val selectChannelsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectChannels) == null + } + var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) + val selectListsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectLists) == null + } + var matchEpisodeSortFromName by prop({ it.matchEpisodeSortFromName }, { copy(matchEpisodeSortFromName = it) }) + val matchEpisodeSortFromNameIsError by derivedStateOf { + matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) + } + } + + val channelFormatNoChannel = ChannelFormatNoChannelConfig() + + @Stable + inner class ChannelFormatNoChannelConfig { + private fun prop( + get: (SelectorChannelFormatNoChannel.Config) -> T, + set: SelectorChannelFormatNoChannel.Config.(T) -> SelectorChannelFormatNoChannel.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorChannelFormatNoChannel.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorChannelFormatNoChannel = searchConfig.selectorChannelFormatNoChannel.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatNoChannel.let(get), + ) + + var selectEpisodes by prop({ it.selectEpisodes }, { copy(selectEpisodes = it) }) + val selectEpisodesIsError by derivedStateOf { QueryParser.parseSelectorOrNull(selectEpisodes) == null } + var matchEpisodeSortFromName by prop( + { it.matchEpisodeSortFromName }, + { copy(matchEpisodeSortFromName = it) }, + ) + val matchEpisodeSortFromNameIsError by derivedStateOf { + matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) + } + } + + // endregion + + var filterByEpisodeSort by argumentsStorage.prop( + { it.searchConfig.filterByEpisodeSort }, { copy(searchConfig = searchConfig.copy(filterByEpisodeSort = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.filterByEpisodeSort, + ) + var filterBySubjectName by argumentsStorage.prop( + { it.searchConfig.filterBySubjectName }, { copy(searchConfig = searchConfig.copy(filterBySubjectName = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.filterBySubjectName, + ) + + val matchVideoConfig: MatchVideoConfig = MatchVideoConfig() + + @Stable + inner class MatchVideoConfig { + private fun prop( + get: (SelectorSearchConfig.MatchVideoConfig) -> T, + set: SelectorSearchConfig.MatchVideoConfig.(T) -> SelectorSearchConfig.MatchVideoConfig, + ) = argumentsStorage.prop( + { it.searchConfig.matchVideo.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + matchVideo = searchConfig.matchVideo.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.matchVideo.let(get), + ) + + var matchVideoUrl by prop( + { it.matchVideoUrl }, { copy(matchVideoUrl = it) }, + ) + val matchVideoUrlIsError by derivedStateOf { + matchVideoUrl.isBlank() || !isValidRegex(matchVideoUrl) + } + + val videoHeaders = HeadersConfig() + + @Stable + inner class HeadersConfig { + private fun prop( + get: (SelectorSearchConfig.VideoHeaders) -> T, + set: SelectorSearchConfig.VideoHeaders.(T) -> SelectorSearchConfig.VideoHeaders, + ) = this@MatchVideoConfig.prop( + { it.addHeadersToVideo.let(get) }, + { copy(addHeadersToVideo = addHeadersToVideo.set(it)) }, + ) + + var referer by prop( + { it.referer }, { copy(referer = referer) }, + ) + var userAgent by prop( + { it.userAgent }, { copy(userAgent = userAgent) }, + ) + } + } + + val searchConfigState = derivedStateOf { + argumentsStorage.container?.searchConfig + } +} + +@Composable +internal fun SelectorConfigurationPane( + state: SelectorConfigurationState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, +) { + Column( + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(contentPadding), + ) { + // 大图标和标题 + MediaSourceHeadline(state.iconUrl, state.displayName) + + Column( + Modifier + .fillMaxHeight() + .padding(vertical = 16.dp), + ) { + val listItemColors = ListItemDefaults.colors(containerColor = Color.Transparent) + + Column(verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + OutlinedTextField( + state.displayName, { state.displayName = it }, + Modifier + .fillMaxWidth() + .moveFocusOnEnter(), + label = { Text("名称*") }, + placeholder = { Text("设置显示在列表中的名称") }, + isError = state.displayNameIsError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + OutlinedTextField( + state.iconUrl, { state.iconUrl = it }, + Modifier + .fillMaxWidth() + .moveFocusOnEnter(), + label = { Text("图标链接") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text(SelectorConfigurationDefaults.STEP_NAME_1) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + OutlinedTextField( + state.searchUrl, { state.searchUrl = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("搜索链接*") }, + placeholder = { + Text( + "示例:https://www.nyacg.net/search.html?wd={keyword}", + color = MaterialTheme.colorScheme.outline, + ) + }, + supportingText = { + Text( + """ + 替换规则: + {keyword} 替换为条目 (番剧) 名称 + """.trimIndent(), + ) + }, + isError = state.searchUrlIsError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + val conf = state.subjectFormatA + OutlinedTextField( + conf.selectLists, { conf.selectLists = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取条目列表") }, + supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个条目,将会读取其 href 属性和 text") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + isError = conf.selectListsIsError, + ) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text(SelectorConfigurationDefaults.STEP_NAME_2) + } + } + + SubjectChannelSelectionButtonRow( + state, + Modifier.fillMaxWidth(), + ) + + AnimatedContent( + state.channelFormatId, + Modifier + .padding(vertical = 12.dp) + .fillMaxWidth() + .animateContentSize(tween(EasingDurations.standard, easing = StandardEasing)), + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { formatId -> + SelectorChannelConfigurationColumn(formatId, state, Modifier.fillMaxWidth()) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text("过滤设置") + } + } + + Column(Modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + ListItem( + headlineContent = { Text("使用条目名称过滤") }, + Modifier.focusable(false).clickable { state.filterBySubjectName = !state.filterBySubjectName }, + supportingContent = { Text("要求资源标题包含条目名称。适用于数据源可能搜到无关内容的情况。通常建议开启") }, + trailingContent = { Switch(state.filterBySubjectName, { state.filterBySubjectName = it }) }, + colors = listItemColors, + ) + ListItem( + headlineContent = { Text("使用剧集序号过滤") }, + Modifier.focusable(false).clickable { state.filterByEpisodeSort = !state.filterByEpisodeSort }, + supportingContent = { Text("要求资源标题包含剧集序号。适用于数据源可能搜到无关内容的情况。通常建议开启") }, + trailingContent = { Switch(state.filterByEpisodeSort, { state.filterByEpisodeSort = it }) }, + colors = listItemColors, + ) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text(SelectorConfigurationDefaults.STEP_NAME_3) + } + } + + SelectorConfigurationDefaults.MatchVideoSection( + state, + textFieldShape = textFieldShape, + verticalSpacing = verticalSpacing, + ) + + Row(Modifier.align(Alignment.End).padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.labelMedium, + MaterialTheme.colorScheme.outline, + ) { + Text("提示:修改自动保存") + } + } + } + + } +} + +@Composable +private fun SubjectChannelSelectionButtonRow( + state: SelectorConfigurationState, + modifier: Modifier = Modifier, +) { + SingleChoiceSegmentedButtonRow(modifier) { + @Composable + fun Btn( + id: SelectorFormatId, index: Int, + label: @Composable () -> Unit, + ) { + SegmentedButton( + state.channelFormatId == id, + { state.channelFormatId = id }, + SegmentedButtonDefaults.itemShape(index, state.allChannelFormats.size), + icon = { SegmentedButtonDefaults.Icon(state.channelFormatId == id) }, + label = label, + ) + } + + for ((index, selectorChannelFormat) in state.allChannelFormats.withIndex()) { + Btn(selectorChannelFormat.id, index) { + Text( + when (selectorChannelFormat) { // type-safe to handle all formats + SelectorChannelFormatNoChannel -> "不区分线路" + SelectorChannelFormatFlattened -> "多线路扁平" + }, + softWrap = false, + ) + } + } + } +} + +object SelectorConfigurationDefaults { + const val STEP_NAME_1 = "步骤 1:搜索条目" + const val STEP_NAME_2 = "步骤 2:搜索剧集" + const val STEP_NAME_3 = "步骤 3:匹配视频" + + val verticalSpacing: Dp + @Composable + get() = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding + + val textFieldShape + @Composable + get() = MaterialTheme.shapes.medium +} + +@Suppress("UnusedReceiverParameter") +@Composable +internal fun SelectorConfigurationDefaults.MatchVideoSection( + state: SelectorConfigurationState, + modifier: Modifier = Modifier, + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + val matchVideoConfig = state.matchVideoConfig + OutlinedTextField( + matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("匹配视频链接") }, + supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。将会使用匹配结果的分组 v") }, + 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/test/SelectTestEpisodeResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt new file mode 100644 index 0000000000..e3429197ca --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt @@ -0,0 +1,91 @@ +/* + * 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.test + +import androidx.compose.runtime.Immutable +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.data.source.media.source.web.WebSearchEpisodeInfo +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.xml.Element + +@Immutable +sealed class SelectorTestEpisodeListResult : RefreshResult { + @Immutable + data class Success( + val channels: List?, + val episodes: List, + ) : SelectorTestEpisodeListResult(), RefreshResult.Success + + @Immutable + data class ApiError( + override val reason: ApiFailure + ) : SelectorTestEpisodeListResult(), RefreshResult.ApiError + + @Immutable + data object InvalidConfig : SelectorTestEpisodeListResult(), RefreshResult.InvalidConfig + + @Immutable + data class UnknownError( + override val exception: Throwable + ) : SelectorTestEpisodeListResult(), RefreshResult.UnknownError +} + +@Immutable +class SelectorTestEpisodePresentation( + val name: String, + val episodeSort: EpisodeSort?, + val playUrl: String, + val tags: List, + val origin: Element?, +) { + companion object { + fun compute( + info: WebSearchEpisodeInfo, + searchQuery: SelectorSearchQuery, + origin: Element?, + config: SelectorSearchConfig, + ): SelectorTestEpisodePresentation { + return SelectorTestEpisodePresentation( + name = info.name, + episodeSort = info.episodeSort, + playUrl = info.playUrl, + tags = buildMatchTags { + if (config.filterByEpisodeSort) { + if (info.episodeSort == null) { + emit("缺失 EP", isMissing = true) + } else { + emit("EP: ${info.episodeSort}", isMatch = info.episodeSort == searchQuery.episodeSort) + } + } + + when { + info.playUrl.isEmpty() -> { + emit("缺失播放地址", isMissing = true) + } + + !info.playUrl.startsWith("http") -> { + emit("播放地址: ${info.playUrl}", isMatch = false) + } + + else -> { + emit(info.playUrl, isMatch = true) + } + } + }, + origin = origin, + ) + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt new file mode 100644 index 0000000000..e89c77b7f1 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt @@ -0,0 +1,276 @@ +/* + * 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.test + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +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.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.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +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 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.datasources.api.matcher.WebVideo +import me.him188.ani.datasources.api.matcher.WebVideoMatcher +import kotlin.coroutines.CoroutineContext + +/** + * 测试 [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, +) { + 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 { + if (episodeUrl != null && extractor != null) { + extractor.getVideoResourceUrl(context, episodeUrl) { + collect(it) + } + } + } + } + + @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)) + } + .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( + state: SelectorEpisodeState, + modifier: Modifier = Modifier, + itemSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, + cardColors: CardColors = AniThemeDefaults.backgroundCardColors(), + itemColors: ListItemColors = ListItemDefaults.colors(), +) { + Column(modifier) { + Card( + colors = cardColors, + shape = MaterialTheme.shapes.large, + ) { + Row(Modifier.padding(horizontal = 16.dp).padding(top = 16.dp)) { + ProvideTextStyle( + MaterialTheme.typography.titleLarge, + ) { + Text("匹配视频") + } + } + + ListItem( + headlineContent = { Text(state.episodeName) }, + supportingContent = { Text(state.episodeUrl) }, + colors = ListItemDefaults.colors(containerColor = cardColors.containerColor), + ) + } + + val list by state.matchResults.collectAsStateWithLifecycle(emptyList()) + + LazyVerticalGrid( + columns = GridCells.Adaptive(300.dp), + horizontalArrangement = Arrangement.spacedBy(itemSpacing), + verticalArrangement = Arrangement.spacedBy(itemSpacing), + ) { + for (matchResult in list) { + item(key = matchResult.originalUrl) { + val isMatch = matchResult.isMatch() + ListItem( + headlineContent = { Text(matchResult.originalUrl) }, + Modifier.animateItem(), + supportingContent = { + Text(matchResult.video?.m3u8Url ?: "未匹配") + }, + colors = itemColors, + trailingContent = { + if (isMatch) { + Icon(Icons.Rounded.Check, "匹配", tint = MaterialTheme.colorScheme.primary) + } else { + Icon(Icons.Rounded.Close, "未匹配") + } + }, + ) + } + } + } + } +} + +enum class SelectorEpisodePaneLayout { + WithBottomSheet, + ListOnly, ; + + companion object { + fun calculate( + scaffoldValue: ThreePaneScaffoldValue, + ): SelectorEpisodePaneLayout { + return when { + scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded -> { + // list 和 extra 同时展开, 也就是大屏环境. list 内包含了配置, 所以我们无需使用 bottom sheet 显示配置 + ListOnly + } + + else -> WithBottomSheet + } + } + } +} + +/** + * 测试 [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/test/SelectorTestEpisodeList.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt new file mode 100644 index 0000000000..a323780366 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt @@ -0,0 +1,93 @@ +/* + * 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.test + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +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.unit.dp +import me.him188.ani.app.ui.foundation.layout.cardHorizontalPadding +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +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), +) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), + modifier, + state, + contentPadding, + horizontalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardHorizontalPadding), + verticalItemSpacing = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding, + ) { + for (episode in episodes) { + item(key = episode) { + EpisodeCard( + title = { Text(episode.name) }, + { onClick(episode) }, + ) { + episode.tags.forEach { + OutlinedMatchTag(it) + } + } + } + } + } +} + +@Composable +private fun EpisodeCard( + title: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tags: @Composable FlowRowScope.() -> Unit, +) { + val color = CardDefaults.cardColors() + Card( + onClick, + modifier, + colors = color, + ) { + ListItem( + headlineContent = title, + supportingContent = { + FlowRow( + Modifier.padding(top = 8.dp, bottom = 8.dp).width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = tags, + ) + }, + colors = ListItemDefaults.colors(containerColor = color.containerColor), + ) + } +} 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 new file mode 100644 index 0000000000..6c367aa0a2 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt @@ -0,0 +1,395 @@ +/* + * 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.test + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.Card +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.runtime.Stable +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 androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.fold +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.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.ui.foundation.interaction.nestedScrollWorkaround +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.rememberConnectedScrollState +import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.widgets.FastLinearProgressIndicator +import me.him188.ani.app.ui.settings.mediasource.AbstractMediaSourceTestState +import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher +import me.him188.ani.app.ui.settings.mediasource.EditMediaSourceTestDataCardDefaults +import me.him188.ani.app.ui.settings.mediasource.RefreshIndicatedHeadlineRow +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.xml.Document +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class SelectorTestState( + searchConfigState: State, + private val engine: SelectorMediaSourceEngine, + backgroundScope: CoroutineScope, +) : AbstractMediaSourceTestState() { + // null for invalid config + private val queryState = derivedStateOf { + val searchKeyword = searchKeyword.ifEmpty { searchKeywordPlaceholder } + val sort = sort + if (searchKeyword.isBlank() || sort.isBlank()) { + null + } else { + SelectorSearchQuery(subjectName = searchKeyword, episodeSort = EpisodeSort(sort)) + } + } + + var selectedSubjectIndex by mutableIntStateOf(-1) + val selectedSubjectState = derivedStateOf { + val success = subjectSearchSelectResult as? SelectorTestSearchSubjectResult.Success + ?: return@derivedStateOf null + success.subjects.getOrNull(selectedSubjectIndex) + } + val selectedSubject by selectedSubjectState + private val searchUrl by derivedStateOf { + searchConfigState.value?.searchUrl + } + + /** + * 用于查询条目列表, 每当编辑请求和 `searchUrl`, 会重新搜索, 但不会筛选. + * 筛选在 [subjectSearchSelectResult]. + */ + val subjectSearcher = BackgroundSearcher( + backgroundScope, + derivedStateOf { + val url = searchUrl + url to searchKeyword + }, + search = { (url, searchKeyword) -> + // 不清除 selectedSubjectIndex + + launchRequestInBackground { + if (url.isNullOrBlank() || searchKeyword.isBlank()) { + null + } else { + try { + val res = engine.searchSubjects( + url, + searchKeyword, + ) + Result.success(res) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + } + } + }, + ) + + val subjectSearchSelectResult by derivedStateOf { + val res = subjectSearcher.searchResult + val config = searchConfigState.value + val query = queryState.value + when { + res == null -> { + null + } + + config == null || query == null -> { + SelectorTestSearchSubjectResult.InvalidConfig + } + + else -> { + res.fold( + onSuccess = { + selectSubjectResult(it, config, query) + }, + onFailure = { + SelectorTestSearchSubjectResult.UnknownError(it) + }, + ) + } + } + } + + /** + * 用于查询条目的剧集列表, 每当选择新的条目时, 会重新搜索. 但不会筛选. 筛选在 [episodeListSearchSelectResult]. + */ + val episodeListSearcher = BackgroundSearcher( + backgroundScope, + selectedSubjectState, + search = { selectedSubject -> + launchRequestInBackground { + if (selectedSubject == null) { + null + } else { + try { + engine.searchEpisodes( + selectedSubject.subjectDetailsPageUrl, + ) + } catch (e: CancellationException) { + throw e + } + } + } + }, + ) + + /** + * 经过筛选的条目的剧集列表 + */ + val episodeListSearchSelectResult by derivedStateOf { + val subjectDetailsPageDocument = episodeListSearcher.searchResult + val searchConfig = searchConfigState.value + val queryState = queryState.value + + when { + queryState == null || searchConfig == null -> { + SelectorTestEpisodeListResult.InvalidConfig + } + + subjectDetailsPageDocument == null -> { + SelectorTestEpisodeListResult.Success(null, emptyList()) + } + + else -> { + convertEpisodeResult( + subjectDetailsPageDocument, + searchConfig, + queryState, + ) + } + } + } + + // 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, + query: SelectorSearchQuery, + ): SelectorTestEpisodeListResult { + return res.fold( + onSuccess = { document -> + try { + val episodeList = engine.selectEpisodes(document, config) + ?: return SelectorTestEpisodeListResult.InvalidConfig + SelectorTestEpisodeListResult.Success( + episodeList.channels, + episodeList.episodes.map { + SelectorTestEpisodePresentation.compute(it, query, document, config) + }, + ) + } catch (e: Throwable) { + SelectorTestEpisodeListResult.UnknownError(e) + } + }, + onKnownFailure = { reason -> + SelectorTestEpisodeListResult.ApiError(reason) + }, + ) + } + + private fun selectSubjectResult( + res: ApiResponse, + searchConfig: SelectorSearchConfig, + query: SelectorSearchQuery, + ): SelectorTestSearchSubjectResult { + return res.fold( + onSuccess = { data -> + val document = data.document + + val originalList = if (document == null) { + emptyList() + } else { + engine.selectSubjects(document, searchConfig).let { + if (it == null) { + return SelectorTestSearchSubjectResult.InvalidConfig + } + it + } + } + + SelectorTestSearchSubjectResult.Success( + data.url.toString(), + originalList.map { + SelectorTestSubjectPresentation.compute(it, query, document, searchConfig) + }, + ) + }, + onKnownFailure = { reason -> + SelectorTestSearchSubjectResult.ApiError(reason) + }, + ) + } +} + +/** + * 测试数据源. 编辑 + */ +@Composable +fun SelectorTestPane( + state: SelectorTestState, + onViewEpisode: (SelectorTestEpisodePresentation) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + 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)) { + Text( + "测试数据源", + style = MaterialTheme.typography.headlineSmall, + ) + + EditTestDataCard( + state, + Modifier + .padding(top = verticalSpacing) + .fillMaxWidth(), + ) + + RefreshIndicatedHeadlineRow( + headline = { Text(SelectorConfigurationDefaults.STEP_NAME_1) }, + onRefresh = { state.subjectSearcher.restartCurrentSearch() }, + result = state.subjectSearchSelectResult, + Modifier.padding(top = verticalSpacing), + ) + + Box(Modifier.height(12.dp), contentAlignment = Alignment.Center) { + FastLinearProgressIndicator( + state.subjectSearcher.isSearching, + delayMillis = 0, + minimumDurationMillis = 300, + ) + } + + AnimatedContent( + state.subjectSearchSelectResult, + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { result -> + if (result is SelectorTestSearchSubjectResult.Success) { + SelectorTestSubjectResultLazyRow( + items = result.subjects, + state.selectedSubjectIndex, + onSelect = { index, _ -> + state.selectedSubjectIndex = index + }, + modifier = Modifier.padding(top = verticalSpacing - 8.dp), + ) + } + } + } + + AnimatedContent( + state.selectedSubject, + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { selectedSubjectIndex -> + if (selectedSubjectIndex != null) { + Column { + RefreshIndicatedHeadlineRow( + headline = { Text(SelectorConfigurationDefaults.STEP_NAME_2) }, + onRefresh = { state.episodeListSearcher.restartCurrentSearch() }, + result = state.episodeListSearchSelectResult, + Modifier.padding(top = verticalSpacing), + ) + + Box(Modifier.height(12.dp), contentAlignment = Alignment.Center) { + FastLinearProgressIndicator( + state.episodeListSearcher.isSearching, + delayMillis = 0, + minimumDurationMillis = 300, + ) + } + } + } + } + + if (state.selectedSubject != null) { + AnimatedContent(state.episodeListSearchSelectResult) { result -> + if (result is SelectorTestEpisodeListResult.Success) { + val staggeredGridState = rememberLazyStaggeredGridState() + SelectorTestEpisodeListGrid( + result.episodes, + onClick = onViewEpisode, + modifier = Modifier.padding(top = verticalSpacing - 8.dp) + .nestedScroll(connectedScrollState.nestedScrollConnection) + .nestedScrollWorkaround(staggeredGridState, connectedScrollState), + state = staggeredGridState, + ) + } + } + } + } +} + +@Composable +private fun EditTestDataCard( + state: SelectorTestState, + modifier: Modifier = Modifier, +) { + with(EditMediaSourceTestDataCardDefaults) { + Card( + modifier, + shape = cardShape, + colors = cardColors, + ) { + FlowRow { + KeywordTextField(state, Modifier.weight(1f)) + EpisodeSortTextField(state, Modifier.weight(1f)) + } + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt new file mode 100644 index 0000000000..623f20b11c --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt @@ -0,0 +1,77 @@ +/* + * 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.test + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.layout.cardHorizontalPadding + + +/** + * @see selectedItemIndex `-1` for no selection + */ +@Composable +internal fun SelectorTestResultLazyRow( + items: List, + selectedItemIndex: Int, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + item: @Composable LazyItemScope.(index: Int, T) -> Unit, +) { + require(selectedItemIndex == -1 || selectedItemIndex >= 0) { + "selectedItemIndex must be -1 or in the range of items" + } + val density = LocalDensity.current + val lazyListState = rememberLazyListState() + LaunchedEffect(selectedItemIndex) { + if (selectedItemIndex != -1 && !lazyListState.isItemFullyVisible(selectedItemIndex)) { + // 如果有选择一个目前不可见的项目, 将其滚动到可见区域 + lazyListState.animateScrollToItem( + selectedItemIndex, + // 左边显示一点点上个项目, 这样让他知道左边还有东西 + scrollOffset = -with(density) { 36.dp.roundToPx() }, + ) + } + } + LazyRow( + modifier = modifier, + state = lazyListState, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardHorizontalPadding), + ) { + for ((index, value) in items.withIndex()) { + item { + item(index, value) + } + } + } +} + +@Stable +private fun LazyListState.isItemFullyVisible(index: Int): Boolean { + val layoutInfo = layoutInfo + val item = layoutInfo.visibleItemsInfo.find { it.index == index } + ?: return false + if (item.offset < 0) return false + + // Check if the item is fully visible + return item.offset + item.size <= layoutInfo.viewportEndOffset +} 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 new file mode 100644 index 0000000000..5b341478b9 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt @@ -0,0 +1,88 @@ +/* + * 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.test + +import androidx.compose.runtime.Immutable +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.data.source.media.source.MediaListFilters +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.data.source.media.source.web.WebSearchSubjectInfo +import me.him188.ani.app.data.source.media.source.web.asCandidate +import me.him188.ani.app.data.source.media.source.web.toFilterContext +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.utils.xml.Element + +// For UI +@Immutable +sealed class SelectorTestSearchSubjectResult : RefreshResult { + @Immutable + data class Success( + val encodedUrl: String, + val subjects: List, + ) : SelectorTestSearchSubjectResult(), RefreshResult.Success + + @Immutable + data class ApiError( + override val reason: ApiFailure, + ) : SelectorTestSearchSubjectResult(), RefreshResult.ApiError + + @Immutable + data object InvalidConfig : SelectorTestSearchSubjectResult(), RefreshResult.InvalidConfig + + @Immutable + data class UnknownError( + override val exception: Throwable, + ) : SelectorTestSearchSubjectResult(), RefreshResult.UnknownError +} + +@Immutable +class SelectorTestSubjectPresentation( + val name: String, + val subjectDetailsPageUrl: String, + val origin: Element?, + val tags: List, +) { + companion object { + fun compute( + info: WebSearchSubjectInfo, + query: SelectorSearchQuery, + origin: Element?, + config: SelectorSearchConfig, + ): SelectorTestSubjectPresentation { + val tags = computeTags(info, query, config) + return SelectorTestSubjectPresentation( + name = info.name, + subjectDetailsPageUrl = info.subjectDetailsPageUrl, + origin = origin, + tags = tags, + ) + } + + private fun computeTags( + info: WebSearchSubjectInfo, + query: SelectorSearchQuery, + config: SelectorSearchConfig, + ) = buildMatchTags { + with(query.toFilterContext()) { + val candidate = info.asCandidate() + if (config.filterBySubjectName) { + if (!MediaListFilters.ContainsSubjectName.applyOn(candidate)) { + emit("标题", isMatch = false) + } else { + emit("标题", isMatch = true) + } + } + } + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt new file mode 100644 index 0000000000..6703053e99 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt @@ -0,0 +1,135 @@ +/* + * 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.test + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.Tag +import me.him188.ani.app.ui.settings.mediasource.rss.test.OutlinedMatchTag + +/** + * @see selectedItemIndex `-1` for no selection + */ +@Composable +internal fun SelectorTestSubjectResultLazyRow( + items: List, + selectedItemIndex: Int, + onSelect: (Int, SelectorTestSubjectPresentation) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + SubcomposeLayout { constraints -> + // measure 一个卡片的高度 + val (measurable) = subcompose(0) { + SelectorTestResultCard( + title = { Text("1\n2") }, + isSelected = false, + onClick = {}, + tags = { Tag { Text("Dummy") } }, + ) + } + val itemSize = measurable.measure(constraints) + + val (lazyRow) = subcompose(1) { + SelectorTestResultLazyRow(items, selectedItemIndex, modifier, contentPadding) { index, item -> + SelectorTestSubjectResultCard( + item, + selectedItemIndex == index, + onClick = { onSelect(index, item) }, + Modifier.height(itemSize.height.toDp()), // 使用固定高度 + ) + } + } + + val lazyRowPlaceable = lazyRow.measure(constraints) + layout(lazyRowPlaceable.width, lazyRowPlaceable.height) { + lazyRowPlaceable.place(0, 0) + } + } +} + +@Composable +internal fun SelectorTestSubjectResultCard( + item: SelectorTestSubjectPresentation, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + SelectorTestResultCard( + title = { + Text( + item.name, + Modifier.width(IntrinsicSize.Max), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + isSelected = isSelected, + onClick = onClick, + modifier + .width(IntrinsicSize.Min) // 有 widthIn max 之后不知道为什么它就会默认 fillMaxWidth + .widthIn(min = 120.dp, max = 240.dp), + tags = { + for (tag in item.tags) { + OutlinedMatchTag(tag) + } + }, + ) +} + + +@Composable +internal fun SelectorTestResultCard( + title: @Composable () -> Unit, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tags: @Composable FlowRowScope.() -> Unit, +) { + val color = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer + else CardDefaults.cardColors().containerColor, + ) + Card( + onClick, + modifier, colors = color, + ) { + ListItem( + headlineContent = title, + supportingContent = { + FlowRow( + Modifier.padding(top = 8.dp, bottom = 8.dp).width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = tags, + ) + }, + colors = ListItemDefaults.colors(containerColor = color.containerColor), + ) + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt index 33a5126054..11bb942c59 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt @@ -1,3 +1,12 @@ +/* + * 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.tabs.media.source import androidx.compose.animation.AnimatedVisibility @@ -44,6 +53,7 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +69,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import me.him188.ani.app.data.source.media.source.RssMediaSource +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSource import me.him188.ani.app.navigation.LocalNavigator import me.him188.ani.app.ui.foundation.ifThen import me.him188.ani.app.ui.settings.framework.ConnectionTesterResultIndicator @@ -73,8 +84,10 @@ import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorderAfterLongPress import org.burnoutcrew.reorderable.reorderable +@Stable internal val MediaSourcesUsingNewSettings = listOf( RssMediaSource.FactoryId, + SelectorMediaSource.FactoryId, ) @Composable From a335a332020f28c4a98608304c16e1c0d87df92b Mon Sep 17 00:00:00 2001 From: Him188 Date: Thu, 19 Sep 2024 23:57:58 +0100 Subject: [PATCH 03/11] Extract SElectorConfigurationState and SelectorConfigurationDefaults to separate files --- .../edit/SelectorConfigurationDefaults.kt | 80 ++++++ ...itPane.kt => SelectorConfigurationPane.kt} | 259 ------------------ .../edit/SelectorConfigurationState.kt | 216 +++++++++++++++ 3 files changed, 296 insertions(+), 259 deletions(-) create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt rename app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/{SelectorEditPane.kt => SelectorConfigurationPane.kt} (51%) create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationState.kt 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 new file mode 100644 index 0000000000..f0097b165f --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt @@ -0,0 +1,80 @@ +/* + * 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.edit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.Shape +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding + +object SelectorConfigurationDefaults { + const val STEP_NAME_1 = "步骤 1:搜索条目" + const val STEP_NAME_2 = "步骤 2:搜索剧集" + const val STEP_NAME_3 = "步骤 3:匹配视频" + + val verticalSpacing: Dp + @Composable + get() = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding + + val textFieldShape + @Composable + get() = MaterialTheme.shapes.medium +} + +@Suppress("UnusedReceiverParameter") +@Composable +internal fun SelectorConfigurationDefaults.MatchVideoSection( + state: SelectorConfigurationState, + modifier: Modifier = Modifier, + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + val matchVideoConfig = state.matchVideoConfig + OutlinedTextField( + matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("匹配视频链接") }, + supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。将会使用匹配结果的分组 v") }, + 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/SelectorEditPane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt similarity index 51% rename from app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.kt rename to app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt index 78319d53ae..7975017948 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt @@ -33,11 +33,7 @@ import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -45,216 +41,15 @@ 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.data.source.media.source.web.SelectorMediaSourceArguments -import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig -import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId -import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA import me.him188.ani.app.ui.foundation.animation.StandardEasing import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter -import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults import me.him188.ani.app.ui.foundation.theme.EasingDurations -import me.him188.ani.app.ui.settings.danmaku.isValidRegex -import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage import me.him188.ani.app.ui.settings.mediasource.rss.edit.MediaSourceHeadline -import me.him188.ani.utils.xml.QueryParser -import me.him188.ani.utils.xml.parseSelectorOrNull - -/** - * 编辑配置 - */ -@Stable -class SelectorConfigurationState( - private val argumentsStorage: SaveableStorage, -) { - private val arguments by argumentsStorage.containerState - val isLoading by derivedStateOf { arguments == null } - val isSaving by argumentsStorage.isSavingState - - var displayName by argumentsStorage.prop( - { it.name }, { copy(name = it) }, - "", - ) - - val displayNameIsError by derivedStateOf { displayName.isBlank() } - - var iconUrl by argumentsStorage.prop( - { it.iconUrl }, { copy(iconUrl = it) }, - "", - ) - - var searchUrl by argumentsStorage.prop( - { it.searchConfig.searchUrl }, { copy(searchConfig = searchConfig.copy(searchUrl = it)) }, - "", - ) - val searchUrlIsError by derivedStateOf { searchUrl.isBlank() } - - // region SubjectFormat - - val subjectFormatA = SubjectFormatAConfig() - - @Stable - inner class SubjectFormatAConfig { - private fun prop( - get: (SelectorSubjectFormatA.Config) -> T, - set: SelectorSubjectFormatA.Config.(T) -> SelectorSubjectFormatA.Config, - ) = argumentsStorage.prop( - { it.searchConfig.selectorSubjectFormatA.let(get) }, - { - copy( - searchConfig = searchConfig.copy( - selectorSubjectFormatA = searchConfig.selectorSubjectFormatA.set(it), - ), - ) - }, - SelectorMediaSourceArguments.Default.searchConfig.selectorSubjectFormatA.let(get), - ) - - var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) - val selectListsIsError by derivedStateOf { - QueryParser.parseSelectorOrNull(selectLists) == null - } - } - - // endregion - - // region ChannelFormat - - var channelFormatId by argumentsStorage.prop( - { it.searchConfig.channelFormatId }, { copy(searchConfig = searchConfig.copy(channelFormatId = it)) }, - SelectorMediaSourceArguments.Default.searchConfig.channelFormatId, - ) - val allChannelFormats get() = SelectorChannelFormat.entries - - val channelFormatIndexed = ChannelFormatIndexedConfig() - - @Stable - inner class ChannelFormatIndexedConfig { - private fun prop( - get: (SelectorChannelFormatFlattened.Config) -> T, - set: SelectorChannelFormatFlattened.Config.(T) -> SelectorChannelFormatFlattened.Config, - ) = argumentsStorage.prop( - { it.searchConfig.selectorChannelFormatFlattened.let(get) }, - { - copy( - searchConfig = searchConfig.copy( - selectorChannelFormatFlattened = searchConfig.selectorChannelFormatFlattened.set(it), - ), - ) - }, - SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatFlattened.let(get), - ) - - var selectChannels by prop({ it.selectChannels }, { copy(selectChannels = it) }) - val selectChannelsIsError by derivedStateOf { - QueryParser.parseSelectorOrNull(selectChannels) == null - } - var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) - val selectListsIsError by derivedStateOf { - QueryParser.parseSelectorOrNull(selectLists) == null - } - var matchEpisodeSortFromName by prop({ it.matchEpisodeSortFromName }, { copy(matchEpisodeSortFromName = it) }) - val matchEpisodeSortFromNameIsError by derivedStateOf { - matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) - } - } - - val channelFormatNoChannel = ChannelFormatNoChannelConfig() - - @Stable - inner class ChannelFormatNoChannelConfig { - private fun prop( - get: (SelectorChannelFormatNoChannel.Config) -> T, - set: SelectorChannelFormatNoChannel.Config.(T) -> SelectorChannelFormatNoChannel.Config, - ) = argumentsStorage.prop( - { it.searchConfig.selectorChannelFormatNoChannel.let(get) }, - { - copy( - searchConfig = searchConfig.copy( - selectorChannelFormatNoChannel = searchConfig.selectorChannelFormatNoChannel.set(it), - ), - ) - }, - SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatNoChannel.let(get), - ) - - var selectEpisodes by prop({ it.selectEpisodes }, { copy(selectEpisodes = it) }) - val selectEpisodesIsError by derivedStateOf { QueryParser.parseSelectorOrNull(selectEpisodes) == null } - var matchEpisodeSortFromName by prop( - { it.matchEpisodeSortFromName }, - { copy(matchEpisodeSortFromName = it) }, - ) - val matchEpisodeSortFromNameIsError by derivedStateOf { - matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) - } - } - - // endregion - - var filterByEpisodeSort by argumentsStorage.prop( - { it.searchConfig.filterByEpisodeSort }, { copy(searchConfig = searchConfig.copy(filterByEpisodeSort = it)) }, - SelectorMediaSourceArguments.Default.searchConfig.filterByEpisodeSort, - ) - var filterBySubjectName by argumentsStorage.prop( - { it.searchConfig.filterBySubjectName }, { copy(searchConfig = searchConfig.copy(filterBySubjectName = it)) }, - SelectorMediaSourceArguments.Default.searchConfig.filterBySubjectName, - ) - - val matchVideoConfig: MatchVideoConfig = MatchVideoConfig() - - @Stable - inner class MatchVideoConfig { - private fun prop( - get: (SelectorSearchConfig.MatchVideoConfig) -> T, - set: SelectorSearchConfig.MatchVideoConfig.(T) -> SelectorSearchConfig.MatchVideoConfig, - ) = argumentsStorage.prop( - { it.searchConfig.matchVideo.let(get) }, - { - copy( - searchConfig = searchConfig.copy( - matchVideo = searchConfig.matchVideo.set(it), - ), - ) - }, - SelectorMediaSourceArguments.Default.searchConfig.matchVideo.let(get), - ) - - var matchVideoUrl by prop( - { it.matchVideoUrl }, { copy(matchVideoUrl = it) }, - ) - val matchVideoUrlIsError by derivedStateOf { - matchVideoUrl.isBlank() || !isValidRegex(matchVideoUrl) - } - - val videoHeaders = HeadersConfig() - - @Stable - inner class HeadersConfig { - private fun prop( - get: (SelectorSearchConfig.VideoHeaders) -> T, - set: SelectorSearchConfig.VideoHeaders.(T) -> SelectorSearchConfig.VideoHeaders, - ) = this@MatchVideoConfig.prop( - { it.addHeadersToVideo.let(get) }, - { copy(addHeadersToVideo = addHeadersToVideo.set(it)) }, - ) - - var referer by prop( - { it.referer }, { copy(referer = referer) }, - ) - var userAgent by prop( - { it.userAgent }, { copy(userAgent = userAgent) }, - ) - } - } - - val searchConfigState = derivedStateOf { - argumentsStorage.container?.searchConfig - } -} @Composable internal fun SelectorConfigurationPane( @@ -459,57 +254,3 @@ private fun SubjectChannelSelectionButtonRow( } } } - -object SelectorConfigurationDefaults { - const val STEP_NAME_1 = "步骤 1:搜索条目" - const val STEP_NAME_2 = "步骤 2:搜索剧集" - const val STEP_NAME_3 = "步骤 3:匹配视频" - - val verticalSpacing: Dp - @Composable - get() = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding - - val textFieldShape - @Composable - get() = MaterialTheme.shapes.medium -} - -@Suppress("UnusedReceiverParameter") -@Composable -internal fun SelectorConfigurationDefaults.MatchVideoSection( - state: SelectorConfigurationState, - modifier: Modifier = Modifier, - textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, - verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, -) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { - val matchVideoConfig = state.matchVideoConfig - OutlinedTextField( - matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, - Modifier.fillMaxWidth().moveFocusOnEnter(), - label = { Text("匹配视频链接") }, - supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。将会使用匹配结果的分组 v") }, - 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/SelectorConfigurationState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationState.kt new file mode 100644 index 0000000000..c25923f61e --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationState.kt @@ -0,0 +1,216 @@ +/* + * 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.edit + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA +import me.him188.ani.app.ui.settings.danmaku.isValidRegex +import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.utils.xml.QueryParser +import me.him188.ani.utils.xml.parseSelectorOrNull + +/** + * 编辑配置 + */ +@Stable +class SelectorConfigurationState( + private val argumentsStorage: SaveableStorage, +) { + private val arguments by argumentsStorage.containerState + val isLoading by derivedStateOf { arguments == null } + val isSaving by argumentsStorage.isSavingState + + var displayName by argumentsStorage.prop( + { it.name }, { copy(name = it) }, + "", + ) + + val displayNameIsError by derivedStateOf { displayName.isBlank() } + + var iconUrl by argumentsStorage.prop( + { it.iconUrl }, { copy(iconUrl = it) }, + "", + ) + + var searchUrl by argumentsStorage.prop( + { it.searchConfig.searchUrl }, { copy(searchConfig = searchConfig.copy(searchUrl = it)) }, + "", + ) + val searchUrlIsError by derivedStateOf { searchUrl.isBlank() } + + // region SubjectFormat + + val subjectFormatA = SubjectFormatAConfig() + + @Stable + inner class SubjectFormatAConfig { + private fun prop( + get: (SelectorSubjectFormatA.Config) -> T, + set: SelectorSubjectFormatA.Config.(T) -> SelectorSubjectFormatA.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorSubjectFormatA.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorSubjectFormatA = searchConfig.selectorSubjectFormatA.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorSubjectFormatA.let(get), + ) + + var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) + val selectListsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectLists) == null + } + } + + // endregion + + // region ChannelFormat + + var channelFormatId by argumentsStorage.prop( + { it.searchConfig.channelFormatId }, { copy(searchConfig = searchConfig.copy(channelFormatId = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.channelFormatId, + ) + val allChannelFormats get() = SelectorChannelFormat.entries + + val channelFormatIndexed = ChannelFormatIndexedConfig() + + @Stable + inner class ChannelFormatIndexedConfig { + private fun prop( + get: (SelectorChannelFormatFlattened.Config) -> T, + set: SelectorChannelFormatFlattened.Config.(T) -> SelectorChannelFormatFlattened.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorChannelFormatFlattened.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorChannelFormatFlattened = searchConfig.selectorChannelFormatFlattened.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatFlattened.let(get), + ) + + var selectChannels by prop({ it.selectChannels }, { copy(selectChannels = it) }) + val selectChannelsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectChannels) == null + } + var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) + val selectListsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectLists) == null + } + var matchEpisodeSortFromName by prop({ it.matchEpisodeSortFromName }, { copy(matchEpisodeSortFromName = it) }) + val matchEpisodeSortFromNameIsError by derivedStateOf { + matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) + } + } + + val channelFormatNoChannel = ChannelFormatNoChannelConfig() + + @Stable + inner class ChannelFormatNoChannelConfig { + private fun prop( + get: (SelectorChannelFormatNoChannel.Config) -> T, + set: SelectorChannelFormatNoChannel.Config.(T) -> SelectorChannelFormatNoChannel.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorChannelFormatNoChannel.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorChannelFormatNoChannel = searchConfig.selectorChannelFormatNoChannel.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatNoChannel.let(get), + ) + + var selectEpisodes by prop({ it.selectEpisodes }, { copy(selectEpisodes = it) }) + val selectEpisodesIsError by derivedStateOf { QueryParser.parseSelectorOrNull(selectEpisodes) == null } + var matchEpisodeSortFromName by prop( + { it.matchEpisodeSortFromName }, + { copy(matchEpisodeSortFromName = it) }, + ) + val matchEpisodeSortFromNameIsError by derivedStateOf { + matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) + } + } + + // endregion + + var filterByEpisodeSort by argumentsStorage.prop( + { it.searchConfig.filterByEpisodeSort }, { copy(searchConfig = searchConfig.copy(filterByEpisodeSort = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.filterByEpisodeSort, + ) + var filterBySubjectName by argumentsStorage.prop( + { it.searchConfig.filterBySubjectName }, { copy(searchConfig = searchConfig.copy(filterBySubjectName = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.filterBySubjectName, + ) + + val matchVideoConfig: MatchVideoConfig = MatchVideoConfig() + + @Stable + inner class MatchVideoConfig { + private fun prop( + get: (SelectorSearchConfig.MatchVideoConfig) -> T, + set: SelectorSearchConfig.MatchVideoConfig.(T) -> SelectorSearchConfig.MatchVideoConfig, + ) = argumentsStorage.prop( + { it.searchConfig.matchVideo.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + matchVideo = searchConfig.matchVideo.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.matchVideo.let(get), + ) + + var matchVideoUrl by prop( + { it.matchVideoUrl }, { copy(matchVideoUrl = it) }, + ) + val matchVideoUrlIsError by derivedStateOf { + matchVideoUrl.isBlank() || !isValidRegex(matchVideoUrl) + } + + val videoHeaders = HeadersConfig() + + @Stable + inner class HeadersConfig { + private fun prop( + get: (SelectorSearchConfig.VideoHeaders) -> T, + set: SelectorSearchConfig.VideoHeaders.(T) -> SelectorSearchConfig.VideoHeaders, + ) = this@MatchVideoConfig.prop( + { it.addHeadersToVideo.let(get) }, + { copy(addHeadersToVideo = addHeadersToVideo.set(it)) }, + ) + + var referer by prop( + { it.referer }, { copy(referer = referer) }, + ) + var userAgent by prop( + { it.userAgent }, { copy(userAgent = userAgent) }, + ) + } + } + + val searchConfigState = derivedStateOf { + argumentsStorage.container?.searchConfig + } +} \ No newline at end of file From 15a1b38a50c63b6d7b1b6f46004930ca736c74bd Mon Sep 17 00:00:00 2001 From: Him188 Date: Fri, 20 Sep 2024 00:00:02 +0100 Subject: [PATCH 04/11] Extract SelectorTestState to separate file --- .../selector/test/SelectorTestPane.kt | 231 ----------------- .../selector/test/SelectorTestState.kt | 242 ++++++++++++++++++ 2 files changed, 242 insertions(+), 231 deletions(-) create mode 100644 app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt 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 6c367aa0a2..44d5799f97 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 @@ -23,250 +23,19 @@ import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -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 androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CoroutineScope -import me.him188.ani.app.data.models.ApiResponse -import me.him188.ani.app.data.models.fold -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.data.source.media.source.web.SelectorSearchQuery import me.him188.ani.app.ui.foundation.interaction.nestedScrollWorkaround 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.rememberConnectedScrollState import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults import me.him188.ani.app.ui.foundation.widgets.FastLinearProgressIndicator -import me.him188.ani.app.ui.settings.mediasource.AbstractMediaSourceTestState -import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher import me.him188.ani.app.ui.settings.mediasource.EditMediaSourceTestDataCardDefaults import me.him188.ani.app.ui.settings.mediasource.RefreshIndicatedHeadlineRow import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults -import me.him188.ani.datasources.api.EpisodeSort -import me.him188.ani.utils.xml.Document -import kotlin.coroutines.cancellation.CancellationException - -@Stable -class SelectorTestState( - searchConfigState: State, - private val engine: SelectorMediaSourceEngine, - backgroundScope: CoroutineScope, -) : AbstractMediaSourceTestState() { - // null for invalid config - private val queryState = derivedStateOf { - val searchKeyword = searchKeyword.ifEmpty { searchKeywordPlaceholder } - val sort = sort - if (searchKeyword.isBlank() || sort.isBlank()) { - null - } else { - SelectorSearchQuery(subjectName = searchKeyword, episodeSort = EpisodeSort(sort)) - } - } - - var selectedSubjectIndex by mutableIntStateOf(-1) - val selectedSubjectState = derivedStateOf { - val success = subjectSearchSelectResult as? SelectorTestSearchSubjectResult.Success - ?: return@derivedStateOf null - success.subjects.getOrNull(selectedSubjectIndex) - } - val selectedSubject by selectedSubjectState - private val searchUrl by derivedStateOf { - searchConfigState.value?.searchUrl - } - - /** - * 用于查询条目列表, 每当编辑请求和 `searchUrl`, 会重新搜索, 但不会筛选. - * 筛选在 [subjectSearchSelectResult]. - */ - val subjectSearcher = BackgroundSearcher( - backgroundScope, - derivedStateOf { - val url = searchUrl - url to searchKeyword - }, - search = { (url, searchKeyword) -> - // 不清除 selectedSubjectIndex - - launchRequestInBackground { - if (url.isNullOrBlank() || searchKeyword.isBlank()) { - null - } else { - try { - val res = engine.searchSubjects( - url, - searchKeyword, - ) - Result.success(res) - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - Result.failure(e) - } - } - } - }, - ) - - val subjectSearchSelectResult by derivedStateOf { - val res = subjectSearcher.searchResult - val config = searchConfigState.value - val query = queryState.value - when { - res == null -> { - null - } - - config == null || query == null -> { - SelectorTestSearchSubjectResult.InvalidConfig - } - - else -> { - res.fold( - onSuccess = { - selectSubjectResult(it, config, query) - }, - onFailure = { - SelectorTestSearchSubjectResult.UnknownError(it) - }, - ) - } - } - } - - /** - * 用于查询条目的剧集列表, 每当选择新的条目时, 会重新搜索. 但不会筛选. 筛选在 [episodeListSearchSelectResult]. - */ - val episodeListSearcher = BackgroundSearcher( - backgroundScope, - selectedSubjectState, - search = { selectedSubject -> - launchRequestInBackground { - if (selectedSubject == null) { - null - } else { - try { - engine.searchEpisodes( - selectedSubject.subjectDetailsPageUrl, - ) - } catch (e: CancellationException) { - throw e - } - } - } - }, - ) - - /** - * 经过筛选的条目的剧集列表 - */ - val episodeListSearchSelectResult by derivedStateOf { - val subjectDetailsPageDocument = episodeListSearcher.searchResult - val searchConfig = searchConfigState.value - val queryState = queryState.value - - when { - queryState == null || searchConfig == null -> { - SelectorTestEpisodeListResult.InvalidConfig - } - - subjectDetailsPageDocument == null -> { - SelectorTestEpisodeListResult.Success(null, emptyList()) - } - - else -> { - convertEpisodeResult( - subjectDetailsPageDocument, - searchConfig, - queryState, - ) - } - } - } - - // 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, - query: SelectorSearchQuery, - ): SelectorTestEpisodeListResult { - return res.fold( - onSuccess = { document -> - try { - val episodeList = engine.selectEpisodes(document, config) - ?: return SelectorTestEpisodeListResult.InvalidConfig - SelectorTestEpisodeListResult.Success( - episodeList.channels, - episodeList.episodes.map { - SelectorTestEpisodePresentation.compute(it, query, document, config) - }, - ) - } catch (e: Throwable) { - SelectorTestEpisodeListResult.UnknownError(e) - } - }, - onKnownFailure = { reason -> - SelectorTestEpisodeListResult.ApiError(reason) - }, - ) - } - - private fun selectSubjectResult( - res: ApiResponse, - searchConfig: SelectorSearchConfig, - query: SelectorSearchQuery, - ): SelectorTestSearchSubjectResult { - return res.fold( - onSuccess = { data -> - val document = data.document - - val originalList = if (document == null) { - emptyList() - } else { - engine.selectSubjects(document, searchConfig).let { - if (it == null) { - return SelectorTestSearchSubjectResult.InvalidConfig - } - it - } - } - - SelectorTestSearchSubjectResult.Success( - data.url.toString(), - originalList.map { - SelectorTestSubjectPresentation.compute(it, query, document, searchConfig) - }, - ) - }, - onKnownFailure = { reason -> - SelectorTestSearchSubjectResult.ApiError(reason) - }, - ) - } -} /** * 测试数据源. 编辑 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 new file mode 100644 index 0000000000..379281e33f --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt @@ -0,0 +1,242 @@ +/* + * 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.test + +import androidx.compose.runtime.Stable +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 +import me.him188.ani.app.data.models.fold +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.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.ui.settings.mediasource.AbstractMediaSourceTestState +import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.xml.Document +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class SelectorTestState( + searchConfigState: State, + private val engine: SelectorMediaSourceEngine, + backgroundScope: CoroutineScope, +) : AbstractMediaSourceTestState() { + // null for invalid config + private val queryState = derivedStateOf { + val searchKeyword = searchKeyword.ifEmpty { searchKeywordPlaceholder } + val sort = sort + if (searchKeyword.isBlank() || sort.isBlank()) { + null + } else { + SelectorSearchQuery(subjectName = searchKeyword, episodeSort = EpisodeSort(sort)) + } + } + + var selectedSubjectIndex by mutableIntStateOf(-1) + val selectedSubjectState = derivedStateOf { + val success = subjectSearchSelectResult as? SelectorTestSearchSubjectResult.Success + ?: return@derivedStateOf null + success.subjects.getOrNull(selectedSubjectIndex) + } + val selectedSubject by selectedSubjectState + private val searchUrl by derivedStateOf { + searchConfigState.value?.searchUrl + } + + /** + * 用于查询条目列表, 每当编辑请求和 `searchUrl`, 会重新搜索, 但不会筛选. + * 筛选在 [subjectSearchSelectResult]. + */ + val subjectSearcher = BackgroundSearcher( + backgroundScope, + derivedStateOf { + val url = searchUrl + url to searchKeyword + }, + search = { (url, searchKeyword) -> + // 不清除 selectedSubjectIndex + + launchRequestInBackground { + if (url.isNullOrBlank() || searchKeyword.isBlank()) { + null + } else { + try { + val res = engine.searchSubjects( + url, + searchKeyword, + ) + Result.success(res) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + } + } + }, + ) + + val subjectSearchSelectResult by derivedStateOf { + val res = subjectSearcher.searchResult + val config = searchConfigState.value + val query = queryState.value + when { + res == null -> { + null + } + + config == null || query == null -> { + SelectorTestSearchSubjectResult.InvalidConfig + } + + else -> { + res.fold( + onSuccess = { + selectSubjectResult(it, config, query) + }, + onFailure = { + SelectorTestSearchSubjectResult.UnknownError(it) + }, + ) + } + } + } + + /** + * 用于查询条目的剧集列表, 每当选择新的条目时, 会重新搜索. 但不会筛选. 筛选在 [episodeListSearchSelectResult]. + */ + val episodeListSearcher = BackgroundSearcher( + backgroundScope, + selectedSubjectState, + search = { selectedSubject -> + launchRequestInBackground { + if (selectedSubject == null) { + null + } else { + try { + engine.searchEpisodes( + selectedSubject.subjectDetailsPageUrl, + ) + } catch (e: CancellationException) { + throw e + } + } + } + }, + ) + + /** + * 经过筛选的条目的剧集列表 + */ + val episodeListSearchSelectResult by derivedStateOf { + val subjectDetailsPageDocument = episodeListSearcher.searchResult + val searchConfig = searchConfigState.value + val queryState = queryState.value + + when { + queryState == null || searchConfig == null -> { + SelectorTestEpisodeListResult.InvalidConfig + } + + subjectDetailsPageDocument == null -> { + SelectorTestEpisodeListResult.Success(null, emptyList()) + } + + else -> { + convertEpisodeResult( + subjectDetailsPageDocument, + searchConfig, + queryState, + ) + } + } + } + + // 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, + query: SelectorSearchQuery, + ): SelectorTestEpisodeListResult { + return res.fold( + onSuccess = { document -> + try { + val episodeList = engine.selectEpisodes(document, config) + ?: return SelectorTestEpisodeListResult.InvalidConfig + SelectorTestEpisodeListResult.Success( + episodeList.channels, + episodeList.episodes.map { + SelectorTestEpisodePresentation.compute(it, query, document, config) + }, + ) + } catch (e: Throwable) { + SelectorTestEpisodeListResult.UnknownError(e) + } + }, + onKnownFailure = { reason -> + SelectorTestEpisodeListResult.ApiError(reason) + }, + ) + } + + private fun selectSubjectResult( + res: ApiResponse, + searchConfig: SelectorSearchConfig, + query: SelectorSearchQuery, + ): SelectorTestSearchSubjectResult { + return res.fold( + onSuccess = { data -> + val document = data.document + + val originalList = if (document == null) { + emptyList() + } else { + engine.selectSubjects(document, searchConfig).let { + if (it == null) { + return SelectorTestSearchSubjectResult.InvalidConfig + } + it + } + } + + SelectorTestSearchSubjectResult.Success( + data.url.toString(), + originalList.map { + SelectorTestSubjectPresentation.compute(it, query, document, searchConfig) + }, + ) + }, + onKnownFailure = { reason -> + SelectorTestSearchSubjectResult.ApiError(reason) + }, + ) + } +} \ No newline at end of file From 3e3b6786f0da7456872a50e9f885295aad59234e Mon Sep 17 00:00:00 2001 From: Him188 Date: Fri, 20 Sep 2024 00:02:43 +0100 Subject: [PATCH 05/11] Move SelectorEpisodePane.kt --- .../{test => episode}/SelectorEpisodePane.android.kt | 4 +++- .../selector/EditSelectorMediaSourcePage.kt | 10 +++++----- .../selector/{test => episode}/SelectorEpisodePane.kt | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) rename app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/{test => episode}/SelectorEpisodePane.android.kt (94%) rename app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/{test => episode}/SelectorEpisodePane.kt (98%) diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt similarity index 94% rename from app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt rename to app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt index e0adacf7de..e2b38b582e 100644 --- a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.android.kt +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt @@ -7,7 +7,7 @@ * https://github.com/open-ani/ani/blob/main/LICENSE */ -package me.him188.ani.app.ui.settings.mediasource.selector.test +package me.him188.ani.app.ui.settings.mediasource.selector.episode import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues @@ -24,6 +24,8 @@ import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPrev import me.him188.ani.app.ui.foundation.stateOf 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.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 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 0c7ee340c7..bd65b697f5 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 @@ -46,11 +46,11 @@ 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.SelectorConfigurationPane import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationState -import me.him188.ani.app.ui.settings.mediasource.selector.test.ConfigurationContent -import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodePane -import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodePaneDefaults -import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodePaneLayout -import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorEpisodeState +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.SelectorEpisodeState import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestPane import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestState diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt similarity index 98% rename from app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt rename to app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt index e89c77b7f1..9b5e95a80e 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorEpisodePane.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt @@ -7,7 +7,7 @@ * https://github.com/open-ani/ani/blob/main/LICENSE */ -package me.him188.ani.app.ui.settings.mediasource.selector.test +package me.him188.ani.app.ui.settings.mediasource.selector.episode import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -61,6 +61,7 @@ import me.him188.ani.app.ui.settings.mediasource.launchCollectedInBackground import me.him188.ani.app.ui.settings.mediasource.selector.edit.MatchVideoSection 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 From a9b79b171baee367e987bc6b06153bd9dc069221 Mon Sep 17 00:00:00 2001 From: Him188 Date: Fri, 20 Sep 2024 00:48:58 +0100 Subject: [PATCH 06/11] 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, From 22d92d914eb98d760974f2544bb6dd1e5cfb13fa Mon Sep 17 00:00:00 2001 From: Him188 Date: Sun, 22 Sep 2024 21:29:42 +0100 Subject: [PATCH 07/11] Fix bugs --- .../media/source/web/SelectorMediaSource.kt | 1 + .../source/web/SelectorMediaSourceEngine.kt | 16 +++++++-- .../media/source/web/SelectorSearchConfig.kt | 20 ++++++++++- .../selector/EditSelectorMediaSourcePage.kt | 26 +++++++------- .../edit/SelectorConfigurationPane.kt | 7 +++- .../selector/episode/SelectorEpisodePane.kt | 8 +++-- .../episode/SelectorEpisodePaneDefaults.kt | 36 +++++++++++++++---- .../selector/test/SelectorTestState.kt | 29 ++++++++++----- 8 files changed, 109 insertions(+), 34 deletions(-) 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( From 80c7f8aeb921a807b2acfacbb59e89d024659348 Mon Sep 17 00:00:00 2001 From: Him188 Date: Mon, 23 Sep 2024 17:04:19 +0100 Subject: [PATCH 08/11] WebViewVideoExtractor: support more nested loading and headless options --- .../resolver/AndroidWebVideoSourceResolver.kt | 155 +++++--- .../preference/VideoResolverSettings.kt | 11 + .../media/resolver/VideoSourceResolver.kt | 14 + .../media/resolver/WebViewVideoExtractor.kt | 35 +- .../resolver/DesktopWebVideoSourceResolver.kt | 347 +++++++++--------- .../subject/episode/video/PlayerLauncher.kt | 10 + .../kotlin/matcher/WebVideoMatcher.kt | 15 +- .../web/gugufan/src/GugufanMediaSource.kt | 37 +- .../web/mxdongman/src/MxdongmanMediaSource.kt | 37 +- datasource/web/ntdm/src/NtdmMediaSource.kt | 35 +- .../web/nyafun/src/NyafunMediaSource.kt | 37 +- datasource/web/xfdm/src/XfdmMediaSource.kt | 37 +- 12 files changed, 480 insertions(+), 290 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt b/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt index 820bedacb2..0e3c00c260 100644 --- a/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt +++ b/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor.Instruction import me.him188.ani.app.platform.LocalContext import me.him188.ani.app.videoplayer.HttpStreamingVideoSource import me.him188.ani.app.videoplayer.data.VideoSource @@ -30,10 +31,13 @@ import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.matcher.MediaSourceWebVideoMatcherLoader import me.him188.ani.datasources.api.matcher.WebVideoMatcher import me.him188.ani.datasources.api.matcher.WebVideoMatcherContext +import me.him188.ani.datasources.api.matcher.videoOrNull import me.him188.ani.datasources.api.topic.ResourceLocation import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import java.io.ByteArrayInputStream +import java.util.concurrent.ConcurrentSkipListSet + /** * 用 WebView 加载网站, 拦截 WebView 加载资源, 用各数据源提供的 [WebVideoMatcher] @@ -65,20 +69,36 @@ class AndroidWebVideoSourceResolver( override suspend fun resolve(media: Media, episode: EpisodeMetadata): VideoSource<*> { if (!supports(media)) throw UnsupportedMediaException(media) - val matcherContext = WebVideoMatcherContext(media) val matchersFromMediaSource = matcherLoader.loadMatchers(media.mediaSourceId) val allMatchers = matchersFromMediaSource + matchersFromClasspath + val context = WebVideoMatcherContext(media) + fun match(url: String): WebVideoMatcher.MatchResult? { + return allMatchers + .asSequence() + .map { matcher -> + matcher.match(url, context) + } + .firstOrNull { it !is WebVideoMatcher.MatchResult.Continue } + } + val webVideo = AndroidWebViewVideoExtractor().getVideoResourceUrl( attached ?: throw IllegalStateException("WebVideoSourceResolver not attached"), media.download.uri, resourceMatcher = { - allMatchers.firstNotNullOfOrNull { matcher -> - matcher.match(it, matcherContext) + when (match(it)) { + WebVideoMatcher.MatchResult.Continue -> Instruction.Continue + WebVideoMatcher.MatchResult.LoadPage -> Instruction.LoadPage + is WebVideoMatcher.MatchResult.Matched -> Instruction.FoundResource + null -> Instruction.Continue } }, - ) + )?.let { resource -> + allMatchers.firstNotNullOfOrNull { matcher -> + matcher.match(resource.url, context).videoOrNull + } + } ?: throw VideoSourceResolutionException(ResolutionFailures.NO_MATCHING_RESOURCE) return HttpStreamingVideoSource(webVideo.m3u8Url, media.originalTitle, webVideo = webVideo, media.extraFiles) } } @@ -86,54 +106,64 @@ class AndroidWebVideoSourceResolver( class AndroidWebViewVideoExtractor : WebViewVideoExtractor { private companion object { private val logger = logger() + private val consoleMessageUrlRegex = Regex("""'https?://.*?'""") } @OptIn(DelicateCoroutinesApi::class) @SuppressLint("SetJavaScriptEnabled") - override suspend fun getVideoResourceUrl( + override suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R?, - ): R { - val deferred = CompletableDeferred() + resourceMatcher: (String) -> Instruction, + ): WebResource? { + val deferred = CompletableDeferred() withContext(Dispatchers.Main) { - val webView = WebView(context) - deferred.invokeOnCompletion { - GlobalScope.launch(Dispatchers.Main.immediate) { - webView.destroy() - } - } - - webView.settings.javaScriptEnabled = true - - webView.webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest? - ): WebResourceResponse? { - if (request == null) return null - val url = request.url ?: return super.shouldInterceptRequest(view, request) - if (url.toString().contains(".mp4")) { - logger.info { "Found url: $url" } + val loadedNestedUrls = ConcurrentSkipListSet() + + /** + * @return if the url has been consumed + */ + fun handleUrl(webView: WebView, url: String): Boolean { + val matched = resourceMatcher(url) + when (matched) { + Instruction.Continue -> return false + Instruction.FoundResource -> { + deferred.complete(WebResource(url)) + return true } - val matched = resourceMatcher(url.toString()) - if (matched != null) { - logger.info { "Found video resource via shouldInterceptRequest: $url" } - deferred.complete(matched) - - // 拦截, 以防资源只能加载一次 - return WebResourceResponse( - "text/plain", - "UTF-8", 500, - "Internal Server Error", - mapOf(), - ByteArrayInputStream(ByteArray(0)), - ) + + Instruction.LoadPage -> { + logger.info { "WebView loading nested page: $url" } + launch { + withContext(Dispatchers.Main) { + @Suppress("LABEL_NAME_CLASH") + if (webView.url == url) return@withContext // avoid infinite loop + if (!loadedNestedUrls.add(url)) return@withContext + logger.info { "New webview created" } + createWebView(context, deferred, ::handleUrl).loadUrl(url) + } + } + return false } - return super.shouldInterceptRequest(view, request) } } - webView.loadUrl(pageUrl) + + loadedNestedUrls.add(pageUrl) + createWebView(context, deferred, ::handleUrl).loadUrl(pageUrl) + +// webView.webChromeClient = object : WebChromeClient() { +// override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { +// consoleMessage ?: return false +// val message = consoleMessage.message() ?: return false +// // HTTPS 页面加载 HTTP 的视频时会有日志 +// for (matchResult in consoleMessageUrlRegex.findAll(message)) { +// val url = matchResult.value.removeSurrounding("'") +// logger.info { "WebView console get url: $url" } +// handleUrl(url) +// } +// return false +// } +// } } return try { @@ -145,4 +175,47 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { throw e } } + + @SuppressLint("SetJavaScriptEnabled") + @OptIn(DelicateCoroutinesApi::class) + private fun createWebView( + context: Context, + deferred: CompletableDeferred, + handleUrl: (WebView, String) -> Boolean, + ): WebView = WebView(context).apply { + val webView = this + deferred.invokeOnCompletion { + GlobalScope.launch(Dispatchers.Main.immediate) { + webView.destroy() + } + } + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url ?: return super.shouldInterceptRequest(view, request) + if (handleUrl(view, url.toString())) { + logger.info { "Found video resource via shouldInterceptRequest: $url" } + // 拦截, 以防资源只能加载一次 + return WebResourceResponse( + "text/plain", + "UTF-8", 500, + "Internal Server Error", + mapOf(), + ByteArrayInputStream(ByteArray(0)), + ) + } + return super.shouldInterceptRequest(view, request) + } + + override fun onLoadResource(view: WebView, url: String) { + if (handleUrl(view, url)) { + logger.info { "Found video resource via onLoadResource: $url" } + } + super.onLoadResource(view, url) + } + } + } } diff --git a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt index 97a0d29d03..40db33c4b8 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt @@ -1,13 +1,24 @@ +/* + * 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.data.models.preference import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import me.him188.ani.app.data.models.preference.WebViewDriver.entries @Serializable @Immutable data class VideoResolverSettings( val driver: WebViewDriver = WebViewDriver.AUTO, + val headless: Boolean = true, @Suppress("PropertyName") @Transient val _placeholder: Int = 0, diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt index 6a8433eead..fcf5fd4f83 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt @@ -1,3 +1,12 @@ +/* + * 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.data.source.media.resolver import androidx.compose.runtime.Composable @@ -80,6 +89,11 @@ enum class ResolutionFailures { NETWORK_ERROR, + /** + * Web 没有匹配到资源 + */ + NO_MATCHING_RESOURCE, + /** * 引擎自身错误 (bug) */ diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt index a24c4d1417..f267e543e7 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt @@ -11,17 +11,36 @@ package me.him188.ani.app.data.source.media.resolver import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.data.models.preference.VideoResolverSettings +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor.Instruction import me.him188.ani.app.platform.Context import me.him188.ani.utils.platform.annotations.TestOnly interface WebViewVideoExtractor { - suspend fun getVideoResourceUrl( + sealed class Instruction { + /** + * 继续加载这个链接 + */ + data object LoadPage : Instruction() + + /** + * 已经找到资源, 停止加载 + */ + data object FoundResource : Instruction() + + data object Continue : Instruction() + } + + suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R?, - ): R + resourceMatcher: (String) -> Instruction, + ): WebResource? } +data class WebResource( + val url: String +) + expect fun WebViewVideoExtractor( proxyConfig: ProxyConfig?, videoResolverSettings: VideoResolverSettings, @@ -31,13 +50,15 @@ expect fun WebViewVideoExtractor( class TestWebViewVideoExtractor( private val urls: (pageUrl: String) -> List, ) : WebViewVideoExtractor { - override suspend fun getVideoResourceUrl( + override suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R? - ): R { + resourceMatcher: (String) -> Instruction, + ): WebResource { urls(pageUrl).forEach { - resourceMatcher(it)?.let { return it } + if (resourceMatcher(it) is Instruction.FoundResource) { + return WebResource(it) + } } throw IllegalStateException("No match found") } diff --git a/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt b/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt index fe6f2826b2..a6d778d093 100644 --- a/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt +++ b/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt @@ -20,6 +20,7 @@ import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.data.models.preference.VideoResolverSettings import me.him188.ani.app.data.models.preference.WebViewDriver import me.him188.ani.app.data.repository.SettingsRepository +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor.Instruction import me.him188.ani.app.platform.Context import me.him188.ani.app.videoplayer.HttpStreamingVideoSource import me.him188.ani.app.videoplayer.data.VideoSource @@ -33,23 +34,17 @@ import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.openqa.selenium.WebDriver import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.devtools.HasDevTools -import org.openqa.selenium.devtools.NetworkInterceptor +import org.openqa.selenium.devtools.v125.network.Network import org.openqa.selenium.edge.EdgeDriver import org.openqa.selenium.edge.EdgeOptions import org.openqa.selenium.remote.RemoteWebDriver -import org.openqa.selenium.remote.http.HttpHandler -import org.openqa.selenium.remote.http.HttpMethod -import org.openqa.selenium.remote.http.HttpResponse -import org.openqa.selenium.remote.http.Route import org.openqa.selenium.safari.SafariDriver import org.openqa.selenium.safari.SafariOptions -import org.openqa.selenium.support.events.EventFiringDecorator -import org.openqa.selenium.support.events.WebDriverListener -import java.lang.reflect.Method +import java.util.Optional +import java.util.function.Consumer import java.util.logging.Level /** @@ -74,16 +69,31 @@ class DesktopWebVideoSourceResolver( val matchersFromMediaSource = matcherLoader.loadMatchers(media.mediaSourceId) val allMatchers = matchersFromMediaSource + matchersFromClasspath + val context = WebVideoMatcherContext(media) + fun match(url: String): WebVideoMatcher.MatchResult? { + return allMatchers + .asSequence() + .map { matcher -> + matcher.match(url, context) + } + .firstOrNull { it !is WebVideoMatcher.MatchResult.Continue } + } + val webVideo = SeleniumWebViewVideoExtractor(config.config.takeIf { config.enabled }, resolverSettings) .getVideoResourceUrl( media.download.uri, resourceMatcher = { - allMatchers.firstNotNullOfOrNull { matcher -> - matcher.match(it, context) + when (match(it)) { + WebVideoMatcher.MatchResult.Continue -> Instruction.Continue + WebVideoMatcher.MatchResult.LoadPage -> Instruction.LoadPage + is WebVideoMatcher.MatchResult.Matched -> Instruction.FoundResource + null -> Instruction.Continue } }, - ) + )?.let { + (match(it.url) as? WebVideoMatcher.MatchResult.Matched)?.video + } ?: throw VideoSourceResolutionException(ResolutionFailures.NO_MATCHING_RESOURCE) return@withContext HttpStreamingVideoSource( webVideo.m3u8Url, media.originalTitle, @@ -110,198 +120,183 @@ class SeleniumWebViewVideoExtractor( } } - private fun createChromeDriver(): ChromeDriver { - WebDriverManager.chromedriver().setup() - return ChromeDriver( - ChromeOptions().apply { - addArguments("--headless") - addArguments("--disable-gpu") -// addArguments("--log-level=3") - proxyConfig?.let { - addArguments("--proxy-server=${it.url}") - } - }, - ) - } - - private fun createEdgeDriver(): EdgeDriver { - WebDriverManager.edgedriver().setup() - return EdgeDriver( - EdgeOptions().apply { - addArguments("--headless") - addArguments("--disable-gpu") -// addArguments("--log-level=3") - proxyConfig?.let { - addArguments("--proxy-server=${it.url}") - } - }, - ) - } - - /** - * SafariDriver does not support the use of proxies. - * https://github.com/SeleniumHQ/selenium/issues/10401#issuecomment-1054814944 - */ - private fun createSafariDriver(): SafariDriver { - WebDriverManager.safaridriver().setup() - return SafariDriver( - SafariOptions().apply { - proxyConfig?.let { - // Causes an exception - setCapability("proxy", it.url) - } - }, - ) - } - override suspend fun getVideoResourceUrl( + override suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R? - ): R = getVideoResourceUrl(pageUrl, resourceMatcher) + resourceMatcher: (String) -> Instruction + ): WebResource? = getVideoResourceUrl(pageUrl, resourceMatcher) - suspend fun getVideoResourceUrl( + suspend fun getVideoResourceUrl( pageUrl: String, - resourceMatcher: (String) -> R? - ): R { - val deferred = CompletableDeferred() - - withContext(Dispatchers.IO) { - logger.info { "Starting Selenium with Edge to resolve video source from $pageUrl" } + resourceMatcher: (String) -> Instruction + ): WebResource? { + val deferred = CompletableDeferred() + return try { + withContext(Dispatchers.IO) { + logger.info { "Starting Selenium to resolve video source from $pageUrl" } + + val driver: RemoteWebDriver = createDriver() + + /** + * @return if the url has been consumed + */ + fun handleUrl(url: String): Boolean { + val matched = resourceMatcher(url) + when (matched) { + Instruction.Continue -> return false + Instruction.FoundResource -> { + deferred.complete(WebResource(url)) + return true + } - val driver: RemoteWebDriver = kotlin.run { - val primaryDriverFunction = mapWebViewDriverToFunction(videoResolverSettings.driver) - val fallbackDriverFunctions = getFallbackDriverFunctions(primaryDriverFunction) - - // Try user-set ones first, then fallback on the others - val driverCreationFunctions = listOfNotNull(primaryDriverFunction) + fallbackDriverFunctions - var successfulDriver: (() -> RemoteWebDriver)? = null - - val driver = driverCreationFunctions - .asSequence() - .mapNotNull { func -> - runCatching { - func().also { successfulDriver = func } - }.getOrNull() - } - .firstOrNull() - ?: throw Exception("Failed to create a driver") - - // If the rollback is successful, update the user settings - // Except Safari for now, because it does not support proxy settings and is not listed in the optional list - // updateDriverSettingsIfNeeded(successfulDriver) - - driver - } - - logger.info { "Using WebDriver: $driver" } - - val listener = object : WebDriverListener { - override fun beforeAnyNavigationCall( - navigation: WebDriver.Navigation?, - method: Method?, - args: Array? - ) { - logger.info { "Navigating to $pageUrl" } - } - - override fun afterAnyNavigationCall( - navigation: WebDriver.Navigation?, - method: Method?, - args: Array?, - result: Any? - ) { - logger.info { "Navigated to $pageUrl" } - } - - override fun beforeGet(driver: WebDriver?, url: String?) { - if (driver == null || url == null) return - resourceMatcher(url)?.let { matched -> - logger.info { "Found video resource via beforeGet: $url" } - deferred.complete(matched) + Instruction.LoadPage -> { + if (driver.currentUrl == url) return false // don't recurse + logger.info { "WebView loading nested page: $url" } + val script = "window.location.href = '$url'" + logger.info { "WebView executing: $script" } + driver.executeScript(script) +// decoratedDriver.get(url) + return false + } } } - } - - check(driver is HasDevTools) { - "WebDriver must support DevTools" - } - val decoratedDriver: WebDriver = - EventFiringDecorator(WebDriver::class.java, listener).decorate(driver) - val emptyResponseHandler = HttpHandler { _ -> - HttpResponse().apply { - status = 500 - } - } - val route: Route = Route.matching { req -> - if (HttpMethod.GET != req.method) return@matching false + logger.info { "Using WebDriver: $driver" } - val url = req.uri - val matched = resourceMatcher(url) - if (matched != null) { - logger.info { "Found video resource via network interception: $url" } - deferred.complete(matched) - return@matching true + check(driver is HasDevTools) { + "WebDriver must support DevTools" } - false - }.to { emptyResponseHandler } - - val interceptor = NetworkInterceptor(driver, route) - deferred.invokeOnCompletion { - @Suppress("OPT_IN_USAGE") - GlobalScope.launch { - kotlin.runCatching { - interceptor.close() - decoratedDriver.quit() - }.onFailure { - logger.error(it) { "Failed to close selenium" } + val devTools = driver.devTools + devTools.createSession() +// devTools.send(Network.enable(Optional.of(10000000), Optional.of(10000000), Optional.of(10000000))) + devTools.send(Network.enable(Optional.empty(), Optional.empty(), Optional.empty())) + devTools.addListener( + Network.requestWillBeSent(), + Consumer { + val url = it.request.url + if (handleUrl(url)) { + logger.info { "Found video resource via devtools: $url" } + } + }, + ) + devTools.send(Network.clearBrowserCache()) + + deferred.invokeOnCompletion { + @Suppress("OPT_IN_USAGE") + GlobalScope.launch { + kotlin.runCatching { + driver.quit() + }.onFailure { + logger.error(it) { "Failed to close selenium" } + } } } - } + driver.get(pageUrl) + } - decoratedDriver.navigate().to(pageUrl) - } - - return try { deferred.await() } catch (e: Throwable) { if (deferred.isActive) { - deferred.cancel() + deferred.cancel() // will quit driver } throw e } } + private fun createDriver(): RemoteWebDriver { + return getPreferredDriverFactory() + .runCatching { + create(videoResolverSettings, proxyConfig) + } + .let { + // 依次尝试备用 + var result = it + for (fallback in getFallbackDrivers()) { + result = result.recoverCatching { + fallback.create(videoResolverSettings, proxyConfig) + } + } + result + } + .getOrThrow() + // TODO: update user settings if we fell back to a different driver + } + - private fun mapWebViewDriverToFunction(driver: WebViewDriver): (() -> RemoteWebDriver)? { - return when (driver) { - WebViewDriver.CHROME -> ::createChromeDriver - WebViewDriver.EDGE -> ::createEdgeDriver - else -> null + private fun getPreferredDriverFactory(): WebDriverFactory { + return when (videoResolverSettings.driver) { + WebViewDriver.CHROME -> WebDriverFactory.Chrome + WebViewDriver.EDGE -> WebDriverFactory.Edge + else -> WebDriverFactory.Chrome } } - private fun getFallbackDriverFunctions(primaryDriverFunction: (() -> RemoteWebDriver)?): List<() -> RemoteWebDriver> { + private fun getFallbackDrivers(): List { return listOf( - ::createChromeDriver, - ::createEdgeDriver, -// ::createSafariDriver, - ).filter { it != primaryDriverFunction } + WebDriverFactory.Chrome, + WebDriverFactory.Edge, + ) } +} -// private fun updateDriverSettingsIfNeeded(successfulDriver: (() -> RemoteWebDriver)?, primaryDriverFunction: (() -> RemoteWebDriver)?) { -// if (successfulDriver != primaryDriverFunction) { -// val fallbackDriverType = when (successfulDriver) { -// ::createEdgeDriver -> WebViewDriver.EDGE -// ::createChromeDriver -> WebViewDriver.CHROME -// else -> null -// } -// if (fallbackDriverType != null) { -// // TODO: update driver settings -// } -// } -// } -} \ No newline at end of file +private sealed interface WebDriverFactory { + fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver + + data object Edge : WebDriverFactory { + override fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver { + WebDriverManager.edgedriver().setup() + return EdgeDriver( + EdgeOptions().apply { + if (videoResolverSettings.headless) { + addArguments("--headless") + addArguments("--disable-gpu") + } +// addArguments("--log-level=3") + proxyConfig?.let { + addArguments("--proxy-server=${it.url}") + } + }, + ) + } + } + + data object Chrome : WebDriverFactory { + override fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver { + WebDriverManager.chromedriver().setup() + return ChromeDriver( + ChromeOptions().apply { + if (videoResolverSettings.headless) { + addArguments("--headless") + addArguments("--disable-gpu") + } +// addArguments("--log-level=3") + proxyConfig?.let { + addArguments("--proxy-server=${it.url}") + } + }, + ) + } + } + + @Deprecated("Safari is not supported") + data object Safari : WebDriverFactory { + /** + * SafariDriver does not support the use of proxies. + * https://github.com/SeleniumHQ/selenium/issues/10401#issuecomment-1054814944 + */ // 而且还要求用户去设置里开启开发者模式 + override fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver { + WebDriverManager.safaridriver().setup() + return SafariDriver( + SafariOptions().apply { + proxyConfig?.let { + // Causes an exception + setCapability("proxy", it.url) + } + }, + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt index 3ac11c3a26..439c77e748 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt @@ -1,3 +1,12 @@ +/* + * 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.subject.episode.video import kotlinx.coroutines.CancellationException @@ -121,6 +130,7 @@ class PlayerLauncher( ResolutionFailures.FETCH_TIMEOUT -> VideoLoadingState.ResolutionTimedOut ResolutionFailures.ENGINE_ERROR -> VideoLoadingState.UnknownError(e) ResolutionFailures.NETWORK_ERROR -> VideoLoadingState.NetworkError + ResolutionFailures.NO_MATCHING_RESOURCE -> VideoLoadingState.NoMatchingFile } playerState.clearVideoSource() } catch (e: CancellationException) { // 切换数据源 diff --git a/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt b/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt index 785808cf09..fb1618abf4 100644 --- a/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt +++ b/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt @@ -16,14 +16,25 @@ import me.him188.ani.datasources.api.source.MediaSource /** * 匹配 WebView 拦截到的资源. - */ + */ // see also: SelectorMediaSource fun interface WebVideoMatcher { // SPI service load + sealed class MatchResult { + data class Matched( + val video: WebVideo + ) : MatchResult() + + data object Continue : MatchResult() + data object LoadPage : MatchResult() + } + fun match( url: String, context: WebVideoMatcherContext - ): WebVideo? + ): MatchResult } +val WebVideoMatcher.MatchResult.videoOrNull get() = (this as? WebVideoMatcher.MatchResult.Matched)?.video + class WebVideoMatcherContext( val media: Media, // requestInfoLazy: () -> WebVideoRequestInfo, diff --git a/datasource/web/gugufan/src/GugufanMediaSource.kt b/datasource/web/gugufan/src/GugufanMediaSource.kt index bbccff208f..7c9c904842 100644 --- a/datasource/web/gugufan/src/GugufanMediaSource.kt +++ b/datasource/web/gugufan/src/GugufanMediaSource.kt @@ -1,3 +1,12 @@ +/* + * 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.datasources.ntdm import io.ktor.client.plugins.BrowserUserAgent @@ -19,24 +28,26 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class GugufanWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != GugufanMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != GugufanMediaSource.ID) return WebVideoMatcher.MatchResult.Continue // https://fuckjapan.cindiwhite.com/videos/202305/05/64557f4f852ee3050d99fc8c/e8210b/index.m3u8?counts=1×tamp=1721999625000&key=2a2094d5753ae1d26e1332fac72b9db9 if (url.startsWith("https://fuckjapan.cindiwhite.com") && url.contains("index.m3u8")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "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", - "Origin" to "https://a79.yizhoushi.com", + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "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", + "Origin" to "https://a79.yizhoushi.com", + ), ), ) } - return null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/mxdongman/src/MxdongmanMediaSource.kt b/datasource/web/mxdongman/src/MxdongmanMediaSource.kt index d00cb35bbb..7617eead10 100644 --- a/datasource/web/mxdongman/src/MxdongmanMediaSource.kt +++ b/datasource/web/mxdongman/src/MxdongmanMediaSource.kt @@ -1,3 +1,12 @@ +/* + * 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.datasources.mxdongman import io.ktor.client.plugins.BrowserUserAgent @@ -18,23 +27,25 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class MxdongmanWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != MxdongmanMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != MxdongmanMediaSource.ID) return WebVideoMatcher.MatchResult.Continue if (url.contains("https://v16m-default.akamaized.net")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Referer" to "https://www.mxdm4.com/dongmanplay/", - "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( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Referer" to "https://www.mxdm4.com/dongmanplay/", + "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 null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/ntdm/src/NtdmMediaSource.kt b/datasource/web/ntdm/src/NtdmMediaSource.kt index f368741349..b244308de5 100644 --- a/datasource/web/ntdm/src/NtdmMediaSource.kt +++ b/datasource/web/ntdm/src/NtdmMediaSource.kt @@ -1,3 +1,12 @@ +/* + * 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.datasources.ntdm import io.ktor.client.plugins.BrowserUserAgent @@ -19,22 +28,24 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class NtdmWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != NtdmMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != NtdmMediaSource.ID) return WebVideoMatcher.MatchResult.Continue if (url.contains(".akamaized.net")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "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( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "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 null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/nyafun/src/NyafunMediaSource.kt b/datasource/web/nyafun/src/NyafunMediaSource.kt index 1b69c50b95..c94806d0a2 100644 --- a/datasource/web/nyafun/src/NyafunMediaSource.kt +++ b/datasource/web/nyafun/src/NyafunMediaSource.kt @@ -1,3 +1,12 @@ +/* + * 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.datasources.nyafun import io.ktor.client.plugins.BrowserUserAgent @@ -57,26 +66,28 @@ data class NyafunEp( ) class NyafunWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != NyafunMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != NyafunMediaSource.ID) return WebVideoMatcher.MatchResult.Continue // we want https://vod.2bdm.cc/2024/04/gs8h/01.mp4?verify=1716675316-p3ScUWwQbHmMf5%2F63tM6%2FR2Ac8NydzYvECQ1XmTUhbU%3D if ((url.contains(".mp4") || url.contains(".mkv") || url.contains(".m3u8")) && url.contains("verify=") ) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Referer" to "https://play.nyafun.net/", - "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( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Referer" to "https://play.nyafun.net/", + "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 null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/xfdm/src/XfdmMediaSource.kt b/datasource/web/xfdm/src/XfdmMediaSource.kt index d4fbb3b41c..eaf754be1a 100644 --- a/datasource/web/xfdm/src/XfdmMediaSource.kt +++ b/datasource/web/xfdm/src/XfdmMediaSource.kt @@ -1,3 +1,12 @@ +/* + * 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.datasources.ntdm import io.ktor.client.plugins.BrowserUserAgent @@ -19,27 +28,29 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class XfdmWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != XfdmMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != XfdmMediaSource.ID) return WebVideoMatcher.MatchResult.Continue if (url.indexOf("https://", startIndex = 1) != -1) { // 有多个 https - return null + return WebVideoMatcher.MatchResult.Continue } if (url.startsWith("pan.wo.cn") && url.contains("download") || url.contains(".mp4")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "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( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "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 null + return WebVideoMatcher.MatchResult.Continue } } From ac13877a95db5acef963eadb9ab53963168f17f5 Mon Sep 17 00:00:00 2001 From: Him188 Date: Mon, 23 Sep 2024 17:11:19 +0100 Subject: [PATCH 09/11] fix matching video --- .../source/web/SelectorMediaSourceEngine.kt | 45 +++++--- .../media/source/web/SelectorSearchConfig.kt | 12 ++- .../web/format/SelectorChannelFormat.kt | 20 ++-- .../media/source/web/format/SelectorFormat.kt | 1 - .../web/format/SelectorSubjectFormat.kt | 16 ++- .../episode/SelectorEpisodePane.android.kt | 19 ++-- .../selector/EditSelectorMediaSourcePage.kt | 16 ++- .../selector/edit/SelectorConfigState.kt | 10 ++ .../edit/SelectorConfigurationDefaults.kt | 41 ++++++- .../edit/SelectorConfigurationPane.kt | 7 +- .../selector/episode/SelectorEpisodePane.kt | 102 +++++++++++------- .../selector/episode/SelectorEpisodeResult.kt | 11 +- .../selector/episode/SelectorEpisodeState.kt | 98 ++++++++++++++--- .../selector/test/SelectTestEpisodeResult.kt | 3 + .../selector/test/SelectorTestEpisodeList.kt | 2 +- .../selector/test/SelectorTestPane.kt | 5 +- .../selector/test/SelectorTestState.kt | 11 +- 17 files changed, 314 insertions(+), 105 deletions(-) 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) From 8cfb548865a2cb8d5e7aa09dcb353f2375b1c7e3 Mon Sep 17 00:00:00 2001 From: Him188 Date: Mon, 23 Sep 2024 18:08:38 +0100 Subject: [PATCH 10/11] fix loading --- .../resolver/AndroidWebVideoSourceResolver.kt | 64 ++++++++++--------- .../media/source/web/SelectorMediaSource.kt | 6 +- .../source/web/SelectorMediaSourceEngine.kt | 10 ++- .../media/source/web/SelectorSearchConfig.kt | 2 + .../web/format/SelectorSubjectFormat.kt | 10 ++- .../selector/EditSelectorMediaSourcePage.kt | 1 - .../selector/edit/SelectorConfigState.kt | 13 +++- .../edit/SelectorConfigurationDefaults.kt | 6 +- .../edit/SelectorConfigurationPane.kt | 27 +++++++- .../selector/test/SelectorTestState.kt | 17 +++-- 10 files changed, 108 insertions(+), 48 deletions(-) diff --git a/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt b/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt index 0e3c00c260..2217b0bb31 100644 --- a/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt +++ b/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt @@ -116,8 +116,10 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { pageUrl: String, resourceMatcher: (String) -> Instruction, ): WebResource? { - val deferred = CompletableDeferred() - withContext(Dispatchers.Main) { + // WebView requires same thread +// Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { dispatcher -> + return withContext(Dispatchers.Main) { + val deferred = CompletableDeferred() val loadedNestedUrls = ConcurrentSkipListSet() /** @@ -134,14 +136,13 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { Instruction.LoadPage -> { logger.info { "WebView loading nested page: $url" } - launch { - withContext(Dispatchers.Main) { - @Suppress("LABEL_NAME_CLASH") - if (webView.url == url) return@withContext // avoid infinite loop - if (!loadedNestedUrls.add(url)) return@withContext - logger.info { "New webview created" } - createWebView(context, deferred, ::handleUrl).loadUrl(url) - } + launch(Dispatchers.Main) { + @Suppress("LABEL_NAME_CLASH") + if (webView.url == url) return@launch // avoid infinite loop + if (!loadedNestedUrls.add(url)) return@launch + logger.info { "WebView navigating to new url: $url" } + webView.loadUrl(url) +// createWebView(context, deferred, ::handleUrl).loadUrl(url) } return false } @@ -151,29 +152,30 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { loadedNestedUrls.add(pageUrl) createWebView(context, deferred, ::handleUrl).loadUrl(pageUrl) -// webView.webChromeClient = object : WebChromeClient() { -// override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { -// consoleMessage ?: return false -// val message = consoleMessage.message() ?: return false -// // HTTPS 页面加载 HTTP 的视频时会有日志 -// for (matchResult in consoleMessageUrlRegex.findAll(message)) { -// val url = matchResult.value.removeSurrounding("'") -// logger.info { "WebView console get url: $url" } -// handleUrl(url) -// } -// return false -// } -// } - } - - return try { - deferred.await() - } catch (e: Throwable) { - if (deferred.isActive) { - deferred.cancel() + // webView.webChromeClient = object : WebChromeClient() { + // override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + // consoleMessage ?: return false + // val message = consoleMessage.message() ?: return false + // // HTTPS 页面加载 HTTP 的视频时会有日志 + // for (matchResult in consoleMessageUrlRegex.findAll(message)) { + // val url = matchResult.value.removeSurrounding("'") + // logger.info { "WebView console get url: $url" } + // handleUrl(url) + // } + // return false + // } + // } + + try { + deferred.await() + } catch (e: Throwable) { + if (deferred.isActive) { + deferred.cancel() + } + throw e } - throw e } +// } } @SuppressLint("SetJavaScriptEnabled") 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 4fbcad4f2f..ff6ca4cec5 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 @@ -136,7 +136,11 @@ class SelectorMediaSource( query: SelectorSearchQuery, mediaSourceId: String, ): ApiResponse> { - return searchSubjects(searchConfig.searchUrl, query.subjectName).map { (_, document) -> + return searchSubjects( + searchConfig.searchUrl, + subjectName = query.subjectName, + useOnlyFirstWord = searchConfig.searchUseOnlyFirstWord, + ).map { (_, document) -> document ?: return@map emptyList() val episodes = selectSubjects(document, searchConfig) .orEmpty() 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 73374a2ece..a16ee13994 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 @@ -83,8 +83,11 @@ abstract class SelectorMediaSourceEngine { suspend fun searchSubjects( searchUrl: String, subjectName: String, + useOnlyFirstWord: Boolean, ): ApiResponse { - val encodedUrl = MediaSourceEngineHelpers.encodeUrlSegment(subjectName) + val encodedUrl = MediaSourceEngineHelpers.encodeUrlSegment( + if (useOnlyFirstWord) getFirstWord(subjectName) else subjectName, + ) val finalUrl = Url( searchUrl.replace("{keyword}", encodedUrl), @@ -93,6 +96,11 @@ abstract class SelectorMediaSourceEngine { return searchImpl(finalUrl) } + private fun getFirstWord(string: String): String { + if (!(string.contains(' '))) return string + return string.substringBefore(' ').ifBlank { string } + } + protected abstract suspend fun searchImpl( finalUrl: Url, ): ApiResponse 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 bc68dcf409..45c7132c5d 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 @@ -28,6 +28,8 @@ import org.intellij.lang.annotations.Language data class SelectorSearchConfig( // Phase 1, search val searchUrl: String = "", // required + val searchUseOnlyFirstWord: Boolean = true, + val preferShortest: Boolean = true, // Phase 2, for search result, select subjects val subjectFormatId: SelectorFormatId = SelectorSubjectFormatA.id, val selectorSubjectFormatA: SelectorSubjectFormatA.Config = SelectorSubjectFormatA.Config(), 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 ab90e9a4dc..7bf241b415 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 @@ -53,6 +53,7 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat? { val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return null - return document.select(selectLists).map { a -> + val elements = document.select(selectLists) + return elements.mapTo(ArrayList(elements.size)) { a -> val name = a.attr("title").takeIf { it.isNotBlank() } ?: a.text() val href = a.attr("href") val id = href.substringBeforeLast(".html").substringAfterLast("/") @@ -75,6 +77,12 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat + info.name.length + } + } } } } 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 6f5e6f2d36..3e5c94663a 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 @@ -90,7 +90,6 @@ class EditSelectorMediaSourcePageState( if (episodeNavController.currentDestination?.hasRoute() != true) { episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) } - episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) } fun stopViewing() { 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 8b564891a2..188d9649e5 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 @@ -36,22 +36,28 @@ class SelectorConfigState( var displayName by argumentsStorage.prop( { it.name }, { copy(name = it) }, - "", + SelectorMediaSourceArguments.Default.name, ) val displayNameIsError by derivedStateOf { displayName.isBlank() } var iconUrl by argumentsStorage.prop( { it.iconUrl }, { copy(iconUrl = it) }, - "", + SelectorMediaSourceArguments.Default.iconUrl, ) var searchUrl by argumentsStorage.prop( { it.searchConfig.searchUrl }, { copy(searchConfig = searchConfig.copy(searchUrl = it)) }, - "", + SelectorMediaSourceArguments.Default.searchConfig.searchUrl, ) val searchUrlIsError by derivedStateOf { searchUrl.isBlank() } + var searchUseOnlyFirstWord by argumentsStorage.prop( + { it.searchConfig.searchUseOnlyFirstWord }, + { copy(searchConfig = searchConfig.copy(searchUseOnlyFirstWord = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.searchUseOnlyFirstWord, + ) + // region SubjectFormat val subjectFormatA = SubjectFormatAConfig() @@ -77,6 +83,7 @@ class SelectorConfigState( val selectListsIsError by derivedStateOf { QueryParser.parseSelectorOrNull(selectLists) == null } + var preferShorterName by prop({ it.preferShorterName }, { copy(preferShorterName = it) }) } // endregion 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 5faa425f19..932781ae79 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 @@ -64,7 +64,7 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( Modifier .padding(bottom = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) .clickable { matchVideoConfig.enableNestedUrl = !matchVideoConfig.enableNestedUrl }, - supportingContent = { Text("当遇到匹配的链接时,跳转到该链接,并继续匹配视频链接。支持任意次数嵌套") }, + supportingContent = { Text("当遇到匹配的链接时,终止父页面加载并跳转到匹配的链接,在嵌套页面中继续查找视频链接。支持任意次数嵌套") }, trailingContent = { Switch(matchVideoConfig.enableNestedUrl, { matchVideoConfig.enableNestedUrl = it }) }, @@ -79,7 +79,7 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( .moveFocusOnEnter() .padding(bottom = verticalSpacing), label = { Text("匹配嵌套链接") }, - supportingText = { Text("从播放页面中加载的所有资源链接中匹配出需要跳转进入的链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + supportingText = { Text("正则表达式,从播放页面中加载的所有资源链接中匹配出需要跳转进入的链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, isError = matchVideoConfig.matchNestedUrlIsError, @@ -90,7 +90,7 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), label = { Text("匹配视频链接") }, - supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + supportingText = { Text("正则表达式,从播放页面中加载的所有资源链接中匹配出视频链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, isError = matchVideoConfig.matchVideoUrlIsError, 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 95da9f20a2..89d8673ccc 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 @@ -107,7 +107,7 @@ internal fun SelectorConfigurationPane( } } - Column(verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + Column { OutlinedTextField( state.searchUrl, { state.searchUrl = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), @@ -130,16 +130,39 @@ internal fun SelectorConfigurationPane( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, ) + ListItem( + headlineContent = { Text("仅使用第一个词") }, + Modifier + .padding(top = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) + .clickable { state.searchUseOnlyFirstWord = !state.searchUseOnlyFirstWord }, + supportingContent = { Text("以空格分割,仅使用第一个词搜索。适用于搜索兼容性差的情况") }, + trailingContent = { + Switch(state.searchUseOnlyFirstWord, { state.searchUseOnlyFirstWord = it }) + }, + colors = listItemColors, + ) + val conf = state.subjectFormatA OutlinedTextField( conf.selectLists, { conf.selectLists = it }, - Modifier.fillMaxWidth().moveFocusOnEnter(), + Modifier.fillMaxWidth().moveFocusOnEnter().padding(top = verticalSpacing), label = { Text("提取条目列表") }, supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个条目,将会读取其 href 属性和 text") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, isError = conf.selectListsIsError, ) + ListItem( + headlineContent = { Text("选择最短标题") }, + Modifier + .padding(top = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) + .clickable { conf.preferShorterName = !conf.preferShorterName }, + supportingContent = { Text("选择满足匹配的标题最短的条目。可避免为第一季匹配到第二季") }, + trailingContent = { + Switch(conf.preferShorterName, { conf.preferShorterName = it }) + }, + colors = listItemColors, + ) } Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { 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 4c7e418f5b..7dc2f5e644 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 @@ -59,6 +59,9 @@ class SelectorTestState( private val searchUrl by derivedStateOf { searchConfigState.value?.searchUrl } + private val useOnlyFirstWord by derivedStateOf { + searchConfigState.value?.searchUseOnlyFirstWord + } /** * 用于查询条目列表, 每当编辑请求和 `searchUrl`, 会重新搜索, 但不会筛选. @@ -67,20 +70,24 @@ class SelectorTestState( val subjectSearcher = BackgroundSearcher( backgroundScope, derivedStateOf { - val url = searchUrl - url to searchKeyword + Triple( + searchConfigState.value?.searchUrl, + searchKeyword, + searchConfigState.value?.searchUseOnlyFirstWord, + ) }, - search = { (url, searchKeyword) -> + search = { (url, searchKeyword, useOnlyFirstWord) -> // 不清除 selectedSubjectIndex launchRequestInBackground { - if (url.isNullOrBlank() || searchKeyword.isBlank()) { + if (url == null || url.isBlank() || searchKeyword.isBlank() || useOnlyFirstWord == null) { null } else { try { val res = engine.searchSubjects( - url, + searchUrl = url, searchKeyword, + useOnlyFirstWord = useOnlyFirstWord, ) Result.success(res) } catch (e: CancellationException) { From f87e7c5b4064f4c970871600f442d131dc8f62f6 Mon Sep 17 00:00:00 2001 From: Him188 Date: Mon, 23 Sep 2024 18:37:07 +0100 Subject: [PATCH 11/11] improve comment --- .../mediasource/selector/edit/SelectorConfigurationPane.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 89d8673ccc..1a4eefcac3 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 @@ -249,7 +249,7 @@ internal fun SelectorConfigurationPane( conf.referer, { conf.referer = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), label = { Text("Referer") }, - supportingText = { Text("播放视频时执行的 HTTP 请求的 Referer") }, + supportingText = { Text("播放视频时执行的 HTTP 请求的 Referer,可留空") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, )