From 3836ac8d5d65189c4f9f95f5aba4657e22bd970f Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Sat, 11 Jul 2026 22:18:52 +0800 Subject: [PATCH] Improve TV and WebUI interaction flows --- app/build.gradle.kts | 2 +- .../tv/model/PlaybackUiConventions.kt | 2 +- .../tv/player/ExoPlaybackController.kt | 38 ++- .../miruplay/tv/player/PlaybackController.kt | 4 + .../miruplay/tv/ui/components/AnimeCards.kt | 2 + .../miruplay/tv/ui/components/InitialFocus.kt | 2 + .../miruplay/tv/ui/components/TvKeyEvents.kt | 23 +- .../miruplay/tv/ui/components/TvTextField.kt | 13 +- .../tv/ui/detail/AnimeDetailScreen.kt | 11 +- .../tv/ui/detail/DramaDetailScreen.kt | 8 +- .../miruplay/tv/ui/library/LibraryScreen.kt | 187 +++++++++-- .../com/miruplay/tv/ui/player/PlayerScreen.kt | 315 ++++++------------ .../miruplay/tv/ui/player/PlayerViewModel.kt | 18 + .../tv/ui/settings/AddSourceScreen.kt | 53 ++- .../tv/ui/library/LibrarySearchTest.kt | 19 ++ .../ui/player/PlayerExitConfirmationTest.kt | 16 + .../ui/player/PlayerOptionsPanelFocusTest.kt | 15 +- .../PlayerScreenPictureMenuLabelsTest.kt | 9 +- web-control/frontend/src/App.vue | 133 +++++++- web-control/frontend/src/api.js | 7 +- web-control/frontend/src/styles.css | 36 ++ 21 files changed, 608 insertions(+), 305 deletions(-) create mode 100644 ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibrarySearchTest.kt create mode 100644 ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerExitConfirmationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c943d78c..5e02aca7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ plugins { // 支持通过 -PVERSION_NAME / -PVERSION_CODE 显式传入版本信息。 // 未显式传入 VERSION_NAME 时,默认把最后一段 patch 替换为 BUILD_NUMBER。 -val baseAppVersionName = "0.10.0" +val baseAppVersionName = "0.11.0" fun String?.nonBlankOrNull(): String? = this?.trim()?.takeIf { it.isNotBlank() } diff --git a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt index 767fa6df..4b21b804 100644 --- a/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt +++ b/core/model/src/main/kotlin/com/miruplay/tv/model/PlaybackUiConventions.kt @@ -230,7 +230,7 @@ fun pictureSettingsDescriptionLabel(): String = "按 SDR、HDR10、HDR10+ 和 Dolby Vision 分别配置默认画面规则。" fun pictureOsdMenuTitleLabel(): String = - "Picture" + "画面" fun pictureSaveDefaultForFormatLabel(): String = "保存为该格式默认" diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt index 5d8f896d..e8cacb2b 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ExoPlaybackController.kt @@ -14,6 +14,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener @@ -90,6 +91,8 @@ class ExoPlaybackController @Inject constructor( private val availableSubtitles = mutableListOf() private val availableAudioTracks = mutableListOf() + private var selectedSubtitleTrackIndex: Int? = null + private var selectedAudioTrackIndex: Int? = null private var currentSource: PlaybackSource? = null private var autoResumeSeekCalled = false private var playbackPreferences = FormatAwareToneMappingPreferences() @@ -360,6 +363,8 @@ class ExoPlaybackController @Inject constructor( autoResumeSeekCalled = false availableSubtitles.clear() availableAudioTracks.clear() + selectedSubtitleTrackIndex = null + selectedAudioTrackIndex = null containerSignalDescriptor = null _currentVideoSignalDescriptor.value = null PlaybackCodecSelectionState.decoderPreference = PlaybackDecoderPreference.DEFAULT @@ -388,17 +393,21 @@ class ExoPlaybackController @Inject constructor( } override suspend fun setSubtitleTrack(trackIndex: Int) { - updateAvailableTracks() + selectTrackGroup(C.TRACK_TYPE_TEXT, trackIndex) } override suspend fun setAudioTrack(trackIndex: Int) { - updateAvailableTracks() + selectTrackGroup(C.TRACK_TYPE_AUDIO, trackIndex) } override fun getAvailableSubtitles(): List = availableSubtitles.toList() override fun getAvailableAudioTracks(): List = availableAudioTracks.toList() + override fun getSelectedSubtitleTrackIndex(): Int? = selectedSubtitleTrackIndex + + override fun getSelectedAudioTrackIndex(): Int? = selectedAudioTrackIndex + override suspend fun getCurrentPosition(): Long = withContext(Dispatchers.Main) { when (_activeRenderBackend.value) { PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID -> externalMpvPositionMs @@ -852,9 +861,28 @@ class ExoPlaybackController @Inject constructor( } } + private suspend fun selectTrackGroup(trackType: Int, trackIndex: Int) = withContext(Dispatchers.Main) { + val player = activeExoPlayer() + val group = player.currentTracks.groups.filter { it.type == trackType }.getOrNull(trackIndex) + ?: return@withContext + player.trackSelectionParameters = player.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(trackType, false) + .clearOverridesOfType(trackType) + .addOverride(TrackSelectionOverride(group.mediaTrackGroup, 0)) + .build() + if (trackType == C.TRACK_TYPE_TEXT) { + selectedSubtitleTrackIndex = trackIndex + } else { + selectedAudioTrackIndex = trackIndex + } + } + private fun updateAvailableTracks() { availableSubtitles.clear() availableAudioTracks.clear() + selectedSubtitleTrackIndex = null + selectedAudioTrackIndex = null try { val tracks = activeExoPlayer().currentTracks @@ -862,6 +890,7 @@ class ExoPlaybackController @Inject constructor( val group = tracks.groups[i] when (group.type) { C.TRACK_TYPE_TEXT -> { + val trackIndex = availableSubtitles.size val format = group.getTrackFormat(0) availableSubtitles.add( SubtitleTrack( @@ -872,18 +901,21 @@ class ExoPlaybackController @Inject constructor( format = SubtitleFormat.SRT, ), ) + if (group.isTrackSelected(0)) selectedSubtitleTrackIndex = trackIndex } C.TRACK_TYPE_AUDIO -> { + val trackIndex = availableAudioTracks.size val format = group.getTrackFormat(0) availableAudioTracks.add( AudioTrack( - index = availableAudioTracks.size, + index = trackIndex, language = format.language ?: "und", title = format.label, codec = format.codecs, ), ) + if (group.isTrackSelected(0)) selectedAudioTrackIndex = trackIndex } } } diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt index 1ff72c2c..e6f32fba 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/PlaybackController.kt @@ -72,6 +72,10 @@ interface PlaybackController { */ fun getAvailableAudioTracks(): List + fun getSelectedSubtitleTrackIndex(): Int? = null + + fun getSelectedAudioTrackIndex(): Int? = null + /** * Get current playback position in milliseconds */ diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/AnimeCards.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/AnimeCards.kt index b78b9ec4..4628f2bf 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/AnimeCards.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/AnimeCards.kt @@ -149,6 +149,7 @@ fun FeatureAnimeCard( onClick: () -> Unit, modifier: Modifier = Modifier, subtitle: String? = null, + onDirectionUp: (() -> Unit)? = null, backdropPlaceholder: @Composable () -> Unit = { ImagePlaceholder() }, posterPlaceholder: @Composable () -> Unit = { ImagePlaceholder() }, ) { @@ -172,6 +173,7 @@ fun FeatureAnimeCard( ) .tvFocusableClickable( interactionSource = interactionSource, + onDirectionUp = onDirectionUp, onClick = onClick ) ) { diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/InitialFocus.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/InitialFocus.kt index 88e90270..b293ae5c 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/InitialFocus.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/InitialFocus.kt @@ -19,6 +19,8 @@ class InitialFocusHandle internal constructor( private val markPlaced: () -> Unit, private val updateFocused: (Boolean) -> Unit, ) { + fun requester(): FocusRequester = focusRequester + fun modifier(): Modifier = Modifier .focusRequester(focusRequester) diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvKeyEvents.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvKeyEvents.kt index fc8a2f25..98fa5b0d 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvKeyEvents.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvKeyEvents.kt @@ -70,6 +70,7 @@ internal inline fun tvActivateIntentEvent( internal fun Modifier.tvFocusableClickable( interactionSource: MutableInteractionSource, enabled: Boolean = true, + onDirectionUp: (() -> Unit)? = null, onClick: () -> Unit, ): Modifier = this .clickable( @@ -79,12 +80,22 @@ internal fun Modifier.tvFocusableClickable( onClick = onClick, ) .onKeyEvent { event -> - tvActivateKeyEvent( - key = event.key, - type = event.type, - enabled = enabled, - onActivate = onClick, - ) + if ( + enabled && + onDirectionUp != null && + event.type == KeyEventType.KeyDown && + event.key.toMiruPlayInputIntent() == MiruPlayInputIntent.DirectionUp + ) { + onDirectionUp() + true + } else { + tvActivateKeyEvent( + key = event.key, + type = event.type, + enabled = enabled, + onActivate = onClick, + ) + } } .focusable( enabled = enabled, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvTextField.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvTextField.kt index af2659e5..e9dc1b52 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvTextField.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/components/TvTextField.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -25,9 +27,17 @@ fun TvTextField( onValueChange: (String) -> Unit, label: String, modifier: Modifier = Modifier, - isPassword: Boolean = false + isPassword: Boolean = false, + requestInitialFocus: Boolean = false, ) { var isFocused by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(requestInitialFocus) { + if (requestInitialFocus) { + kotlinx.coroutines.delay(120) + focusRequester.requestFocus() + } + } val borderWidth by animateFloatAsState( targetValue = if (isFocused) 3f else 1f, label = "borderW" @@ -59,6 +69,7 @@ fun TvTextField( onValueChange = onValueChange, modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .onFocusChanged { isFocused = it.isFocused }, textStyle = TextStyle( color = TextPrimary, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt index 13e24e2a..ebfe5aa1 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/AnimeDetailScreen.kt @@ -121,7 +121,11 @@ fun AnimeDetailScreen( LaunchedEffect(animeId) { viewModel.loadAnime(animeId) } - BackHandler(enabled = manualMatch.isOpen, onBack = viewModel::closeRescrapeMatcher) + BackHandler(enabled = manualMatch.isOpen) { + if (!manualMatch.isSearching && !manualMatch.isApplying) { + viewModel.closeRescrapeMatcher() + } + } BackHandler(enabled = !manualMatch.isOpen, onBack = onNavigateBack) Box(modifier = Modifier.fillMaxSize()) { @@ -319,7 +323,8 @@ private fun DetailContent( TvButton( text = detailSeasonLabel(season), onClick = { onSelectSeason(season) }, - modifier = Modifier.width(132.dp) + modifier = Modifier.width(132.dp), + secondary = selectedSeason != season, ) } } @@ -354,7 +359,7 @@ private fun BangumiManualMatchDialog( val busy = state.isSearching || state.isApplying val dialogMaxHeight = (LocalConfiguration.current.screenHeightDp.dp * 0.9f).coerceAtLeast(420.dp) - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = { if (!busy) onDismiss() }) { Column( modifier = Modifier .fillMaxWidth(0.9f) diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/DramaDetailScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/DramaDetailScreen.kt index e3b5cd78..aecf8991 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/DramaDetailScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/detail/DramaDetailScreen.kt @@ -116,7 +116,11 @@ fun DramaDetailScreen( LaunchedEffect(seriesId) { viewModel.loadSeries(seriesId) } - BackHandler(enabled = manualMatch.isOpen, onBack = viewModel::closeManualMatch) + BackHandler(enabled = manualMatch.isOpen) { + if (!manualMatch.isSearching && !manualMatch.isApplying) { + viewModel.closeManualMatch() + } + } BackHandler(enabled = !manualMatch.isOpen, onBack = onNavigateBack) Box(modifier = Modifier.fillMaxSize()) { @@ -421,7 +425,7 @@ private fun DramaManualMatchDialog( val busy = state.isSearching || state.isApplying val dialogMaxHeight = (LocalConfiguration.current.screenHeightDp.dp * 0.9f).coerceAtLeast(420.dp) - Dialog(onDismissRequest = onDismiss) { + Dialog(onDismissRequest = { if (!busy) onDismiss() }) { Column( modifier = Modifier .fillMaxWidth(0.9f) diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt index 96154e05..774524ec 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/library/LibraryScreen.kt @@ -1,5 +1,6 @@ package com.miruplay.tv.ui.library +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* @@ -12,11 +13,20 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.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.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -49,6 +59,7 @@ import com.miruplay.tv.model.posterWallSections import com.miruplay.tv.scanner.LibraryScanState import com.miruplay.tv.ui.components.* import com.miruplay.tv.ui.theme.* +import kotlinx.coroutines.delay @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -61,6 +72,31 @@ fun LibraryScreen( val scanState by viewModel.scanState.collectAsStateWithLifecycle() val activeScan = scanState as? LibraryScanState.Scanning val lifecycleOwner = LocalLifecycleOwner.current + var searchQuery by remember { mutableStateOf("") } + var searchActive by remember { mutableStateOf(false) } + var restoreSearchFocus by remember { mutableStateOf(false) } + val headerFocusRequester = remember { FocusRequester() } + val contentState = uiState as? LibraryUiState.HasContent + val libraryScrollState = rememberScrollState() + val initialContentFocus = rememberInitialFocusHandle( + key = contentState?.allAnime?.firstOrNull()?.id, + enabled = contentState != null, + initialDelayMillis = 500, + ) + + BackHandler(enabled = searchActive) { + searchActive = false + searchQuery = "" + restoreSearchFocus = true + } + + LaunchedEffect(searchActive, restoreSearchFocus) { + if (!searchActive && restoreSearchFocus) { + delay(50) + headerFocusRequester.requestFocus() + restoreSearchFocus = false + } + } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -76,10 +112,26 @@ fun LibraryScreen( OverscanContainer { Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .then( + if (contentState != null) Modifier.verticalScroll(libraryScrollState) else Modifier, + ) ) { LibraryHeader( activeScan = activeScan, + searchQuery = searchQuery, + searchActive = searchActive, + headerFocusRequester = headerFocusRequester, + contentFocusRequester = initialContentFocus.requester(), + onSearchQueryChange = { searchQuery = it }, + onSearchActiveChange = { active -> + searchActive = active + if (!active) { + searchQuery = "" + restoreSearchFocus = true + } + }, onScan = { viewModel.scanNow() }, onCancelScan = { viewModel.cancelScan() }, onNavigateToSettings = onNavigateToSettings @@ -119,6 +171,9 @@ fun LibraryScreen( recentlyAdded = state.recentlyAdded, allAnime = state.allAnime, posterWallArrangement = state.posterWallArrangement, + searchQuery = searchQuery, + initialContentFocus = initialContentFocus, + onPrimaryDirectionUp = { headerFocusRequester.requestFocus() }, onNavigateToDetail = onNavigateToDetail ) } @@ -130,15 +185,16 @@ fun LibraryScreen( @Composable private fun LibraryHeader( activeScan: LibraryScanState.Scanning?, + searchQuery: String, + searchActive: Boolean, + headerFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + onSearchQueryChange: (String) -> Unit, + onSearchActiveChange: (Boolean) -> Unit, onScan: () -> Unit, onCancelScan: () -> Unit, onNavigateToSettings: () -> Unit ) { - val headerInitialFocus = rememberInitialFocusHandle( - key = Unit, - enabled = activeScan == null, - ) - Row( modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), horizontalArrangement = Arrangement.spacedBy(18.dp), @@ -162,6 +218,41 @@ private fun LibraryHeader( ) } + if (searchActive) { + TvTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + label = "搜索片库", + modifier = Modifier.width(260.dp), + requestInitialFocus = true, + ) + TvButton( + text = "关闭搜索", + icon = Icons.Filled.Close, + onClick = { onSearchActiveChange(false) }, + modifier = Modifier.width(150.dp), + secondary = true, + ) + } else { + TvButton( + text = "搜索", + icon = Icons.Filled.Search, + onClick = { onSearchActiveChange(true) }, + modifier = Modifier + .width(132.dp) + .focusRequester(headerFocusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown) { + contentFocusRequester.requestFocus() + true + } else { + false + } + }, + secondary = true, + ) + } + if (activeScan != null) { ScanProgressBanner( state = activeScan, @@ -178,9 +269,7 @@ private fun LibraryHeader( text = libraryScanActionLabel(), icon = Icons.Filled.Refresh, onClick = onScan, - modifier = Modifier - .width(132.dp) - .then(headerInitialFocus.modifier()) + modifier = Modifier.width(132.dp) ) } // Keep Settings out of any "initial focus" logic. On TV, reclaiming @@ -201,6 +290,8 @@ private fun EmptyState( buttonText: String, onClick: () -> Unit ) { + val initialFocus = rememberInitialFocusHandle(key = buttonText) + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -213,7 +304,11 @@ private fun EmptyState( textAlign = TextAlign.Center ) Spacer(Modifier.height(24.dp)) - TvButton(text = buttonText, onClick = onClick) + TvButton( + text = buttonText, + onClick = onClick, + modifier = initialFocus.modifier(), + ) } } @@ -288,13 +383,20 @@ private fun LibraryContent( recentlyAdded: List, allAnime: List, posterWallArrangement: PosterWallArrangement, + searchQuery: String, + initialContentFocus: InitialFocusHandle, + onPrimaryDirectionUp: () -> Unit, onNavigateToDetail: (String) -> Unit ) { - val library = remember(allAnime) { allAnime.distinctBy { it.id }.sortedBy { it.displayTitle() } } - val posterWallSections = remember(allAnime, posterWallArrangement) { + val normalizedQuery = searchQuery.trim() + val library = remember(allAnime, normalizedQuery) { allAnime .distinctBy { it.id } - .posterWallSections(posterWallArrangement) + .filter { it.matchesLibraryQuery(normalizedQuery) } + .sortedBy { it.displayTitle() } + } + val posterWallSections = remember(library, posterWallArrangement) { + library.posterWallSections(posterWallArrangement) } val featured = remember(library) { library.sortedWith( @@ -303,12 +405,40 @@ private fun LibraryContent( .thenBy { it.displayTitle() } ).take(8) } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) { - if (featured.isNotEmpty()) { + val initialContentModifier = initialContentFocus.modifier() + + Column(modifier = Modifier.fillMaxWidth()) { + if (normalizedQuery.isNotEmpty()) { + SectionHeader(title = "搜索结果", trailing = libraryCollectedCountLabel(library.size)) + if (library.isEmpty()) { + Text(text = "没有匹配的内容", style = TvTypography.body, color = TextSecondary) + } else { + Column( + verticalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 28.dp), + ) { + library.chunked(6).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + row.forEach { anime -> + AnimePosterCard( + anime = anime, + width = 170.dp, + height = 254.dp, + onClick = { onNavigateToDetail(anime.id) }, + modifier = if (anime == library.first()) { + initialContentModifier + } else { + Modifier + }, + ) + } + } + } + } + } + } + + if (normalizedQuery.isEmpty() && featured.isNotEmpty()) { SectionHeader(title = libraryFeaturedSectionTitle(), trailing = libraryCollectedCountLabel(library.size)) LazyRow( horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -318,13 +448,19 @@ private fun LibraryContent( items(featured, key = { it.id }) { anime -> FeatureAnimeCard( anime = anime, - onClick = { onNavigateToDetail(anime.id) } + onClick = { onNavigateToDetail(anime.id) }, + onDirectionUp = if (anime == featured.first()) onPrimaryDirectionUp else null, + modifier = if (anime == featured.first()) { + initialContentModifier + } else { + Modifier + }, ) } } } - if (continueWatching.isNotEmpty()) { + if (normalizedQuery.isEmpty() && continueWatching.isNotEmpty()) { SectionHeader(title = libraryContinueWatchingSectionTitle()) LazyRow( horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -346,7 +482,7 @@ private fun LibraryContent( } } - if (recentlyAdded.isNotEmpty()) { + if (normalizedQuery.isEmpty() && recentlyAdded.isNotEmpty()) { SectionHeader(title = libraryRecentlyAddedSectionTitle()) LazyRow( horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -362,7 +498,7 @@ private fun LibraryContent( } } - if (posterWallSections.isNotEmpty()) { + if (normalizedQuery.isEmpty() && posterWallSections.isNotEmpty()) { SectionHeader(title = libraryPosterWallSectionTitle()) posterWallSections.forEach { section -> val sectionTitle = section.title @@ -396,6 +532,11 @@ private fun LibraryContent( } } +internal fun Anime.matchesLibraryQuery(query: String): Boolean { + val normalized = query.trim() + return normalized.isEmpty() || listOfNotNull(title, titleCn, id).any { it.contains(normalized, ignoreCase = true) } +} + @Composable private fun SectionHeader( title: String, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt index f8b666a7..8e53f1b0 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt @@ -3,10 +3,12 @@ package com.miruplay.tv.ui.player import android.app.Activity import android.content.Context import android.content.ContextWrapper +import android.os.SystemClock import android.util.Log import android.view.LayoutInflater import android.view.WindowManager import android.widget.FrameLayout +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -75,6 +77,7 @@ 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 import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -104,16 +107,16 @@ import com.miruplay.tv.model.formatPlaybackPosition import com.miruplay.tv.model.pictureOsdMenuTitleLabel import com.miruplay.tv.model.pictureSaveDefaultForFormatLabel import com.miruplay.tv.model.pictureSessionOverrideLabel -import com.miruplay.tv.model.playbackBackendLabel import com.miruplay.tv.model.playbackAudioMenuTitle import com.miruplay.tv.model.playbackAudioOptionLabel import com.miruplay.tv.model.playbackAudioTrackCountLabel import com.miruplay.tv.model.playbackBackLabel +import com.miruplay.tv.model.playbackBackToDetailsLabel import com.miruplay.tv.model.playbackErrorTitle import com.miruplay.tv.model.playbackLocalSourceLabel import com.miruplay.tv.model.playbackPauseLabel import com.miruplay.tv.model.playbackPlayLabel -import com.miruplay.tv.model.playbackConfirmExitLabel +import com.miruplay.tv.model.playbackRetryLabel import com.miruplay.tv.model.playbackSeekBackLabel import com.miruplay.tv.model.playbackSeekForwardLabel import com.miruplay.tv.model.playbackSpeedChipLabel @@ -145,6 +148,7 @@ import kotlinx.coroutines.delay private const val STANDARD_DEBUG_CAPTURE_MIN_POSITION_MS = 5_000L private const val STANDARD_DEBUG_CAPTURE_RETRY_INTERVAL_MS = 1_500L +private const val PLAYER_EXIT_CONFIRMATION_WINDOW_MS = 2_000L @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -189,6 +193,8 @@ private fun PlayerScreenContent( val controlsInteractionToken by viewModel.controlsInteractionToken.collectAsStateWithLifecycle() val availableSubtitles by viewModel.availableSubtitles.collectAsStateWithLifecycle() val availableAudioTracks by viewModel.availableAudioTracks.collectAsStateWithLifecycle() + val selectedSubtitleTrackIndex by viewModel.selectedSubtitleTrackIndex.collectAsStateWithLifecycle() + val selectedAudioTrackIndex by viewModel.selectedAudioTrackIndex.collectAsStateWithLifecycle() val playbackSpeed by viewModel.playbackSpeed.collectAsStateWithLifecycle() val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() val displayTitle by viewModel.displayTitle.collectAsStateWithLifecycle() @@ -260,7 +266,9 @@ private fun PlayerScreenContent( preferDedicatedGlSurface = preferDedicatedExperimentalSurface, ) } + val context = LocalContext.current var openMenu by remember { mutableStateOf(null) } + var exitConfirmationStartedAt by remember(playbackSource) { mutableStateOf(0L) } var playerViewRef by remember { mutableStateOf(null) } var lastStandardDebugCaptureAttempt by remember(playbackSource) { mutableStateOf(null) @@ -276,6 +284,16 @@ private fun PlayerScreenContent( viewModel.saveCurrentProgressAndNavigate(screenOwnerToken, onNavigateBack) } } + val requestExit = { + val now = SystemClock.elapsedRealtime() + if (isConfirmedPlayerExit(exitConfirmationStartedAt, now)) { + exitConfirmationStartedAt = 0L + navigateBack() + } else { + exitConfirmationStartedAt = now + Toast.makeText(context, "再按一次返回键退出播放", Toast.LENGTH_SHORT).show() + } + } LaunchedEffect(playbackSource, hasStartedPlayback, screenOwnerToken) { if (!hasStartedPlayback) { @@ -304,6 +322,9 @@ private fun PlayerScreenContent( } LaunchedEffect(controlsVisible, controlsInteractionToken, playbackState, openMenu) { + if (controlsVisible || openMenu != null) { + exitConfirmationStartedAt = 0L + } if (controlsVisible && openMenu == null && playbackState is PlaybackState.Playing) { delay(4200) viewModel.hideControls() @@ -404,7 +425,7 @@ private fun PlayerScreenContent( openMenu = null viewModel.hideControls() }, - onNavigateBack = navigateBack + onNavigateBack = requestExit ) } .pointerInput(Unit) { @@ -530,13 +551,6 @@ private fun PlayerScreenContent( } } - if (errorMessage != null) { - ErrorOverlay( - message = errorMessage!!, - onExit = onNavigateBack - ) - } - AnimatedVisibility( visible = controlsVisible, enter = fadeIn(), @@ -550,13 +564,11 @@ private fun PlayerScreenContent( duration = duration, subtitles = availableSubtitles, audioTracks = availableAudioTracks, + selectedSubtitleTrackIndex = selectedSubtitleTrackIndex, + selectedAudioTrackIndex = selectedAudioTrackIndex, playbackSpeed = playbackSpeed, signalFormatLabel = currentVideoSignalDescriptor?.displayLabel().orEmpty(), - activeBackend = currentActiveBackend, - requestedBackend = currentRequestedBackend, - fallbackReason = fallbackReason, currentPicturePreset = viewModel.currentToneMappingPreset(), - currentToneMappingRuleSet = currentToneMappingRuleSet, onBack = navigateBack, onTogglePlayback = { viewModel.togglePlayback() @@ -594,30 +606,18 @@ private fun PlayerScreenContent( viewModel.resetCurrentToneMappingToDefault() viewModel.showControls() }, - onAdjustTargetSdrNits = { delta -> - viewModel.adjustCurrentToneMappingTargetSdrNits(delta) - viewModel.showControls() - }, - onAdjustContrastRecovery = { delta -> - viewModel.adjustCurrentToneMappingContrastRecovery(delta) - viewModel.showControls() - }, - onAdjustSaturationRecovery = { delta -> - viewModel.adjustCurrentToneMappingSaturationRecovery(delta) - viewModel.showControls() - }, - onAdjustHighlightCompression = { delta -> - viewModel.adjustCurrentToneMappingHighlightCompression(delta) - viewModel.showControls() - }, - onSelectBackend = { backend -> - viewModel.setToneMappingBackendForSession(backend) - viewModel.showControls() - }, openMenu = openMenu, onOpenMenuChange = { openMenu = it } ) } + + if (errorMessage != null) { + ErrorOverlay( + message = errorMessage!!, + onRetry = viewModel::retry, + onExit = navigateBack, + ) + } } } @@ -712,13 +712,11 @@ private fun PlayerChrome( duration: Long, subtitles: List, audioTracks: List, + selectedSubtitleTrackIndex: Int?, + selectedAudioTrackIndex: Int?, playbackSpeed: Float, signalFormatLabel: String, - activeBackend: PlaybackRenderBackend, - requestedBackend: PlaybackRenderBackend, - fallbackReason: String?, currentPicturePreset: ToneMappingProfilePreset, - currentToneMappingRuleSet: ToneMappingRuleSet, onBack: () -> Unit, onTogglePlayback: () -> Unit, onSkipBackward: () -> Unit, @@ -729,11 +727,6 @@ private fun PlayerChrome( onSelectPicturePreset: (ToneMappingProfilePreset) -> Unit, onSavePictureDefault: () -> Unit, onResetPictureSession: () -> Unit, - onAdjustTargetSdrNits: (Int) -> Unit, - onAdjustContrastRecovery: (Int) -> Unit, - onAdjustSaturationRecovery: (Int) -> Unit, - onAdjustHighlightCompression: (Int) -> Unit, - onSelectBackend: (PlaybackRenderBackend?) -> Unit, openMenu: PlayerMenu?, onOpenMenuChange: (PlayerMenu?) -> Unit ) { @@ -771,24 +764,16 @@ private fun PlayerChrome( menu = openMenu!!, subtitles = subtitles, audioTracks = audioTracks, + selectedSubtitleTrackIndex = selectedSubtitleTrackIndex, + selectedAudioTrackIndex = selectedAudioTrackIndex, playbackSpeed = playbackSpeed, - signalFormatLabel = signalFormatLabel, - activeBackend = activeBackend, - requestedBackend = requestedBackend, - fallbackReason = fallbackReason, currentPicturePreset = currentPicturePreset, - currentToneMappingRuleSet = currentToneMappingRuleSet, onSelectSubtitle = onSelectSubtitle, onSelectAudioTrack = onSelectAudioTrack, onSelectSpeed = onSelectSpeed, onSelectPicturePreset = onSelectPicturePreset, onSavePictureDefault = onSavePictureDefault, onResetPictureSession = onResetPictureSession, - onAdjustTargetSdrNits = onAdjustTargetSdrNits, - onAdjustContrastRecovery = onAdjustContrastRecovery, - onAdjustSaturationRecovery = onAdjustSaturationRecovery, - onAdjustHighlightCompression = onAdjustHighlightCompression, - onSelectBackend = onSelectBackend, modifier = Modifier .align(Alignment.BottomCenter) .padding(start = 52.dp, end = 52.dp, bottom = 164.dp) @@ -800,6 +785,8 @@ private fun PlayerChrome( duration = duration, subtitles = subtitles, audioTracks = audioTracks, + selectedSubtitleTrackIndex = selectedSubtitleTrackIndex, + selectedAudioTrackIndex = selectedAudioTrackIndex, playbackSpeed = playbackSpeed, signalFormatLabel = signalFormatLabel, openMenu = openMenu, @@ -905,6 +892,8 @@ private fun PlayerBottomBar( duration: Long, subtitles: List, audioTracks: List, + selectedSubtitleTrackIndex: Int?, + selectedAudioTrackIndex: Int?, playbackSpeed: Float, signalFormatLabel: String, openMenu: PlayerMenu?, @@ -957,14 +946,22 @@ private fun PlayerBottomBar( ) PlayerActionChip( icon = Icons.Filled.Subtitles, - text = playbackSubtitleCountLabel(subtitles.size), + text = selectedSubtitleTrackIndex + ?.let { index -> subtitles.getOrNull(index)?.let { playbackSubtitleOptionLabel(it, index) } } + ?: playbackSubtitleCountLabel(subtitles.size), selected = openMenu == PlayerMenu.Subtitles, enabled = subtitles.isNotEmpty(), onClick = { onOpenMenu(PlayerMenu.Subtitles) } ) PlayerActionChip( icon = Icons.Filled.Audiotrack, - text = playbackAudioTrackCountLabel(audioTracks.size), + text = selectedAudioTrackIndex + ?.let { index -> + audioTracks.getOrNull(index)?.let { + playbackAudioOptionLabel(it.title, it.language, index) + } + } + ?: playbackAudioTrackCountLabel(audioTracks.size), selected = openMenu == PlayerMenu.Audio, enabled = audioTracks.isNotEmpty(), onClick = { onOpenMenu(PlayerMenu.Audio) } @@ -1160,24 +1157,16 @@ internal fun PlayerOptionsPanel( menu: PlayerMenu, subtitles: List, audioTracks: List, + selectedSubtitleTrackIndex: Int?, + selectedAudioTrackIndex: Int?, playbackSpeed: Float, - signalFormatLabel: String, - activeBackend: PlaybackRenderBackend, - requestedBackend: PlaybackRenderBackend, - fallbackReason: String?, currentPicturePreset: ToneMappingProfilePreset, - currentToneMappingRuleSet: ToneMappingRuleSet, onSelectSubtitle: (Int) -> Unit, onSelectAudioTrack: (Int) -> Unit, onSelectSpeed: (Float) -> Unit, onSelectPicturePreset: (ToneMappingProfilePreset) -> Unit, onSavePictureDefault: () -> Unit, onResetPictureSession: () -> Unit, - onAdjustTargetSdrNits: (Int) -> Unit, - onAdjustContrastRecovery: (Int) -> Unit, - onAdjustSaturationRecovery: (Int) -> Unit, - onAdjustHighlightCompression: (Int) -> Unit, - onSelectBackend: (PlaybackRenderBackend?) -> Unit, modifier: Modifier = Modifier ) { val initialFocusHandle = rememberInitialFocusHandle(key = menu) @@ -1233,7 +1222,7 @@ internal fun PlayerOptionsPanel( subtitles.forEachIndexed { index, track -> PlayerOptionButton( text = playbackSubtitleOptionLabel(track, index), - selected = false, + selected = index == selectedSubtitleTrackIndex, onClick = { onSelectSubtitle(index) }, modifier = if (index == 0) { Modifier.then(initialFocusHandle.modifier()) @@ -1255,7 +1244,7 @@ internal fun PlayerOptionsPanel( language = track.language, index = index, ), - selected = false, + selected = index == selectedAudioTrackIndex, onClick = { onSelectAudioTrack(index) }, modifier = if (index == 0) { Modifier.then(initialFocusHandle.modifier()) @@ -1265,109 +1254,41 @@ internal fun PlayerOptionsPanel( ) } } - PlayerMenu.Picture -> { - val infoItems = buildList { - add(signalFormatLabel.ifBlank { "Auto" }) - add(playbackBackendLabel(activeBackend)) - if (fallbackReason != null) { - add(fallbackReason) - } else if (requestedBackend != activeBackend) { - add("Requested ${playbackBackendLabel(requestedBackend)}") - } - } - - Column( + PlayerMenu.Picture -> Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - infoItems.forEach { label -> - PlayerOptionButton( - text = label, - selected = false, - onClick = {}, - enabled = false, - ) - } - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - picturePresets.forEachIndexed { index, preset -> - PlayerOptionButton( - text = "${pictureSessionOverrideLabel()} ${toneMappingPresetLabel(preset)}", - selected = preset == currentPicturePreset, - onClick = { onSelectPicturePreset(preset) }, - modifier = if (index == 0) { - Modifier.then(initialFocusHandle.modifier()) - } else { - Modifier - } - ) - } - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - PlayerOptionButton( - text = pictureSaveDefaultForFormatLabel(), - selected = false, - onClick = onSavePictureDefault, - ) + picturePresets.forEachIndexed { index, preset -> PlayerOptionButton( - text = "重置本次播放", - selected = false, - onClick = onResetPictureSession, - ) - PlayerOptionButton( - text = "标准 Exo", - selected = activeBackend == PlaybackRenderBackend.STANDARD_EXO, - onClick = { onSelectBackend(PlaybackRenderBackend.STANDARD_EXO) }, - ) - PlayerOptionButton( - text = "旧实验 GL", - selected = requestedBackend == PlaybackRenderBackend.EXPERIMENTAL_GL, - onClick = { onSelectBackend(PlaybackRenderBackend.EXPERIMENTAL_GL) }, - ) - PlayerOptionButton( - text = "实验 mpv 内嵌", - selected = - requestedBackend == PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED || - requestedBackend == PlaybackRenderBackend.EXPERIMENTAL_MPV_ANDROID, - onClick = { onSelectBackend(PlaybackRenderBackend.EXPERIMENTAL_MPV_EMBEDDED) }, - ) - PlayerOptionButton( - text = "跟随默认", - selected = false, - onClick = { onSelectBackend(null) }, + text = "${pictureSessionOverrideLabel()} ${toneMappingPresetLabel(preset)}", + selected = preset == currentPicturePreset, + onClick = { onSelectPicturePreset(preset) }, + modifier = if (index == 0) { + Modifier.then(initialFocusHandle.modifier()) + } else { + Modifier + } ) } - ToneMappingAdjustRow( - label = "目标亮度 ${currentToneMappingRuleSet.targetSdrNits} nit", - onDecrease = { onAdjustTargetSdrNits(-10) }, - onIncrease = { onAdjustTargetSdrNits(10) }, - ) - ToneMappingAdjustRow( - label = "对比恢复 ${currentToneMappingRuleSet.contrastRecovery}", - onDecrease = { onAdjustContrastRecovery(-2) }, - onIncrease = { onAdjustContrastRecovery(2) }, - ) - ToneMappingAdjustRow( - label = "饱和恢复 ${currentToneMappingRuleSet.saturationRecovery}", - onDecrease = { onAdjustSaturationRecovery(-2) }, - onIncrease = { onAdjustSaturationRecovery(2) }, + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + PlayerOptionButton( + text = pictureSaveDefaultForFormatLabel(), + selected = false, + onClick = onSavePictureDefault, ) - ToneMappingAdjustRow( - label = "高光压缩 ${currentToneMappingRuleSet.highlightCompression}", - onDecrease = { onAdjustHighlightCompression(-2) }, - onIncrease = { onAdjustHighlightCompression(2) }, + PlayerOptionButton( + text = "重置本次播放", + selected = false, + onClick = onResetPictureSession, ) } } @@ -1375,36 +1296,6 @@ internal fun PlayerOptionsPanel( } } -@Composable -private fun ToneMappingAdjustRow( - label: String, - onDecrease: () -> Unit, - onIncrease: () -> Unit, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - PlayerOptionButton( - text = "-", - selected = false, - onClick = onDecrease, - ) - PlayerOptionButton( - text = label, - selected = false, - onClick = {}, - enabled = false, - modifier = Modifier.width(260.dp), - ) - PlayerOptionButton( - text = "+", - selected = false, - onClick = onIncrease, - ) - } -} - @Composable private fun PlayerOptionButton( text: String, @@ -1463,8 +1354,11 @@ private fun PlayerOptionButton( @Composable private fun ErrorOverlay( message: String, - onExit: () -> Unit + onRetry: () -> Unit, + onExit: () -> Unit, ) { + val retryFocus = rememberInitialFocusHandle(key = message) + Box( modifier = Modifier .fillMaxSize() @@ -1494,31 +1388,26 @@ private fun ErrorOverlay( overflow = TextOverflow.Ellipsis ) Spacer(Modifier.height(22.dp)) - Row( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(AnimeRed) - .clickable(onClick = onExit) - .padding(horizontal = 28.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(24.dp) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PlayerOptionButton( + text = playbackRetryLabel(), + selected = false, + onClick = onRetry, + modifier = retryFocus.modifier(), ) - Spacer(Modifier.width(8.dp)) - Text( - text = playbackConfirmExitLabel(), - color = Color.White, - style = TvTypography.body.copy(fontWeight = FontWeight.SemiBold) + PlayerOptionButton( + text = playbackBackToDetailsLabel(), + selected = false, + onClick = onExit, ) } } } } +internal fun isConfirmedPlayerExit(lastBackAtMs: Long, nowMs: Long): Boolean = + lastBackAtMs > 0L && nowMs - lastBackAtMs in 0L..PLAYER_EXIT_CONFIRMATION_WINDOW_MS + internal enum class PlayerMenu { Picture, Speed, diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt index e54e30eb..7882b3bb 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerViewModel.kt @@ -78,6 +78,12 @@ class PlayerViewModel @Inject constructor( private val _availableAudioTracks = MutableStateFlow>(emptyList()) val availableAudioTracks: StateFlow> = _availableAudioTracks.asStateFlow() + private val _selectedSubtitleTrackIndex = MutableStateFlow(null) + val selectedSubtitleTrackIndex: StateFlow = _selectedSubtitleTrackIndex.asStateFlow() + + private val _selectedAudioTrackIndex = MutableStateFlow(null) + val selectedAudioTrackIndex: StateFlow = _selectedAudioTrackIndex.asStateFlow() + private val _playbackSpeed = MutableStateFlow(1.0f) val playbackSpeed: StateFlow = _playbackSpeed.asStateFlow() @@ -176,6 +182,12 @@ class PlayerViewModel @Inject constructor( } } + fun retry() { + activeSource?.let { source -> + play(source.copy(startPosition = _currentPosition.value), activeScreenOwnerToken) + } + } + fun pause() { viewModelScope.launch { playbackController.pause() @@ -232,12 +244,14 @@ class PlayerViewModel @Inject constructor( fun selectSubtitle(index: Int) { viewModelScope.launch { playbackController.setSubtitleTrack(index) + refreshTracks() } } fun selectAudioTrack(index: Int) { viewModelScope.launch { playbackController.setAudioTrack(index) + refreshTracks() } } @@ -405,6 +419,8 @@ class PlayerViewModel @Inject constructor( private fun refreshTracks() { _availableSubtitles.value = playbackController.getAvailableSubtitles() _availableAudioTracks.value = playbackController.getAvailableAudioTracks() + _selectedSubtitleTrackIndex.value = playbackController.getSelectedSubtitleTrackIndex() + _selectedAudioTrackIndex.value = playbackController.getSelectedAudioTrackIndex() } private fun startProgressSaving(source: PlaybackSource) { @@ -550,6 +566,8 @@ class PlayerViewModel @Inject constructor( _duration.value = 0L _availableSubtitles.value = emptyList() _availableAudioTracks.value = emptyList() + _selectedSubtitleTrackIndex.value = null + _selectedAudioTrackIndex.value = null } override fun onCleared() { diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt index b2183187..35ecdf5b 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/settings/AddSourceScreen.kt @@ -2,6 +2,7 @@ package com.miruplay.tv.ui.settings import android.net.Uri import android.provider.DocumentsContract +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -386,7 +387,8 @@ fun AddSourceScreen( val proxyStatusMessage by viewModel.proxyStatusMessage.collectAsStateWithLifecycle() val appAboutInfo = rememberAppAboutInfo() - var selectedSection by remember { mutableStateOf(MiruPlaySettingsSection.WEB_UI) } + var selectedSection by remember { mutableStateOf(MiruPlaySettingsSection.SOURCES) } + var contentHasFocus by remember { mutableStateOf(false) } var editingSourceId by remember { mutableStateOf(null) } var selectedType by remember { mutableStateOf(MediaSourceType.LOCAL) } var selectedContentMode by remember { mutableStateOf(MediaContentMode.ANIME) } @@ -424,10 +426,18 @@ fun AddSourceScreen( val menuFocusRequesters = remember { androidTvSettingsSectionOrder.associateWith { FocusRequester() } } - val firstMenuFocusRequester = menuFocusRequesters.getValue(MiruPlaySettingsSection.WEB_UI) + val selectedMenuFocusRequester = menuFocusRequesters.getValue(selectedSection) LaunchedEffect(Unit) { - firstMenuFocusRequester.requestFocus() + selectedMenuFocusRequester.requestFocus() + } + + BackHandler { + if (contentHasFocus) { + selectedMenuFocusRequester.requestFocus() + } else { + onNavigateBack() + } } LaunchedEffect(tokenSaved) { @@ -601,7 +611,7 @@ fun AddSourceScreen( resetSourceForm() } }, - menuFocusRequester = menuFocusRequesters.getValue(selectedSection), + menuFocusRequester = selectedMenuFocusRequester, selectedType = selectedType, onTypeSelected = { type -> if (type != selectedType) { @@ -862,6 +872,7 @@ fun AddSourceScreen( modifier = Modifier .weight(1f) .fillMaxHeight() + .onFocusChanged { contentHasFocus = it.hasFocus } ) } @@ -1254,17 +1265,6 @@ private fun SettingsContent( .weight(0.46f) .fillMaxHeight() .focusProperties { left = menuFocusRequester } - .onPreviewKeyEvent { event -> - if ( - event.type == KeyEventType.KeyDown && - event.key.toMiruPlayInputIntent() == MiruPlayInputIntent.DirectionLeft - ) { - menuFocusRequester.requestFocus() - true - } else { - false - } - } ) Column( modifier = Modifier @@ -1508,6 +1508,14 @@ private fun SettingsSectionHeader(section: MiruPlaySettingsSection) { style = TvTypography.body, color = TextSecondary ) + if (section == MiruPlaySettingsSection.SCAN || section == MiruPlaySettingsSection.PLAYBACK) { + Spacer(Modifier.height(4.dp)) + Text( + text = "此页选择后立即保存", + style = TvTypography.caption, + color = ProgressGreen, + ) + } } } @@ -1718,6 +1726,12 @@ private fun WebUiPanel( onRefresh: () -> Unit ) { val activeUrl = selectedUrl.ifBlank { urls.firstOrNull().orEmpty() } + var showAccessToken by remember(accessToken) { mutableStateOf(false) } + val displayedAccessToken = if (showAccessToken || accessToken.isBlank()) { + accessToken + } else { + "••••••••${accessToken.takeLast(4)}" + } SettingsPanel { Row(verticalAlignment = Alignment.CenterVertically) { @@ -1760,11 +1774,18 @@ private fun WebUiPanel( enabled = enabled, modifier = Modifier.width(150.dp) ) + TvButton( + text = if (showAccessToken) "隐藏令牌" else "显示令牌", + icon = Icons.Filled.Key, + onClick = { showAccessToken = !showAccessToken }, + enabled = accessToken.isNotBlank(), + modifier = Modifier.width(140.dp) + ) } Spacer(Modifier.height(12.dp)) Text( - text = settingsWebUiAccessTokenLabel(accessToken), + text = settingsWebUiAccessTokenLabel(displayedAccessToken), style = TvTypography.caption, color = TextSecondary, maxLines = 2, diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibrarySearchTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibrarySearchTest.kt new file mode 100644 index 00000000..efdfbd61 --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/library/LibrarySearchTest.kt @@ -0,0 +1,19 @@ +package com.miruplay.tv.ui.library + +import com.miruplay.tv.model.Anime +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibrarySearchTest { + + private val anime = Anime(id = "frieren", title = "Sousou no Frieren", titleCn = "葬送的芙莉莲") + + @Test + fun `query matches localized original and stable titles`() { + assertTrue(anime.matchesLibraryQuery("芙莉莲")) + assertTrue(anime.matchesLibraryQuery("SOUSOU")) + assertTrue(anime.matchesLibraryQuery("frieren")) + assertFalse(anime.matchesLibraryQuery("不存在")) + } +} diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerExitConfirmationTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerExitConfirmationTest.kt new file mode 100644 index 00000000..3face02c --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerExitConfirmationTest.kt @@ -0,0 +1,16 @@ +package com.miruplay.tv.ui.player + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerExitConfirmationTest { + + @Test + fun `second back confirms exit only inside the window`() { + assertFalse(isConfirmedPlayerExit(lastBackAtMs = 0L, nowMs = 1_000L)) + assertTrue(isConfirmedPlayerExit(lastBackAtMs = 1_000L, nowMs = 3_000L)) + assertFalse(isConfirmedPlayerExit(lastBackAtMs = 1_000L, nowMs = 3_001L)) + assertFalse(isConfirmedPlayerExit(lastBackAtMs = 2_000L, nowMs = 1_999L)) + } +} diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerOptionsPanelFocusTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerOptionsPanelFocusTest.kt index 5dd6dc00..b112fabc 100644 --- a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerOptionsPanelFocusTest.kt +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerOptionsPanelFocusTest.kt @@ -3,10 +3,7 @@ package com.miruplay.tv.ui.player import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText -import com.miruplay.tv.model.PlaybackRenderBackend import com.miruplay.tv.model.ToneMappingProfilePreset -import com.miruplay.tv.model.defaultToneMappingRuleSet -import com.miruplay.tv.model.VideoRenderRuleKey import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -26,24 +23,16 @@ class PlayerOptionsPanelFocusTest { menu = PlayerMenu.Picture, subtitles = emptyList(), audioTracks = emptyList(), + selectedSubtitleTrackIndex = null, + selectedAudioTrackIndex = null, playbackSpeed = 1.0f, - signalFormatLabel = "HDR10", - activeBackend = PlaybackRenderBackend.EXPERIMENTAL_LIBVLC, - requestedBackend = PlaybackRenderBackend.EXPERIMENTAL_LIBVLC, - fallbackReason = null, currentPicturePreset = ToneMappingProfilePreset.BALANCED, - currentToneMappingRuleSet = defaultToneMappingRuleSet(VideoRenderRuleKey.HDR10), onSelectSubtitle = {}, onSelectAudioTrack = {}, onSelectSpeed = {}, onSelectPicturePreset = {}, onSavePictureDefault = {}, onResetPictureSession = {}, - onAdjustTargetSdrNits = {}, - onAdjustContrastRecovery = {}, - onAdjustSaturationRecovery = {}, - onAdjustHighlightCompression = {}, - onSelectBackend = {}, ) } diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerScreenPictureMenuLabelsTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerScreenPictureMenuLabelsTest.kt index d281a96e..382079e5 100644 --- a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerScreenPictureMenuLabelsTest.kt +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/PlayerScreenPictureMenuLabelsTest.kt @@ -1,6 +1,7 @@ package com.miruplay.tv.ui.player import com.miruplay.tv.model.PlaybackRenderBackend +import com.miruplay.tv.model.pictureOsdMenuTitleLabel import com.miruplay.tv.model.playbackBackendLabel import org.junit.Assert.assertEquals import org.junit.Test @@ -12,11 +13,7 @@ class PlayerScreenPictureMenuLabelsTest { } @Test - fun `tone mapping adjust row labels stay stable`() { - assertEquals("目标亮度 120 nit", "目标亮度 120 nit") - assertEquals("对比恢复 8", "对比恢复 8") - assertEquals("饱和恢复 10", "饱和恢复 10") - assertEquals("高光压缩 18", "高光压缩 18") - assertEquals("重置本次播放", "重置本次播放") + fun `picture menu title is localized`() { + assertEquals("画面", pictureOsdMenuTitleLabel()) } } diff --git a/web-control/frontend/src/App.vue b/web-control/frontend/src/App.vue index 6bbf1f4b..d5c980af 100644 --- a/web-control/frontend/src/App.vue +++ b/web-control/frontend/src/App.vue @@ -1,6 +1,29 @@