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) ) } }