From f12785aaee4c861763470dbca012ddfb7dd54e6e Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:08:45 -0500 Subject: [PATCH 1/2] Various UI fixes & improvements (#159) Various fixes: * Fixes misnamed "Continue watching" home row title * Fix person page layout to account for the new clock * Switch the 5 star rating to a decimal & icon Additionally, the tab row has been rewritten to allow for: * Always left justify tab rows * Always scroll tab rows to ensure the currently selected index is showing * Always start focus on currently selected index when entering tab rows --- .../wholphin/ui/components/Rating.kt | 47 +++++ .../wholphin/ui/components/TabRow.kt | 182 ++++++++++++++++++ .../ui/detail/CollectionFolderLiveTv.kt | 61 +----- .../ui/detail/CollectionFolderMovie.kt | 61 +----- .../wholphin/ui/detail/CollectionFolderTv.kt | 61 +----- .../wholphin/ui/detail/FavoritesPage.kt | 62 +----- .../wholphin/ui/detail/PersonPage.kt | 17 +- .../ui/detail/movie/MovieDetailsHeader.kt | 25 +-- .../ui/detail/series/FocusedEpisodeHeader.kt | 22 +-- .../ui/detail/series/SeriesDetails.kt | 19 +- .../ui/detail/series/SeriesOverviewContent.kt | 75 ++------ .../wholphin/ui/main/HomeViewModel.kt | 2 +- 12 files changed, 296 insertions(+), 338 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index bd13d25d..4a453cce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -4,8 +4,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup 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.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.selection.selectable @@ -33,12 +36,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import java.util.Locale enum class StarRatingPrecision { FULL, @@ -63,6 +71,33 @@ val EmptyStarColor = Color(0x2AFFC700) val ratingBarHeight: Dp = 32.dp +@Composable +fun SimpleStarRating( + communityRating: Float?, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + communityRating?.let { + Text( + text = String.format(Locale.getDefault(), "%.1f", it), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier, + ) + Icon( + imageVector = Icons.Filled.Star, + tint = FilledStarColor, + contentDescription = null, + modifier = Modifier, + ) + } + } +} + /** * Shows a rating out of 5 stars */ @@ -211,3 +246,15 @@ fun StarRating( } } } + +@PreviewTvSpec +@Composable +private fun SimpleStarRatingPreview() { + WholphinTheme { + Column { + SimpleStarRating(7.5f, Modifier.height(32.dp)) + SimpleStarRating(7.5f, Modifier.height(20.dp)) + SimpleStarRating(null, Modifier.height(32.dp)) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt new file mode 100644 index 00000000..dafb5ed9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -0,0 +1,182 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.core.animateDpAsState +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun TabRow( + selectedTabIndex: Int, + tabs: List, + onClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + LaunchedEffect(Unit) { + state.animateScrollToItem(selectedTabIndex) + } + val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } + var rowHasFocus by remember { mutableStateOf(false) } + LazyRow( + state = state, + modifier = + modifier + .onFocusChanged { + rowHasFocus = it.hasFocus + }.focusProperties { + onEnter = { + focusRequesters.getOrNull(selectedTabIndex)?.tryRequestFocus() + } + }, + ) { + itemsIndexed(tabs) { index, tabTitle -> + val interactionSource = remember { MutableInteractionSource() } + Tab( + title = tabTitle, + selected = index == selectedTabIndex, + rowActive = rowHasFocus, + interactionSource = interactionSource, + onClick = { + onClick.invoke(index) + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } +} + +@Composable +fun Tab( + title: String, + selected: Boolean, + rowActive: Boolean, + onClick: () -> Unit, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, +) { + var tabWidth by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + val focused by interactionSource.collectIsFocusedAsState() + val contentColor = + if (rowActive || selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = .5f) + } + Box( + modifier = + modifier + .clickable( + enabled = true, + interactionSource = interactionSource, + onClick = onClick, + indication = null, + ).onGloballyPositioned { + tabWidth = with(density) { it.size.width.toDp() } + }, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier, + ) { + Text( + text = title, + fontSize = 16.sp, + color = contentColor, + modifier = Modifier.padding(horizontal = 16.dp), + ) + TabIndicator( + selected = selected, + rowActive = rowActive, + focused = focused, + tabWidth = tabWidth, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } +} + +@Composable +fun TabIndicator( + selected: Boolean, + rowActive: Boolean, + focused: Boolean, + tabWidth: Dp, + modifier: Modifier = Modifier, +) { + val width by animateDpAsState(if (rowActive && focused) tabWidth else tabWidth * .25f) + val backgroundColor = + if (rowActive && focused) { + MaterialTheme.colorScheme.border + } else if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + Color.Transparent + } + Box( + modifier = + modifier + .height(2.dp) + .fillMaxWidth() + .width(width) + .background(backgroundColor), + ) +} + +@PreviewTvSpec +@Composable +private fun TabRowPreview() { + WholphinTheme { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { + TabRow( + selectedTabIndex = 1, + tabs = listOf("Tab 1", "Tab 2", "Tab 3"), + onClick = {}, + ) + Tab( + title = "This is a Tab", + selected = true, + rowActive = true, + onClick = {}, + interactionSource = remember { MutableInteractionSource() }, + modifier = Modifier.width(120.dp), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 76794b73..66dd2779 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -20,20 +20,12 @@ 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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Tab -import androidx.tv.material3.TabDefaults -import androidx.tv.material3.TabRow -import androidx.tv.material3.TabRowDefaults -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem @@ -41,10 +33,10 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager @@ -103,7 +95,6 @@ fun CollectionFolderLiveTv( TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()), ) + folders - var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -129,55 +120,13 @@ fun CollectionFolderLiveTv( exit = slideOutVertically() + fadeOut(), ) { TabRow( - selectedTabIndex = focusTabIndex, + selectedTabIndex = selectedTabIndex, modifier = Modifier .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRestorer(firstTabFocusRequester) - .onFocusChanged { - if (!it.isFocused) { - focusTabIndex = selectedTabIndex - } - }, - indicator = - @Composable { tabPositions, doesTabRowHaveFocus -> - tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> -// TabRowDefaults.PillIndicator( -// currentTabPosition = currentTabPosition, -// doesTabRowHaveFocus = doesTabRowHaveFocus, -// ) - TabRowDefaults.UnderlinedIndicator( - currentTabPosition = currentTabPosition, - doesTabRowHaveFocus = doesTabRowHaveFocus, - activeColor = MaterialTheme.colorScheme.border, - ) - } - }, - tabs = { - tabs.forEachIndexed { index, tabId -> - Tab( - selected = focusTabIndex == index, - onClick = { selectedTabIndex = index }, - onFocus = { focusTabIndex = index }, - colors = - TabDefaults.pillIndicatorTabColors( - focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Text( - text = tabId.title, - style = MaterialTheme.typography.titleMedium, - modifier = - Modifier - .padding(8.dp) - .ifElse( - index == selectedTabIndex, - Modifier.focusRequester(firstTabFocusRequester), - ), - ) - } - } - }, + .focusRequester(firstTabFocusRequester), + tabs = tabs.map { it.title }, + onClick = { selectedTabIndex = it }, ) } when (selectedTabIndex) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 8fddd0bc..7cef5122 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -19,17 +19,9 @@ 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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Tab -import androidx.tv.material3.TabDefaults -import androidx.tv.material3.TabRow -import androidx.tv.material3.TabRowDefaults -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -38,9 +30,9 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedMovie +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -63,7 +55,6 @@ fun CollectionFolderMovie( stringResource(R.string.collections), stringResource(R.string.genres), ) - var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -90,55 +81,13 @@ fun CollectionFolderMovie( exit = slideOutVertically() + fadeOut(), ) { TabRow( - selectedTabIndex = focusTabIndex, + selectedTabIndex = selectedTabIndex, modifier = Modifier .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRestorer(firstTabFocusRequester) - .onFocusChanged { - if (!it.isFocused) { - focusTabIndex = selectedTabIndex - } - }, - indicator = - @Composable { tabPositions, doesTabRowHaveFocus -> - tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> -// TabRowDefaults.PillIndicator( -// currentTabPosition = currentTabPosition, -// doesTabRowHaveFocus = doesTabRowHaveFocus, -// ) - TabRowDefaults.UnderlinedIndicator( - currentTabPosition = currentTabPosition, - doesTabRowHaveFocus = doesTabRowHaveFocus, - activeColor = MaterialTheme.colorScheme.border, - ) - } - }, - tabs = { - tabs.forEachIndexed { index, title -> - Tab( - selected = focusTabIndex == index, - onClick = { selectedTabIndex = index }, - onFocus = { focusTabIndex = index }, - colors = - TabDefaults.pillIndicatorTabColors( - focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - modifier = - Modifier - .padding(8.dp) - .ifElse( - index == selectedTabIndex, - Modifier.focusRequester(firstTabFocusRequester), - ), - ) - } - } - }, + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, ) } when (selectedTabIndex) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 8b63fe27..8155fbc5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -19,17 +19,9 @@ 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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Tab -import androidx.tv.material3.TabDefaults -import androidx.tv.material3.TabRow -import androidx.tv.material3.TabRowDefaults -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -38,8 +30,8 @@ import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.SeriesSortOptions -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -61,7 +53,6 @@ fun CollectionFolderTv( stringResource(R.string.library), stringResource(R.string.genres), ) - var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -88,55 +79,13 @@ fun CollectionFolderTv( exit = slideOutVertically() + fadeOut(), ) { TabRow( - selectedTabIndex = focusTabIndex, + selectedTabIndex = selectedTabIndex, modifier = Modifier .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRestorer(firstTabFocusRequester) - .onFocusChanged { - if (!it.isFocused) { - focusTabIndex = selectedTabIndex - } - }, - indicator = - @Composable { tabPositions, doesTabRowHaveFocus -> - tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> -// TabRowDefaults.PillIndicator( -// currentTabPosition = currentTabPosition, -// doesTabRowHaveFocus = doesTabRowHaveFocus, -// ) - TabRowDefaults.UnderlinedIndicator( - currentTabPosition = currentTabPosition, - doesTabRowHaveFocus = doesTabRowHaveFocus, - activeColor = MaterialTheme.colorScheme.border, - ) - } - }, - tabs = { - tabs.forEachIndexed { index, title -> - Tab( - selected = focusTabIndex == index, - onClick = { selectedTabIndex = index }, - onFocus = { focusTabIndex = index }, - colors = - TabDefaults.pillIndicatorTabColors( - focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - modifier = - Modifier - .padding(8.dp) - .ifElse( - index == selectedTabIndex, - Modifier.focusRequester(firstTabFocusRequester), - ), - ) - } - } - }, + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, ) } when (selectedTabIndex) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 3546c8de..002ca4ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -19,23 +19,16 @@ 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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Tab -import androidx.tv.material3.TabDefaults -import androidx.tv.material3.TabRow -import androidx.tv.material3.TabRowDefaults -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions @@ -66,14 +59,9 @@ fun FavoritesPage( stringResource(R.string.tv_shows), stringResource(R.string.episodes), ) - val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } - var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - tabFocusRequesters.getOrNull(selectedTabIndex)?.tryRequestFocus() - } LaunchedEffect(selectedTabIndex) { preferencesViewModel.saveRememberedTab( preferences, @@ -97,52 +85,12 @@ fun FavoritesPage( exit = slideOutVertically() + fadeOut(), ) { TabRow( - selectedTabIndex = focusTabIndex, + selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRestorer(tabFocusRequesters[0]) - .onFocusChanged { - if (!it.isFocused) { - focusTabIndex = selectedTabIndex - } - }, - indicator = - @Composable { tabPositions, doesTabRowHaveFocus -> - tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> -// TabRowDefaults.PillIndicator( -// currentTabPosition = currentTabPosition, -// doesTabRowHaveFocus = doesTabRowHaveFocus, -// ) - TabRowDefaults.UnderlinedIndicator( - currentTabPosition = currentTabPosition, - doesTabRowHaveFocus = doesTabRowHaveFocus, - activeColor = MaterialTheme.colorScheme.border, - ) - } - }, - tabs = { - tabs.forEachIndexed { index, title -> - Tab( - selected = focusTabIndex == index, - onClick = { selectedTabIndex = index }, - onFocus = { focusTabIndex = index }, - colors = - TabDefaults.pillIndicatorTabColors( - focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - modifier = - Modifier - .padding(8.dp) - .focusRequester(tabFocusRequesters[index]), - ) - } - } - }, + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), + tabs = tabs, + onClick = { selectedTabIndex = it }, ) } when (selectedTabIndex) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index aaab1b9c..bd04b4c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -17,6 +21,7 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester @@ -322,9 +327,11 @@ fun PersonHeader( overviewOnClick: () -> Unit, modifier: Modifier = Modifier, ) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(32.dp), - modifier = modifier, + modifier = modifier.bringIntoViewRequester(bringIntoViewRequester), ) { AsyncImage( model = imageUrl, @@ -414,10 +421,18 @@ fun PersonHeader( overflow = TextOverflow.Ellipsis, ) } + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + if (focused) { + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + } + } OverviewText( overview = overview ?: "", maxLines = 3, onClick = overviewOnClick, + interactionSource = interactionSource, modifier = Modifier.padding(top = 8.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 534fe23e..1bf0869a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -34,8 +33,7 @@ import com.github.damontecres.wholphin.data.model.chooseStream import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.StarRating -import com.github.damontecres.wholphin.ui.components.StarRatingPrecision +import com.github.damontecres.wholphin.ui.components.SimpleStarRating import com.github.damontecres.wholphin.ui.components.TitleValueText import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.roundMinutes @@ -102,24 +100,13 @@ fun MovieDetailsHeader( DotSeparatedRow( texts = details, textStyle = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier, ) } - dto.communityRating?.let { - if (it > 0f) { - StarRating( - rating100 = (it * 10).toInt(), - onRatingChange = {}, - enabled = false, - precision = StarRatingPrecision.HALF, - playSoundOnFocus = true, - modifier = Modifier.height(32.dp), - ) - } else { - Spacer(Modifier.height(32.dp)) - } - } - + SimpleStarRating( + dto.communityRating, + Modifier.height(20.dp), + ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 7c1425a4..d4cacc99 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -16,8 +15,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.StarRating -import com.github.damontecres.wholphin.ui.components.StarRatingPrecision +import com.github.damontecres.wholphin.ui.components.SimpleStarRating import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.timeRemaining import com.github.damontecres.wholphin.util.formatDateTime @@ -69,20 +67,10 @@ fun FocusedEpisodeHeader( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - dto.communityRating?.let { - if (it > 0f) { - StarRating( - rating100 = (it * 10).toInt(), - onRatingChange = {}, - enabled = false, - precision = StarRatingPrecision.HALF, - playSoundOnFocus = true, - modifier = Modifier.height(24.dp), - ) - } else { - Spacer(Modifier.height(24.dp)) - } - } ?: Spacer(Modifier.height(24.dp)) + SimpleStarRating( + dto.communityRating, + Modifier.height(20.dp), + ) } OverviewText( overview = dto.overview ?: "", diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index a71d9423..6186ff80 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -61,8 +61,7 @@ import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.StarRating -import com.github.damontecres.wholphin.ui.components.StarRatingPrecision +import com.github.damontecres.wholphin.ui.components.SimpleStarRating import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -467,18 +466,10 @@ fun SeriesDetailsHeader( ) } - dto.communityRating?.let { - if (it >= 0f) { - StarRating( - rating100 = (it * 10).toInt(), - onRatingChange = {}, - enabled = false, - precision = StarRatingPrecision.HALF, - playSoundOnFocus = true, - modifier = Modifier.height(24.dp), - ) - } - } + SimpleStarRating( + dto.communityRating, + Modifier.height(20.dp), + ) dto.overview?.let { overview -> OverviewText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 6c19f62f..f2fde9f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -37,10 +36,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Tab -import androidx.tv.material3.TabDefaults -import androidx.tv.material3.TabRow -import androidx.tv.material3.TabRowDefaults import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R @@ -52,6 +47,7 @@ import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -80,10 +76,7 @@ fun SeriesOverviewContent( modifier: Modifier = Modifier, ) { val bringIntoViewRequester = remember { BringIntoViewRequester() } - var focusTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } - val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } } - var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) } val tabRowFocusRequester = remember { FocusRequester() } val focusedEpisode = @@ -139,62 +132,22 @@ fun SeriesOverviewContent( PaddingValues(start = 16.dp, end = 16.dp) } TabRow( - selectedTabIndex = focusTabIndex, + selectedTabIndex = selectedTabIndex, + tabs = + seasons.mapNotNull { + it?.name + ?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}") + }, + onClick = { + selectedTabIndex = it + onFocus.invoke(SeriesOverviewPosition(it, 0)) + }, modifier = Modifier - .ifElse( - focusRequesters.size > selectedTabIndex, - { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) }, - ).focusRequester(tabRowFocusRequester) + .focusRequester(tabRowFocusRequester) .padding(paddingValues) - .fillMaxWidth() - .onFocusChanged { - if (!it.isFocused) { - focusTabIndex = selectedTabIndex - } - }, - indicator = - @Composable { tabPositions, doesTabRowHaveFocus -> - tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> -// TabRowDefaults.PillIndicator( -// currentTabPosition = currentTabPosition, -// doesTabRowHaveFocus = doesTabRowHaveFocus, -// ) - TabRowDefaults.UnderlinedIndicator( - currentTabPosition = currentTabPosition, - doesTabRowHaveFocus = doesTabRowHaveFocus, - activeColor = MaterialTheme.colorScheme.border, - ) - } - }, - ) { - seasons.forEachIndexed { index, season -> - season?.let { season -> - Tab( - selected = focusTabIndex == index, - onFocus = { focusTabIndex = index }, - onClick = { - selectedTabIndex = index - onFocus.invoke(SeriesOverviewPosition(index, 0)) - }, - colors = - TabDefaults.pillIndicatorTabColors( - focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - modifier = - Modifier - .focusRequester(focusRequesters[index]), - ) { - Text( - text = - season.name - ?: (stringResource(R.string.tv_season) + " ${season.data.indexNumber}"), - modifier = Modifier.padding(8.dp), - ) - } - } - } - } + .fillMaxWidth(), + ) } item { series.name?.let { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 806ab9f0..809387be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -90,7 +90,7 @@ class HomeViewModel if (resume.isNotEmpty()) { add( HomeRow( - titleRes = R.string.recently_added, + titleRes = R.string.continue_watching, items = resume, ), ) From b6df72c3c9c2e9dcd651c9c0d4a93cd511b4890c Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:35:20 -0500 Subject: [PATCH 2/2] Optional setting for one click pause w/ d-pad center (#160) Closes #152 Adds a setting to enable one-click-pausing via the D-Pad center button. It is disabled by default. If enabled, when the video is playing, it will be pause and show the controls which is just like pressing the Pause or Play/Pause button. If enable, when the video is paused and controls is not showing, it will resume playback including using the skip back on resume setting. --- .../wholphin/preferences/AppPreference.kt | 13 +++++++++++++ .../wholphin/ui/playback/PlaybackKeyHandler.kt | 12 +++++++++++- .../wholphin/ui/playback/PlaybackPage.kt | 1 + app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 ++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index aa609f40..7a92e252 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -654,6 +654,18 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val OneClickPause = + AppSwitchPreference( + title = R.string.one_click_pause, + defaultValue = false, + getter = { it.playbackPreferences.oneClickPause }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { oneClickPause = value } + }, + summaryOn = R.string.one_click_pause_summary_on, + summaryOff = R.string.disabled, + ) } } @@ -732,6 +744,7 @@ val advancedPreferences = title = R.string.playback, preferences = listOf( + AppPreference.OneClickPause, AppPreference.SkipIntros, AppPreference.SkipOutros, AppPreference.SkipCommercials, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 7420b038..65e1d57e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -23,6 +23,7 @@ class PlaybackKeyHandler( private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, private val skipBackOnResume: Duration?, + private val oneClickPause: Boolean, private val onInteraction: () -> Unit, ) { fun onKeyEvent(it: KeyEvent): Boolean { @@ -33,7 +34,7 @@ class PlaybackKeyHandler( result = false } else if (it.type != KeyEventType.KeyUp) { result = false - } else if (isDpad(it)) { + } else if (isDirectionalDpad(it) || isEnterKey(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && it.key == Key.DirectionLeft) { updateSkipIndicator(-seekBack.inWholeMilliseconds) @@ -41,6 +42,15 @@ class PlaybackKeyHandler( } else if (skipWithLeftRight && it.key == Key.DirectionRight) { player.seekForward(seekForward) updateSkipIndicator(seekForward.inWholeMilliseconds) + } else if (oneClickPause && isEnterKey(it)) { + Util.handlePlayPauseButtonAction(player) + if (!player.isPlaying) { + controllerViewState.showControls() + } else { + skipBackOnResume?.let { + player.seekBack(it) + } + } } else { controllerViewState.showControls() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index b2c8749f..cb98f1ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -208,6 +208,7 @@ fun PlaybackPage( updateSkipIndicator = updateSkipIndicator, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, ) val showSegment = diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index bdfd92a4..f56bc4e1 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -58,6 +58,7 @@ message PlaybackPreferences { int64 pass_out_protection_ms = 16; PrefContentScale global_content_scale = 17; ShowNextUpWhen show_next_up_when = 18; + bool one_click_pause = 19; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6a3287e..cb350af7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,8 @@ Aired Episode Order Create new playlist Add to playlist + Pause with one click + Press D-Pad center to pause/play %d downloads