diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml
index c40222fd..146bfd3e 100644
--- a/.github/workflows/android_ci.yml
+++ b/.github/workflows/android_ci.yml
@@ -20,13 +20,16 @@ jobs:
# Set up JDK 17
- name: Set up JDK 17
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
+ # 不再用 android-actions/setup-android:镜像已预装本项目需要的 SDK
+ # (ANDROID_HOME/cmdline-tools 12.0/platforms;android-36/build-tools 36.0.0)且已接受许可。
+ # 该 action 会因为版本与本镜像不符而自行下载固定版本的 cmdline-tools,
+ # 而 Google 已将这些 zip 下架 → 下载 404,整个 job 在构建前就挂掉。
+ # 即使在最新版 v4 上同样如此,上游 issue:android-actions/setup-android#536。
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8b6abb21..d83a7f27 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,13 +16,13 @@ jobs:
# Set up JDK 17
- name: Set up JDK 17
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
- - name: Set up Android SDK
- uses: android-actions/setup-android@v3
+ # 同 android_ci.yml:不用 android-actions/setup-android,
+ # 它下载的 cmdline-tools 已被 Google 下架(404),镜像自带的本项目需要的 SDK。
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
diff --git a/.gitignore b/.gitignore
index 9b41c1cd..d474e630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,9 @@ local.properties
release/
/.kotlin
debug/
+nul
+.junie
+.mimocode
+.opencode
+openspec/
+.vscode/
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 01a09220..e9a0416c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,9 +5,13 @@
+
+
+
+
+
+
+
+ android:exported="false"
+ android:windowSoftInputMode="adjustResize" />
- navController.navigate(Screen.DownloadDetail(detailUrl, title))
+ onNavigateToDownloadDetail = { detailUrl, title, sourceMode ->
+ navController.navigate(Screen.DownloadDetail(detailUrl, title, sourceMode))
},
onNavigateToSearch = {
navController.navigate(Screen.Search)
diff --git a/app/src/main/java/com/lanlinju/animius/domain/model/Video.kt b/app/src/main/java/com/lanlinju/animius/domain/model/Video.kt
index 86204f65..a3f01332 100644
--- a/app/src/main/java/com/lanlinju/animius/domain/model/Video.kt
+++ b/app/src/main/java/com/lanlinju/animius/domain/model/Video.kt
@@ -8,5 +8,7 @@ data class Video(
val lastPlayPosition: Long = 0L, /* 记忆播放视频位置,单位:毫秒 */
val currentEpisodeIndex: Int, /* 当前播放剧集索引,根据[episodeName]计算得出*/
val episodes: List,
- val headers: Map = emptyMap() /* 用于配置Referer, User-Agent等*/
+ val headers: Map = emptyMap(), /* 用于配置Referer, User-Agent等*/
+ val channels: Map> = emptyMap(), /* 多线路剧集 */
+ val channelIndex: Int = 0, /* 当前线路索引 */
)
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/component/BackTopAppBar.kt b/app/src/main/java/com/lanlinju/animius/presentation/component/BackTopAppBar.kt
index e0355082..05be0bae 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/component/BackTopAppBar.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/component/BackTopAppBar.kt
@@ -10,9 +10,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.lanlinju.animius.R
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -21,6 +28,8 @@ fun BackTopAppBar(
actions: @Composable RowScope.() -> Unit = {},
onBackClick: () -> Unit
) {
+ val (backFocused, focusModifier) = rememberIsFocused()
+
TopAppBar(
title = {
Text(
@@ -31,10 +40,16 @@ fun BackTopAppBar(
)
},
navigationIcon = {
- IconButton(onClick = onBackClick) {
+ IconButton(
+ colors = focusedIconButtonColors(backFocused),
+ modifier = focusModifier,
+ onClick = onBackClick
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = stringResource(id = R.string.back)
+ contentDescription = stringResource(id = R.string.back),
+ tint = if (backFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
},
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/component/DeleteOverlay.kt b/app/src/main/java/com/lanlinju/animius/presentation/component/DeleteOverlay.kt
new file mode 100644
index 00000000..079d97f5
--- /dev/null
+++ b/app/src/main/java/com/lanlinju/animius/presentation/component/DeleteOverlay.kt
@@ -0,0 +1,54 @@
+package com.lanlinju.animius.presentation.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+/**
+ * Trash-icon overlay shown on top of media cards while in delete mode.
+ *
+ * @param visible whether delete mode is active
+ * @param modifier sizes/positions the overlay over the card
+ */
+@Composable
+fun DeleteOverlay(
+ visible: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(Color.Black.copy(alpha = 0.5f), CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Delete,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/component/MediaSmall.kt b/app/src/main/java/com/lanlinju/animius/presentation/component/MediaSmall.kt
index 20965882..e8d85e76 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/component/MediaSmall.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/component/MediaSmall.kt
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -14,20 +15,27 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.*
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.lanlinju.animius.util.CROSSFADE_DURATION
+import com.lanlinju.animius.util.focus.focusBorder
import com.lanlinju.animius.R as Res
/**
@@ -41,6 +49,7 @@ fun MediaSmallRow(
content: @Composable (T) -> Unit
) {
LazyRow(
+ modifier = Modifier.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(Res.dimen.small_padding)),
contentPadding = PaddingValues(
start = dimensionResource(Res.dimen.large_padding),
@@ -59,6 +68,7 @@ fun MediaSmallRow(
* @param image A URL of the image to be shown in the card that this component is.
* @param label A label for the [image], if this is `null`, the [label] is not shown.
* @param onClick Action to happen when the card is clicked.
+ * @param onLongClick Action to happen when the card is long-pressed.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -71,33 +81,33 @@ fun MediaSmall(
label: String? = null,
onSuccess: (Bitmap) -> Unit = { }
) {
- MediaSmall(
+ val cardShape = RoundedCornerShape(dimensionResource(Res.dimen.media_card_corner_radius))
+
+ Card(
modifier = modifier
- .clip(RoundedCornerShape(dimensionResource(Res.dimen.media_card_corner_radius)))
+ .focusBorder(
+ shape = cardShape,
+ width = 3.dp,
+ focusedColor = MaterialTheme.colorScheme.primary,
+ )
+ .clip(cardShape)
.combinedClickable(
enabled = enabled,
onClick = onClick,
onLongClick = onLongClick
),
- image = image,
- label = label,
- onSuccess = onSuccess
- )
-}
-
-@Composable
-fun MediaSmall(
- image: String?,
- modifier: Modifier = Modifier,
- label: String? = null,
- onSuccess: (Bitmap) -> Unit = { }
-) {
- Card(
- modifier = modifier,
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp,
+ focusedElevation = 0.dp,
+ hoveredElevation = 0.dp,
+ draggedElevation = 0.dp,
+ disabledElevation = 0.dp,
+ ),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
- shape = RoundedCornerShape(dimensionResource(Res.dimen.media_card_corner_radius)),
+ shape = cardShape,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
@@ -160,4 +170,4 @@ fun PreviewMediaSmall() {
onClick = { },
modifier = Modifier.width(dimensionResource(Res.dimen.media_card_width))
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/component/NavigationBar.kt b/app/src/main/java/com/lanlinju/animius/presentation/component/NavigationBar.kt
index db1bcbdf..080270dd 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/component/NavigationBar.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/component/NavigationBar.kt
@@ -15,13 +15,19 @@ import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Text
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.dimensionResource
@@ -35,6 +41,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import java.util.Locale
import com.lanlinju.animius.R as Res
+import com.lanlinju.animius.util.focus.rememberIsFocused
@Deprecated("Use AdaptiveNavigationBar instead")
@Composable
@@ -142,11 +149,20 @@ fun AdaptiveNavigationBar(
NavigationRail(modifier) {
destinations.forEachIndexed { index, destination ->
val selected = destination.route == currentDestination
+ val (isFocused, focusModifier) = rememberIsFocused()
NavigationRailItem(
- selected = selected,
+ selected = selected || isFocused,
onClick = { onNavigateToDestination(index) },
icon = destination.icon,
- label = { Text(destination.route) }, // 可选:添加标签
+ label = { Text(destination.route) },
+ colors = if (isFocused)
+ NavigationRailItemDefaults.colors(
+ selectedIconColor = MaterialTheme.colorScheme.onPrimary,
+ unselectedIconColor = MaterialTheme.colorScheme.onPrimary,
+ indicatorColor = MaterialTheme.colorScheme.primary
+ )
+ else NavigationRailItemDefaults.colors(),
+ modifier = focusModifier
)
}
}
@@ -155,11 +171,20 @@ fun AdaptiveNavigationBar(
NavigationBar(modifier) {
destinations.forEachIndexed { index, destination ->
val selected = destination.route == currentDestination
+ val (isFocused, focusModifier) = rememberIsFocused()
NavigationBarItem(
- selected = selected,
+ selected = selected || isFocused,
onClick = { onNavigateToDestination(index) },
icon = destination.icon,
label = { Text(destination.label()) },
+ colors = if (isFocused)
+ NavigationBarItemDefaults.colors(
+ selectedIconColor = MaterialTheme.colorScheme.onPrimary,
+ unselectedIconColor = MaterialTheme.colorScheme.onPrimary,
+ indicatorColor = MaterialTheme.colorScheme.primary
+ )
+ else NavigationBarItemDefaults.colors(),
+ modifier = focusModifier
)
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/component/PopupMenuListItem.kt b/app/src/main/java/com/lanlinju/animius/presentation/component/PopupMenuListItem.kt
index a87f79e2..9fcfc538 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/component/PopupMenuListItem.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/component/PopupMenuListItem.kt
@@ -4,9 +4,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -14,12 +11,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.lanlinju.animius.R
import com.lanlinju.animius.util.VIDEO_ASPECT_RATIO
+import com.lanlinju.animius.util.focus.FocusedDropdownMenuItem
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -34,13 +37,23 @@ fun PopupMenuListItem(
val haptic = LocalHapticFeedback.current
Box(
- modifier = Modifier.combinedClickable(
- onLongClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- expanded = true
- },
- onClick = onClick
- )
+ modifier = Modifier
+ .combinedClickable(
+ onLongClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ expanded = true
+ },
+ onClick = onClick
+ )
+ .onPreviewKeyEvent { event ->
+ when {
+ event.key == Key.Menu && event.type == KeyEventType.KeyDown -> {
+ if (expanded) expanded = false else expanded = true
+ true
+ }
+ else -> false
+ }
+ }
) {
content()
@@ -56,13 +69,8 @@ fun PopupMenuListItem(
),
) {
- DropdownMenuItem(
- text = {
- Text(
- text = menuText,
- style = MaterialTheme.typography.bodyMedium
- )
- },
+ FocusedDropdownMenuItem(
+ text = menuText,
onClick = {
expanded = false
onMenuItemClick()
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/component/WarningMessage.kt b/app/src/main/java/com/lanlinju/animius/presentation/component/WarningMessage.kt
index a560d9df..42c83c82 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/component/WarningMessage.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/component/WarningMessage.kt
@@ -9,11 +9,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.lanlinju.animius.R
+import com.lanlinju.animius.util.focus.focusedOutlinedButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
@Composable
fun WarningMessage(
@@ -43,7 +47,15 @@ fun WarningMessage(
style = MaterialTheme.typography.bodyMedium
)
if (onRetryClick != null) {
- OutlinedButton(onClick = onRetryClick) {
+ val (isFocused, focusModifier) = rememberIsFocused()
+ OutlinedButton(
+ onClick = onRetryClick,
+ modifier = Modifier.then(focusModifier),
+ colors = focusedOutlinedButtonColors(
+ isFocused,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
Text(text = stringResource(id = R.string.lbl_retry))
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/navigation/AnimeNavHost.kt b/app/src/main/java/com/lanlinju/animius/presentation/navigation/AnimeNavHost.kt
index 8603710c..60ba4703 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/navigation/AnimeNavHost.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/navigation/AnimeNavHost.kt
@@ -27,7 +27,7 @@ fun AnimeNavHost(
onNavigateToVideoPlay: (parameters: String) -> Unit,
onNavigateToHistory: () -> Unit,
onNavigateToDownload: () -> Unit,
- onNavigateToDownloadDetail: (detailUrl: String, title: String) -> Unit,
+ onNavigateToDownloadDetail: (detailUrl: String, title: String, sourceMode: SourceMode) -> Unit,
onNavigateToSearch: () -> Unit,
onNavigateToAppearance: () -> Unit,
onNavigateToDanmakuSettings: () -> Unit,
@@ -83,7 +83,8 @@ fun AnimeNavHost(
composable {
DownloadDetailScreen(
onBackClick = onBackClick,
- onNavigateToVideoPlay = onNavigateToVideoPlay
+ onNavigateToVideoPlay = onNavigateToVideoPlay,
+ onNavigateToAnimeDetail = onNavigateToAnimeDetail
)
}
composable {
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/navigation/Screen.kt b/app/src/main/java/com/lanlinju/animius/presentation/navigation/Screen.kt
index 96891297..478cb6fa 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/navigation/Screen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/navigation/Screen.kt
@@ -27,7 +27,7 @@ sealed class Screen {
object DanmakuSettings : Screen()
@Serializable
- data class DownloadDetail(val detailUrl: String, val title: String) : Screen()
+ data class DownloadDetail(val detailUrl: String, val title: String, val sourceMode: SourceMode) : Screen()
@Serializable
data class AnimeDetail(val detailUrl: String, val mode: SourceMode) : Screen()
@@ -43,6 +43,8 @@ data class PlayerParameters(
val episodes: List, /* 剧集列表 */
val mode: SourceMode?, /* 来源 */
val isLocalVideo: Boolean = false, /* 是否为本地视频 */
+ val channels: Map> = emptyMap(), /* 多线路剧集 */
+ val channelIndex: Int = 0, /* 当前线路索引 */
) {
companion object {
suspend fun serialize(
@@ -50,7 +52,9 @@ data class PlayerParameters(
episodeIndex: Int,
episodes: List,
mode: SourceMode? = null,
- isLocalVideo: Boolean = false
+ isLocalVideo: Boolean = false,
+ channels: Map> = emptyMap(),
+ channelIndex: Int = 0
): String {
return Json.encodeToString(
PlayerParameters(
@@ -59,6 +63,8 @@ data class PlayerParameters(
episodes,
mode,
isLocalVideo,
+ channels,
+ channelIndex,
)
)
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/captcha/CaptchaWebViewActivity.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/captcha/CaptchaWebViewActivity.kt
index 2c9faee4..b1ff136d 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/captcha/CaptchaWebViewActivity.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/captcha/CaptchaWebViewActivity.kt
@@ -8,13 +8,16 @@ import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -27,10 +30,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.lanlinju.animius.data.remote.parse.util.CaptchaCookieManager
import com.lanlinju.animius.presentation.theme.AnimeTheme
+import com.lanlinju.animius.util.focus.rememberIsFocused
class CaptchaWebViewActivity : ComponentActivity() {
@@ -83,6 +91,10 @@ class CaptchaWebViewActivity : ComponentActivity() {
setResult(RESULT_OK, resultIntent)
finish()
},
+ onCancel = {
+ setResult(RESULT_CANCELED)
+ finish()
+ },
modifier = Modifier.padding(innerPadding)
)
}
@@ -96,9 +108,23 @@ class CaptchaWebViewActivity : ComponentActivity() {
private fun CaptchaWebViewContent(
url: String,
onVerificationComplete: (String) -> Unit,
+ onCancel: () -> Unit,
modifier: Modifier = Modifier
) {
var webView by remember { mutableStateOf(null) }
+ val buttonFocusRequester = remember { FocusRequester() }
+ val (isButtonFocused, buttonFocusModifier) = rememberIsFocused()
+ // 用 View 级别的焦点监听,Compose 的 onFocusChanged 无法正确捕获 WebView 内部焦点
+ var isWebViewFocused by remember { mutableStateOf(true) }
+ val context = LocalContext.current
+
+ // 焦点流转: WebView 焦点时按返回 -> 转移到按钮; 按钮焦点时按返回 -> 退出页面
+ BackHandler(enabled = isWebViewFocused) {
+ buttonFocusRequester.requestFocus()
+ }
+ BackHandler(enabled = isButtonFocused) {
+ onCancel()
+ }
Column(modifier = modifier) {
AndroidView(
@@ -112,10 +138,46 @@ private fun CaptchaWebViewContent(
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
- // 页面加载完成,等待用户手动操作
+ // 先让 WebView 获得焦点,否则 JS 的 input.focus() 会失效
+ view?.requestFocus()
+ // 轮询检测元素出现后立即操作,比固定延迟更快更可靠
+ view?.evaluateJavascript("""
+ (function() {
+ var tries = 0;
+ function clickAnnouncement() {
+ var els = document.querySelectorAll('button, a');
+ for (var i = 0; i < els.length; i++) {
+ var t = (els[i].textContent || '').trim();
+ if (t.indexOf('我已了解') >= 0 || t.indexOf('知道了') >= 0 || t === '确定') {
+ els[i].click();
+ return true;
+ }
+ }
+ return false;
+ }
+ function focusInput() {
+ var input = document.querySelector('input[name=verify]');
+ if (input) { input.focus(); input.click(); return true; }
+ return false;
+ }
+ function poll() {
+ tries++;
+ if (tries > 50) return; // 最多轮询 5 秒
+ if (!clickAnnouncement() || !focusInput()) {
+ setTimeout(poll, 100);
+ }
+ }
+ poll();
+ })();
+ """.trimIndent(), null)
}
}
+ // 用 View 级别的焦点监听
+ setOnFocusChangeListener { _, hasFocus ->
+ isWebViewFocused = hasFocus
+ }
+
loadUrl(url)
webView = this
}
@@ -135,6 +197,16 @@ private fun CaptchaWebViewContent(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
+ .focusRequester(buttonFocusRequester)
+ .then(buttonFocusModifier)
+ .border(
+ width = if (isButtonFocused) 3.dp else 0.dp,
+ color = if (isButtonFocused) MaterialTheme.colorScheme.primary else Color.Transparent,
+ shape = MaterialTheme.shapes.medium
+ ),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
) {
Text("已完成验证")
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/crash/CrashScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/crash/CrashScreen.kt
index 9c8d7614..9e11f2e7 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/crash/CrashScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/crash/CrashScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Newspaper
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarDefaults
@@ -26,6 +27,8 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -47,6 +50,8 @@ import com.lanlinju.animius.presentation.theme.SECONDARY_ALPHA
import com.lanlinju.animius.presentation.theme.padding
import com.lanlinju.animius.util.shareCrashLog
import com.lanlinju.animius.util.toast
+import com.lanlinju.animius.util.focus.focusedOutlinedButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import kotlinx.coroutines.launch
@Composable
@@ -146,26 +151,37 @@ fun InfoScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
+ val (acceptFocused, acceptModifier) = rememberIsFocused()
Button(
- modifier = Modifier.weight(1f),
+ modifier = Modifier.weight(1f).then(acceptModifier),
enabled = canAccept,
onClick = onAcceptClick,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (acceptFocused) MaterialTheme.colorScheme.primary
+ else ButtonDefaults.buttonColors().containerColor,
+ contentColor = if (acceptFocused) MaterialTheme.colorScheme.onPrimary
+ else ButtonDefaults.buttonColors().contentColor
+ )
) {
Text(text = acceptText)
}
if (onCopyClick != null) {
+ val (copyFocused, copyModifier) = rememberIsFocused()
OutlinedButton(
- modifier = Modifier.weight(1f),
+ modifier = Modifier.weight(1f).then(copyModifier),
onClick = onCopyClick,
+ colors = focusedOutlinedButtonColors(copyFocused)
) {
Text(text = logText)
}
}
}
if (rejectText != null && onRejectClick != null) {
+ val (rejectFocused, rejectModifier) = rememberIsFocused()
OutlinedButton(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth().then(rejectModifier),
onClick = onRejectClick,
+ colors = focusedOutlinedButtonColors(rejectFocused)
) {
Text(text = rejectText)
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailScreen.kt
index d9f2cccf..cdcdfef7 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailScreen.kt
@@ -5,13 +5,15 @@ import android.graphics.Bitmap
import android.text.Html
import android.widget.Toast
import androidx.compose.foundation.Image
-import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.hoverable
-import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -42,6 +44,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
@@ -58,7 +61,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SuggestionChip
@@ -77,13 +79,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.focus.FocusDirection
+import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
@@ -125,12 +131,16 @@ import com.lanlinju.animius.util.SettingsPreferences
import com.lanlinju.animius.util.SourceHolder
import com.lanlinju.animius.util.SourceMode
import com.lanlinju.animius.util.bannerParallax
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.focusedTextButtonColors
+import com.lanlinju.animius.util.focus.handleDPadKeyEvents
+import com.lanlinju.animius.util.focus.rememberIsFocused
import com.lanlinju.animius.util.dynamicColorOf
-import com.lanlinju.animius.util.isAndroidTV
import com.lanlinju.animius.util.isWideScreen
import com.lanlinju.animius.util.log
import com.lanlinju.animius.util.rememberPreference
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import com.lanlinju.animius.R as Res
@@ -171,14 +181,20 @@ fun AnimeDetailScreen(
var showBottomSheet by remember { mutableStateOf(false) }
var showChannelSelectorDialog by remember { mutableStateOf(false) }
var showDownloadBottomSheet by remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
- val background = Color(
- ColorUtils.blendARGB(
- MaterialTheme.colorScheme.background.toArgb(),
- MaterialTheme.colorScheme.primaryContainer.toArgb(),
- 0.05f
+ val pureBackground by SettingsPreferences.pureBackground.collectAsState()
+ val background = if (pureBackground) {
+ MaterialTheme.colorScheme.background
+ } else {
+ Color(
+ ColorUtils.blendARGB(
+ MaterialTheme.colorScheme.background.toArgb(),
+ MaterialTheme.colorScheme.primaryContainer.toArgb(),
+ 0.05f
+ )
)
- )
+ }
Box(
modifier = Modifier
@@ -252,7 +268,10 @@ fun AnimeDetailScreen(
color = MaterialTheme.colorScheme.inversePrimary
)
- Box {
+ val controlFocusRequester = remember { FocusRequester() }
+ val firstRelatedFocusRequester = remember { FocusRequester() }
+
+ Column {
AnimeEpisodes(
episodes = animeDetail.episodes,
lastPosition = animeDetail.lastPosition,
@@ -266,6 +285,9 @@ fun AnimeDetailScreen(
end = dimensionResource(Res.dimen.large_padding)
),
reverseList = reverseList,
+ modifier = Modifier.handleDPadKeyEvents(
+ onDown = { controlFocusRequester.requestFocus() }
+ ),
onEpisodeClick = { index, episode ->
handleEpisodeSelection(
index, episode, animeDetail,
@@ -275,20 +297,38 @@ fun AnimeDetailScreen(
)
EpisodeListControl(
- modifier = Modifier.align(Alignment.BottomEnd),
channelIndex = animeDetail.channelIndex,
isShowChannel = animeDetail.channels.size > 1,
+ reverseList = reverseList,
+ focusRequester = controlFocusRequester,
+ onUpFocusRequest = { focusManager.moveFocus(FocusDirection.Up) },
+ // 相关番剧为空时 requester 不会挂到任何节点,按下键需交回默认焦点搜索
+ onDownFocusRequest = if (animeDetail.relatedAnimes.isEmpty()) {
+ null
+ } else {
+ {
+ // 相关列表滚动后首项可能已被回收,此时 requester 未初始化
+ runCatching { firstRelatedFocusRequester.requestFocus() }
+ .onFailure { focusManager.moveFocus(FocusDirection.Down) }
+ }
+ },
onReverseClick = { reverseList = !reverseList },
onMoreClick = { showBottomSheet = true },
onChannelClick = { showChannelSelectorDialog = true }
)
}
- AnimeRelated(
- animes = animeDetail.relatedAnimes,
- contentPadding = PaddingValues(horizontal = dimensionResource(Res.dimen.large_padding)),
- onRelatedAnimeClick = { onRelatedAnimeClick(it, viewModel.mode) }
- )
+ if (animeDetail.relatedAnimes.isNotEmpty()) {
+ AnimeRelated(
+ animes = animeDetail.relatedAnimes,
+ firstItemFocusRequester = firstRelatedFocusRequester,
+ contentPadding = PaddingValues(horizontal = dimensionResource(Res.dimen.large_padding)),
+ modifier = Modifier.handleDPadKeyEvents(
+ onUp = { controlFocusRequester.requestFocus() }
+ ),
+ onRelatedAnimeClick = { onRelatedAnimeClick(it, viewModel.mode) }
+ )
+ }
}
Box(
@@ -319,7 +359,7 @@ fun AnimeDetailScreen(
}
)
- FavouriteIcon(isFavourite, animeDetail, viewModel)
+ FavouriteIcon(isFavourite, animeDetail, viewModel, focusManager)
}
if (showBottomSheet) {
@@ -398,25 +438,36 @@ private fun TopAppBar(
onBackClick: () -> Unit,
onDownloadClick: () -> Unit,
) {
+ val context = LocalContext.current
var expanded by remember { mutableStateOf(false) }
+ val (backFocused, backFocusModifier) = rememberIsFocused()
+ val (moreFocused, moreFocusModifier) = rememberIsFocused()
TopAppBar(
title = { },
navigationIcon = {
- IconButton(onClick = onBackClick) {
+ IconButton(
+ colors = focusedIconButtonColors(backFocused, Color.White.copy(alpha = 0.85f)),
+ modifier = backFocusModifier,
+ onClick = onBackClick
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(id = R.string.back),
- tint = Color.White.copy(alpha = 0.85f)
+ tint = if (backFocused) MaterialTheme.colorScheme.onPrimary else Color.White.copy(alpha = 0.85f)
)
}
},
actions = {
Box {
- IconButton(onClick = { expanded = true }) {
+ IconButton(
+ colors = focusedIconButtonColors(moreFocused, Color.White.copy(alpha = 0.85f)),
+ modifier = moreFocusModifier,
+ onClick = { expanded = true }
+ ) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = stringResource(id = R.string.more),
- tint = Color.White.copy(alpha = 0.85f)
+ tint = if (moreFocused) MaterialTheme.colorScheme.onPrimary else Color.White.copy(alpha = 0.85f)
)
}
@@ -424,25 +475,47 @@ private fun TopAppBar(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
+ val (downloadFocused, downloadFocusModifier) = rememberIsFocused()
DropdownMenuItem(
- text = { Text(stringResource(id = R.string.download)) },
+ text = {
+ Text(
+ text = stringResource(id = R.string.download),
+ color = if (downloadFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
onClick = {
expanded = false
onDownloadClick()
},
+ modifier = Modifier
+ .then(downloadFocusModifier)
+ .then(
+ if (downloadFocused) Modifier.background(MaterialTheme.colorScheme.primary)
+ else Modifier
+ ),
leadingIcon = {
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
modifier = Modifier
.size(24.dp)
.rotate(90f),
- contentDescription = stringResource(id = R.string.download)
+ contentDescription = stringResource(id = R.string.download),
+ tint = if (downloadFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
)
val uriHandler = LocalUriHandler.current
+ val (websiteFocused, websiteFocusModifier) = rememberIsFocused()
DropdownMenuItem(
- text = { Text(stringResource(id = R.string.website_address)) },
+ text = {
+ Text(
+ text = stringResource(id = R.string.website_address),
+ color = if (websiteFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
onClick = {
expanded = false
// gogoanime 的detailUrl包含域名地址,其他的不包含,所以需要判断一下
@@ -450,10 +523,18 @@ private fun TopAppBar(
if (detailUrl.contains("http")) detailUrl else "${SourceHolder.currentSource.baseUrl}$detailUrl"
uriHandler.openUri(url)
},
+ modifier = Modifier
+ .then(websiteFocusModifier)
+ .then(
+ if (websiteFocused) Modifier.background(MaterialTheme.colorScheme.primary)
+ else Modifier
+ ),
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_domain),
- contentDescription = stringResource(id = R.string.website_address)
+ contentDescription = stringResource(id = R.string.website_address),
+ tint = if (websiteFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
)
@@ -469,17 +550,23 @@ private fun FavouriteIcon(
isFavourite: Boolean,
animeDetail: AnimeDetail,
viewModel: AnimeDetailViewModel,
+ focusManager: FocusManager,
) {
val context = LocalContext.current
+ val (isFocused, focusModifier) = rememberIsFocused()
val msg = stringResource(
id = if (!isFavourite) Res.string.add_favourite else Res.string.remove_favourite
)
IconButton(
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(
- alpha = 0.45f
- )
+ modifier = Modifier
+ .then(focusModifier)
+ .handleDPadKeyEvents(
+ onDown = { focusManager.moveFocus(FocusDirection.Down) }
+ ),
+ colors = focusedIconButtonColors(
+ isFocused,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
),
onClick = {
val favourite = Favourite(
@@ -494,7 +581,7 @@ private fun FavouriteIcon(
Icon(
if (isFavourite) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder,
contentDescription = stringResource(id = Res.string.favourite),
- tint = MaterialTheme.colorScheme.primary,
+ tint = if (isFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
)
}
}
@@ -571,27 +658,22 @@ fun AnimeGenres(
modifier = modifier
) {
items(genres) { genre ->
- SuggestionChip(
- label = {
- Text(
- text = genre.uppercase(),
- color = MaterialTheme.colorScheme.onBackground,
- style = MaterialTheme.typography.labelMedium,
- modifier = Modifier.padding(
- vertical = dimensionResource(Res.dimen.small_padding)
- )
+ Box(
+ modifier = Modifier
+ .clip(CircleShape)
+ .background(color.copy(alpha = 0.25f))
+ .border(0.dp, Color.Transparent, CircleShape)
+ ) {
+ Text(
+ text = genre.uppercase(),
+ color = MaterialTheme.colorScheme.onBackground,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(
+ horizontal = dimensionResource(Res.dimen.small_padding),
+ vertical = dimensionResource(Res.dimen.small_padding)
)
- },
- onClick = { },
- shape = CircleShape,
- colors = SuggestionChipDefaults.suggestionChipColors(
- containerColor = color.copy(alpha = 0.25f)
- ),
- border = SuggestionChipDefaults.suggestionChipBorder(
- enabled = true,
- borderColor = Color.Transparent
- ),
- )
+ )
+ }
}
}
}
@@ -606,12 +688,18 @@ fun AnimeEpisodes(
color: Color = MaterialTheme.colorScheme.secondaryContainer,
onEpisodeClick: (index: Int, episode: Episode) -> Unit
) {
+ val targetIndex = if (reverseList) episodes.size - 1 - lastPosition else lastPosition
val scrollState = rememberLazyListState(
- initialFirstVisibleItemIndex = if (lastPosition < 3) 0 else lastPosition,
- initialFirstVisibleItemScrollOffset = if (lastPosition < 3) 0 else -200
+ initialFirstVisibleItemIndex = if (targetIndex < 3) 0 else targetIndex,
+ initialFirstVisibleItemScrollOffset = if (targetIndex < 3) 0 else -200
)
- val isAndroidTV = isAndroidTV(LocalContext.current)
+ // Initial focus: scroll to lastPosition then focus that item (only once)
+ val lastPlayedFocusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ scrollState.scrollToItem(targetIndex)
+ runCatching { lastPlayedFocusRequester.requestFocus() }
+ }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(dimensionResource(Res.dimen.medium_padding)),
@@ -619,40 +707,41 @@ fun AnimeEpisodes(
modifier = modifier,
state = scrollState
) {
- itemsIndexed(if (!reverseList) episodes else episodes.reversed()) { index, episode ->
- val focusRequester = remember { FocusRequester() }
+ val displayList = if (!reverseList) episodes else episodes.reversed()
+ itemsIndexed(displayList, key = { _, episode -> episode.url }) { index, episode ->
val interactionSource = remember { MutableInteractionSource() }
-// var focusIndex by rememberSaveable { mutableStateOf(lastPosition) } // 保存焦点位置
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isActive = isFocused
+ val isLastPlayed = index == targetIndex
FilledTonalButton(
onClick = { onEpisodeClick(index, episode) },
- colors = ButtonDefaults.filledTonalButtonColors(containerColor = color.copy(0.5f)),
- modifier = Modifier.run {
- if (isAndroidTV) {
-// onFocusChanged { if (it.isFocused) focusIndex = index }
- clip(CircleShape)
- .indication(interactionSource, LocalIndication.current)
- .hoverable(interactionSource)
- .focusRequester(focusRequester)
- .focusable(interactionSource = interactionSource)
- } else this
- }
+ interactionSource = interactionSource,
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = when {
+ isActive -> MaterialTheme.colorScheme.primary
+ else -> color.copy(0.5f)
+ }
+ ),
+ modifier = Modifier
+ .clip(CircleShape)
+ .then(
+ if (isLastPlayed) Modifier.focusRequester(lastPlayedFocusRequester)
+ else Modifier
+ )
) {
Text(
text = episode.name,
- color = if (episode.isPlayed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
+ color = when {
+ isActive -> MaterialTheme.colorScheme.onPrimary
+ episode.isPlayed -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onBackground
+ },
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(
vertical = dimensionResource(Res.dimen.small_padding)
)
)
}
-
- LaunchedEffect(Unit) {
- if (index == lastPosition && isAndroidTV) {
- "focusRequester: ${lastPosition + 1}".log("AnimeDetailScreen")
- focusRequester.requestFocus()
- }
- }
}
}
}
@@ -675,6 +764,8 @@ private fun handleEpisodeSelection(
title = title,
episodeIndex = index,
episodes = episodes.conditionalReverse(reverseList),
+ channels = channels.mapValues { it.value.conditionalReverse(reverseList) },
+ channelIndex = channelIndex,
mode = viewModel.mode
).let(onNavigateToVideoPlay)
}
@@ -690,53 +781,110 @@ private fun EpisodeListControl(
modifier: Modifier = Modifier,
channelIndex: Int,
isShowChannel: Boolean,
+ reverseList: Boolean = false,
+ focusRequester: FocusRequester? = null,
+ onUpFocusRequest: () -> Unit = {},
+ onDownFocusRequest: (() -> Unit)? = null,
onReverseClick: () -> Unit,
onMoreClick: () -> Unit,
onChannelClick: () -> Unit,
) {
- Row(
- modifier = modifier
- .fillMaxWidth()
- .offset(y = dimensionResource(id = Res.dimen.large_padding) + 6.dp)
- .padding(
- top = dimensionResource(id = Res.dimen.small_padding),
- end = dimensionResource(id = Res.dimen.small_padding)
- ),
+ val context = LocalContext.current
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .handleDPadKeyEvents(
+ onUp = onUpFocusRequest,
+ onDown = onDownFocusRequest
+ )
+ .padding(
+ top = dimensionResource(id = Res.dimen.small_padding),
+ end = dimensionResource(id = Res.dimen.small_padding)
+ ),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (isShowChannel) {
+ val (channelFocused, channelFocusModifier) = rememberIsFocused()
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ when {
+ channelFocused -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
+ }
+ )
+ .then(channelFocusModifier)
+ .then(
+ if (focusRequester != null) Modifier.focusRequester(focusRequester!!)
+ else Modifier
+ )
+ .clickable(onClick = onChannelClick)
+ .padding(horizontal = 12.dp, vertical = 3.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.channel_number, channelIndex + 1),
+ color = if (channelFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ Spacer(modifier = Modifier.size(12.dp))
+ }
+
+ val (reverseFocused, reverseFocusModifier) = rememberIsFocused()
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ when {
+ reverseFocused -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
+ }
+ )
+ .then(reverseFocusModifier)
+ .then(
+ if (focusRequester != null && !isShowChannel) Modifier.focusRequester(focusRequester!!)
+ else Modifier
+ )
+ .clickable(onClick = onReverseClick)
+ .padding(horizontal = 12.dp, vertical = 3.dp)
+ ) {
Text(
- modifier = Modifier.clickable(onClick = onChannelClick),
- text = stringResource(Res.string.channel_number, channelIndex + 1),
- color = MaterialTheme.colorScheme.primary,
+ text = if (reverseList) "列表正序" else stringResource(id = Res.string.reverse_list),
+ color = if (reverseFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelMedium
)
-
- Spacer(modifier = Modifier.size(12.dp))
}
- Text(
- modifier = Modifier.clickable(onClick = onReverseClick),
- text = stringResource(id = Res.string.reverse_list),
- color = MaterialTheme.colorScheme.primary,
- style = MaterialTheme.typography.labelMedium
- )
Spacer(modifier = Modifier.size(12.dp))
+ val (moreFocused, moreFocusModifier) = rememberIsFocused()
Row(
- modifier = Modifier.clickable(onClick = onMoreClick),
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .background(
+ when {
+ moreFocused -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)
+ }
+ )
+ .then(moreFocusModifier)
+ .clickable(onClick = onMoreClick)
+ .padding(horizontal = 12.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(id = Res.string.more_episodes),
- color = MaterialTheme.colorScheme.primary,
+ color = if (moreFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelMedium
)
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = stringResource(id = Res.string.more_episodes),
- tint = MaterialTheme.colorScheme.primary
+ tint = if (moreFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(16.dp)
)
}
}
@@ -747,6 +895,7 @@ fun AnimeRelated(
animes: List,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
+ firstItemFocusRequester: FocusRequester? = null,
onRelatedAnimeClick: (detailUrl: String) -> Unit
) {
Column(modifier) {
@@ -760,15 +909,28 @@ fun AnimeRelated(
Spacer(Modifier.size(dimensionResource(Res.dimen.medium_padding)))
- MediaSmallRow(
- mediaList = animes
- ) { anime ->
- MediaSmall(
- image = anime.img,
- label = anime.title,
- onClick = { onRelatedAnimeClick(anime.detailUrl) },
- modifier = Modifier.width(dimensionResource(Res.dimen.media_card_width))
+ LazyRow(
+ modifier = Modifier.focusGroup(),
+ horizontalArrangement = Arrangement.spacedBy(dimensionResource(Res.dimen.small_padding)),
+ contentPadding = PaddingValues(
+ start = dimensionResource(Res.dimen.large_padding),
+ end = dimensionResource(Res.dimen.large_padding)
)
+ ) {
+ itemsIndexed(animes) { index, anime ->
+ MediaSmall(
+ image = anime.img,
+ label = anime.title,
+ onClick = { onRelatedAnimeClick(anime.detailUrl) },
+ modifier = Modifier
+ .width(dimensionResource(Res.dimen.media_card_width))
+ .then(
+ if (index == 0 && firstItemFocusRequester != null) {
+ Modifier.focusRequester(firstItemFocusRequester!!)
+ } else Modifier
+ )
+ )
+ }
}
}
}
@@ -785,6 +947,9 @@ private fun EpisodeBottomSheet(
onDownloadClick: (index: Int, episode: Episode) -> Unit = { _, _ -> },
) {
val sheetState = rememberModalBottomSheetState()
+ val context = LocalContext.current
+
+ val firstItemFocusRequester = remember { FocusRequester() }
ModalBottomSheet(
onDismissRequest = onDismissRequest,
@@ -804,32 +969,47 @@ private fun EpisodeBottomSheet(
itemsIndexed(
items = if (!reverseList) episodes else episodes.reversed(),
key = { _, e -> e.url }) { index, episode ->
+ val focusModifier = if (index == 0) Modifier.focusRequester(firstItemFocusRequester) else Modifier
+ val chipInteractionSource = remember { MutableInteractionSource() }
+ val isChipFocused by chipInteractionSource.collectIsFocusedAsState()
+ val isChipActive = isChipFocused
SuggestionChip(
+ modifier = focusModifier,
onClick = {
when {
isDownload -> onDownloadClick(index, episode)
else -> onEpisodeClick(index, episode)
}
},
+ interactionSource = chipInteractionSource,
+ colors = SuggestionChipDefaults.suggestionChipColors(
+ containerColor = when {
+ isChipActive -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ },
+ labelColor = when {
+ isChipActive -> MaterialTheme.colorScheme.onPrimary
+ else -> if (episode.isPlayed || episode.isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground
+ }
+ ),
label = {
-
- val isPrimaryColor =
- if (isDownload) episode.isDownloaded else episode.isPlayed
-
Text(
modifier = Modifier
.padding(end = dimensionResource(id = Res.dimen.small_padding))
.fillMaxWidth(),
text = episode.name,
- color = if (isPrimaryColor) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
-
}
)
}
}
+
+ LaunchedEffect(Unit) {
+ delay(200) // wait for ModalBottomSheet animation
+ runCatching { firstItemFocusRequester.requestFocus() }
+ }
}
}
@@ -860,6 +1040,9 @@ fun ChannelSelectorDialog(
onChannelClick: (index: Int, episodes: List) -> Unit,
modifier: Modifier = Modifier,
) {
+ val lazyListState = rememberLazyListState()
+ val selectedFocusRequester = remember { FocusRequester() }
+
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
@@ -869,16 +1052,30 @@ fun ChannelSelectorDialog(
// 显示所有剧集线路
LazyColumn(
modifier = modifier,
+ state = lazyListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(channels.size) { index ->
+ val (itemFocused, itemModifier) = rememberIsFocused()
Text(
text = stringResource(Res.string.channel_number, index + 1),
- color = if (index == channelIndex) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
+ color = when {
+ itemFocused -> MaterialTheme.colorScheme.onPrimary
+ index == channelIndex -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onBackground
+ },
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
- .background(MaterialTheme.colorScheme.surfaceVariant)
+ .background(
+ if (itemFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
+ .then(itemModifier)
+ .then(
+ if (index == channelIndex) Modifier.focusRequester(selectedFocusRequester)
+ else Modifier
+ )
.clickable {
onChannelClick(index, channels[index]!!)
}
@@ -887,15 +1084,31 @@ fun ChannelSelectorDialog(
}
}
},
+ confirmButton = {},
dismissButton = {
- TextButton(onClick = onDismissRequest) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
+ TextButton(
+ onClick = onDismissRequest,
+ interactionSource = interactionSource,
+ colors = focusedTextButtonColors(isActive)
+ ) {
Text(text = stringResource(id = R.string.cancel))
}
- },
- confirmButton = {
- TextButton(onClick = onDismissRequest) {
- Text(text = stringResource(id = R.string.confirm))
- }
}
)
+
+ // 打开弹窗时聚焦到当前线路,而不是“取消”按钮
+ LaunchedEffect(Unit) {
+ if (channels.isNotEmpty() && channelIndex in channels.keys) {
+ runCatching {
+ // 先滚动到当前线路,确保其已组合(线路较多时可能不在可视区内)
+ lazyListState.scrollToItem(channelIndex)
+ withFrameNanos { }
+ selectedFocusRequester.requestFocus()
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailViewModel.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailViewModel.kt
index ad68f215..93b8b279 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailViewModel.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/detail/AnimeDetailViewModel.kt
@@ -65,8 +65,26 @@ class AnimeDetailViewModel @Inject constructor(
private fun getAnimeDetail(detailUrl: String) {
viewModelScope.launch {
_isFavourite.value = roomRepository.checkFavourite(detailUrl).first()
- getAnimeDetailUseCase(detailUrl, mode).collect {
- _animeDetailState.value = it
+ getAnimeDetailUseCase(detailUrl, mode).collect { resource ->
+ if (resource is Resource.Success && resource.data != null) {
+ val current = (_animeDetailState.value as? Resource.Success)?.data
+ if (current != null && current.channelIndex > 0) {
+ // Preserve user's channel selection across Room-triggered re-emissions.
+ // The UseCase flow re-emits with channelIndex=0 whenever Room DB changes
+ // (e.g., after addHistory), which would overwrite the user's choice.
+ _animeDetailState.value = Resource.Success(
+ resource.data.copy(
+ channelIndex = current.channelIndex,
+ episodes = current.episodes,
+ )
+ )
+ } else {
+ // First load or same channel: accept Room-updated episode metadata
+ _animeDetailState.value = resource
+ }
+ } else {
+ _animeDetailState.value = resource
+ }
}
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/download/DownloadScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/download/DownloadScreen.kt
index a5ead195..d907e2f4 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/download/DownloadScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/download/DownloadScreen.kt
@@ -44,7 +44,7 @@ import com.lanlinju.download.utils.formatSize
@Composable
fun DownloadScreen(
onNavigateToAnimeDetail: (detailUrl: String, mode: SourceMode) -> Unit,
- onNavigateToDownloadDetail: (detailUrl: String, title: String) -> Unit,
+ onNavigateToDownloadDetail: (detailUrl: String, title: String, sourceMode: SourceMode) -> Unit,
onBackClick: () -> Unit
) {
val viewModel: DownloadViewModel = hiltViewModel()
@@ -80,7 +80,7 @@ fun DownloadScreen(
},
menuText = stringResource(id = R.string.anime_detail),
onClick = {
- onNavigateToDownloadDetail(download.detailUrl, download.title)
+ onNavigateToDownloadDetail(download.detailUrl, download.title, download.sourceMode)
},
onMenuItemClick = {
onNavigateToAnimeDetail(
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailScreen.kt
index b699133d..b024a890 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailScreen.kt
@@ -2,6 +2,7 @@ package com.lanlinju.animius.presentation.screen.downloaddetail
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
@@ -19,12 +20,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -42,17 +46,22 @@ import coil.request.ImageRequest
import com.lanlinju.animius.R
import com.lanlinju.animius.domain.model.Episode
import com.lanlinju.animius.presentation.component.BackTopAppBar
+import com.lanlinju.animius.presentation.component.DeleteOverlay
import com.lanlinju.animius.presentation.component.LoadingIndicator
import com.lanlinju.animius.presentation.component.PopupMenuListItem
import com.lanlinju.animius.presentation.component.StateHandler
import com.lanlinju.animius.presentation.navigation.PlayerParameters
import com.lanlinju.animius.util.CROSSFADE_DURATION
+import com.lanlinju.animius.util.SourceMode
import com.lanlinju.animius.util.VIDEO_ASPECT_RATIO
+import com.lanlinju.animius.util.focus.focusedTextButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import com.lanlinju.animius.util.toast
import com.lanlinju.download.Progress
import com.lanlinju.download.core.DownloadTask
import com.lanlinju.download.download
import com.lanlinju.download.utils.formatSize
+import androidx.activity.compose.BackHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
@@ -65,6 +74,7 @@ import com.lanlinju.download.State as DownloadSate
@Composable
fun DownloadDetailScreen(
onNavigateToVideoPlay: (parameters: String) -> Unit,
+ onNavigateToAnimeDetail: (detailUrl: String, mode: SourceMode) -> Unit,
onBackClick: () -> Unit
) {
val viewModel: DownloadDetailViewModel = hiltViewModel()
@@ -78,10 +88,39 @@ fun DownloadDetailScreen(
onLoading = { LoadingIndicator() },
onFailure = { }
) { resource ->
+ var isDeleteMode by remember { mutableStateOf(false) }
+ val detailUrlState = viewModel.detailUrl.collectAsState()
+ val sourceModeState = viewModel.sourceMode.collectAsState()
Scaffold(topBar = {
- BackTopAppBar(title = titleState.value, onBackClick = onBackClick)
+ BackTopAppBar(
+ title = titleState.value,
+ onBackClick = onBackClick,
+ actions = {
+ val (detailFocused, detailModifier) = rememberIsFocused()
+ TextButton(
+ onClick = { onNavigateToAnimeDetail(detailUrlState.value, sourceModeState.value) },
+ modifier = detailModifier,
+ colors = focusedTextButtonColors(
+ detailFocused,
+ unfocusedContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) { Text(stringResource(id = R.string.anime_detail)) }
+ val (deleteFocused, deleteModifier) = rememberIsFocused()
+ TextButton(
+ onClick = { isDeleteMode = !isDeleteMode },
+ modifier = deleteModifier,
+ colors = focusedTextButtonColors(
+ deleteFocused,
+ unfocusedContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) { Text(if (isDeleteMode) stringResource(R.string.cancel) else stringResource(R.string.delete)) }
+ }
+ )
}) { paddingValues ->
+ if (isDeleteMode) {
+ BackHandler { isDeleteMode = false }
+ }
LazyColumn(modifier = Modifier.padding(paddingValues)) {
resource.data?.let { downloadDetailList ->
@@ -112,16 +151,34 @@ fun DownloadDetailScreen(
PopupMenuListItem(
content = {
- DownloadEpisodeItem(
- title = downloadDetail.title,
- imgUrl = downloadDetail.imgUrl,
- state = state
- )
+ Box {
+ DownloadEpisodeItem(
+ title = downloadDetail.title,
+ imgUrl = downloadDetail.imgUrl,
+ state = state
+ )
+ DeleteOverlay(
+ visible = isDeleteMode,
+ modifier = Modifier
+ .padding(
+ horizontal = dimensionResource(id = R.dimen.small_padding),
+ vertical = dimensionResource(id = R.dimen.small_padding)
+ )
+ .height(dimensionResource(id = R.dimen.image_cover_height))
+ .aspectRatio(VIDEO_ASPECT_RATIO)
+ )
+ }
},
menuText = stringResource(id = R.string.delete),
onClick = {
- when {
- state.isSucceed.value -> {
+ if (isDeleteMode) {
+ viewModel.deleteDownloadDetail(
+ downloadDetail.downloadUrl,
+ deleteFile = { state.remove() }
+ )
+ } else {
+ when {
+ state.isSucceed.value -> {
val title = titleState.value
val episodeName = downloadDetail.title
scope.launch {
@@ -151,6 +208,7 @@ fun DownloadDetailScreen(
state.isStarted() -> state.stop()
else -> state.start()
}
+ }
},
onMenuItemClick = {
viewModel.deleteDownloadDetail(
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailViewModel.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailViewModel.kt
index ad065106..8fade490 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailViewModel.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/downloaddetail/DownloadDetailViewModel.kt
@@ -8,6 +8,7 @@ import com.lanlinju.animius.domain.model.DownloadDetail
import com.lanlinju.animius.domain.repository.RoomRepository
import com.lanlinju.animius.presentation.navigation.Screen
import com.lanlinju.animius.util.Resource
+import com.lanlinju.animius.util.SourceMode
import com.lanlinju.download.core.DownloadTask
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,10 +30,18 @@ class DownloadDetailViewModel @Inject constructor(
private val _title: MutableStateFlow = MutableStateFlow("")
val title: StateFlow get() = _title
+ private val _detailUrl: MutableStateFlow = MutableStateFlow("")
+ val detailUrl: StateFlow get() = _detailUrl
+
+ private val _sourceMode: MutableStateFlow = MutableStateFlow(SourceMode.Agedm)
+ val sourceMode: StateFlow get() = _sourceMode
+
init {
savedStateHandle.toRoute().let {
_title.value = it.title
+ _detailUrl.value = it.detailUrl
+ _sourceMode.value = it.sourceMode
getDownloadDetails(it.detailUrl)
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/favourite/FavouriteScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/favourite/FavouriteScreen.kt
index ef6f5c3f..dda4c267 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/favourite/FavouriteScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/favourite/FavouriteScreen.kt
@@ -1,45 +1,64 @@
package com.lanlinju.animius.presentation.screen.favourite
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.exclude
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.navigationBars
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.systemBars
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.exclude
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
import androidx.hilt.navigation.compose.hiltViewModel
import com.lanlinju.animius.R
+import com.lanlinju.animius.presentation.component.DeleteOverlay
import com.lanlinju.animius.presentation.component.LoadingIndicator
import com.lanlinju.animius.presentation.component.MediaSmall
import com.lanlinju.animius.presentation.component.SourceBadge
import com.lanlinju.animius.presentation.component.StateHandler
import com.lanlinju.animius.util.SourceMode
+import com.lanlinju.animius.util.focus.FocusedDropdownMenuItem
+import com.lanlinju.animius.util.focus.focusedTextButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import com.lanlinju.animius.util.isWideScreen
@OptIn(ExperimentalMaterial3Api::class)
@@ -53,9 +72,10 @@ fun FavouriteScreen(
StateHandler(state = availableDataList.value, onLoading = {
LoadingIndicator()
}, onFailure = {}) { resource ->
+ var isDeleteMode by remember { mutableStateOf(false) }
+
Scaffold(
- modifier = Modifier
- .fillMaxSize(),
+ modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
@@ -64,16 +84,47 @@ fun FavouriteScreen(
style = MaterialTheme.typography.titleLarge,
)
},
+ actions = {
+ val (deleteFocused, deleteModifier) = rememberIsFocused()
+ TextButton(
+ onClick = { isDeleteMode = !isDeleteMode },
+ modifier = deleteModifier,
+ colors = focusedTextButtonColors(
+ deleteFocused,
+ unfocusedContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) {
+ Text(
+ text = if (isDeleteMode) stringResource(id = R.string.cancel)
+ else stringResource(id = R.string.delete)
+ )
+ }
+ }
)
},
contentWindowInsets = WindowInsets.systemBars.exclude(WindowInsets.navigationBars)
) { paddingValues ->
+ if (isDeleteMode) {
+ BackHandler { isDeleteMode = false }
+ }
val context = LocalContext.current
+ val gridFocusRequester = remember { FocusRequester() }
+ var initialFocusDone by remember { mutableStateOf(false) }
+
+ // After deletion, return focus to the grid instead of NavigationBar
+ LaunchedEffect(resource.data?.size) {
+ if (resource.data != null && initialFocusDone) {
+ runCatching { gridFocusRequester.requestFocus() }
+ }
+ if (resource.data != null) initialFocusDone = true
+ }
LazyVerticalGrid(
modifier = Modifier
- .padding(paddingValues),
+ .padding(paddingValues)
+ .focusGroup()
+ .focusRequester(gridFocusRequester),
columns = if (isWideScreen(context)) GridCells.Adaptive(dimensionResource(R.dimen.min_media_card_width)) else GridCells.Fixed(
3
),
@@ -97,34 +148,44 @@ fun FavouriteScreen(
image = anime.imgUrl,
label = anime.title,
onClick = {
- onNavigateToAnimeDetail(
- anime.detailUrl,
- anime.sourceMode
- )
+ if (isDeleteMode) {
+ favouriteViewModel.removeFavourite(anime.detailUrl)
+ } else {
+ onNavigateToAnimeDetail(anime.detailUrl, anime.sourceMode)
+ }
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
expanded = true
+ },
+ modifier = Modifier.onPreviewKeyEvent { event ->
+ when {
+ event.key == Key.Menu && event.type == KeyEventType.KeyDown -> {
+ if (expanded) expanded = false else expanded = true
+ true
+ }
+ else -> false
+ }
}
)
}
+ // Delete mode: trash icon overlay centered on image
+ DeleteOverlay(
+ visible = isDeleteMode,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(0.7f)
+ )
+
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
- offset = DpOffset(
- x = 40.dp,
- y = 0.dp
- ),
+ offset = DpOffset(x = 40.dp, y = 0.dp),
) {
- DropdownMenuItem(
- text = {
- Text(
- text = stringResource(id = R.string.delete),
- style = MaterialTheme.typography.bodyMedium
- )
- },
+ FocusedDropdownMenuItem(
+ text = stringResource(id = R.string.delete),
onClick = {
expanded = false
favouriteViewModel.removeFavourite(anime.detailUrl)
@@ -135,8 +196,6 @@ fun FavouriteScreen(
}
}
}
-
}
-
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/history/HistoryScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/history/HistoryScreen.kt
index 405b7b0a..8c3e2fc2 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/history/HistoryScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/history/HistoryScreen.kt
@@ -17,8 +17,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -31,6 +33,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
@@ -50,6 +53,9 @@ import com.lanlinju.animius.util.CROSSFADE_DURATION
import com.lanlinju.animius.util.LOW_CONTENT_ALPHA
import com.lanlinju.animius.util.SourceMode
import com.lanlinju.animius.util.VIDEO_ASPECT_RATIO
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.focusedTextButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
@Composable
fun HistoryScreen(
@@ -179,8 +185,18 @@ fun HistoryItem(
fun DeleteHistoryButton(
onClick: () -> Unit,
) {
- IconButton(onClick = onClick) {
- Icon(Icons.Outlined.DeleteOutline, contentDescription = "Delete")
+ val (isFocused, focusModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onClick,
+ colors = focusedIconButtonColors(isFocused),
+ modifier = focusModifier
+ ) {
+ Icon(
+ Icons.Outlined.DeleteOutline,
+ contentDescription = "Delete",
+ tint = if (isFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
}
}
@@ -195,15 +211,25 @@ fun DeleteAllHistoriesDialog(
title = { Text(text = stringResource(R.string.clear_all_histories)) },
text = { Text(text = stringResource(R.string.are_you_sure_you_want_to_clear_all_histories)) },
confirmButton = {
- TextButton(onClick = {
- onDismissRequest()
- onDeleteAllHistories()
- }) {
+ val (isFocused, focusModifier) = rememberIsFocused()
+ TextButton(
+ onClick = {
+ onDismissRequest()
+ onDeleteAllHistories()
+ },
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
+ ) {
Text(stringResource(id = R.string.confirm))
}
},
dismissButton = {
- TextButton(onClick = onDismissRequest) {
+ val (isFocused, focusModifier) = rememberIsFocused()
+ TextButton(
+ onClick = onDismissRequest,
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
+ ) {
Text(stringResource(id = R.string.cancel))
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/home/HomeScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/home/HomeScreen.kt
index 84c0136b..c5705e3d 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/home/HomeScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/home/HomeScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -35,17 +36,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -78,11 +84,15 @@ import com.lanlinju.animius.presentation.component.TranslucentStatusBarLayout
import com.lanlinju.animius.presentation.component.WarningMessage
import com.lanlinju.animius.util.KEY_HOME_BACKGROUND_URI
import com.lanlinju.animius.util.KEY_USE_GRID_LAYOUT
+import com.lanlinju.animius.util.SettingsPreferences
import com.lanlinju.animius.util.SourceHolder
import com.lanlinju.animius.util.SourceMode
+import com.lanlinju.animius.presentation.screen.week.SourceSwitchDialog
import com.lanlinju.animius.util.bannerParallax
import com.lanlinju.animius.util.isWideScreen
import com.lanlinju.animius.util.rememberPreference
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import kotlinx.coroutines.launch
import com.lanlinju.animius.R as Res
@@ -93,11 +103,11 @@ fun HomeScreen(
) {
val homeViewModel = hiltViewModel()
val availableDataList = homeViewModel.homeDataList.collectAsState()
+ val isSourceChanged by SourceHolder.isSourceChanged.collectAsState()
- LaunchedEffect(SourceHolder.isSourceChanged) {
- if (SourceHolder.isSourceChanged) {
+ LaunchedEffect(isSourceChanged) {
+ if (isSourceChanged > 0) {
homeViewModel.refresh()
- SourceHolder.isSourceChanged = false
}
}
@@ -243,7 +253,7 @@ private fun GridLayoutTabs(
GridCells.Fixed(3)
}
LazyVerticalGrid(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier.fillMaxSize().focusGroup(),
columns = columns,
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -268,27 +278,39 @@ private fun Tab(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
- Box(
+ val (isFocused, focusModifier) = rememberIsFocused()
+ Surface(
+ onClick = onClick,
modifier = modifier
.padding(8.dp)
- .background(
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
- shape = RoundedCornerShape(11.dp)
- )
- .clip(RoundedCornerShape(11.dp))
- .clickable { onClick() }
- .padding(horizontal = 8.dp, vertical = 4.dp)
+ .then(focusModifier),
+ shape = RoundedCornerShape(20.dp),
+ color = when {
+ isFocused -> MaterialTheme.colorScheme.primary
+ selected -> MaterialTheme.colorScheme.onSurface
+ else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ },
+ contentColor = when {
+ isFocused -> MaterialTheme.colorScheme.onPrimary
+ selected -> MaterialTheme.colorScheme.surface
+ else -> MaterialTheme.colorScheme.onBackground
+ },
) {
- Text(
- text = text(),
-// color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.align(Alignment.Center)
- )
+ Box(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = text(),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
}
}
@Composable
fun getBlendedBackgroundColor(): Color {
+ val pureBackground by SettingsPreferences.pureBackground.collectAsState()
+ if (pureBackground) return MaterialTheme.colorScheme.background
return Color(
ColorUtils.blendARGB(
MaterialTheme.colorScheme.background.toArgb(),
@@ -373,10 +395,9 @@ fun HomeBackground(
.offset(y = offsetY)
.align(Alignment.BottomStart),
useGridLayout = useGridLayout,
- onSwitchGridLayout = onSwitchLayout
- ) {
- launcher.launch(arrayOf("image/*"))
- }
+ onSwitchGridLayout = onSwitchLayout,
+ onClick = { launcher.launch(arrayOf("image/*")) }
+ )
}
}
@@ -388,53 +409,76 @@ private fun HomeTile(
onClick: () -> Unit = {},
) {
val isWideScreen = isWideScreen(LocalContext.current)
+ var showSourceSwitchDialog by remember { mutableStateOf(false) }
+ var currentSourceName by remember { mutableStateOf(SourceHolder.currentSourceMode.name) }
+ val (isSourceFocused, sourceFocusModifier) = rememberIsFocused()
Row(
modifier = modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.Top
) {
- Box(
+ Column(
modifier = Modifier
.padding(
start = dimensionResource(Res.dimen.large_padding),
- bottom = if (!isWideScreen) dimensionResource(Res.dimen.medium_padding) else 0.dp
- )
+ top = if (isWideScreen) 8.dp else 64.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy((-8).dp)
) {
if (!isWideScreen) {
+ val (animeFocused, animeFocusModifier) = rememberIsFocused()
+ Surface(
+ onClick = onClick,
+ shape = RoundedCornerShape(20.dp),
+ color = if (animeFocused) MaterialTheme.colorScheme.primary
+ else Color.Transparent,
+ modifier = animeFocusModifier
+ ) {
+ Text(
+ text = stringResource(Res.string.lbl_anime),
+ color = if (animeFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
+ )
+ }
+ }
+ Surface(
+ onClick = { showSourceSwitchDialog = true },
+ shape = RoundedCornerShape(20.dp),
+ color = if (isSourceFocused) MaterialTheme.colorScheme.primary
+ else Color.Transparent,
+ modifier = sourceFocusModifier
+ ) {
Text(
- text = stringResource(Res.string.lbl_anime),
- color = MaterialTheme.colorScheme.onSecondaryContainer,
- style = MaterialTheme.typography.displayMedium,
- modifier = Modifier.clickable { onClick() }
+ text = currentSourceName,
+ color = if (isSourceFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
- Text(
- text = SourceHolder.currentSourceMode.name,
- color = MaterialTheme.colorScheme.onSecondaryContainer,
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier
- .align(Alignment.BottomStart)
- .offset(y = 8.dp)
- .padding(vertical = if (isWideScreen && useGridLayout) 8.dp else 0.dp)
- .run {
- if (isWideScreen) {
- // 获取焦点
- clickable { }
- } else this
- }
- )
}
if (!isWideScreen) {
LayoutTypeSelector(
modifier = Modifier
- .padding(end = 24.dp, bottom = 16.dp),
+ .padding(end = 24.dp, top = 88.dp, bottom = 16.dp),
checked = useGridLayout,
onCheckedChange = { onSwitchGridLayout(it) }
)
}
}
+ if (showSourceSwitchDialog) {
+ SourceSwitchDialog(
+ onDismissRequest = { showSourceSwitchDialog = false },
+ onSourceChanged = { mode ->
+ currentSourceName = mode.name
+ SourceHolder.isSourceChanged.value++
+ }
+ )
+ }
}
@Composable
@@ -508,20 +552,23 @@ private fun LayoutTypeSelector(
) {
repeat(2) { index ->
val selectedIndex = if (!checked) 0 else 1
+ val (isFocused, focusModifier) = rememberIsFocused()
IconButton(
onClick = { if (selectedIndex != index) onCheckedChange?.invoke(!checked) },
- modifier = Modifier.requiredWidth(dimensionResource(Res.dimen.media_type_choice_size))
+ modifier = Modifier
+ .requiredWidth(dimensionResource(Res.dimen.media_type_choice_size))
+ .then(focusModifier),
+ colors = focusedIconButtonColors(isFocused)
) {
Icon(
imageVector = if (index == 0) Icons.Rounded.PlayArrow else ImageVector.vectorResource(
id = Res.drawable.manga
),
- tint = animateColorAsState(
- targetValue = if (selectedIndex == index) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.background,
- animationSpec = tween(400),
- label = "icon_color"
- ).value,
+ tint = when {
+ isFocused -> MaterialTheme.colorScheme.onPrimary
+ selectedIndex == index -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.background
+ },
contentDescription = null,
)
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/main/MainScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/main/MainScreen.kt
index ad08b1fb..bd90e9fd 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/main/MainScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/main/MainScreen.kt
@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -47,7 +49,7 @@ fun MainScreen(
val pagerState = rememberPagerState(initialPage = 1) { NavigationBarPath.entries.size }
val scope = rememberCoroutineScope()
- val configuration = LocalConfiguration.current // 屏幕方向改变会触发 recompose
+ val configuration = LocalConfiguration.current
val context = LocalContext.current
// 在屏幕方向变化时更新 isWideScreen
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchScreen.kt
index a75b0d4c..9ef60c59 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchScreen.kt
@@ -4,15 +4,21 @@ package com.lanlinju.animius.presentation.screen.search
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.border
-import androidx.compose.foundation.focusable
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -29,7 +35,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -39,17 +44,24 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
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.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.compose.collectAsLazyPagingItems
@@ -59,8 +71,10 @@ import com.lanlinju.animius.presentation.component.PaginationStateHandler
import com.lanlinju.animius.presentation.component.WarningMessage
import com.lanlinju.animius.presentation.screen.captcha.CaptchaWebViewActivity
import com.lanlinju.animius.util.SourceMode
-import com.lanlinju.animius.util.isAndroidTV
import com.lanlinju.animius.util.isWideScreen
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.focusedTextButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -76,7 +90,29 @@ fun SearchScreen(
var menuExpanded by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
- var searchBarExpanded by rememberSaveable { mutableStateOf(false) }
+ var isEditing by remember { mutableStateOf(true) }
+ var searchBarHeight by remember { mutableStateOf(0.dp) }
+ val density = LocalDensity.current
+ val navFocusRequester = remember { FocusRequester() }
+
+ // PagingSource 加载完成后检查是否需要验证码
+ // 不能在 collect 中检查,因为 PagingData 在加载前就 emit 了
+ LaunchedEffect(animesState.loadState.refresh) {
+ if (animesState.loadState.refresh is androidx.paging.LoadState.NotLoading) {
+ viewModel.checkNeedCaptcha()
+ }
+ }
+
+ // Request focus on the appropriate element when isEditing changes:
+ // - Enter editing → focus the InputField so the keyboard and cursor work
+ // - Exit editing → focus the non-editing search bar so D-Pad can navigate buttons
+ LaunchedEffect(isEditing) {
+ if (isEditing) {
+ focusRequester.requestFocus()
+ } else {
+ navFocusRequester.requestFocus()
+ }
+ }
val context = LocalContext.current
val captchaLauncher = rememberLauncherForActivityResult(
@@ -96,20 +132,26 @@ fun SearchScreen(
title = { Text("需要验证码验证") },
text = { Text("搜索时遇到验证码,请完成验证后重试") },
confirmButton = {
+ val (isFocused, focusModifier) = rememberIsFocused()
TextButton(
onClick = {
viewModel.clearNeedCaptcha()
captchaLauncher.launch(
CaptchaWebViewActivity.createIntent(context, url)
)
- }
+ },
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
) {
Text("去验证")
}
},
dismissButton = {
+ val (isFocused, focusModifier) = rememberIsFocused()
TextButton(
- onClick = { viewModel.clearNeedCaptcha() }
+ onClick = { viewModel.clearNeedCaptcha() },
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
) {
Text("取消")
}
@@ -118,154 +160,299 @@ fun SearchScreen(
}
Box(Modifier.fillMaxSize()) {
- SearchBar(
- inputField = {
- SearchBarDefaults.InputField(
- query = searchQuery,
- onQueryChange = viewModel::onQuery,
- onSearch = {
- searchBarExpanded = true
- viewModel.onSearch(it, viewModel.currentSourceMode)
- keyboardController?.hide()
- },
- expanded = searchBarExpanded,
- onExpandedChange = { },
- placeholder = {
- Text(stringResource(id = R.string.lbl_search_placeholder))
- },
- leadingIcon = {
- IconButton(onClick = onBackClick) {
+ // 返回键优先级: 菜单展开时关闭菜单 > 编辑时退出编辑 > 返回上一页
+ BackHandler(enabled = menuExpanded) {
+ menuExpanded = false
+ }
+ BackHandler(enabled = isEditing) {
+ keyboardController?.hide()
+ isEditing = false
+ }
+ BackHandler(enabled = !isEditing && !menuExpanded) {
+ onBackClick()
+ }
+
+ Column(Modifier.fillMaxSize().statusBarsPadding()) {
+ // 搜索栏 - InputField 测量高度 → 非编辑 Row 精确匹配,零跳动
+ if (isEditing) {
+ SearchBarDefaults.InputField(
+ query = searchQuery,
+ onQueryChange = viewModel::onQuery,
+ onSearch = {
+ viewModel.onSearch(it, viewModel.currentSourceMode)
+ keyboardController?.hide()
+ isEditing = false
+ },
+ expanded = false,
+ onExpandedChange = { },
+ placeholder = {
+ Text(stringResource(id = R.string.lbl_search_placeholder))
+ },
+ leadingIcon = {
+ val (backFocused, backModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onBackClick,
+ colors = focusedIconButtonColors(backFocused),
+ modifier = backModifier
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(id = R.string.back),
+ tint = if (backFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ },
+ trailingIcon = {
+ Row {
+ val (clearFocused, clearModifier) = rememberIsFocused()
+ IconButton(
+ onClick = viewModel::clearSearchQuery,
+ colors = focusedIconButtonColors(clearFocused),
+ modifier = clearModifier
+ ) {
Icon(
- imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = stringResource(id = R.string.back)
+ imageVector = Icons.Rounded.Clear,
+ contentDescription = stringResource(id = R.string.clear),
+ tint = if (clearFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
)
}
- },
- trailingIcon = {
- Row {
- IconButton(onClick = viewModel::clearSearchQuery) {
- Icon(imageVector = Icons.Rounded.Clear, contentDescription = "")
+ SourceModeMenu(
+ expanded = menuExpanded,
+ currentMode = viewModel.currentSourceMode,
+ onExpandedChange = { menuExpanded = it },
+ onModeSelected = { mode ->
+ menuExpanded = false
+ viewModel.currentSourceMode = mode
+ viewModel.getSearchData(searchQuery, mode)
}
- Box {
- IconButton(onClick = { menuExpanded = true }) {
- Icon(
- imageVector = Icons.Rounded.MoreVert,
- contentDescription = stringResource(id = R.string.more)
- )
- }
-
- DropdownMenu(
- expanded = menuExpanded,
- onDismissRequest = { menuExpanded = false }
- ) {
-
- SourceMode.entries.forEach { mode ->
- DropdownMenuItem(
- text = {
- Text(
- text = mode.name,
- color = if (viewModel.currentSourceMode == mode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
- )
- },
- onClick = {
- menuExpanded = false
- viewModel.currentSourceMode = mode
- viewModel.getSearchData(searchQuery, mode)
- },
- )
+ )
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .focusRequester(focusRequester)
+ .onGloballyPositioned { coordinates ->
+ searchBarHeight = with(density) { coordinates.size.height.toDp() }
+ }
+ .onPreviewKeyEvent { event ->
+ if (event.type == KeyEventType.KeyDown) {
+ when (event.key) {
+ Key.DirectionUp, Key.DirectionDown -> true
+ Key.DirectionCenter -> {
+ if (searchQuery.isNotEmpty()) {
+ viewModel.onSearch(searchQuery, viewModel.currentSourceMode)
}
+ keyboardController?.hide()
+ isEditing = false
+ true
}
+ else -> false
}
+ } else {
+ false
}
- },
- )
- },
- modifier = Modifier
- .align(Alignment.TopCenter)
- .focusRequester(focusRequester),
- expanded = searchBarExpanded,
- onExpandedChange = { if (!it) onBackClick() },
- ) {
- val context = LocalContext.current
- val isAndroidTV = isAndroidTV(LocalContext.current)
-
- LazyVerticalGrid(
- columns = if (isWideScreen(context)) GridCells.Adaptive(dimensionResource(R.dimen.media_card_width)) else GridCells.Fixed(
- 3
- ),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = PaddingValues(8.dp),
+ }
+ )
+ } else {
+ val (isNavFocused, navPillModifier) = rememberIsFocused()
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(searchBarHeight)
+ .padding(start = 12.dp, end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
- items(count = animesState.itemCount) { index ->
- val mediaFocusRequester = remember { FocusRequester() }
- var isFocused by remember { mutableStateOf(false) }
- val item = animesState[index]!!
- MediaSmall(
- image = item.img,
- label = item.title,
- onClick = {
- onNavigateToAnimeDetail(item.detailUrl, viewModel.currentSourceMode)
- },
- modifier = Modifier
- .onFocusChanged(onFocusChanged = { isFocused = it.isFocused })
- .run {
- if (isFocused && isAndroidTV) {
- border(
- 4.dp, MaterialTheme.colorScheme.primary,
- RoundedCornerShape(dimensionResource(R.dimen.media_card_corner_radius))
- )
- } else this
- }
-// .scale(if (isFocused && isAndroidTV) 1.1f else 1f)
- .focusRequester(mediaFocusRequester) // 设置焦点请求者
- .focusable()
+ val (backFocused, backModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onBackClick,
+ colors = focusedIconButtonColors(backFocused),
+ modifier = backModifier
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(id = R.string.back),
+ tint = if (backFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
)
-
- LaunchedEffect(item.detailUrl) {
- if (index == 0 && isAndroidTV) {
- mediaFocusRequester.requestFocus() // 将焦点移动到 MediaSmall
- }
- }
}
- item(span = { GridItemSpan(maxLineSpan) }) {
- PaginationStateHandler(
- paginationState = animesState,
- loadingComponent = {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(
- vertical = dimensionResource(
- id = R.dimen.medium_padding
- )
- ),
- contentAlignment = Alignment.Center
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .focusRequester(navFocusRequester)
+ .then(navPillModifier)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {
+ keyboardController?.show()
+ isEditing = true
+ }
+ .onPreviewKeyEvent { event ->
+ if (event.type == KeyEventType.KeyDown &&
+ event.key == Key.DirectionCenter
) {
- CircularProgressIndicator()
+ keyboardController?.show()
+ isEditing = true
+ true
+ } else {
+ false
}
- },
- errorComponent = {
- WarningMessage(
- textId = R.string.txt_empty_result,
- onRetryClick = {
- animesState.retry()
- }
- )
}
+ .clip(RoundedCornerShape(28.dp))
+ .background(
+ if (isNavFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Text(
+ text = searchQuery.ifEmpty { stringResource(id = R.string.lbl_search_placeholder) },
+ color = when {
+ isNavFocused -> MaterialTheme.colorScheme.onPrimary
+ searchQuery.isEmpty() -> MaterialTheme.colorScheme.onSurfaceVariant
+ else -> MaterialTheme.colorScheme.onSurface
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
}
+
+ val (clearFocused, clearModifier) = rememberIsFocused()
+ IconButton(
+ onClick = viewModel::clearSearchQuery,
+ colors = focusedIconButtonColors(clearFocused),
+ modifier = clearModifier
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Clear,
+ contentDescription = stringResource(id = R.string.clear),
+ tint = if (clearFocused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ SourceModeMenu(
+ expanded = menuExpanded,
+ currentMode = viewModel.currentSourceMode,
+ onExpandedChange = { menuExpanded = it },
+ onModeSelected = { mode ->
+ menuExpanded = false
+ viewModel.currentSourceMode = mode
+ viewModel.getSearchData(searchQuery, mode)
+ }
+ )
+ }
+ }
+
+ // 搜索结果 - 始终在搜索栏下方显示
+ val context = LocalContext.current
+
+ LazyVerticalGrid(
+ modifier = Modifier
+ .weight(1f)
+ .focusGroup(),
+ columns = if (isWideScreen(context)) GridCells.Adaptive(dimensionResource(R.dimen.media_card_width)) else GridCells.Fixed(
+ 3
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(8.dp),
+ ) {
+ items(count = animesState.itemCount) { index ->
+ val mediaFocusRequester = remember { FocusRequester() }
+ val item = animesState[index]!!
+ MediaSmall(
+ image = item.img,
+ label = item.title,
+ onClick = {
+ onNavigateToAnimeDetail(item.detailUrl, viewModel.currentSourceMode)
+ },
+ modifier = Modifier
+ .focusRequester(mediaFocusRequester)
+ )
+
+ LaunchedEffect(item.detailUrl) {
+ if (index == 0) {
+ mediaFocusRequester.requestFocus()
+ }
+ }
}
+
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ PaginationStateHandler(
+ paginationState = animesState,
+ loadingComponent = {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(
+ vertical = dimensionResource(
+ id = R.dimen.medium_padding
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ },
+ errorComponent = {
+ WarningMessage(
+ textId = R.string.txt_empty_result,
+ onRetryClick = {
+ animesState.retry()
+ }
+ )
+ }
+ )
+ }
+ }
}
}
+}
- LaunchedEffect(Unit) {
- if (!viewModel.hasFocusRequest) {
- viewModel.hasFocusRequest = true
- focusRequester.requestFocus()
+/**
+ * "More" button + source-mode dropdown, shared by both search-bar states.
+ */
+@Composable
+private fun SourceModeMenu(
+ expanded: Boolean,
+ currentMode: SourceMode,
+ onExpandedChange: (Boolean) -> Unit,
+ onModeSelected: (SourceMode) -> Unit,
+) {
+ val (moreFocused, moreModifier) = rememberIsFocused()
+ Box {
+ IconButton(
+ onClick = { onExpandedChange(true) },
+ colors = focusedIconButtonColors(moreFocused),
+ modifier = moreModifier
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.MoreVert,
+ contentDescription = stringResource(id = R.string.more),
+ tint = if (moreFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
}
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { onExpandedChange(false) }
+ ) {
+ SourceMode.entries.forEach { mode ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = mode.name,
+ color = if (currentMode == mode) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
+ onClick = { onModeSelected(mode) },
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchViewModel.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchViewModel.kt
index 331bb8e0..e70f98ad 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchViewModel.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/search/SearchViewModel.kt
@@ -34,9 +34,6 @@ class SearchViewModel @Inject constructor(
val needCaptchaUrl: StateFlow
get() = _needCaptchaUrl
- // 只在第一次进入时请求焦点
- var hasFocusRequest = false
-
/**
* 用于标识使用当前动漫源搜索数据
*
@@ -70,15 +67,14 @@ class SearchViewModel @Inject constructor(
.cachedIn(viewModelScope)
.collect {
_animesState.value = it
- checkNeedCaptcha()
}
}
}
/**
- * 检查是否需要验证码
+ * 检查是否需要验证码 — 由 UI 在 PagingSource 加载完成后调用
*/
- private fun checkNeedCaptcha() {
+ fun checkNeedCaptcha() {
val url = CaptchaCookieManager.captchaUrl
if (url.isNotEmpty()) {
_needCaptchaUrl.value = url
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/AppearanceScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/AppearanceScreen.kt
index 647cf459..8e488748 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/AppearanceScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/AppearanceScreen.kt
@@ -3,8 +3,11 @@ package com.lanlinju.animius.presentation.screen.settings
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,7 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
@@ -24,12 +27,15 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Colorize
+import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
@@ -42,12 +48,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+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.draw.drawBehind
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
@@ -63,6 +72,9 @@ import com.lanlinju.animius.util.KEY_DYNAMIC_IMAGE_COLOR
import com.lanlinju.animius.util.SettingsPreferences
import com.lanlinju.animius.util.catpucchinLatte
import com.lanlinju.animius.util.rememberPreference
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.handleDPadKeyEvents
+import com.lanlinju.animius.util.focus.rememberIsFocused
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,10 +90,17 @@ fun AppearanceScreen(
title = { Text(text = stringResource(id = R.string.appearance_settings)) },
scrollBehavior = topBarBehavior,
navigationIcon = {
- IconButton(onClick = onBackClick) {
+ val (backFocused, backFocusModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onBackClick,
+ colors = focusedIconButtonColors(backFocused),
+ modifier = backFocusModifier
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = stringResource(id = R.string.back)
+ contentDescription = stringResource(id = R.string.back),
+ tint = if (backFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
}
@@ -96,7 +115,18 @@ fun AppearanceScreen(
.verticalScroll(rememberScrollState()), // 先nestedScroll,然后verticalScroll的顺序
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- ThemeModeSettings(modifier = Modifier.padding(start = 24.dp))
+ val themeFocusRequester = remember { FocusRequester() }
+ val dynamicColorFocusRequester = remember { FocusRequester() }
+ val firstColorFocusRequester = remember { FocusRequester() }
+
+ ThemeModeSettings(
+ modifier = Modifier
+ .padding(start = 24.dp)
+ .handleDPadKeyEvents(
+ onDown = { firstColorFocusRequester.requestFocus() }
+ ),
+ focusRequester = themeFocusRequester
+ )
val selectedColor by SettingsPreferences.customColor.collectAsState()
val dynamicColor by SettingsPreferences.dynamicColor.collectAsState()
@@ -105,6 +135,7 @@ fun AppearanceScreen(
ColorBall(
selectedColor = selectedColor,
isCheckVisible = !dynamicColor,
+ firstItemFocusRequester = firstColorFocusRequester,
onSelect = {
isDynamicImageColor = false
SettingsPreferences.changeDynamicColor(false)
@@ -129,6 +160,10 @@ fun AppearanceScreen(
summary = stringResource(id = R.string.dynamic_color_description),
painter = rememberVectorPainter(image = Icons.Outlined.Colorize),
checked = dynamicColor,
+ focusRequester = dynamicColorFocusRequester,
+ modifier = Modifier.handleDPadKeyEvents(
+ onUp = { firstColorFocusRequester.requestFocus() }
+ ),
onCheckedChange = {
isDynamicImageColor = false
SettingsPreferences.changeDynamicColor(it)
@@ -146,13 +181,27 @@ fun AppearanceScreen(
SettingsPreferences.changeDynamicColor(!isDynamicImageColor)
}
)
+
+ val pureBackground by SettingsPreferences.pureBackground.collectAsState()
+ SwitchPref(
+ title = stringResource(id = R.string.pure_background),
+ summary = stringResource(id = R.string.pure_background_description),
+ painter = rememberVectorPainter(image = Icons.Outlined.Contrast),
+ checked = pureBackground,
+ onCheckedChange = {
+ SettingsPreferences.changePureBackground(it)
+ }
+ )
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
-private fun ThemeModeSettings(modifier: Modifier = Modifier) {
+private fun ThemeModeSettings(
+ modifier: Modifier = Modifier,
+ focusRequester: FocusRequester? = null,
+) {
Column(modifier = modifier) {
val options =
SettingsPreferences.ThemeMode.entries.map { stringResource(id = it.resId) }
@@ -168,6 +217,8 @@ private fun ThemeModeSettings(modifier: Modifier = Modifier) {
SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
+ val (isFocused, focusModifier) = rememberIsFocused()
+ val defaultColors = SegmentedButtonDefaults.colors()
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
@@ -178,7 +229,23 @@ private fun ThemeModeSettings(modifier: Modifier = Modifier) {
val themeMode = SettingsPreferences.ThemeMode.values()[index]
SettingsPreferences.changeThemeMode(themeMode)
},
- selected = index == selectedIndex
+ selected = index == selectedIndex,
+ colors = SegmentedButtonDefaults.colors(
+ activeContainerColor = if (isFocused) MaterialTheme.colorScheme.primary
+ else defaultColors.activeContainerColor,
+ activeContentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary
+ else defaultColors.activeContentColor,
+ inactiveContainerColor = if (isFocused) MaterialTheme.colorScheme.primary
+ else defaultColors.inactiveContainerColor,
+ inactiveContentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary
+ else defaultColors.inactiveContentColor
+ ),
+ modifier = focusModifier
+ .then(
+ if (index == 0 && focusRequester != null) {
+ Modifier.focusRequester(focusRequester)
+ } else Modifier
+ )
) {
Text(label)
}
@@ -196,6 +263,7 @@ fun ColorBall(
selectedColor: Int,
isCheckVisible: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
+ firstItemFocusRequester: FocusRequester? = null,
onSelect: (Int) -> Unit,
) {
LazyRow(
@@ -205,16 +273,27 @@ fun ColorBall(
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(horizontal = 24.dp),
) {
- items(catpucchinLatte) { color ->
+ itemsIndexed(catpucchinLatte) { index, color ->
val isSelected = color == selectedColor && isCheckVisible
val containerSize by animateDpAsState(targetValue = if (isSelected) 28.dp else 0.dp)
val iconSize by animateDpAsState(targetValue = if (isSelected) 16.dp else 0.dp)
+ val (isFocused, focusModifier) = rememberIsFocused()
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color(color))
+ .then(
+ if (isFocused) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
+ else Modifier
+ )
+ .then(focusModifier)
+ .then(
+ if (index == 0 && firstItemFocusRequester != null) {
+ Modifier.focusRequester(firstItemFocusRequester!!)
+ } else Modifier
+ )
.clickable { onSelect(color) },
contentAlignment = Alignment.Center
) {
@@ -245,6 +324,8 @@ fun SwitchPref(
summary: String? = null,
checked: Boolean,
painter: Painter? = null,
+ focusRequester: FocusRequester? = null,
+ modifier: Modifier = Modifier,
titleStyle: TextStyle = MaterialTheme.typography.titleLarge,
onCheckedChange: (Boolean) -> Unit = {}
) {
@@ -253,20 +334,41 @@ fun SwitchPref(
} else {
null
}
+ val (isFocused, focusModifier) = rememberIsFocused()
ListItem(
- modifier = Modifier
+ modifier = modifier
.fillMaxWidth()
+ .then(focusModifier)
+ .then(
+ if (focusRequester != null) Modifier.focusRequester(focusRequester)
+ else Modifier
+ )
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current
) {
onCheckedChange(!checked)
},
- headlineContent = { Text(text = title, style = titleStyle) },
+ colors = ListItemDefaults.colors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.surfaceVariant
+ else Color.Transparent
+ ),
+ headlineContent = {
+ Text(
+ text = title,
+ style = titleStyle,
+ color = if (isFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
leadingContent = leadingContent,
supportingContent = {
if (summary != null) {
- Text(text = summary)
+ Text(
+ text = summary,
+ color = if (isFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
},
trailingContent = {
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/DanmakuSettingsScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/DanmakuSettingsScreen.kt
index 4b078367..232f1b30 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/DanmakuSettingsScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/settings/DanmakuSettingsScreen.kt
@@ -13,11 +13,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
@@ -30,6 +32,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -51,6 +60,8 @@ import com.lanlinju.animius.presentation.screen.settings.DanmakuConfigData.Compa
import com.lanlinju.animius.util.KEY_DANMAKU_CONFIG_DATA
import com.lanlinju.animius.util.KEY_DANMAKU_ENABLED
import com.lanlinju.animius.util.rememberPreference
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import kotlinx.serialization.Serializable
import java.util.UUID
import kotlin.math.roundToInt
@@ -67,10 +78,17 @@ fun DanmakuSettingsScreen(onBackClick: () -> Unit = {}) {
title = { Text(text = stringResource(id = R.string.danmaku_settings)) },
scrollBehavior = topBarBehavior,
navigationIcon = {
- IconButton(onClick = onBackClick) {
+ val (backFocused, backModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onBackClick,
+ colors = focusedIconButtonColors(backFocused),
+ modifier = backModifier
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = stringResource(id = R.string.back)
+ contentDescription = stringResource(id = R.string.back),
+ tint = if (backFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
}
@@ -160,11 +178,19 @@ fun DanmakuFontPreview(
@Composable
fun ResetButton(onReset: () -> Unit) {
+ val (isFocused, focusModifier) = rememberIsFocused()
Button(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp),
- onClick = onReset
+ .padding(16.dp)
+ .then(focusModifier),
+ onClick = onReset,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
) {
Text(text = stringResource(R.string.reset_default))
}
@@ -211,7 +237,8 @@ fun DanmakuSliders(
onValueChange = { alpha = it },
onValueChangeFinished = {
onConfigChange(config.copy(alpha = alpha))
- }
+ },
+ dpadStep = 0.05f
)
var fontSize by remember(config) { mutableFloatStateOf(config.fontSize / defaultDanmakuStyle.fontSize.value) }
@@ -225,7 +252,8 @@ fun DanmakuSliders(
config.copy(fontSize = fontSize * defaultDanmakuStyle.fontSize.value)
)
},
- valueLabel = "${(fontSize * 100).roundToInt()}%"
+ valueLabel = "${(fontSize * 100).roundToInt()}%",
+ dpadStep = 0.05f
)
var strokeWidth by remember(config) {
@@ -242,6 +270,7 @@ fun DanmakuSliders(
valueRange = 0f..2f,
title = stringResource(R.string.danmaku_stroke_width),
valueLabel = "${(strokeWidth * 100).roundToInt()}%",
+ dpadStep = 0.1f
)
var fontWeight by remember(config) { mutableStateOf(config.fontWeight.toFloat()) }
@@ -270,6 +299,7 @@ fun DanmakuSliders(
valueRange = 0.2f..3f,
title = stringResource(R.string.danmaku_speed),
valueLabel = "${(speed * 100).roundToInt()}%",
+ dpadStep = 0.1f
)
var displayArea by remember(config) {
@@ -335,16 +365,34 @@ fun SliderItem(
onValueChangeFinished: (() -> Unit)? = null,
valueLabel: String = "",
titleStyle: TextStyle = MaterialTheme.typography.titleMedium,
+ dpadStep: Float = Float.NaN,
) {
+ val (isFocused, focusModifier) = rememberIsFocused()
ListItem(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(focusModifier),
+ colors = ListItemDefaults.colors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.surfaceVariant
+ else Color.Transparent
+ ),
headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
- Text(text = title, style = titleStyle)
- Text(text = valueLabel, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ text = title,
+ style = titleStyle,
+ color = if (isFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = valueLabel,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (isFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
},
supportingContent = {
@@ -354,6 +402,27 @@ fun SliderItem(
onValueChangeFinished = onValueChangeFinished,
valueRange = valueRange,
steps = steps,
+ modifier = Modifier.onKeyEvent { event ->
+ if (event.type == KeyEventType.KeyDown) {
+ val range = valueRange.endInclusive - valueRange.start
+ val step = if (!dpadStep.isNaN()) dpadStep
+ else if (steps > 0) range / (steps + 1)
+ else range / 20f
+ when (event.key) {
+ Key.DirectionRight -> {
+ onValueChange((value + step).coerceIn(valueRange))
+ onValueChangeFinished?.invoke()
+ true
+ }
+ Key.DirectionLeft -> {
+ onValueChange((value - step).coerceIn(valueRange))
+ onValueChangeFinished?.invoke()
+ true
+ }
+ else -> false
+ }
+ } else false
+ }
)
},
)
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerScreen.kt
index d8b276f1..49b975c8 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerScreen.kt
@@ -26,6 +26,9 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -45,8 +48,10 @@ 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.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -72,6 +77,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -95,6 +101,7 @@ import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
@@ -105,6 +112,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
@@ -115,6 +123,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.media3.common.C
import com.anime.danmaku.api.DanmakuEvent
import com.anime.danmaku.api.DanmakuPresentation
import com.anime.danmaku.api.DanmakuSession
@@ -133,6 +142,9 @@ import com.lanlinju.animius.util.KEY_AUTO_ORIENTATION_ENABLED
import com.lanlinju.animius.util.KEY_DANMAKU_CONFIG_DATA
import com.lanlinju.animius.util.isAndroidTV
import com.lanlinju.animius.util.isTabletDevice
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.focusedOutlinedButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import com.lanlinju.animius.util.isWideScreen
import com.lanlinju.animius.util.openExternalPlayer
import com.lanlinju.animius.util.rememberPreference
@@ -142,6 +154,7 @@ import com.lanlinju.videoplayer.VideoPlayer
import com.lanlinju.videoplayer.VideoPlayerControl
import com.lanlinju.videoplayer.VideoPlayerState
import com.lanlinju.videoplayer.prettyVideoTimestamp
+import com.lanlinju.videoplayer.component.Slider
import com.lanlinju.videoplayer.rememberVideoPlayerState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -190,11 +203,78 @@ fun VideoPlayScreen(
val playerState = rememberVideoPlayerState(isAutoOrientation = isAutoOrientation)
+ val sliderFocusRequester = remember { FocusRequester() }
+ val backFocusRequester = remember { FocusRequester() }
+ val forwardFocusRequester = remember { FocusRequester() }
+ val playPauseFocusRequester = remember { FocusRequester() }
+ val episodeFocusRequester = remember { FocusRequester() }
+ var pendingFocusTarget by remember { mutableStateOf(null) }
+
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
- .adaptiveSize(playerState.isFullscreen.value, view, activity),
+ .adaptiveSize(playerState.isFullscreen.value, view, activity)
+ .focusable()
+ .onKeyEvent { event ->
+ // Back key: immediately hide any visible player UI
+ // Intercepted here because OnBackPressedDispatcher/BackHandler
+ // does not fire reliably on tablet/non-TV Compose for the first press.
+ if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
+ when {
+ playerState.isSpeedUiVisible.value -> { playerState.hideSpeedUi(); return@onKeyEvent true }
+ playerState.isResizeUiVisible.value -> { playerState.hideResizeUi(); return@onKeyEvent true }
+ playerState.isEpisodeUiVisible.value -> { playerState.hideEpisodeUi(); return@onKeyEvent true }
+ playerState.isControlUiVisible.value -> { playerState.hideControlUi(); return@onKeyEvent true }
+ }
+ }
+ // 侧栏打开时交给侧栏自己处理按键
+ if (playerState.isEpisodeUiVisible.value ||
+ playerState.isSpeedUiVisible.value ||
+ playerState.isResizeUiVisible.value) return@onKeyEvent false
+ // 控制 UI 可见:交给默认焦点导航(Slider 可聚焦,按钮间正常左右导航)
+ if (playerState.isControlUiVisible.value) return@onKeyEvent false
+ // 隐藏 UI 快捷键
+ if (event.type == KeyEventType.KeyDown) {
+ when (event.key) {
+ Key.DirectionLeft -> {
+ playerState.onTimedSeek(-10000)
+ pendingFocusTarget = FocusTarget.SLIDER
+ true
+ }
+ Key.DirectionRight -> {
+ playerState.onTimedSeek(10000)
+ pendingFocusTarget = FocusTarget.SLIDER
+ true
+ }
+ else -> false
+ }
+ } else if (event.type == KeyEventType.KeyUp) {
+ when (event.key) {
+ Key.DirectionUp -> {
+ playerState.showControlUi()
+ pendingFocusTarget = FocusTarget.FORWARD
+ true
+ }
+ Key.DirectionDown -> {
+ playerState.showControlUi()
+ pendingFocusTarget = FocusTarget.EPISODE
+ true
+ }
+ Key.DirectionCenter, Key.Spacebar -> {
+ if (playerState.isPlaying.value) {
+ playerState.control.pause()
+ playerState.showControlUi()
+ pendingFocusTarget = FocusTarget.PLAY_PAUSE
+ } else {
+ playerState.control.play()
+ }
+ true
+ }
+ else -> false
+ }
+ } else false
+ },
contentAlignment = Alignment.Center
) {
@@ -205,16 +285,42 @@ fun VideoPlayScreen(
playerState = playerState,
headers = video.headers,
onBackPress = { handleBackPress(playerState, onBackClick, view, activity) },
- modifier = Modifier
- .focusable()
- .defaultRemoteControlHandler(
- playerState = playerState,
- onNextClick = { viewModel.playNextEpisode(playerState.player.currentPosition) }
- )
) {
+
+ // Deferred focus: wait for AnimatedVisibility to compose before requesting
+ LaunchedEffect(pendingFocusTarget) {
+ pendingFocusTarget?.let { target ->
+ delay(50)
+ runCatching {
+ when (target) {
+ FocusTarget.FORWARD -> forwardFocusRequester.requestFocus()
+ FocusTarget.PLAY_PAUSE -> playPauseFocusRequester.requestFocus()
+ FocusTarget.EPISODE -> episodeFocusRequester.requestFocus()
+ FocusTarget.SLIDER -> sliderFocusRequester.requestFocus()
+ }
+ }
+ pendingFocusTarget = null
+ }
+ }
+
+ val videoSize = playerState.videoSize.value
+ val subtitle = if (videoSize.width > 0 && videoSize.height > 0) {
+ val resolution = "${videoSize.width}×${videoSize.height}"
+ val bitrate = playerState.player.currentTracks.groups
+ .firstOrNull { it.type == C.TRACK_TYPE_VIDEO }
+ ?.getTrackFormat(0)?.bitrate
+ ?.takeIf { it > 0 }
+ if (bitrate != null) {
+ "$resolution · Bitrate ${"%.1f".format(bitrate / 1_000_000f)}Mbps"
+ } else {
+ resolution
+ }
+ } else null
+
VideoPlayerControl(
state = playerState,
title = "${video.title}-${video.episodeName}",
+ subtitle = subtitle,
danmakuEnabled = danmakuEnabled,
onBackClick = { handleBackPress(playerState, onBackClick, view, activity) },
onNextClick = {
@@ -227,13 +333,26 @@ fun VideoPlayScreen(
video = video,
isAutoContinuePlayEnabled = isAutoContinuePlayEnabled,
onAutoContinuePlayClick = { isAutoContinuePlayEnabled = it },
- onForwardClick = { playerState.control.skip(85000) }
+ onForwardClick = { playerState.control.skip(85000) },
+ forwardFocusRequester = forwardFocusRequester
)
},
- onDanmakuClick = { viewModel.setEnabledDanmaku(it) }
+ onDanmakuClick = { viewModel.setEnabledDanmaku(it) },
+ modifier = Modifier,
+ sliderFocusRequester = sliderFocusRequester,
+ backFocusRequester = backFocusRequester,
+ forwardFocusRequester = forwardFocusRequester,
+ playPauseFocusRequester = playPauseFocusRequester,
+ episodeFocusRequester = episodeFocusRequester
)
}
+ // Default focus on initial load
+ LaunchedEffect(Unit) {
+ delay(100)
+ runCatching { playPauseFocusRequester.requestFocus() }
+ }
+
// Danmaku and additional UI components
DanmakuHost(playerState, danmakuSession, danmakuEnabled)
VideoStateMessage(playerState, viewModel, isAutoContinuePlayEnabled)
@@ -281,6 +400,8 @@ private fun ShowLoadingPage() {
// Failure screen composable
@Composable
private fun ShowFailurePage(viewModel: VideoPlayerViewModel, onBackClick: () -> Unit) {
+ val retryFocusRequester = remember { FocusRequester() }
+
Column(
modifier = Modifier
.fillMaxSize()
@@ -300,17 +421,31 @@ private fun ShowFailurePage(viewModel: VideoPlayerViewModel, onBackClick: () ->
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.padding(vertical = 8.dp))
- OutlinedButton(onClick = onBackClick) {
- Text(text = stringResource(id = R.string.back), color = Color.White)
+ val (backFocused, backModifier) = rememberIsFocused()
+ OutlinedButton(
+ onClick = onBackClick,
+ modifier = Modifier.then(backModifier),
+ colors = focusedOutlinedButtonColors(
+ backFocused,
+ unfocusedContentColor = Color.White
+ )
+ ) {
+ Text(text = stringResource(id = R.string.back))
}
Spacer(modifier = Modifier.padding(vertical = 8.dp))
- OutlinedButton(onClick = { viewModel.retry() }) {
- Text(
- text = stringResource(id = R.string.retry),
- color = MaterialTheme.colorScheme.primary
- )
+ val (retryFocused, retryModifier) = rememberIsFocused()
+ OutlinedButton(
+ onClick = { viewModel.retry() },
+ modifier = Modifier.focusRequester(retryFocusRequester).then(retryModifier),
+ colors = focusedOutlinedButtonColors(retryFocused)
+ ) {
+ Text(text = stringResource(id = R.string.retry))
}
}
+
+ LaunchedEffect(Unit) {
+ runCatching { retryFocusRequester.requestFocus() }
+ }
}
// Back press handler
@@ -376,72 +511,45 @@ private fun DanmakuHost(
}
}
-private fun Modifier.defaultRemoteControlHandler(
- playerState: VideoPlayerState,
- onNextClick: () -> Unit = {},
-) = onKeyEvent { keyEvent: KeyEvent ->
- if (keyEvent.type == KeyEventType.KeyDown)
- when (keyEvent.key) {
- Key.DirectionLeft -> {
- playerState.showControlUi()
- playerState.control.rewind()
- true
- }
-
- Key.DirectionRight -> {
- playerState.showControlUi()
- playerState.control.forward()
- true
- }
-
- Key.DirectionUp -> {
- playerState.showEpisodeUi()
- true
- }
-
- Key.DirectionDown -> {
- playerState.showControlUi()
- onNextClick()
- true
- }
-
- Key.DirectionCenter, Key.Spacebar -> {
- if (playerState.isPlaying.value) {
- playerState.showControlUi()
- playerState.control.pause()
- } else {
- playerState.control.play()
- }
- true
- }
-
- else -> false
- } else {
- false
- }
-}
+private enum class FocusTarget { FORWARD, PLAY_PAUSE, EPISODE, SLIDER }
@Composable
private fun OptionsContent(
video: Video,
isAutoContinuePlayEnabled: Boolean,
onAutoContinuePlayClick: (Boolean) -> Unit,
- onForwardClick: () -> Unit = {}
+ onForwardClick: () -> Unit = {},
+ forwardFocusRequester: FocusRequester = remember { FocusRequester() }
) {
var expanded by remember { mutableStateOf(false) }
+ val (forwardFocused, forwardModifier) = rememberIsFocused()
+ val (moreFocused, moreModifier) = rememberIsFocused()
Row {
- IconButton(onClick = onForwardClick) {
+ IconButton(
+ onClick = onForwardClick,
+ colors = focusedIconButtonColors(forwardFocused, Color.White),
+ modifier = forwardModifier
+ .focusRequester(forwardFocusRequester)
+ ) {
Icon(
imageVector = Icons.Rounded.Forward85,
- contentDescription = "Forward 85s"
+ contentDescription = "Forward 85s",
+ tint = if (forwardFocused) MaterialTheme.colorScheme.onPrimary
+ else Color.White
)
}
Box {
- IconButton(onClick = { expanded = true }) {
+ IconButton(
+ onClick = { expanded = true },
+ colors = focusedIconButtonColors(moreFocused, Color.White),
+ modifier = moreModifier
+ ) {
Icon(
imageVector = Icons.Rounded.MoreVert,
- contentDescription = null
+ contentDescription = null,
+ tint = if (moreFocused) MaterialTheme.colorScheme.onPrimary
+ else Color.White
)
}
@@ -449,26 +557,46 @@ private fun OptionsContent(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
+ val (externalPlayFocused, externalModifier) = rememberIsFocused()
DropdownMenuItem(
text = {
- Text(text = stringResource(id = R.string.external_play))
+ Text(
+ text = stringResource(id = R.string.external_play),
+ color = if (externalPlayFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
},
onClick = {
expanded = false
openExternalPlayer(video.url)
- }
+ },
+ modifier = externalModifier
+ .then(
+ if (externalPlayFocused) Modifier.background(MaterialTheme.colorScheme.primary)
+ else Modifier
+ )
)
+ val (autoPlayFocused, autoPlayModifier) = rememberIsFocused()
DropdownMenuItem(
text = {
Text(
text = stringResource(R.string.auto_continue_play),
- color = if (isAutoContinuePlayEnabled) MaterialTheme.colorScheme.primary else Color.Black
+ color = when {
+ autoPlayFocused -> MaterialTheme.colorScheme.onPrimary
+ isAutoContinuePlayEnabled -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onSurface
+ }
)
},
onClick = {
onAutoContinuePlayClick(!isAutoContinuePlayEnabled)
- }
+ },
+ modifier = autoPlayModifier
+ .then(
+ if (autoPlayFocused) Modifier.background(MaterialTheme.colorScheme.primary)
+ else Modifier
+ )
)
}
}
@@ -530,19 +658,23 @@ private fun VideoStateMessage(
modifier: Modifier = Modifier
) {
val videoState = viewModel.videoState.collectAsState().value
+ val videoLoadError by viewModel.videoLoadError.collectAsState()
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
- if (playerState.isLoading.value && !playerState.isError.value && !playerState.isSeeking.value) {
+ if (playerState.isLoading.value && !playerState.isError.value && !playerState.isSeeking.value && videoLoadError == null) {
CircularProgressIndicator()
}
- if (playerState.isError.value) {
+ // 加载失败(切换剧集/线路或播放器错误):显示错误浮层
+ if (videoLoadError != null || playerState.isError.value) {
ShowVideoMessage(
stringResource(id = R.string.video_error_msg),
- onRetryClick = { playerState.control.retry() }
+ onRetryClick = {
+ if (videoLoadError != null) viewModel.retryLoad() else playerState.control.retry()
+ }
)
}
@@ -553,7 +685,7 @@ private fun VideoStateMessage(
FloatingMessageIndicator(stringResource(R.string.auto_play_next, countdown))
}
- if (playerState.isSeeking.value) {
+ if (playerState.isSeeking.value && playerState.isControlUiVisible.value) {
TimelineIndicator(
(playerState.videoDurationMs.value * playerState.videoProgress.value).toLong(),
playerState.videoDurationMs.value
@@ -802,26 +934,26 @@ private fun ShowVideoMessage(text: String, onRetryClick: (() -> Unit)? = null) {
// 重试 Button
onRetryClick?.let {
val focusRequester = remember { FocusRequester() }
- val isAndroidTV = isAndroidTV(LocalContext.current)
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
Spacer(modifier = Modifier.padding(vertical = 8.dp))
OutlinedButton(
- colors = ButtonDefaults.outlinedButtonColors(
- containerColor = if (isAndroidTV) MaterialTheme.colorScheme.primary.copy(
- alpha = 0.3f
- ) else Color.Unspecified
+ colors = focusedOutlinedButtonColors(
+ isActive,
+ unfocusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
),
modifier = Modifier
- .focusRequester(focusRequester)
- .focusable(),
+ .focusRequester(focusRequester),
+ interactionSource = interactionSource,
onClick = it
) {
Text(text = stringResource(id = R.string.retry))
}
LaunchedEffect(Unit) {
- if (isAndroidTV) {
- focusRequester.requestFocus()
- }
+ focusRequester.requestFocus()
}
}
}
@@ -862,7 +994,11 @@ private fun VideoSideSheet(
var selectedEpisodeIndex by remember(video.currentEpisodeIndex) { mutableIntStateOf(video.currentEpisodeIndex) }
EpisodeSideSheet(
episodes = video.episodes,
- selectedEpisodeIndex = selectedEpisodeIndex,
+ channels = video.channels,
+ channelIndex = video.channelIndex,
+ playingEpisodeUrl = video.episodeUrl,
+ focusIndex = selectedEpisodeIndex,
+ onChannelClick = { channelIndex -> viewModel.switchChannel(channelIndex) },
onEpisodeClick = { index, episode ->
playerState.control.pause()
playerState.setLoading(true)
@@ -887,6 +1023,9 @@ private fun SpeedSideSheet(
onDismissRequest: () -> Unit
) {
val speeds = remember { Speeds.reversedArray() }
+ val focusRequester = remember { FocusRequester() }
+
+ BackHandler { onDismissRequest() }
SideSheet(onDismissRequest = onDismissRequest, widthRatio = 0.2f) {
Column(
@@ -896,14 +1035,21 @@ private fun SpeedSideSheet(
) {
speeds.forEachIndexed { index, speed ->
AdaptiveTextButton(
+ // 不强制固定尺寸:按内容自适应宽度(最小 42dp),避免 "1.25X"/"0.75X" 被截断成 "1.2..."/"0.7..."
text = speed.first,
- modifier = Modifier.size(MediumTextButtonSize),
+ modifier = Modifier
+ .then(if (index == 0) Modifier.focusRequester(focusRequester) else Modifier),
onClick = { onSpeedClick(index, speed) },
color = if (selectedSpeedIndex == index) MaterialTheme.colorScheme.primary else Color.LightGray,
+ fontWeight = if (selectedSpeedIndex == index) FontWeight.Bold else null,
)
}
}
}
+
+ LaunchedEffect(Unit) {
+ runCatching { focusRequester.requestFocus() }
+ }
}
@Composable
@@ -912,6 +1058,9 @@ private fun ResizeSideSheet(
onResizeClick: (Int, Pair) -> Unit,
onDismissRequest: () -> Unit
) {
+ val focusRequester = remember { FocusRequester() }
+
+ BackHandler { onDismissRequest() }
SideSheet(onDismissRequest = onDismissRequest, widthRatio = 0.2f) {
Column(
@@ -922,74 +1071,257 @@ private fun ResizeSideSheet(
Resizes.forEachIndexed { index, resize ->
AdaptiveTextButton(
text = resize.first,
- modifier = Modifier.size(MediumTextButtonSize),
+ modifier = Modifier
+ .size(MediumTextButtonSize)
+ .then(if (index == 0) Modifier.focusRequester(focusRequester) else Modifier),
onClick = { onResizeClick(index, resize) },
color = if (selectedResizeIndex == index) MaterialTheme.colorScheme.primary else Color.LightGray,
+ fontWeight = if (selectedResizeIndex == index) FontWeight.Bold else null,
)
}
}
}
+
+ LaunchedEffect(Unit) {
+ runCatching { focusRequester.requestFocus() }
+ }
}
+// 线路 tab 与集数按钮共用的边框/颜色:active=聚焦或按压,secondary=选中或播放
+@Composable
+private fun channelBorderStroke(active: Boolean, secondary: Boolean): BorderStroke {
+ val primary = MaterialTheme.colorScheme.primary
+ return BorderStroke(
+ 1.0.dp,
+ if (active || secondary) primary else MaterialTheme.colorScheme.outline.copy(0.5f)
+ )
+}
+
+@Composable
+private fun channelButtonColors(active: Boolean, secondary: Boolean) =
+ ButtonDefaults.outlinedButtonColors(
+ containerColor = if (active) MaterialTheme.colorScheme.primary else Color.Transparent,
+ contentColor = when {
+ active -> MaterialTheme.colorScheme.onPrimary
+ secondary -> MaterialTheme.colorScheme.primary
+ else -> Color.LightGray
+ }
+ )
+
@Composable
private fun EpisodeSideSheet(
episodes: List,
- selectedEpisodeIndex: Int,
+ channels: Map>,
+ channelIndex: Int,
+ playingEpisodeUrl: String,
+ focusIndex: Int,
+ onChannelClick: (Int) -> Unit,
onEpisodeClick: (Int, Episode) -> Unit,
onDismissRequest: () -> Unit
) {
val context = LocalContext.current
- val isAndroidTV = remember { isAndroidTV(context) }
+ // 稳定焦点请求器:打开侧栏或切换线路后主动夺焦
+ val selectedFocusRequester = remember { FocusRequester() }
+ val channelListState = rememberLazyListState()
+ // 待聚焦的集数 index:首帧为 focusIndex(打开即聚焦播放集),左/右切线路时设为同 index
+ var pendingFocusIndex by remember { mutableStateOf(focusIndex) }
+ // UP 键从第一集跳转到当前线路 tab 的待聚焦标记(null = 无)
+ var pendingTabFocusIndex by remember { mutableStateOf(null) }
+ // 当前聚焦的集数 index(左/右切线路后保持同 index)
+ var focusedEpisodeIndex by remember { mutableIntStateOf(focusIndex) }
+ // 当前线路 tab 的焦点请求器(UP 键从集数列表聚焦到它)
+ val channelFocusRequester = remember { FocusRequester() }
+
+ // 正在播放的集数在当前线路列表中的 index(不在当前线路则为 -1)
+ val playingIndex = episodes.indexOfFirst { it.url == playingEpisodeUrl }
+ // 正在播放的线路 index(跨所有线路查找)
+ val playingChannelIndex = channels.entries
+ .firstOrNull { (_, eps) -> eps.any { it.url == playingEpisodeUrl } }
+ ?.key ?: -1
+ // 焦点目标:左/右切线路后保持同 index,否则为默认 focusIndex
+ val targetFocusIndex = (pendingFocusIndex ?: focusIndex)
+ .coerceIn(0, (episodes.size - 1).coerceAtLeast(0))
+
SideSheet(onDismissRequest = onDismissRequest, widthRatio = 0.38f) {
- LazyColumn(
- modifier = Modifier.padding(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- state = rememberLazyListState(selectedEpisodeIndex, -200)
- ) {
- itemsIndexed(episodes) { index, episode ->
- val focusRequester = remember { FocusRequester() }
- var isFocused by remember { mutableStateOf(false) }
- val selected = index == selectedEpisodeIndex
-
- OutlinedButton(
- onClick = { onEpisodeClick(index, episode) },
- contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp),
- shape = RoundedCornerShape(4.dp),
- border = BorderStroke(
- 1.0.dp,
- if (selected) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.outline.copy(0.5f)
- ),
+ Column(Modifier.fillMaxSize()) {
+ if (channels.size > 1) {
+ LazyRow(
modifier = Modifier
.fillMaxWidth()
- .onFocusChanged(onFocusChanged = { isFocused = it.isFocused })
- .focusRequester(focusRequester)
- .focusable()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ state = channelListState
) {
- Box(modifier = Modifier.fillMaxSize()) {
- if (selected) {
- EpisodePlaybackIndicator(Modifier.align(Alignment.CenterStart))
+ items(channels.size) { index ->
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
+ val selected = index == channelIndex
+ val isPlayingChannel = index == playingChannelIndex
+
+ OutlinedButton(
+ onClick = { onChannelClick(index) },
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
+ shape = RoundedCornerShape(4.dp),
+ border = channelBorderStroke(isActive, selected),
+ colors = channelButtonColors(isActive, selected),
+ // 焦点在 tab 上时:左右切换 tab 并同步切换线路,焦点保持在 tab 上
+ modifier = Modifier
+ .then(
+ if (index == channelIndex) {
+ Modifier.focusRequester(channelFocusRequester)
+ } else Modifier
+ )
+ .onFocusChanged {
+ if (it.isFocused && index != channelIndex) onChannelClick(index)
+ },
+ interactionSource = interactionSource
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ // 左侧 15dp 空位(播放指示图标位置)
+ Box(
+ modifier = Modifier
+ .width(15.dp)
+ .height(16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ if (isPlayingChannel) {
+ EpisodePlaybackIndicator(
+ tint = if (isActive) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ Text(
+ text = stringResource(R.string.channel_number, index + 1),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1
+ )
+ // 右侧 15dp 空位,与左侧对称
+ Spacer(Modifier.width(15.dp))
+ }
}
-
- Text(
- text = episode.name,
- color = if (selected) MaterialTheme.colorScheme.primary else Color.LightGray,
- style = MaterialTheme.typography.labelLarge,
- maxLines = 1,
- modifier = Modifier.align(Alignment.Center)
- )
}
}
+ }
- LaunchedEffect(selected) {
- if (selected && isAndroidTV) {
- focusRequester.requestFocus()
+ // 切换线路后让选中标签滚动到可见位置
+ LaunchedEffect(channelIndex) {
+ if (channels.size > 1) {
+ channelListState.animateScrollToItem(channelIndex.coerceAtLeast(0))
+ }
+ }
+
+ key(channelIndex) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(8.dp)
+ // 焦点在集数列表时:左/右快速切换线路并聚焦同 index;第一集 UP 聚焦播放线路 tab
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (event.key) {
+ Key.DirectionLeft -> {
+ if (channelIndex > 0) {
+ pendingFocusIndex = focusedEpisodeIndex
+ onChannelClick(channelIndex - 1)
+ }
+ true
+ }
+ Key.DirectionRight -> {
+ if (channelIndex < channels.size - 1) {
+ pendingFocusIndex = focusedEpisodeIndex
+ onChannelClick(channelIndex + 1)
+ }
+ true
+ }
+ Key.DirectionUp -> {
+ // 仅在第一集时 UP 聚焦当前显示线路的 tab;否则交给正常纵向导航
+ if (focusedEpisodeIndex == 0 && channels.size > 1) {
+ pendingTabFocusIndex = channelIndex
+ true
+ } else false
+ }
+ else -> false
+ }
+ },
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ state = rememberLazyListState(targetFocusIndex, -200)
+ ) {
+ itemsIndexed(episodes) { index, episode ->
+ val focusRequester = remember { FocusRequester() }
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
+ // 播放指示:通过 URL 匹配当前线路中正在播放的集数(切走再切回仍正确)
+ val isPlaying = index == playingIndex
+ val isFocusTarget = index == targetFocusIndex
+
+ OutlinedButton(
+ onClick = { onEpisodeClick(index, episode) },
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp),
+ shape = RoundedCornerShape(4.dp),
+ border = channelBorderStroke(isActive, isPlaying),
+ colors = channelButtonColors(isActive, isPlaying),
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onFocusChanged { if (it.isFocused) focusedEpisodeIndex = index }
+ .then(
+ if (isFocusTarget) {
+ Modifier.focusRequester(selectedFocusRequester)
+ } else Modifier
+ ),
+ interactionSource = interactionSource
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (isPlaying) {
+ EpisodePlaybackIndicator(
+ Modifier.align(Alignment.CenterStart),
+ tint = if (isActive) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.primary
+ )
+ }
+
+ Text(
+ text = episode.name,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
}
}
}
}
}
+
+ // 打开侧栏聚焦播放中的集数;左/右切线路聚焦同 index;tab 切线路时 pending 已为 null 不夺焦
+ LaunchedEffect(channelIndex) {
+ delay(200)
+ if (pendingFocusIndex != null) {
+ runCatching { selectedFocusRequester.requestFocus() }
+ }
+ pendingFocusIndex = null
+ }
+
+ // UP 键从第一集跳转当前线路 tab:先滚动到可见,再延迟夺焦
+ LaunchedEffect(pendingTabFocusIndex) {
+ if (pendingTabFocusIndex != null) {
+ channelListState.animateScrollToItem(channelIndex.coerceAtLeast(0))
+ delay(100)
+ runCatching { channelFocusRequester.requestFocus() }
+ pendingTabFocusIndex = null
+ }
+ }
}
@Preview
@@ -1003,12 +1335,12 @@ fun PreviewEEpisodePlaybackIndicator() {
}
@Composable
-fun EpisodePlaybackIndicator(modifier: Modifier = Modifier) {
- BouncingBarsAnimation(modifier)
+fun EpisodePlaybackIndicator(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary) {
+ BouncingBarsAnimation(modifier, tint)
}
@Composable
-private fun BouncingBarsAnimation(modifier: Modifier = Modifier) {
+private fun BouncingBarsAnimation(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.primary) {
val transition = rememberInfiniteTransition(label = "BouncingBars")
val barWidth = 3.dp
val maxHeight = 16.dp
@@ -1016,7 +1348,6 @@ private fun BouncingBarsAnimation(modifier: Modifier = Modifier) {
val barSpacing = 2.dp
val durationMs = 800
val startOffset = durationMs / 3
- val barColor = MaterialTheme.colorScheme.primary
Row(
modifier = modifier,
@@ -1044,7 +1375,7 @@ private fun BouncingBarsAnimation(modifier: Modifier = Modifier) {
width = barWidth,
maxHeight = maxHeight,
minHeight = minHeight,
- color = barColor
+ color = tint
)
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerViewModel.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerViewModel.kt
index 472547bd..e499fb22 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerViewModel.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/videoplayer/VideoPlayerViewModel.kt
@@ -45,6 +45,10 @@ class VideoPlayerViewModel @Inject constructor(
private val _videoState: MutableStateFlow> = MutableStateFlow(Resource.Loading)
val videoState: StateFlow> get() = _videoState
+ // 切换剧集/线路加载失败的错误(保留当前视频数据时使用,避免整个播放器被失败页替换)
+ private val _videoLoadError = MutableStateFlow(null)
+ val videoLoadError: StateFlow get() = _videoLoadError
+
// 获取保存的偏好设置,初始化弹幕启用状态
private val preferences = application.preferences
private val _danmakuEnabled =
@@ -63,8 +67,14 @@ class VideoPlayerViewModel @Inject constructor(
private var currentEpisodeIndex: Int = 0
private var historyId: Long = -1L
+ // 线路数据
+ private var channels: Map> = emptyMap()
+ private var currentChannelIndex: Int = 0
+
// 自动连播相关
private var autoContinuePlayJob: Job? = null
+ // 视频加载 Job(用于取消上一条 fetch,避免竞态错误)
+ private var videoLoadJob: Job? = null
init {
viewModelScope.launch {
@@ -72,6 +82,8 @@ class VideoPlayerViewModel @Inject constructor(
savedStateHandle.toRoute().let {
val params = PlayerParameters.deserialize(it.parameters)
this@VideoPlayerViewModel.mode = params.mode
+ channels = params.channels
+ currentChannelIndex = params.channelIndex
val url = params.episodes[params.episodeIndex].url
if (params.isLocalVideo) {
// 如果是本地视频,获取本地视频
@@ -97,7 +109,9 @@ class VideoPlayerViewModel @Inject constructor(
lastPlayPosition = lastPlayPosition,
currentEpisodeIndex = params.episodeIndex,
episodes = params.episodes,
- headers = it.headers
+ headers = it.headers,
+ channels = channels,
+ channelIndex = currentChannelIndex
).let {
_videoState.value = Resource.Success(it)
@@ -159,12 +173,14 @@ class VideoPlayerViewModel @Inject constructor(
* @param episodeUrl 视频集数的URL
*/
private fun getVideoFromRemote(episodeUrl: String, index: Int) {
- viewModelScope.launch {
+ videoLoadJob?.cancel()
+ videoLoadJob = viewModelScope.launch {
// 使用用例从远程获取视频信息
getWebVideo(episodeUrl, mode!!)
.onSuccess { webVideo ->
val lastPlayPosition =
roomRepository.getEpisode(episodeUrl).first()?.lastPlayPosition ?: 0L
+ _videoLoadError.value = null
_videoState.value = Resource.Success(
_videoState.value.data!!.let {
it.copy(
@@ -180,7 +196,13 @@ class VideoPlayerViewModel @Inject constructor(
fetchDanmakuSession() // 视频加载成功后获取弹幕
}
.onError {
- _videoState.value = Resource.Error(it)
+ // 已有正在播放的视频时(切换剧集/线路失败),保留当前视频与播放器 UI,
+ // 仅标记加载失败,用户可继续切换选集/线路;首次加载失败仍走整页失败。
+ if (_videoState.value is Resource.Success) {
+ _videoLoadError.value = it
+ } else {
+ _videoState.value = Resource.Error(it)
+ }
}
}
}
@@ -241,11 +263,29 @@ class VideoPlayerViewModel @Inject constructor(
// 如果是远程视频,保存播放进度并重新获取远程视频
currentEpisodeUrl = url
currentEpisodeIndex = index
+ _videoLoadError.value = null
saveVideoPosition(videoPosition)
getVideoFromRemote(url, index)
}
}
+ /**
+ * 切换线路,仅更新剧集列表,不中断当前播放
+ * @param channelIndex 目标线路索引
+ */
+ fun switchChannel(channelIndex: Int) {
+ if (channelIndex == currentChannelIndex) return
+ currentChannelIndex = channelIndex
+ _videoState.value.data?.let { video ->
+ _videoState.value = Resource.Success(
+ video.copy(
+ channelIndex = channelIndex,
+ episodes = channels[channelIndex] ?: video.episodes
+ )
+ )
+ }
+ }
+
/**
* 播放器播放结束时触发,启动 3 秒延迟的自动连播
*/
@@ -312,4 +352,13 @@ class VideoPlayerViewModel @Inject constructor(
_videoState.value = Resource.Loading
getVideoFromRemote(currentEpisodeUrl, currentEpisodeIndex)
}
+
+ /**
+ * 在播放器内重试加载(切换剧集/线路失败后使用)。
+ * 不置 Loading、不替换整个页面,仅重新拉取当前选中的集。
+ */
+ fun retryLoad() {
+ _videoLoadError.value = null
+ getVideoFromRemote(currentEpisodeUrl, currentEpisodeIndex)
+ }
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/screen/week/WeekScreen.kt b/app/src/main/java/com/lanlinju/animius/presentation/screen/week/WeekScreen.kt
index f20ead20..a147637e 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/screen/week/WeekScreen.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/screen/week/WeekScreen.kt
@@ -3,6 +3,9 @@ package com.lanlinju.animius.presentation.screen.week
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -41,10 +44,14 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
+import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
@@ -64,6 +71,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
@@ -99,6 +107,10 @@ import com.lanlinju.animius.util.TABS
import com.lanlinju.animius.util.isAndroidTV
import com.lanlinju.animius.util.isWideScreen
import com.lanlinju.animius.util.rememberPreference
+import com.lanlinju.animius.util.focus.focusBorder
+import com.lanlinju.animius.util.focus.focusedIconButtonColors
+import com.lanlinju.animius.util.focus.focusedTextButtonColors
+import com.lanlinju.animius.util.focus.rememberIsFocused
import kotlinx.coroutines.launch
import java.time.LocalDate
@@ -119,6 +131,14 @@ fun WeekScreen(
var showSourceSwitchDialog by remember { mutableStateOf(false) }
var showSettingsDialog by remember { mutableStateOf(false) }
var showDomainChangeDialog by remember { mutableStateOf(false) }
+ var currentSourceName by remember { mutableStateOf(SourceHolder.currentSourceMode.name) }
+ val isSourceChanged by SourceHolder.isSourceChanged.collectAsState()
+
+ LaunchedEffect(isSourceChanged) {
+ if (isSourceChanged > 0) {
+ viewModel.refresh()
+ }
+ }
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
@@ -132,19 +152,29 @@ fun WeekScreen(
Column {
TopAppBar(
title = {
- Column(modifier = Modifier.clickable {
- showSourceSwitchDialog = true
- }) {
- Text(
- text = stringResource(id = R.string.lbl_schedule),
- style = MaterialTheme.typography.titleLarge
- )
- Text(
- text = SourceHolder.currentSourceMode.name,
- style = MaterialTheme.typography.bodyLarge
- )
+ val (titleFocused, titleModifier) = rememberIsFocused()
+ Surface(
+ onClick = { showSourceSwitchDialog = true },
+ shape = RoundedCornerShape(20.dp),
+ color = if (titleFocused) MaterialTheme.colorScheme.primary
+ else Color.Transparent,
+ modifier = titleModifier
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp)) {
+ Text(
+ text = stringResource(id = R.string.lbl_schedule),
+ style = MaterialTheme.typography.titleLarge,
+ color = if (titleFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = currentSourceName,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (titleFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ }
}
-
},
actions = {
AppBarAction(
@@ -166,10 +196,25 @@ fun WeekScreen(
selectedTabIndex = pagerState.currentPage,
) {
TABS.forEachIndexed { index, title ->
+ val (tabFocused, tabModifier) = rememberIsFocused()
Tab(
- text = { Text(text = title) },
+ text = {
+ Text(
+ text = title,
+ color = if (tabFocused) MaterialTheme.colorScheme.onPrimary
+ else LocalContentColor.current
+ )
+ },
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.scrollToPage(index) } },
+ selectedContentColor = if (tabFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.primary,
+ unselectedContentColor = if (tabFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = tabModifier.then(
+ if (tabFocused) Modifier.background(MaterialTheme.colorScheme.primary)
+ else Modifier
+ )
)
}
}
@@ -224,6 +269,7 @@ fun WeekScreen(
onDownloadUpdate = { lifecycleOwner ->
viewModel.downloadVersionUpdate(context, lifecycleOwner)
},
+ onSourceChanged = { mode -> currentSourceName = mode.name },
)
}
@@ -286,10 +332,26 @@ fun WeekItem(
onClick: () -> Unit
) {
+ val cardShape = RoundedCornerShape(12.dp)
+
ElevatedCard(
- modifier = modifier.height(80.dp),
+ modifier = modifier
+ .height(80.dp)
+ .focusBorder(
+ shape = cardShape,
+ width = 3.dp,
+ focusedColor = MaterialTheme.colorScheme.primary,
+ ),
onClick = onClick,
- elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.5.dp)
+ elevation = CardDefaults.elevatedCardElevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp,
+ focusedElevation = 0.dp,
+ hoveredElevation = 0.dp,
+ draggedElevation = 0.dp,
+ disabledElevation = 0.dp,
+ ),
+ shape = cardShape,
) {
Box(
modifier = Modifier
@@ -333,10 +395,17 @@ private fun AppBarAction(
)
Box {
- IconButton(onClick = { menuExpanded = true }) {
+ val (moreFocused, moreModifier) = rememberIsFocused()
+ IconButton(
+ onClick = { menuExpanded = true },
+ colors = focusedIconButtonColors(moreFocused),
+ modifier = moreModifier
+ ) {
Icon(
imageVector = Icons.Rounded.MoreVert,
- contentDescription = stringResource(id = R.string.more)
+ contentDescription = stringResource(id = R.string.more),
+ tint = if (moreFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
@@ -360,25 +429,46 @@ private fun AppBarNavigation(
onNavigateToSearch: () -> Unit,
onNavigateToDownload: () -> Unit
) {
- IconButton(onClick = onNavigateToHistory) {
+ val (historyFocused, historyModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onNavigateToHistory,
+ colors = focusedIconButtonColors(historyFocused),
+ modifier = historyModifier
+ ) {
Icon(
painter = painterResource(id = R.drawable.ic_history),
- contentDescription = stringResource(id = R.string.history)
+ contentDescription = stringResource(id = R.string.history),
+ tint = if (historyFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
- IconButton(onClick = onNavigateToSearch) {
+ val (searchFocused, searchModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onNavigateToSearch,
+ colors = focusedIconButtonColors(searchFocused),
+ modifier = searchModifier
+ ) {
Icon(
imageVector = Icons.Rounded.Search,
- contentDescription = stringResource(id = R.string.search)
+ contentDescription = stringResource(id = R.string.search),
+ tint = if (searchFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
- IconButton(onClick = onNavigateToDownload) {
+ val (downloadFocused, downloadModifier) = rememberIsFocused()
+ IconButton(
+ onClick = onNavigateToDownload,
+ colors = focusedIconButtonColors(downloadFocused),
+ modifier = downloadModifier
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
modifier = Modifier.rotate(90f),
- contentDescription = stringResource(id = R.string.download_list)
+ contentDescription = stringResource(id = R.string.download_list),
+ tint = if (downloadFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
}
@@ -436,25 +526,41 @@ private fun DropdownMenu(
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismissMenu) {
menuItems.forEach { item ->
+ val (itemFocused, itemModifier) = rememberIsFocused()
DropdownMenuItem(
- text = { Text(stringResource(id = item.textId)) },
+ text = {
+ Text(
+ text = stringResource(id = item.textId),
+ color = if (itemFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
onClick = {
onDismissMenu()
item.action()
},
+ modifier = itemModifier
+ .then(
+ if (itemFocused) Modifier.background(MaterialTheme.colorScheme.primary)
+ else Modifier
+ ),
leadingIcon = {
item.icon?.let { icon ->
Icon(
imageVector = icon,
modifier = Modifier.rotate(item.iconRotation),
- contentDescription = stringResource(id = item.textId)
+ contentDescription = stringResource(id = item.textId),
+ tint = if (itemFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
)
}
item.iconPainter?.let { iconPainter ->
Icon(
modifier = Modifier.size(24.dp),
painter = iconPainter,
- contentDescription = stringResource(id = item.textId)
+ contentDescription = stringResource(id = item.textId),
+ tint = if (itemFocused) MaterialTheme.colorScheme.onPrimary
+ else LocalContentColor.current
)
}
}
@@ -487,11 +593,12 @@ fun Dialogs(
onDismissLoadingIndicationDialog: () -> Unit,
onDownloadUpdate: (LifecycleOwner) -> Unit,
onRefresh: () -> Unit,
+ onSourceChanged: (SourceMode) -> Unit = {}
) {
if (showSourceSwitchDialog) {
SourceSwitchDialog(
onDismissRequest = onDismissSourceSwitchDialog,
- onRefresh = onRefresh
+ onSourceChanged = onSourceChanged
)
}
if (showSettingsDialog) {
@@ -547,12 +654,22 @@ fun VersionUpdateDialog(
}
},
confirmButton = {
- TextButton(onClick = { onDownloadUpdate(lifecycleOwner) }) {
+ val (isFocused, focusModifier) = rememberIsFocused()
+ TextButton(
+ onClick = { onDownloadUpdate(lifecycleOwner) },
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
+ ) {
Text(text = stringResource(id = R.string.download_software))
}
},
dismissButton = {
- TextButton(onClick = onDismissUpdateDialog) {
+ val (isFocused, focusModifier) = rememberIsFocused()
+ TextButton(
+ onClick = onDismissUpdateDialog,
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
+ ) {
Text(text = stringResource(id = R.string.cancel))
}
}
@@ -579,13 +696,23 @@ private fun LoadingIndicationDialog(
}
},
confirmButton = {},
+ dismissButton = {
+ val (isFocused, focusModifier) = rememberIsFocused()
+ TextButton(
+ onClick = onDismissRequest,
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
+ ) {
+ Text(text = stringResource(id = R.string.cancel))
+ }
+ }
)
}
@Composable
-private fun SourceSwitchDialog(
+fun SourceSwitchDialog(
onDismissRequest: () -> Unit,
- onRefresh: () -> Unit
+ onSourceChanged: (SourceMode) -> Unit = {}
) {
var currentSourceMode by rememberPreference(KEY_SOURCE_MODE, DEFAULT_ANIME_SOURCE)
@@ -608,9 +735,10 @@ private fun SourceSwitchDialog(
radioOptions.forEachIndexed { index, text ->
val topCorner = if (index == 0) 24.dp else 4.dp
val bottomCorner = if (index == radioOptions.lastIndex) 24.dp else 4.dp
+ val (rowFocused, rowModifier) = rememberIsFocused()
Row(
- Modifier
+ rowModifier
.fillMaxWidth()
.height(dimensionResource(id = R.dimen.radio_button_height))
.clip(
@@ -621,10 +749,20 @@ private fun SourceSwitchDialog(
bottomEnd = bottomCorner
)
)
- .background(MaterialTheme.colorScheme.surfaceVariant)
+ .background(
+ if (rowFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
.selectable(
selected = (text == selectedOption),
- onClick = { onOptionSelected(text) },
+ onClick = {
+ val mode = SourceMode.valueOf(text)
+ currentSourceMode = mode
+ SourceHolder.isSourceChanged.value++
+ SourceHolder.switchSource(mode)
+ onSourceChanged(mode)
+ onDismissRequest()
+ },
role = Role.RadioButton
)
.padding(start = dimensionResource(id = R.dimen.large_padding)),
@@ -637,29 +775,23 @@ private fun SourceSwitchDialog(
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
+ color = if (rowFocused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = dimensionResource(id = R.dimen.medium_padding))
)
}
}
}
},
- confirmButton = {
+ confirmButton = {},
+ dismissButton = {
+ val (isFocused, focusModifier) = rememberIsFocused()
TextButton(
- onClick = {
- val mode = SourceMode.valueOf(selectedOption)
- currentSourceMode = mode // 保存默认源到偏好设置
- SourceHolder.isSourceChanged = true // 触发Home数据刷新
- SourceHolder.switchSource(mode) // 切换数据源
- onDismissRequest() // 关闭Dialog
- onRefresh() // Week页面重新获取数据
- }
+ onClick = onDismissRequest,
+ modifier = Modifier.then(focusModifier),
+ colors = focusedTextButtonColors(isFocused)
) {
- Text(stringResource(R.string.confirm))
- }
- },
- dismissButton = {
- TextButton(onClick = onDismissRequest) {
- Text(stringResource(R.string.cancel))
+ Text(text = stringResource(id = R.string.cancel))
}
}
)
@@ -701,9 +833,15 @@ private fun SettingsDialog(
onCheckedChange = { isDownloadDirEnabled = it }
)
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
TextButton(
modifier = Modifier.align(Alignment.End),
- onClick = onDismissRequest
+ onClick = onDismissRequest,
+ interactionSource = interactionSource,
+ colors = focusedTextButtonColors(isActive)
) {
Text(stringResource(R.string.close))
}
@@ -718,23 +856,32 @@ private fun SettingsItem(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
- Row(
- Modifier
+ val (isFocused, focusModifier) = rememberIsFocused()
+ ListItem(
+ modifier = Modifier
.fillMaxWidth()
- .height(dimensionResource(id = R.dimen.radio_button_height)),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = text,
- style = MaterialTheme.typography.bodyLarge,
- )
-
- Switch(
- checked = checked,
- onCheckedChange = onCheckedChange
- )
- }
+ .height(dimensionResource(id = R.dimen.radio_button_height))
+ .then(focusModifier)
+ .clickable { onCheckedChange(!checked) },
+ colors = ListItemDefaults.colors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.surfaceVariant
+ else Color.Transparent
+ ),
+ headlineContent = {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (isFocused) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
+ trailingContent = {
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange
+ )
+ }
+ )
}
@Composable
@@ -800,21 +947,36 @@ private fun DomainChangeDialog(
},
confirmButton = {
- TextButton(onClick = {
- if (text.isNotEmpty()) {
- currentDomain = text
- SourceHolder.isSourceChanged = true
- SourceHolder.currentSource.baseUrl = currentDomain
- onDismissRequest(true)
- } else {
- onDismissRequest(false)
- }
- }) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
+ TextButton(
+ onClick = {
+ if (text.isNotEmpty()) {
+ currentDomain = text
+ SourceHolder.currentSource.baseUrl = currentDomain
+ onDismissRequest(true)
+ } else {
+ onDismissRequest(false)
+ }
+ },
+ interactionSource = interactionSource,
+ colors = focusedTextButtonColors(isActive)
+ ) {
Text(text = stringResource(id = R.string.confirm))
}
},
dismissButton = {
- TextButton(onClick = { onDismissRequest(false) }) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val isActive = isFocused || isPressed
+ TextButton(
+ onClick = { onDismissRequest(false) },
+ interactionSource = interactionSource,
+ colors = focusedTextButtonColors(isActive)
+ ) {
Text(text = stringResource(id = R.string.cancel))
}
}
diff --git a/app/src/main/java/com/lanlinju/animius/presentation/theme/Theme.kt b/app/src/main/java/com/lanlinju/animius/presentation/theme/Theme.kt
index 4ad5e408..4060a67e 100644
--- a/app/src/main/java/com/lanlinju/animius/presentation/theme/Theme.kt
+++ b/app/src/main/java/com/lanlinju/animius/presentation/theme/Theme.kt
@@ -16,6 +16,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
@@ -75,6 +76,7 @@ fun AnimeTheme(
customColorScheme: ColorScheme = LightColorScheme,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
+ pureBackground: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -84,6 +86,14 @@ fun AnimeTheme(
}
else -> customColorScheme
+ }.let { scheme ->
+ if (pureBackground) {
+ val bgColor = if (darkTheme) Color(0xFF121212) else Color.White
+ scheme.copy(
+ background = bgColor,
+ surface = bgColor
+ )
+ } else scheme
}
val view = LocalView.current
@@ -109,6 +119,7 @@ fun AnimeTheme(content: @Composable () -> Unit) {
val themeModeState by SettingsPreferences.themeMode.collectAsState()
val customColor by SettingsPreferences.customColor.collectAsState()
val dynamicColor by SettingsPreferences.dynamicColor.collectAsState()
+ val pureBackground by SettingsPreferences.pureBackground.collectAsState()
val darkTheme = when (themeModeState) {
SettingsPreferences.ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -120,6 +131,7 @@ fun AnimeTheme(content: @Composable () -> Unit) {
darkTheme = darkTheme,
customColorScheme = getSchemeFromSeed(customColor, darkTheme),
dynamicColor = dynamicColor,
+ pureBackground = pureBackground,
content = content
)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lanlinju/animius/util/Preferences.kt b/app/src/main/java/com/lanlinju/animius/util/Preferences.kt
index 337e7711..18a0e137 100644
--- a/app/src/main/java/com/lanlinju/animius/util/Preferences.kt
+++ b/app/src/main/java/com/lanlinju/animius/util/Preferences.kt
@@ -25,6 +25,7 @@ const val KEY_CUSTOM_COLOR = "customColor"
const val KEY_THEME_MODE = "themeMode"
const val KEY_DYNAMIC_COLOR = "dynamicColor"
const val KEY_DYNAMIC_IMAGE_COLOR = "dynamicImageColor"
+const val KEY_PURE_BACKGROUND = "pureBackground"
// danmaku
const val KEY_DANMAKU_ENABLED = "danmakuEnabled"
diff --git a/app/src/main/java/com/lanlinju/animius/util/SettingsPreferences.kt b/app/src/main/java/com/lanlinju/animius/util/SettingsPreferences.kt
index 3cf60c77..20def30a 100644
--- a/app/src/main/java/com/lanlinju/animius/util/SettingsPreferences.kt
+++ b/app/src/main/java/com/lanlinju/animius/util/SettingsPreferences.kt
@@ -24,6 +24,9 @@ object SettingsPreferences {
private val _dynamicColor = MutableStateFlow(preferences.getBoolean(KEY_DYNAMIC_COLOR, false))
val dynamicColor = _dynamicColor.asStateFlow()
+ private val _pureBackground = MutableStateFlow(preferences.getBoolean(KEY_PURE_BACKGROUND, false))
+ val pureBackground = _pureBackground.asStateFlow()
+
fun changeThemeMode(themeMode: ThemeMode) {
_themeMode.value = themeMode
@@ -43,4 +46,9 @@ object SettingsPreferences {
_dynamicColor.value = dynamicTheme
preferences.edit { putBoolean(KEY_DYNAMIC_COLOR, dynamicTheme) }
}
+
+ fun changePureBackground(pureBackground: Boolean) {
+ _pureBackground.value = pureBackground
+ preferences.edit { putBoolean(KEY_PURE_BACKGROUND, pureBackground) }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lanlinju/animius/util/SourceHolder.kt b/app/src/main/java/com/lanlinju/animius/util/SourceHolder.kt
index 18b75ba2..84f4a13a 100644
--- a/app/src/main/java/com/lanlinju/animius/util/SourceHolder.kt
+++ b/app/src/main/java/com/lanlinju/animius/util/SourceHolder.kt
@@ -1,5 +1,6 @@
package com.lanlinju.animius.util
+import kotlinx.coroutines.flow.MutableStateFlow
import com.lanlinju.animius.application.AnimeApplication
import com.lanlinju.animius.data.remote.parse.AgedmSource
import com.lanlinju.animius.data.remote.parse.AnimeSource
@@ -29,7 +30,7 @@ object SourceHolder {
val currentSourceMode: SourceMode
get() = _currentSourceMode
- var isSourceChanged = false
+ val isSourceChanged = MutableStateFlow(0)
init {
val preferences = AnimeApplication.getInstance().preferences
diff --git a/app/src/main/java/com/lanlinju/animius/util/focus/DpadKeyHandler.kt b/app/src/main/java/com/lanlinju/animius/util/focus/DpadKeyHandler.kt
new file mode 100644
index 00000000..0d332f28
--- /dev/null
+++ b/app/src/main/java/com/lanlinju/animius/util/focus/DpadKeyHandler.kt
@@ -0,0 +1,48 @@
+package com.lanlinju.animius.util.focus
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+
+/**
+ * Intercepts D-pad direction keys at the composable boundary before children
+ * process them. Useful for custom focus navigation at layout boundaries where
+ * the default 2D spatial algorithm produces wrong targets.
+ *
+ * Each direction callback is optional. If a callback is null, that direction
+ * key falls through to child composables for normal handling.
+ *
+ * Usage:
+ * ```
+ * Box(
+ * modifier = Modifier.handleDPadKeyEvents(
+ * onDown = { focusRequester.requestFocus() },
+ * onUp = { backFocusRequester.requestFocus() }
+ * )
+ * ) { ... }
+ * ```
+ */
+fun Modifier.handleDPadKeyEvents(
+ onUp: (() -> Unit)? = null,
+ onDown: (() -> Unit)? = null,
+): Modifier = onPreviewKeyEvent { keyEvent: KeyEvent ->
+ if (keyEvent.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+
+ when (keyEvent.key) {
+ Key.DirectionUp -> {
+ onUp?.invoke()
+ onUp != null
+ }
+
+ Key.DirectionDown -> {
+ onDown?.invoke()
+ onDown != null
+ }
+
+ else -> false
+ }
+}
diff --git a/app/src/main/java/com/lanlinju/animius/util/focus/FocusBorder.kt b/app/src/main/java/com/lanlinju/animius/util/focus/FocusBorder.kt
new file mode 100644
index 00000000..20ceee7c
--- /dev/null
+++ b/app/src/main/java/com/lanlinju/animius/util/focus/FocusBorder.kt
@@ -0,0 +1,51 @@
+package com.lanlinju.animius.util.focus
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.Dp
+
+/**
+ * Draws a border around the composable that responds to focus state changes
+ * **without triggering recomposition**.
+ *
+ * Focus state is stored in a [mutableStateOf] but is only read inside
+ * [drawWithContent]'s draw lambda. Because the state read happens in the draw
+ * phase (not in composition), a focus change invalidates only the draw layer —
+ * no recomposition, no relayout.
+ *
+ * Uses [Modifier.composed] so this can be called in a regular modifier chain
+ * without requiring a @Composable call site.
+ *
+ * @param shape the shape of the border outline
+ * @param width border stroke width
+ * @param focusedColor border color when this composable has focus
+ * @param unfocusedColor border color when no focus; [Color.Transparent] by default
+ */
+fun Modifier.focusBorder(
+ shape: Shape,
+ width: Dp,
+ focusedColor: Color,
+ unfocusedColor: Color = Color.Transparent,
+): Modifier = composed {
+ var isFocused by remember { mutableStateOf(false) }
+ this
+ .onFocusChanged { isFocused = it.isFocused }
+ .drawWithContent {
+ drawContent()
+ val color = if (isFocused) focusedColor else unfocusedColor
+ if (color != Color.Transparent) {
+ val outline = shape.createOutline(size, layoutDirection, this)
+ drawOutline(outline, color, style = Stroke(width.toPx()))
+ }
+ }
+}
diff --git a/app/src/main/java/com/lanlinju/animius/util/focus/FocusHighlight.kt b/app/src/main/java/com/lanlinju/animius/util/focus/FocusHighlight.kt
new file mode 100644
index 00000000..02554d01
--- /dev/null
+++ b/app/src/main/java/com/lanlinju/animius/util/focus/FocusHighlight.kt
@@ -0,0 +1,110 @@
+package com.lanlinju.animius.util.focus
+
+import androidx.compose.foundation.background
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.IconButtonColors
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+
+/**
+ * Replaces the repetitive `var isFocused by remember { mutableStateOf(false) }`
+ * + `Modifier.onFocusChanged { isFocused = it.isFocused }` boilerplate.
+ *
+ * Returns a [Pair] of `(isFocused: Boolean, focusModifier: Modifier)`.
+ * Attach the modifier to your composable; read `isFocused` for colors.
+ *
+ * Usage:
+ * ```
+ * val (isFocused, focusModifier) = rememberIsFocused()
+ * IconButton(
+ * modifier = Modifier.then(focusModifier),
+ * colors = focusedIconButtonColors(isFocused)
+ * ) {
+ * Icon(tint = if (isFocused) MaterialTheme.colorScheme.onPrimary else Color.White)
+ * }
+ * ```
+ */
+@Composable
+fun rememberIsFocused(): Pair {
+ var isFocused by remember { mutableStateOf(false) }
+ return isFocused to Modifier.onFocusChanged { isFocused = it.isFocused }
+}
+/**
+ * IconButton colors with the shared TV focus highlight: primary container +
+ * onPrimary content while focused, transparent container otherwise.
+ */
+@Composable
+fun focusedIconButtonColors(
+ isFocused: Boolean,
+ unfocusedContentColor: Color = MaterialTheme.colorScheme.onSurface,
+ unfocusedContainerColor: Color = Color.Transparent,
+): IconButtonColors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.primary else unfocusedContainerColor,
+ contentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary else unfocusedContentColor
+)
+
+/**
+ * TextButton colors with the shared TV focus highlight.
+ */
+@Composable
+fun focusedTextButtonColors(
+ isFocused: Boolean,
+ unfocusedContentColor: Color = MaterialTheme.colorScheme.primary,
+): ButtonColors = ButtonDefaults.textButtonColors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent,
+ contentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary else unfocusedContentColor
+)
+
+/**
+ * OutlinedButton colors with the shared TV focus highlight.
+ */
+@Composable
+fun focusedOutlinedButtonColors(
+ isFocused: Boolean,
+ unfocusedContainerColor: Color = Color.Transparent,
+ unfocusedContentColor: Color = MaterialTheme.colorScheme.primary,
+): ButtonColors = ButtonDefaults.outlinedButtonColors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.primary else unfocusedContainerColor,
+ contentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary else unfocusedContentColor
+)
+
+/**
+ * DropdownMenuItem with the shared TV focus highlight (primary container +
+ * onPrimary text while focused).
+ */
+@Composable
+fun FocusedDropdownMenuItem(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ style: TextStyle = MaterialTheme.typography.bodyMedium,
+) {
+ val (focused, focusModifier) = rememberIsFocused()
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = text,
+ style = style,
+ color = if (focused) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurface
+ )
+ },
+ onClick = onClick,
+ modifier = focusModifier
+ .then(if (focused) Modifier.background(MaterialTheme.colorScheme.primary) else Modifier)
+ .then(modifier)
+ )
+}
+
diff --git a/app/src/main/res/drawable/ic_banner.webp b/app/src/main/res/drawable/ic_banner.webp
new file mode 100644
index 00000000..831af3c8
Binary files /dev/null and b/app/src/main/res/drawable/ic_banner.webp differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a8dc3a96..5227ce8a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -86,6 +86,8 @@
动态图片颜色
应用来自于壁纸的颜色
根据动漫封面动态更改主题颜色
+ 黑白背景
+ 浅色模式使用白色背景和表面色,深色模式使用黑色背景和表面色
Github发布页
@@ -126,4 +128,5 @@
使用Download目录
自动连播
%1$ds后自动播放下一集...
+ 清除
\ No newline at end of file
diff --git a/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayer.kt b/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayer.kt
index 1be61baa..0a58bd0c 100644
--- a/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayer.kt
+++ b/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayer.kt
@@ -17,6 +17,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -69,6 +70,7 @@ private fun Modifier.defaultPlayerDragGestures(playerState: VideoPlayerState) =
var gestureDownBrightness = 0f
val maxFastForwardDuration = 180_000L // 最大快进时长为180秒
val threshold = (12.dp).toPx() // 拖动距离超过12dp后开始计算
+ val horizontalEdgeExclusionPx = (40.dp).toPx() // 屏幕左右边缘安全区域,避免与系统侧滑返回冲突
val audioManager = playerState.audioManager
val layoutParams = playerState.window.attributes
detectDragGestures(
@@ -91,8 +93,12 @@ private fun Modifier.defaultPlayerDragGestures(playerState: VideoPlayerState) =
) {
if (absDeltaX > threshold || absDeltaY > threshold) {
if (absDeltaX >= threshold) {
- isChangePosition = true
- gestureDownPosition = playerState.player.currentPosition
+ // 水平滑动仅在下按点位于水平安全区域内时触发快进快退,
+ // 屏幕左右边缘 40dp 内留给系统侧滑返回手势
+ if (downX > horizontalEdgeExclusionPx && downX < size.width - horizontalEdgeExclusionPx) {
+ isChangePosition = true
+ gestureDownPosition = playerState.player.currentPosition
+ }
} else {
if (change.position.x < size.width * 0.5f) {
isChangeBrightness = true
@@ -185,6 +191,10 @@ private fun VideoPlayer(
Box(
modifier = modifier
.fillMaxSize()
+ .onPreviewKeyEvent {
+ playerState.onUserInteraction()
+ false // don't consume, pass through to children
+ }
.defaultPlayerTapGestures(playerState, haptics)
.defaultPlayerDragGestures(playerState)
) {
@@ -256,10 +266,13 @@ fun VideoPlayer(
playerState.player.prepare()
playerState.player.seekTo(videoPosition)
playerState.player.playWhenReady = true
- playerState.showControlUi()
}
BackHandler {
- onBackPress()
+ if (playerState.isControlUiVisible.value) {
+ playerState.hideControlUi()
+ } else {
+ onBackPress()
+ }
}
}
diff --git a/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerControl.kt b/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerControl.kt
index c18a6337..c4202d9c 100644
--- a/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerControl.kt
+++ b/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerControl.kt
@@ -1,16 +1,17 @@
package com.lanlinju.videoplayer
+import android.content.pm.PackageManager
import android.content.res.Configuration
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
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.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -21,21 +22,37 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.getValue
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import com.lanlinju.videoplayer.icons.Fullscreen
import com.lanlinju.videoplayer.icons.FullscreenExit
@@ -51,7 +68,6 @@ fun VideoPlayerControl(
state: VideoPlayerState,
title: String,
subtitle: String? = null,
- background: Color = Color.Black.copy(0.2f),
contentColor: Color = Color.LightGray,
progressLineColor: Color = MaterialTheme.colorScheme.inversePrimary,
danmakuEnabled: Boolean,
@@ -59,46 +75,75 @@ fun VideoPlayerControl(
onNextClick: () -> Unit = {},
onDanmakuClick: (Boolean) -> Unit = {},
optionsContent: (@Composable () -> Unit)? = null,
+ modifier: Modifier = Modifier,
+ sliderFocusRequester: FocusRequester = remember { FocusRequester() },
+ backFocusRequester: FocusRequester = remember { FocusRequester() },
+ forwardFocusRequester: FocusRequester = remember { FocusRequester() },
+ playPauseFocusRequester: FocusRequester = remember { FocusRequester() },
+ episodeFocusRequester: FocusRequester = remember { FocusRequester() },
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(background)
- .padding(
- start = horizontalPadding(),
- end = horizontalPadding(),
- top = 18.dp
+ Box(modifier = modifier.fillMaxSize()) {
+ // Gradient overlay — hidden during seeking (compact slider-only mode)
+ if (!state.isSeeking.value) {
+ Box(
+ Modifier
+ .matchParentSize()
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.Black.copy(0.75f),
+ 0.35f to Color.Transparent,
+ 0.65f to Color.Transparent,
+ 1f to Color.Black.copy(0.75f),
+ )
+ )
)
- ) {
- Column(
- modifier = Modifier.fillMaxSize(),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceBetween
+ }
+
+ // Controls on top of the gradient
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(
+ start = horizontalPadding(),
+ end = horizontalPadding(),
+ top = 18.dp
+ )
) {
- ControlHeader(
- modifier = Modifier.fillMaxWidth(),
- title = title,
- subtitle = subtitle,
- isSeeking = state.isSeeking.value,
- onBackClick = onBackClick,
- optionsContent = optionsContent,
- )
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ ControlHeader(
+ modifier = Modifier.fillMaxWidth(),
+ title = title,
+ subtitle = subtitle,
+ isSeeking = state.isSeeking.value,
+ onBackClick = onBackClick,
+ optionsContent = optionsContent,
+ backFocusRequester = backFocusRequester,
+ sliderFocusRequester = sliderFocusRequester,
+ )
- Spacer(Modifier.size(1.dp))
+ Spacer(Modifier.weight(1f))
- BottomControlBar(
- modifier = Modifier.fillMaxWidth(),
- progressLineColor = progressLineColor,
- state = state,
- enabledDanmaku = danmakuEnabled,
- onNextClick = onNextClick,
- onDanmakuClick = onDanmakuClick
+ BottomControlBar(
+ modifier = Modifier.fillMaxWidth(),
+ progressLineColor = progressLineColor,
+ state = state,
+ enabledDanmaku = danmakuEnabled,
+ onNextClick = onNextClick,
+ onDanmakuClick = onDanmakuClick,
+ sliderFocusRequester = sliderFocusRequester,
+ backFocusRequester = backFocusRequester,
+ playPauseFocusRequester = playPauseFocusRequester,
+ episodeFocusRequester = episodeFocusRequester,
)
}
}
}
}
+}
@Composable
private fun ControlHeader(
@@ -108,6 +153,8 @@ private fun ControlHeader(
isSeeking: Boolean,
onBackClick: (() -> Unit)?,
optionsContent: (@Composable () -> Unit)? = null,
+ backFocusRequester: FocusRequester = remember { FocusRequester() },
+ sliderFocusRequester: FocusRequester = remember { FocusRequester() },
) {
if (isSeeking) return
@@ -116,9 +163,20 @@ private fun ControlHeader(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
+ var backFocused by remember { mutableStateOf(false) }
IconButton(
- modifier = Modifier.size(BigIconButtonSize),
- onClick = { onBackClick?.invoke() }
+ modifier = Modifier
+ .size(MediumIconButtonSize)
+ .focusRequester(backFocusRequester)
+ .focusProperties { down = sliderFocusRequester }
+ .onFocusChanged { backFocused = it.isFocused },
+ onClick = { onBackClick?.invoke() },
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (backFocused) MaterialTheme.colorScheme.primary
+ else Color.Transparent,
+ contentColor = if (backFocused) MaterialTheme.colorScheme.onPrimary
+ else Color.White
+ )
) {
Icon(imageVector = Icons.Rounded.ArrowBackIos, contentDescription = null)
}
@@ -154,7 +212,11 @@ private fun BottomControlBar(
state: VideoPlayerState,
enabledDanmaku: Boolean,
onNextClick: () -> Unit,
- onDanmakuClick: (Boolean) -> Unit
+ onDanmakuClick: (Boolean) -> Unit,
+ sliderFocusRequester: FocusRequester,
+ backFocusRequester: FocusRequester,
+ playPauseFocusRequester: FocusRequester,
+ episodeFocusRequester: FocusRequester,
) {
val timestamp =
remember(
@@ -184,9 +246,16 @@ private fun BottomControlBar(
onValueChangeFinished = { state.onSeeked() },
modifier = Modifier
.fillMaxWidth()
- .height(30.dp),
+ .height(30.dp)
+ .focusProperties {
+ up = backFocusRequester
+ down = playPauseFocusRequester
+ },
isSeeking = state.isSeeking.value,
+ onDpadUpDown = { state.showControlUi() },
color = progressLineColor,
+ focusRequester = sliderFocusRequester,
+ durationMs = state.videoDurationMs.value,
)
if (!state.isSeeking.value) {
@@ -200,7 +269,10 @@ private fun BottomControlBar(
resizeText = state.resizeText.value,
onSpeedClick = state::showSpeedUi,
onResizeClick = state::showResizeUi,
- onEpisodeClick = state::showEpisodeUi
+ onEpisodeClick = state::showEpisodeUi,
+ playPauseFocusRequester = playPauseFocusRequester,
+ episodeFocusRequester = episodeFocusRequester,
+ sliderFocusRequester = sliderFocusRequester
)
} else Spacer(modifier = Modifier.size(MediumIconButtonSize))
}
@@ -212,6 +284,8 @@ private fun TimelineControl(
isFullScreen: Boolean,
onFullScreenToggle: () -> Unit
) {
+ val isTv = LocalContext.current.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
+
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
@@ -219,14 +293,16 @@ private fun TimelineControl(
) {
Text(text = timestamp, style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.weight(1.0f))
- AdaptiveIconButton(
- modifier = Modifier.size(SmallIconButtonSize),
- onClick = onFullScreenToggle
- ) {
- Icon(
- imageVector = if (isFullScreen) Icons.Rounded.FullscreenExit else Icons.Rounded.Fullscreen,
- contentDescription = null
- )
+ if (!isTv) {
+ AdaptiveIconButton(
+ modifier = Modifier.size(SmallIconButtonSize),
+ onClick = onFullScreenToggle
+ ) {
+ Icon(
+ imageVector = if (isFullScreen) Icons.Rounded.FullscreenExit else Icons.Rounded.Fullscreen,
+ contentDescription = null
+ )
+ }
}
}
}
@@ -242,10 +318,14 @@ private fun PlaybackControl(
resizeText: String,
onSpeedClick: () -> Unit,
onResizeClick: () -> Unit,
- onEpisodeClick: () -> Unit
+ onEpisodeClick: () -> Unit,
+ playPauseFocusRequester: FocusRequester = remember { FocusRequester() },
+ episodeFocusRequester: FocusRequester = remember { FocusRequester() },
+ sliderFocusRequester: FocusRequester = remember { FocusRequester() },
) {
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -253,13 +333,17 @@ private fun PlaybackControl(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
- PlayPauseButton(isPlaying, onPlayPause)
+ PlayPauseButton(isPlaying, onPlayPause, playPauseFocusRequester, sliderFocusRequester)
NextEpisodeIcon(onClick = onNextClick)
DanmakuIcon(onClick = onDanmakuClick, danmakuEnabled = enabledDanmaku)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
- AdaptiveTextButton(text = "选集", onClick = onEpisodeClick)
+ AdaptiveTextButton(
+ text = "线路/选集",
+ onClick = onEpisodeClick,
+ modifier = Modifier.focusRequester(episodeFocusRequester)
+ )
AdaptiveTextButton(text = speedText, onClick = onSpeedClick)
AdaptiveTextButton(text = resizeText, onClick = onResizeClick)
}
@@ -267,9 +351,12 @@ private fun PlaybackControl(
}
@Composable
-private fun PlayPauseButton(isPlaying: Boolean, onPlayPause: () -> Unit) {
+private fun PlayPauseButton(isPlaying: Boolean, onPlayPause: () -> Unit, focusRequester: FocusRequester = remember { FocusRequester() }, upFocusRequester: FocusRequester = remember { FocusRequester() }) {
AdaptiveIconButton(
- modifier = Modifier.size(MediumIconButtonSize),
+ modifier = Modifier
+ .size(MediumIconButtonSize)
+ .focusRequester(focusRequester)
+ .focusProperties { up = upFocusRequester },
onClick = onPlayPause
) {
Icon(
@@ -329,16 +416,28 @@ fun AdaptiveTextButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
color: Color = LocalContentColor.current,
- style: TextStyle = MaterialTheme.typography.bodyMedium
+ style: TextStyle = MaterialTheme.typography.bodyMedium,
+ fontWeight: FontWeight? = null,
) {
- AdaptiveIconButton(
- modifier = modifier.size(MediumIconButtonSize),
- onClick = onClick
+ var isFocused by remember { mutableStateOf(false) }
+ Box(
+ modifier = modifier
+ .defaultMinSize(minWidth = 42.dp, minHeight = 42.dp)
+ .clip(CircleShape)
+ .onFocusChanged { isFocused = it.isFocused }
+ .background(if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center
) {
Text(
text = text,
- color = color,
+ color = if (isFocused) MaterialTheme.colorScheme.onPrimary else color,
style = style,
+ fontWeight = fontWeight,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ // 4dp 水平内边距让文字不贴边;固定尺寸(42dp)按钮下内容仍放得下(34dp ≥ 两字宽)
+ modifier = Modifier.padding(horizontal = 4.dp)
)
}
}
@@ -348,22 +447,21 @@ private fun AdaptiveIconButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
- interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
- enabledIndication: Boolean = true,
content: @Composable () -> Unit
) {
- val indication = LocalIndication.current
-
- Box(
+ var isFocused by remember { mutableStateOf(false) }
+ IconButton(
+ onClick = onClick,
modifier = modifier
- .clip(CircleShape)
- .clickable(
- onClick = onClick,
- enabled = enabled,
- interactionSource = interactionSource,
- indication = if (enabledIndication) indication else null
- ),
- contentAlignment = Alignment.Center
+ .size(MediumIconButtonSize)
+ .onFocusChanged { isFocused = it.isFocused },
+ enabled = enabled,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (isFocused) MaterialTheme.colorScheme.primary
+ else Color.Transparent,
+ contentColor = if (isFocused) MaterialTheme.colorScheme.onPrimary
+ else LocalContentColor.current
+ )
) {
content()
}
diff --git a/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerSate.kt b/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerSate.kt
index 46868b9f..c2aadd26 100644
--- a/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerSate.kt
+++ b/video-player/src/main/java/com/lanlinju/videoplayer/VideoPlayerSate.kt
@@ -36,8 +36,8 @@ fun rememberVideoPlayerState(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
config: ExoPlayer.Builder.() -> Unit = {
setLoadControl(loadControlCreator())
- setSeekForwardIncrementMs(15 * 1000)
- setSeekBackIncrementMs(15 * 1000)
+ setSeekForwardIncrementMs(10 * 1000)
+ setSeekBackIncrementMs(10 * 1000)
}
): VideoPlayerState = remember {
VideoPlayerStateImpl(
@@ -64,6 +64,8 @@ class VideoPlayerStateImpl(
private val hideControllerAfterMs: Long,
private val videoPositionPollInterval: Long,
) : VideoPlayerState, Player.Listener {
+ private val seekHideAfterMs = 1500L
+ private var hideAfterMs = hideControllerAfterMs
override val videoSize = mutableStateOf(player.videoSize)
override val videoResizeMode = mutableStateOf(ResizeMode.Fit)
override val videoPositionMs = mutableStateOf(0L)
@@ -100,9 +102,10 @@ class VideoPlayerStateImpl(
override val onSeeking: (Float) -> Unit
get() = {
controlUiLastInteractionMs = 0
- isSeeking.value = true
isEnded.value = false
if (!isControlUiVisible.value) showControlUi()
+ isSeeking.value = true
+ hideAfterMs = seekHideAfterMs
this.videoProgress.value = it
}
@@ -111,8 +114,11 @@ class VideoPlayerStateImpl(
*/
override val onSeeked: () -> Unit
get() = {
- isSeeking.value = false
player.seekTo((player.duration * videoProgress.value).toLong())
+ // Don't reset isSeeking — keep compact overlay during fade-out.
+ // isSeeking is reset in showControlUi() when UI is shown again.
+ hideAfterMs = seekHideAfterMs
+ controlUiLastInteractionMs = 0
}
/**
@@ -126,6 +132,27 @@ class VideoPlayerStateImpl(
player.seekTo((player.duration * videoProgress.value).toLong())
}
+ /**
+ * D-pad LEFT/RIGHT with hidden UI: seek by skipMs, show compact overlay (isSeeking=true
+ * hides header + playback), use short 1.5s auto-hide timer. isSeeking is NOT reset here —
+ * it stays true until showControlUi() is called next, so the compact overlay fades out
+ * directly without flashing to full UI.
+ */
+ override val onTimedSeek: (Long) -> Unit
+ get() = { skipMs ->
+ val duration = videoDurationMs.value
+ if (duration > 0) {
+ val newPos = (player.currentPosition + skipMs).coerceIn(0, duration)
+ val progress = newPos.toFloat() / duration
+ if (!isControlUiVisible.value) showControlUi()
+ isSeeking.value = true
+ hideAfterMs = seekHideAfterMs
+ controlUiLastInteractionMs = 0
+ this.videoProgress.value = progress
+ player.seekTo(newPos)
+ }
+ }
+
override fun onChangeVolume(value: Float) {
isChangingVolume.value = true
volumeBrightnessProgress.value = value
@@ -204,6 +231,9 @@ class VideoPlayerStateImpl(
override fun setLoading(loading: Boolean) {
isLoading.value = loading
+ // 新的加载开始(选集/切线路/下一集/连播)时清除播放错误,
+ // 否则加载圈会被 isError 阻塞,失败图标残留
+ if (loading) isError.value = false
}
private var pollVideoPositionJob: Job? = null
@@ -212,6 +242,8 @@ class VideoPlayerStateImpl(
override fun hideControlUi() {
controlUiLastInteractionMs = 0
isControlUiVisible.value = false
+ // Don't reset isSeeking here — keep compact overlay during fade-out so
+ // the full UI doesn't flash. isSeeking is reset in showControlUi() instead.
pollVideoPositionJob?.cancel()
pollVideoPositionJob = null
}
@@ -219,6 +251,10 @@ class VideoPlayerStateImpl(
override fun showControlUi() {
controlUiLastInteractionMs = 0
isControlUiVisible.value = true
+ // Reset to full UI mode: isSeeking=false (show header + playback), 6s auto-hide timer.
+ // onSeeking/onTimedSeek will override these to compact overlay + 1.5s timer after calling us.
+ isSeeking.value = false
+ hideAfterMs = hideControllerAfterMs
pollVideoPositionJob?.cancel()
pollVideoPositionJob = coroutineScope.launch {
while (true) {
@@ -236,7 +272,7 @@ class VideoPlayerStateImpl(
controlUiLastInteractionMs += videoPositionPollInterval
delay(videoPositionPollInterval)
- if (controlUiLastInteractionMs >= hideControllerAfterMs) {
+ if (controlUiLastInteractionMs >= hideAfterMs) {
hideControlUi()
break
}
@@ -287,6 +323,10 @@ class VideoPlayerStateImpl(
isEpisodeUiVisible.value = false
}
+ override fun onUserInteraction() {
+ controlUiLastInteractionMs = 0
+ }
+
override fun onIsPlayingChanged(isPlaying: Boolean) {
this.isPlaying.value = isPlaying
}
@@ -423,6 +463,7 @@ interface VideoPlayerState {
val onSeeking: (dragProcess: Float) -> Unit // 当拖动进度条时调用
val onSeeked: () -> Unit // 当拖动进度条结束时调用
val onClickSlider: (progress: Float) -> Unit // 当点击进度条时调用
+ val onTimedSeek: (skipMs: Long) -> Unit // D-pad LEFT/RIGHT: seek + compact overlay + short hide timer
val speedText: State
val resizeText: State
@@ -463,6 +504,8 @@ interface VideoPlayerState {
fun showEpisodeUi()
fun hideEpisodeUi()
+
+ fun onUserInteraction()
}
interface VideoPlayerControl {
diff --git a/video-player/src/main/java/com/lanlinju/videoplayer/component/Slider.kt b/video-player/src/main/java/com/lanlinju/videoplayer/component/Slider.kt
index ffc6a0f4..a6bb5d13 100644
--- a/video-player/src/main/java/com/lanlinju/videoplayer/component/Slider.kt
+++ b/video-player/src/main/java/com/lanlinju/videoplayer/component/Slider.kt
@@ -3,9 +3,14 @@ package com.lanlinju.videoplayer.component
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -20,8 +25,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@@ -36,16 +49,52 @@ fun Slider(
color: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = Color.LightGray.copy(alpha = 0.38f),
secondTrackColor: Color =Color.LightGray.copy(alpha = 0.78f),
- isSeeking: Boolean = false
+ isSeeking: Boolean = false,
+ onDpadUpDown: (() -> Unit)? = null,
+ focusRequester: FocusRequester = remember { FocusRequester() },
+ durationMs: Long = 0L,
) {
val isAnimHeight = remember(isSeeking) { mutableStateOf(isSeeking) }
val animHeight = animateDpAsState(
targetValue = if (isAnimHeight.value) 4.dp else 2.dp,
animationSpec = tween()
)
+ var isFocused by remember { mutableStateOf(false) }
+ val isActive = isFocused || isSeeking
+ val thumbSize by animateDpAsState(
+ targetValue = if (isActive) 20.dp else 15.dp,
+ animationSpec = tween(150)
+ )
Box(
modifier = modifier
+ .focusRequester(focusRequester)
+ .onFocusChanged { isFocused = it.isFocused }
+ .focusable()
+ .onKeyEvent { event ->
+ if (event.type == KeyEventType.KeyDown) {
+ val stepFraction = if (durationMs > 0) 10000f / durationMs else 0.02f
+ when (event.key) {
+ Key.DirectionRight -> {
+ onClick((value + stepFraction).coerceIn(0f, 1f))
+ true
+ }
+ Key.DirectionLeft -> {
+ onClick((value - stepFraction).coerceIn(0f, 1f))
+ true
+ }
+ Key.DirectionUp, Key.DirectionDown -> {
+ if (isSeeking) {
+ onDpadUpDown?.invoke()
+ true
+ } else {
+ false // let focus system handle spatial navigation
+ }
+ }
+ else -> false
+ }
+ } else false
+ }
.pointerInput(Unit) {
detectTapGestures(onTap = { offset ->
isAnimHeight.value = true
@@ -110,15 +159,15 @@ fun Slider(
// thumb
Box(
modifier = Modifier
- .clip(CircleShape)
.align(
BiasAlignment(
- horizontalBias = (value * 2) - 1f, // -1 start | 0 center | 1 end
+ horizontalBias = (value * 2) - 1f,
verticalBias = 0f
)
)
- .size(15.dp)
- .background(color)
+ .size(thumbSize)
+ .then(if (isFocused) Modifier.border(2.dp, Color.White, CircleShape) else Modifier)
+ .background(color, CircleShape)
)
}
}