diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08ff6e4e..b06d070a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { // 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。 // 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。 -val baseAppVersionName = "2.8.0" +val baseAppVersionName = "2.9.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } @@ -62,6 +62,9 @@ android { compose = true buildConfig = true } + compileOptions { + isCoreLibraryDesugaringEnabled = true + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -98,6 +101,8 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) + implementation(project(":ui-design")) implementation(project(":background-task")) implementation(project(":ui-tv")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a317acd..4c66ecb7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,8 @@ protobuf = "3.25.9" protobuf-plugin = "0.9.6" onnxruntime = "1.25.1" icu4j = "78.3" +jsoup = "1.23.1" +desugar-jdk-libs-nio = "2.0.1" libvlc = "3.6.0" [libraries] @@ -78,6 +80,8 @@ androidx-security-crypto = { group = "androidx.security", name = "security-crypt kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinx-serialization" } icu4j = { group = "com.ibm.icu", name = "icu4j", version.ref = "icu4j" } +jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" } +desugar-jdk-libs-nio = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugar-jdk-libs-nio" } # === Coroutines === kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/repository-api/src/main/kotlin/com/miruplay/tv/repository/BangumiEpisodeComments.kt b/repository-api/src/main/kotlin/com/miruplay/tv/repository/BangumiEpisodeComments.kt new file mode 100644 index 00000000..e5161bb0 --- /dev/null +++ b/repository-api/src/main/kotlin/com/miruplay/tv/repository/BangumiEpisodeComments.kt @@ -0,0 +1,42 @@ +package com.miruplay.tv.repository + +import com.miruplay.tv.core.common.Result + +sealed interface BangumiCommentContent { + data class Text( + val value: String, + val style: BangumiCommentTextStyle = BangumiCommentTextStyle(), + ) : BangumiCommentContent + data class Image( + val url: String, + val description: String? = null, + val inline: Boolean = false, + ) : BangumiCommentContent + data class Spoiler(val children: List) : BangumiCommentContent +} + +data class BangumiCommentTextStyle( + val bold: Boolean = false, + val italic: Boolean = false, + val underline: Boolean = false, + val strikethrough: Boolean = false, + val linkUrl: String? = null, +) + +data class BangumiCommentUser( + val id: Int? = null, + val name: String, + val avatarUrl: String? = null, +) + +data class BangumiEpisodeComment( + val id: Int, + val user: BangumiCommentUser, + val content: List, + val createdAt: String? = null, + val replies: List = emptyList(), +) + +interface BangumiEpisodeCommentsService { + suspend fun getEpisodeComments(episodeId: Int): Result> +} diff --git a/scraper-core/build.gradle.kts b/scraper-core/build.gradle.kts index 848f6ccc..516414e9 100644 --- a/scraper-core/build.gradle.kts +++ b/scraper-core/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { api(project(":core:common")) api(project(":repository-api")) implementation(libs.icu4j) + implementation(libs.jsoup) implementation(libs.okhttp) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) diff --git a/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiApiClient.kt b/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiApiClient.kt index bb2b51f9..f9c0b0e2 100644 --- a/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiApiClient.kt +++ b/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiApiClient.kt @@ -10,6 +10,8 @@ import com.miruplay.tv.repository.BangumiApiPayloads import com.miruplay.tv.repository.BangumiCollectionService import com.miruplay.tv.repository.BangumiEpisodeCollection import com.miruplay.tv.repository.BangumiEpisodeCollectionType +import com.miruplay.tv.repository.BangumiEpisodeCommentsService +import com.miruplay.tv.repository.BangumiEpisodeComment import com.miruplay.tv.repository.BangumiEpisodeMetadata import com.miruplay.tv.repository.BangumiJsonMapper import com.miruplay.tv.repository.BangumiSubjectCollection @@ -36,12 +38,14 @@ import java.util.concurrent.TimeUnit class BangumiApiClient( baseUrl: String = DEFAULT_BASE_URL, + websiteBaseUrl: String = DEFAULT_WEBSITE_BASE_URL, private val tokenProvider: () -> String? = { null }, private val userAgent: String = DEFAULT_USER_AGENT, private val normalizeQuery: (String) -> String = { it }, private val archiveSearch: BangumiArchiveSubjectSearch? = null, -) : BangumiCollectionService { +) : BangumiCollectionService, BangumiEpisodeCommentsService { private val baseHttpUrl: HttpUrl = baseUrl.toHttpUrl() + private val websiteHttpUrl: HttpUrl = websiteBaseUrl.toHttpUrl() private val client = BangumiProxyAwareOkHttpClient(defaultClient()) private val json = Json { ignoreUnknownKeys = true } private val mediaType = "application/json".toMediaType() @@ -216,6 +220,38 @@ class BangumiApiClient( } } + override suspend fun getEpisodeComments( + episodeId: Int, + ): Result> = PerformanceLog.measureSuspendResult( + tag = PERFORMANCE_TAG, + operation = "bangumi.episode_comments", + attributes = mapOf("episode_id" to episodeId.toString()), + ) { + withContext(Dispatchers.IO) { + try { + val url = websiteHttpUrl.newBuilder().encodedPath("/ep/$episodeId").build() + val request = Request.Builder() + .url(url) + .addHeader("User-Agent", userAgent) + .addHeader("Accept", "text/html,application/xhtml+xml") + .get() + .build() + Result.success( + BangumiEpisodeCommentHtmlParser.parse( + html = executeText(request), + baseUrl = url.toString(), + ), + ) + } catch (error: Exception) { + Result.failure( + AppError.ScrapingError.ApiError( + SOURCE_NAME, + error.message ?: "Failed to read episode comments", + ), + ) + } + } + } override suspend fun getCurrentUser(): Result = withContext(Dispatchers.IO) { try { requireToken() @@ -342,6 +378,21 @@ class BangumiApiClient( } } + private fun executeText(request: Request): String = PerformanceLog.measure( + tag = PERFORMANCE_TAG, + operation = "bangumi.http_text", + attributes = request.performanceAttributes(), + ) { + client.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw IllegalStateException("HTTP ${response.code}: ${body.ifBlank { response.message }}") + } + if (body.isBlank()) throw IllegalStateException("Empty response") + body + } + } + private fun executeJson(request: Request): JsonElement = PerformanceLog.measure( tag = PERFORMANCE_TAG, operation = "bangumi.http_json", @@ -381,6 +432,7 @@ class BangumiApiClient( companion object { const val DEFAULT_BASE_URL = "https://api.bgm.tv" + const val DEFAULT_WEBSITE_BASE_URL = "https://bgm.tv" const val DEFAULT_USER_AGENT = "ModerRAS/MiruPlay/0.1.0 (https://github.com/ModerRAS/MiruPlay)" const val SOURCE_NAME = "Bangumi" diff --git a/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiEpisodeCommentHtmlParser.kt b/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiEpisodeCommentHtmlParser.kt new file mode 100644 index 00000000..c5dd9667 --- /dev/null +++ b/scraper-core/src/main/kotlin/com/miruplay/tv/scraper/core/BangumiEpisodeCommentHtmlParser.kt @@ -0,0 +1,165 @@ +package com.miruplay.tv.scraper.core + +import com.miruplay.tv.repository.BangumiCommentContent +import com.miruplay.tv.repository.BangumiCommentTextStyle +import com.miruplay.tv.repository.BangumiCommentUser +import com.miruplay.tv.repository.BangumiEpisodeComment +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode + +internal object BangumiEpisodeCommentHtmlParser { + fun parse(html: String, baseUrl: String): List { + val document = Jsoup.parse(html, baseUrl) + val comments = document.select( + "#comment_list > .row_reply, .singleCommentList .commentList > .row_reply", + ).distinctBy(Element::id).mapNotNull(::parseTopLevelComment) + val declaredCount = document.selectFirst(".singleCommentList h2.subtitle .tip") + ?.text() + ?.trim() + ?.toIntOrNull() + if (declaredCount != null && declaredCount > 0 && comments.isEmpty()) { + throw IllegalStateException("Bangumi episode comment markup is unsupported") + } + return comments + } + + private fun parseTopLevelComment(element: Element): BangumiEpisodeComment? { + val comment = parseComment(element) ?: return null + val replies = element.children().firstOrNull { it.hasClass("topic_sub_reply") } + ?.children() + ?.filter { it.hasClass("sub_reply_bg") } + ?.mapNotNull(::parseComment) + .orEmpty() + return comment.copy(replies = replies) + } + + private fun parseComment(element: Element): BangumiEpisodeComment? { + val id = element.id().removePrefix("post_").toIntOrNull() ?: return null + val inner = element.children().firstOrNull { it.hasClass("inner") } + val author = inner?.selectFirst("strong > a") ?: inner?.selectFirst("a.l") + val username = element.attr("data-item-user").ifBlank { + author?.attr("href")?.substringAfterLast('/').orEmpty() + } + val name = author?.text()?.trim().orEmpty().ifBlank { + username.ifBlank { "Bangumi 用户" } + } + val avatar = element.children().firstOrNull { it.tagName() == "a" && it.hasClass("avatar") } + ?.selectFirst("[style*=background-image]") + ?.attr("style") + ?.substringAfter("url(", "") + ?.substringBeforeLast(')') + ?.trim(' ', '\'', '"') + ?.let { resolveUrl(it, element.baseUri()) } + val time = element.children().firstOrNull { it.hasClass("post_actions") } + ?.selectFirst("small") + ?.text() + ?.substringAfter(" - ", "") + ?.trim() + ?.ifBlank { null } + val body = inner?.children()?.firstOrNull { it.hasClass("reply_content") } + ?.selectFirst(".message") + ?: return null + return BangumiEpisodeComment( + id = id, + user = BangumiCommentUser(name = name, avatarUrl = avatar), + content = parseContent(body), + createdAt = time, + ) + } + + private fun parseContent(root: Element): List = + parseNodes(root.childNodes(), BangumiCommentTextStyle(), root.baseUri()).trimBoundaryWhitespace() + + private fun parseNodes( + nodes: List, + style: BangumiCommentTextStyle, + baseUrl: String, + ): List { + val result = mutableListOf() + nodes.forEach { node -> + when (node) { + is TextNode -> appendText(result, node.wholeText, style) + is Element -> when { + node.hasClass("text_mask") -> result += BangumiCommentContent.Spoiler( + parseNodes(node.childNodes(), style, baseUrl), + ) + node.tagName() == "img" -> { + val url = resolveUrl(node.attr("src"), baseUrl) + if (url != null) { + result += BangumiCommentContent.Image( + url = url, + description = node.attr("alt").ifBlank { null }, + inline = node.hasClass("smile"), + ) + } else { + appendText(result, node.attr("alt"), style) + } + } + node.tagName() == "br" -> appendText(result, "\n", style) + else -> result += parseNodes( + node.childNodes(), + styleForElement(style, node, baseUrl), + baseUrl, + ) + } + } + } + return result + } + + private fun styleForElement( + style: BangumiCommentTextStyle, + element: Element, + baseUrl: String, + ): BangumiCommentTextStyle = when (element.tagName()) { + "b", "strong" -> style.copy(bold = true) + "i", "em" -> style.copy(italic = true) + "u" -> style.copy(underline = true) + "s", "del", "strike" -> style.copy(strikethrough = true) + "a" -> style.copy(linkUrl = resolveUrl(element.attr("href"), baseUrl)) + else -> style + } + + private fun appendText( + result: MutableList, + text: String, + style: BangumiCommentTextStyle, + ) { + if (text.isEmpty()) return + val previous = result.lastOrNull() as? BangumiCommentContent.Text + if (previous?.style == style) { + result[result.lastIndex] = previous.copy(value = previous.value + text) + } else { + result += BangumiCommentContent.Text(text, style) + } + } + + private fun List.trimBoundaryWhitespace(): List { + val result = toMutableList() + val first = result.firstOrNull() as? BangumiCommentContent.Text + if (first != null) { + val value = first.value.trimStart() + if (value.isEmpty()) result.removeAt(0) else result[0] = first.copy(value = value) + } + val last = result.lastOrNull() as? BangumiCommentContent.Text + if (last != null) { + val value = last.value.trimEnd() + if (value.isEmpty()) result.removeAt(result.lastIndex) else result[result.lastIndex] = last.copy(value = value) + } + return result + } + + private fun resolveUrl(value: String, baseUrl: String): String? { + val normalized = when { + value.startsWith("//") -> "https:$value" + value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true) -> value + value.startsWith('/') -> Jsoup.parse("", baseUrl).selectFirst("a")?.absUrl("href") + else -> null + } + return normalized?.takeIf { + it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true) + } + } +} diff --git a/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiApiClientTest.kt b/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiApiClientTest.kt index 5173eb5d..fd551249 100644 --- a/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiApiClientTest.kt +++ b/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiApiClientTest.kt @@ -570,6 +570,51 @@ class BangumiApiClientTest { } } + @Test + fun `getEpisodeComments reads anonymous episode page comment tree`() = runBlocking { + MockWebServer().use { server -> + server.enqueue( + MockResponse().setBody( + """ +
+
+
+ Alice +
主评论
+
+
+
+
+ Bob +
回复
+
+
+
+
+
+ """.trimIndent(), + ), + ) + val client = BangumiApiClient( + baseUrl = server.url("/").toString(), + websiteBaseUrl = server.url("/").toString(), + tokenProvider = { "" }, + ) + + val result = client.getEpisodeComments(123) + + assertTrue(result is Result.Success) + val comments = (result as Result.Success).data + assertEquals(10, comments.single().id) + assertEquals(11, comments.single().replies.single().id) + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals("/ep/123", request.path) + assertEquals("text/html,application/xhtml+xml", request.getHeader("Accept")) + assertEquals(null, request.getHeader("Authorization")) + } + } + @Test fun `collection service reports missing token`() = runBlocking { val client = BangumiApiClient(tokenProvider = { "" }) diff --git a/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiEpisodeCommentHtmlParserTest.kt b/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiEpisodeCommentHtmlParserTest.kt new file mode 100644 index 00000000..0ea1950c --- /dev/null +++ b/scraper-core/src/test/kotlin/com/miruplay/tv/scraper/core/BangumiEpisodeCommentHtmlParserTest.kt @@ -0,0 +1,106 @@ +package com.miruplay.tv.scraper.core + +import com.miruplay.tv.repository.BangumiCommentContent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows +import org.junit.Test + +class BangumiEpisodeCommentHtmlParserTest { + @Test + fun `parser preserves comments replies rich content images and masks`() { + val comments = BangumiEpisodeCommentHtmlParser.parse( + html = """ +
+
+
+
#1 - 2013-12-03 21:45
+
+ + + +
+ chaucer +
+ 父评论粗体剧透 + (bgm38) + 链接 +
+
+
+
+
#1-1 - 2014-10-19 18:10
+ +
+ 老白 +
子回复
+
+
+
+
+
+
+ 匿名昵称 +
第二条
+
+
+
+ """.trimIndent(), + baseUrl = "https://bgm.tv/ep/1027", + ) + + assertEquals(listOf(205402, 300000), comments.map { it.id }) + val first = comments.first() + assertEquals("chaucer", first.user.name) + assertEquals("https://lain.bgm.tv/pic/user/l/1.jpg", first.user.avatarUrl) + assertEquals("2013-12-03 21:45", first.createdAt) + assertEquals(242518, first.replies.single().id) + assertEquals("老白", first.replies.single().user.name) + assertEquals("子回复", first.replies.single().content.text()) + assertFalse(first.content.text().contains("子回复")) + + val texts = first.content.filterIsInstance() + assertTrue(texts.any { it.value == "粗体" && it.style.bold }) + assertTrue(texts.any { it.value == "链接" && it.style.linkUrl == "https://example.com" }) + val spoiler = first.content.filterIsInstance().single() + assertEquals("剧透", spoiler.children.text()) + assertEquals( + "https://bgm.tv/img/secret.jpg", + spoiler.children.filterIsInstance().single().url, + ) + assertTrue( + first.content.filterIsInstance() + .any { + it.url == "https://bgm.tv/img/smiles/tv/15.gif" && + it.inline && + it.description == "(bgm38)" + }, + ) + } + + @Test + fun `parser rejects changed markup when page declares comments`() { + assertThrows(IllegalStateException::class.java) { + BangumiEpisodeCommentHtmlParser.parse( + "

吐槽箱 3

", + "https://bgm.tv/ep/1027", + ) + } + } + + @Test + fun `parser returns empty list when comment container is absent`() { + assertTrue(BangumiEpisodeCommentHtmlParser.parse("", "https://bgm.tv").isEmpty()) + } +} + +private fun List.text(): String = joinToString("") { node -> + when (node) { + is BangumiCommentContent.Text -> node.value + is BangumiCommentContent.Image -> "" + is BangumiCommentContent.Spoiler -> node.children.text() + } +} diff --git a/scraper/src/main/kotlin/com/miruplay/tv/scraper/BangumiScraper.kt b/scraper/src/main/kotlin/com/miruplay/tv/scraper/BangumiScraper.kt index 0091180f..dc20b37a 100644 --- a/scraper/src/main/kotlin/com/miruplay/tv/scraper/BangumiScraper.kt +++ b/scraper/src/main/kotlin/com/miruplay/tv/scraper/BangumiScraper.kt @@ -9,6 +9,8 @@ import com.miruplay.tv.repository.AppCredentialStore import com.miruplay.tv.repository.BangumiCollectionService import com.miruplay.tv.repository.BangumiEpisodeMetadata import com.miruplay.tv.repository.BangumiEpisodeCollection +import com.miruplay.tv.repository.BangumiEpisodeComment +import com.miruplay.tv.repository.BangumiEpisodeCommentsService import com.miruplay.tv.repository.BangumiEpisodeCollectionType import com.miruplay.tv.repository.BangumiSubjectCollection import com.miruplay.tv.repository.BangumiSubjectCollectionType @@ -27,7 +29,7 @@ class BangumiScraper @Inject constructor( private val credentials: AppCredentialStore, private val cloudDriveRepository: CloudDriveAutomationRepository, archiveSearch: BangumiArchiveSubjectSearch, -) : MetadataScraper, MetadataImageBackfillScraper, ManualMetadataSearchScraper, BangumiCollectionService { +) : MetadataScraper, MetadataImageBackfillScraper, ManualMetadataSearchScraper, BangumiCollectionService, BangumiEpisodeCommentsService { override val sourceName: String = "Bangumi" @@ -76,6 +78,12 @@ class BangumiScraper @Inject constructor( ), ) { api.searchByAlias(normalizedName, candidates) } + override suspend fun getEpisodeComments(episodeId: Int): Result> = + withConfiguredProxy( + "bangumi.scraper.episode_comments", + mapOf("episode_id" to episodeId.toString()), + ) { api.getEpisodeComments(episodeId) } + override suspend fun getCurrentUser(): Result = withConfiguredProxy("bangumi.scraper.current_user") { api.getCurrentUser() } diff --git a/scraper/src/main/kotlin/com/miruplay/tv/scraper/di/ScraperModule.kt b/scraper/src/main/kotlin/com/miruplay/tv/scraper/di/ScraperModule.kt index 50604d1b..c6860392 100644 --- a/scraper/src/main/kotlin/com/miruplay/tv/scraper/di/ScraperModule.kt +++ b/scraper/src/main/kotlin/com/miruplay/tv/scraper/di/ScraperModule.kt @@ -15,6 +15,7 @@ import com.miruplay.tv.model.FilenameMetadataParser import com.miruplay.tv.repository.AnimeMetadataSearchAggregator import com.miruplay.tv.repository.AnimeMetadataSearchProvider import com.miruplay.tv.repository.BangumiCollectionService +import com.miruplay.tv.repository.BangumiEpisodeCommentsService import com.miruplay.tv.repository.DramaMetadataRepository import com.miruplay.tv.repository.DramaMetadataSearchAggregator import com.miruplay.tv.repository.DramaMetadataSearchProvider @@ -59,6 +60,10 @@ object ScraperModule { @Singleton fun provideBangumiCollectionService(scraper: BangumiScraper): BangumiCollectionService = scraper + @Provides + @Singleton + fun provideBangumiEpisodeCommentsService(scraper: BangumiScraper): BangumiEpisodeCommentsService = scraper + @Provides @Singleton fun provideDramaMetadataRepository( diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/BangumiCommentsPanel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/BangumiCommentsPanel.kt new file mode 100644 index 00000000..7e55e6f6 --- /dev/null +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/BangumiCommentsPanel.kt @@ -0,0 +1,316 @@ +package com.miruplay.tv.ui.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Text +import com.miruplay.tv.repository.BangumiCommentContent +import com.miruplay.tv.repository.BangumiEpisodeComment +import com.miruplay.tv.ui.components.RemoteImage +import com.miruplay.tv.ui.theme.AnimeRed +import com.miruplay.tv.ui.theme.FocusBorder +import com.miruplay.tv.ui.theme.TextPrimary +import com.miruplay.tv.ui.theme.TextSecondary +import com.miruplay.tv.ui.theme.TvTypography +import java.net.InetAddress +import java.net.URI + +@Composable +internal fun BangumiCommentsPanel( + state: EpisodeCommentsUiState, + onRetry: () -> Unit, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + LaunchedEffect(state.comments.isNotEmpty(), state.errorMessage) { + if (!state.isLoading && (state.comments.isNotEmpty() || state.errorMessage != null)) { + focusRequester.requestFocus() + } + } + Column( + modifier = modifier + .width(620.dp) + .fillMaxHeight() + .background(Color.Black.copy(alpha = 0.84f)) + .border(1.dp, Color.White.copy(alpha = 0.14f)) + .padding(horizontal = 24.dp, vertical = 26.dp), + ) { + Text("Bangumi 当集评论", style = TvTypography.subtitle, color = TextPrimary) + Spacer(Modifier.height(6.dp)) + Text( + text = state.episodeId?.let { "Bangumi Ep. $it" } ?: "当前剧集", + style = TvTypography.caption, + color = TextSecondary, + ) + Spacer(Modifier.height(18.dp)) + + when { + state.isLoading && state.comments.isEmpty() -> PanelMessage("正在加载评论…") + state.errorMessage != null && state.comments.isEmpty() -> { + PanelActionMessage( + message = state.errorMessage, + action = "重试", + onClick = onRetry, + actionModifier = Modifier.focusRequester(focusRequester), + ) + } + state.comments.isEmpty() -> PanelMessage("这一集还没有评论。") + else -> LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + itemsIndexed(state.comments, key = { _, comment -> comment.id }) { index, comment -> + BangumiCommentThread( + comment = comment, + focusRequester = focusRequester.takeIf { index == 0 }, + ) + } + if (state.hasMore || state.errorMessage != null) { + item { + PanelActionMessage( + message = state.errorMessage ?: if (state.isLoading) "正在加载更多…" else "还有更多评论", + action = if (state.errorMessage != null) "重试" else "加载更多", + onClick = onLoadMore, + enabled = !state.isLoading, + ) + } + } + } + } + } +} + +@Composable +private fun BangumiCommentThread( + comment: BangumiEpisodeComment, + depth: Int = 0, + focusRequester: FocusRequester? = null, +) { + Column( + modifier = Modifier.padding(start = (depth.coerceAtMost(3) * 30).dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BangumiCommentCard(comment, isReply = depth > 0, focusRequester = focusRequester) + comment.replies.forEach { reply -> + BangumiCommentThread(reply, depth + 1) + } + } +} + +@Composable +private fun BangumiCommentCard( + comment: BangumiEpisodeComment, + isReply: Boolean, + focusRequester: FocusRequester? = null, +) { + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + RemoteImage( + url = comment.user.avatarUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(if (isReply) 36.dp else 44.dp).clip(CircleShape), + ) + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(Color.White.copy(alpha = if (isReply) 0.06f else 0.10f)) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) FocusBorder else Color.Transparent, + shape = RoundedCornerShape(6.dp), + ) + .focusable(interactionSource = interaction) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(comment.user.name, style = TvTypography.body.copy(fontWeight = FontWeight.SemiBold), color = TextPrimary) + comment.createdAt?.let { Text(it.toCommentTimeLabel(), style = TvTypography.caption, color = TextSecondary) } + } + BangumiCommentBody(comment.content) + } + } +} + +@Composable +private fun BangumiCommentBody(content: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + content.forEachIndexed { index, node -> + when (node) { + is BangumiCommentContent.Text -> Text( + text = node.value, + style = TvTypography.body.copy( + fontWeight = if (node.style.bold) FontWeight.Bold else FontWeight.Normal, + fontStyle = if (node.style.italic) FontStyle.Italic else FontStyle.Normal, + textDecoration = when { + node.style.underline && node.style.strikethrough -> TextDecoration.combine( + listOf(TextDecoration.Underline, TextDecoration.LineThrough), + ) + node.style.underline || node.style.linkUrl != null -> TextDecoration.Underline + node.style.strikethrough -> TextDecoration.LineThrough + else -> TextDecoration.None + }, + ), + color = if (node.style.linkUrl != null) AnimeRed else TextPrimary, + ) + is BangumiCommentContent.Image -> RemoteImage( + url = safeBangumiCommentImageUrl(node.url), + contentDescription = node.description ?: "评论图片", + contentScale = ContentScale.Fit, + modifier = if (node.inline) { + Modifier.size(32.dp) + } else { + Modifier + .fillMaxWidth() + .heightIn(min = 120.dp, max = 360.dp) + .clip(RoundedCornerShape(6.dp)) + }, + ) + is BangumiCommentContent.Spoiler -> BangumiSpoiler(node.children, key = index) + } + } + } +} + +@Composable +private fun BangumiSpoiler(content: List, key: Int) { + var revealed by remember(key) { mutableStateOf(false) } + if (revealed) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .padding(10.dp), + ) { BangumiCommentBody(content) } + } else { + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.72f)) + .border(if (focused) 2.dp else 1.dp, if (focused) FocusBorder else Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp)) + .clickable(interactionSource = interaction, indication = null) { revealed = true } + .focusable(interactionSource = interaction) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Visibility, contentDescription = null, tint = AnimeRed, modifier = Modifier.size(18.dp)) + Text("剧透内容 · 按确认键显示", style = TvTypography.caption, color = TextSecondary) + } + } +} + +@Composable +private fun PanelMessage(message: String) { + Box(Modifier.fillMaxWidth().padding(vertical = 28.dp), contentAlignment = Alignment.Center) { + Text(message, style = TvTypography.body, color = TextSecondary) + } +} + +@Composable +private fun PanelActionMessage( + message: String, + action: String, + onClick: () -> Unit, + enabled: Boolean = true, + actionModifier: Modifier = Modifier, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(message, style = TvTypography.body, color = TextSecondary) + PlayerOptionButton( + text = action, + selected = false, + onClick = onClick, + enabled = enabled, + modifier = actionModifier, + ) + } +} + +internal fun safeBangumiCommentImageUrl(value: String): String? { + val uri = runCatching { URI(value) }.getOrNull() ?: return null + if (uri.scheme?.lowercase() !in setOf("http", "https")) return null + val host = uri.host?.lowercase()?.trimEnd('.')?.removeSurrounding("[", "]") ?: return null + if (host == "localhost" || host.endsWith(".local") || (!host.contains('.') && !host.contains(':'))) { + return null + } + if (host.contains(':')) { + val address = runCatching { InetAddress.getByName(host) }.getOrNull() ?: return null + if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || + address.isSiteLocalAddress || address.isMulticastAddress || + host.startsWith("fc", ignoreCase = true) || host.startsWith("fd", ignoreCase = true) + ) return null + } else { + val octets = host.split('.').map { it.toIntOrNull() } + if (octets.size == 4 && octets.all { it != null && it in 0..255 }) { + val first = octets[0]!! + val second = octets[1]!! + if (first == 0 || first == 10 || first == 127 || + first == 169 && second == 254 || first == 172 && second in 16..31 || + first == 192 && second == 168 + ) return null + } + } + return value +} + +internal fun String.toCommentTimeLabel(): String = + replace('T', ' ').substringBefore('.').removeSuffix("Z") diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt index 208e3659..c7f6736a 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.material.icons.filled.FastForward import androidx.compose.material.icons.filled.FastRewind import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Forum import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PhotoFilter @@ -310,6 +311,7 @@ private fun PlayerScreenContent( val fallbackReason by viewModel.fallbackReason.collectAsStateWithLifecycle() val formatAwarePreferences by viewModel.formatAwarePreferences.collectAsStateWithLifecycle() val subtitleBackgroundTransparent by viewModel.subtitleBackgroundTransparent.collectAsStateWithLifecycle() + val episodeComments by viewModel.episodeComments.collectAsStateWithLifecycle() val keepScreenOn = playbackState.keepsScreenOn() val view = LocalView.current val playerFocusRequester = remember { FocusRequester() } @@ -319,6 +321,7 @@ private fun PlayerScreenContent( val speedFocusRequester = remember { FocusRequester() } val subtitlesFocusRequester = remember { FocusRequester() } val audioFocusRequester = remember { FocusRequester() } + val commentsFocusRequester = remember { FocusRequester() } val currentPlaybackSource = activePlaybackSource ?: playbackSource val pendingDebugCaptureLabel = viewModel.pendingGlFrameCaptureLabel() var preferCapturableTextureView by remember(playbackSource) { @@ -338,6 +341,7 @@ private fun PlayerScreenContent( val context = LocalContext.current var openMenu by remember { mutableStateOf(null) } var infoPanelVisible by remember { mutableStateOf(false) } + var commentsPanelVisible by remember { mutableStateOf(false) } var infoTab by remember { mutableStateOf(PlayerInfoTab.Information) } var pressedDedicatedIntent by remember { mutableStateOf(null) } var pendingChromeFocus by remember { mutableStateOf(null) } @@ -378,6 +382,9 @@ private fun PlayerScreenContent( val closeOpenOverlay = { if (infoPanelVisible) { infoPanelVisible = false + } else if (commentsPanelVisible) { + commentsPanelVisible = false + viewModel.showControls() } else { openMenu?.let { pendingChromeFocus = focusTargetForMenu(it) } openMenu = null @@ -389,10 +396,24 @@ private fun PlayerScreenContent( infoPanelVisible = false } else { openMenu = null + commentsPanelVisible = false viewModel.hideControls() infoPanelVisible = true } } + val openCommentsPanel = { + infoPanelVisible = false + openMenu = null + viewModel.hideControls() + commentsPanelVisible = true + if (episodeComments.episodeId == null && episodeComments.errorMessage == null && !episodeComments.isLoading) { + viewModel.loadEpisodeComments() + } + } + + LaunchedEffect(playbackSource) { + commentsPanelVisible = false + } LaunchedEffect(playbackSource, hasStartedPlayback, screenOwnerToken) { if (!hasStartedPlayback) { @@ -413,8 +434,8 @@ private fun PlayerScreenContent( } } - LaunchedEffect(controlsVisible, infoPanelVisible) { - if (!controlsVisible && !infoPanelVisible) { + LaunchedEffect(controlsVisible, infoPanelVisible, commentsPanelVisible) { + if (!controlsVisible && !infoPanelVisible && !commentsPanelVisible) { openMenu = null playerFocusRequester.requestFocus() } @@ -529,7 +550,7 @@ private fun PlayerScreenContent( event = event, repeatsDedicatedCommand = repeatsDedicatedCommand, controlsVisible = controlsVisible, - hasOpenMenu = openMenu != null || infoPanelVisible, + hasOpenMenu = openMenu != null || infoPanelVisible || commentsPanelVisible, hasSubtitles = availableSubtitles.isNotEmpty(), canPlayPreviousEpisode = canPlayPreviousEpisode, canPlayNextEpisode = canPlayNextEpisode, @@ -541,6 +562,7 @@ private fun PlayerScreenContent( }, onOpenCaptions = { infoPanelVisible = false + commentsPanelVisible = false if (openMenu == PlayerMenu.Subtitles) { pendingChromeFocus = PlayerChromeFocusTarget.Subtitles openMenu = null @@ -551,6 +573,7 @@ private fun PlayerScreenContent( }, onFocusOptions = { infoPanelVisible = false + commentsPanelVisible = false openMenu = null pendingChromeFocus = PlayerChromeFocusTarget.Picture viewModel.showControls() @@ -684,10 +707,12 @@ private fun PlayerScreenContent( speedFocusRequester = speedFocusRequester, subtitlesFocusRequester = subtitlesFocusRequester, audioFocusRequester = audioFocusRequester, + commentsFocusRequester = commentsFocusRequester, canPlayPreviousEpisode = canPlayPreviousEpisode, canPlayNextEpisode = canPlayNextEpisode, onBack = navigateBack, onInfo = toggleInfoPanel, + onComments = openCommentsPanel, onTogglePlayback = { viewModel.togglePlayback() viewModel.showControls() @@ -738,6 +763,19 @@ private fun PlayerScreenContent( ) } + AnimatedVisibility( + visible = commentsPanelVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + BangumiCommentsPanel( + state = episodeComments, + onRetry = { viewModel.loadEpisodeComments() }, + onLoadMore = { viewModel.loadEpisodeComments(loadMore = true) }, + ) + } + AnimatedVisibility( visible = infoPanelVisible, enter = fadeIn(), @@ -846,10 +884,12 @@ private fun PlayerChrome( speedFocusRequester: FocusRequester, subtitlesFocusRequester: FocusRequester, audioFocusRequester: FocusRequester, + commentsFocusRequester: FocusRequester, canPlayPreviousEpisode: Boolean, canPlayNextEpisode: Boolean, onBack: () -> Unit, onInfo: () -> Unit, + onComments: () -> Unit, onTogglePlayback: () -> Unit, onPreviousEpisode: () -> Unit, onNextEpisode: () -> Unit, @@ -936,8 +976,10 @@ private fun PlayerChrome( speedFocusRequester = speedFocusRequester, subtitlesFocusRequester = subtitlesFocusRequester, audioFocusRequester = audioFocusRequester, + commentsFocusRequester = commentsFocusRequester, onSkipBackward = onSkipBackward, onSkipForward = onSkipForward, + onComments = onComments, onOpenMenu = { menu -> onOpenMenuChange(menu) }, @@ -1071,8 +1113,10 @@ internal fun PlayerBottomBar( speedFocusRequester: FocusRequester, subtitlesFocusRequester: FocusRequester, audioFocusRequester: FocusRequester, + commentsFocusRequester: FocusRequester, onSkipBackward: () -> Unit, onSkipForward: () -> Unit, + onComments: () -> Unit, onOpenMenu: (PlayerMenu) -> Unit, modifier: Modifier = Modifier ) { @@ -1094,7 +1138,7 @@ internal fun PlayerBottomBar( downFocusRequester = when { audioTracks.isNotEmpty() -> audioFocusRequester subtitles.isNotEmpty() -> subtitlesFocusRequester - else -> speedFocusRequester + else -> commentsFocusRequester }, onSkipBackward = onSkipBackward, onSkipForward = onSkipForward, @@ -1117,6 +1161,17 @@ internal fun PlayerBottomBar( icon = Icons.Filled.GraphicEq, text = signalFormatLabel.ifBlank { playbackLocalSourceLabel() } ) + PlayerActionChip( + icon = Icons.Filled.Forum, + text = "评论", + selected = false, + onClick = onComments, + modifier = Modifier + .focusRequester(commentsFocusRequester) + .focusProperties { + if (openMenu == null) up = timelineFocusRequester + }, + ) PlayerActionChip( icon = Icons.Filled.PhotoFilter, text = pictureOsdMenuTitleLabel(), @@ -1737,7 +1792,7 @@ private fun playbackStateInfoLabel(state: PlaybackState): String = private fun Boolean.yesNoLabel(): String = if (this) "是" else "否" @Composable -private fun PlayerOptionButton( +internal fun PlayerOptionButton( text: String, selected: Boolean, onClick: () -> Unit, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt index b30c085f..4e71bba2 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt @@ -34,6 +34,8 @@ import com.miruplay.tv.player.LibassSubtitleSession import com.miruplay.tv.player.PlaybackController import com.miruplay.tv.model.SubtitleTrack import com.miruplay.tv.model.toPlaybackSource +import com.miruplay.tv.repository.BangumiEpisodeComment +import com.miruplay.tv.repository.BangumiEpisodeCommentsService import com.miruplay.tv.repository.MediaIndexRepository import com.miruplay.tv.repository.MediaSourceRepository import com.miruplay.tv.repository.MetadataRepository @@ -66,6 +68,7 @@ class PlayerViewModel @Inject constructor( private val mediaSourceFactory: Lazy, private val mediaIndexRepository: Lazy, private val bangumiSyncEngine: Lazy, + private val bangumiEpisodeCommentsService: Lazy, private val playbackPreferences: PlaybackPreferencesRepository, private val scanPreferences: ScanPreferencesRepository, ) : ViewModel() { @@ -123,6 +126,10 @@ class PlayerViewModel @Inject constructor( val formatAwarePreferences: StateFlow = _formatAwarePreferences.asStateFlow() private val _subtitleBackgroundTransparent = MutableStateFlow(false) val subtitleBackgroundTransparent: StateFlow = _subtitleBackgroundTransparent.asStateFlow() + private var allEpisodeComments: List = emptyList() + private var episodeCommentsGeneration = 0L + private val _episodeComments = MutableStateFlow(EpisodeCommentsUiState()) + val episodeComments: StateFlow = _episodeComments.asStateFlow() init { viewModelScope.launch { @@ -210,6 +217,9 @@ class PlayerViewModel @Inject constructor( _currentPosition.value = resolvedSource.startPosition.coerceAtLeast(0L) activeSource = resolvedSource activeScreenOwnerToken = ownerToken + episodeCommentsGeneration += 1 + allEpisodeComments = emptyList() + _episodeComments.value = EpisodeCommentsUiState() _pendingNextEpisode.value = null pendingSelectionExitsPlayback = false _activePlaybackSource.value = resolvedSource @@ -226,6 +236,53 @@ class PlayerViewModel @Inject constructor( } } + fun loadEpisodeComments(loadMore: Boolean = false) { + val source = activeSource ?: return + val generation = episodeCommentsGeneration + val current = _episodeComments.value + if (current.isLoading) return + if (loadMore && allEpisodeComments.isNotEmpty()) { + val visibleCount = (current.comments.size + COMMENTS_PAGE_SIZE).coerceAtMost(allEpisodeComments.size) + _episodeComments.value = current.copy( + comments = allEpisodeComments.take(visibleCount), + hasMore = visibleCount < allEpisodeComments.size, + errorMessage = null, + ) + return + } + viewModelScope.launch { + val episodeId = source.episodeId + val episode = episodeId?.let { metadataRepository.get().getCachedEpisode(it).getOrNull() } + if (activeSource != source || episodeCommentsGeneration != generation) return@launch + val bangumiEpisodeId = episode?.bangumiEpisodeId + if (bangumiEpisodeId == null) { + _episodeComments.value = EpisodeCommentsUiState( + errorMessage = "当前剧集没有匹配到 Bangumi 单集,无法加载评论。", + ) + return@launch + } + _episodeComments.value = current.copy(isLoading = true, errorMessage = null) + when (val result = bangumiEpisodeCommentsService.get().getEpisodeComments(bangumiEpisodeId)) { + is Result.Success -> { + if (activeSource != source || episodeCommentsGeneration != generation) return@launch + allEpisodeComments = result.data + val visibleComments = result.data.take(COMMENTS_PAGE_SIZE) + _episodeComments.value = EpisodeCommentsUiState( + episodeId = bangumiEpisodeId, + comments = visibleComments, + hasMore = visibleComments.size < result.data.size, + ) + } + is Result.Error -> { + if (activeSource != source || episodeCommentsGeneration != generation) return@launch + _episodeComments.value = current.copy( + isLoading = false, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } fun retry() { activeSource?.let { source -> play(source.copy(startPosition = _currentPosition.value), activeScreenOwnerToken) @@ -676,6 +733,9 @@ class PlayerViewModel @Inject constructor( _canPlayNextEpisode.value = false _displayTitle.value = "" _displaySubtitle.value = "" + allEpisodeComments = emptyList() + episodeCommentsGeneration += 1 + _episodeComments.value = EpisodeCommentsUiState() _currentPosition.value = 0L _duration.value = 0L _availableSubtitles.value = emptyList() @@ -695,8 +755,20 @@ class PlayerViewModel @Inject constructor( } super.onCleared() } + + companion object { + private const val COMMENTS_PAGE_SIZE = 50 + } } +data class EpisodeCommentsUiState( + val episodeId: Int? = null, + val comments: List = emptyList(), + val isLoading: Boolean = false, + val hasMore: Boolean = false, + val errorMessage: String? = null, +) + internal fun shouldOwnerStopPlayback(activeOwnerToken: Any?, candidateOwnerToken: Any): Boolean = activeOwnerToken === candidateOwnerToken diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/BangumiCommentsPanelTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/BangumiCommentsPanelTest.kt new file mode 100644 index 00000000..a7e1ecf8 --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/BangumiCommentsPanelTest.kt @@ -0,0 +1,43 @@ +package com.miruplay.tv.ui.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BangumiCommentsPanelTest { + @Test + fun `comment images allow public HTTP URLs`() { + assertEquals( + "https://lain.bgm.tv/pic/user/l/1.jpg", + safeBangumiCommentImageUrl("https://lain.bgm.tv/pic/user/l/1.jpg"), + ) + assertEquals( + "http://203.0.113.10/image.jpg", + safeBangumiCommentImageUrl("http://203.0.113.10/image.jpg"), + ) + } + + @Test + fun `comment images reject local and non HTTP targets`() { + listOf( + "file:///tmp/private", + "/relative/image.jpg", + "http://localhost/image.jpg", + "http://printer/image.jpg", + "http://device.local/image.jpg", + "http://127.0.0.1/image.jpg", + "http://10.0.0.1/image.jpg", + "http://169.254.1.1/image.jpg", + "http://172.16.0.1/image.jpg", + "http://192.168.1.1/image.jpg", + "http://[::1]/image.jpg", + "http://[fd00::1]/image.jpg", + "http://[::ffff:127.0.0.1]/image.jpg", + "http://[::ffff:10.0.0.1]/image.jpg", + "http://[::ffff:169.254.1.1]/image.jpg", + "http://[::ffff:172.16.0.1]/image.jpg", + "http://[::ffff:192.168.1.1]/image.jpg", + "http://[::ffff:0.0.0.0]/image.jpg", + ).forEach { value -> assertNull(value, safeBangumiCommentImageUrl(value)) } + } +} diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerTimelineFocusTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerTimelineFocusTest.kt index 13237754..26a1aa76 100644 --- a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerTimelineFocusTest.kt +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerTimelineFocusTest.kt @@ -46,6 +46,7 @@ class PlayerTimelineFocusTest { val speedFocusRequester = remember { FocusRequester() } val subtitlesFocusRequester = remember { FocusRequester() } val audioFocusRequester = remember { FocusRequester() } + val commentsFocusRequester = remember { FocusRequester() } LaunchedEffect(controlsVisible) { if (controlsVisible) { @@ -75,8 +76,10 @@ class PlayerTimelineFocusTest { speedFocusRequester = speedFocusRequester, subtitlesFocusRequester = subtitlesFocusRequester, audioFocusRequester = audioFocusRequester, + commentsFocusRequester = commentsFocusRequester, onSkipBackward = { error("Timeline seek was not expected") }, onSkipForward = { error("Timeline seek was not expected") }, + onComments = {}, onOpenMenu = {}, ) } @@ -104,4 +107,55 @@ class PlayerTimelineFocusTest { compose.onNodeWithTag(PLAYER_TIMELINE_TEST_TAG).assertIsFocused() } + @Test + fun `timeline down focuses comments when track actions are unavailable`() { + compose.setContent { + val timelineFocusRequester = remember { FocusRequester() } + val transportFocusRequester = remember { FocusRequester() } + val pictureFocusRequester = remember { FocusRequester() } + val speedFocusRequester = remember { FocusRequester() } + val subtitlesFocusRequester = remember { FocusRequester() } + val audioFocusRequester = remember { FocusRequester() } + val commentsFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + timelineFocusRequester.requestFocus() + } + + PlayerBottomBar( + currentPosition = 12_000L, + duration = 60_000L, + subtitles = emptyList(), + audioTracks = emptyList(), + selectedSubtitleTrackIndex = null, + selectedAudioTrackIndex = null, + playbackSpeed = 1.0f, + signalFormatLabel = "", + openMenu = null, + timelineFocusRequester = timelineFocusRequester, + transportFocusRequester = transportFocusRequester, + pictureFocusRequester = pictureFocusRequester, + speedFocusRequester = speedFocusRequester, + subtitlesFocusRequester = subtitlesFocusRequester, + audioFocusRequester = audioFocusRequester, + commentsFocusRequester = commentsFocusRequester, + onSkipBackward = { error("Timeline seek was not expected") }, + onSkipForward = { error("Timeline seek was not expected") }, + onComments = {}, + onOpenMenu = {}, + ) + } + + compose.waitUntil(timeoutMillis = 5_000) { + runCatching { + compose.onNodeWithTag(PLAYER_TIMELINE_TEST_TAG).assertIsFocused() + true + }.getOrDefault(false) + } + compose.onNodeWithTag(PLAYER_TIMELINE_TEST_TAG).performKeyInput { + pressKey(Key.DirectionDown) + } + compose.onNodeWithText("评论").assertIsFocused() + } + }