From 55e87d9d049d884c205c334382b42aca87658e63 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 29 Mar 2026 17:12:43 +0200 Subject: [PATCH] Add series shortcut to TV episode top bar --- .../app/content/episode/EpisodeComponents.kt | 23 +++++ .../app/content/episode/EpisodeScreen.kt | 11 +++ .../ui/components/MediaDetailsTopBar.kt | 90 +++++++++++++++++-- .../episode/EpisodeTopBarShortcutTest.kt | 37 ++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 app-tv/src/test/java/hu/bbara/purefin/app/content/episode/EpisodeTopBarShortcutTest.kt diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index ba2f971..e931410 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -4,15 +4,38 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar +import hu.bbara.purefin.common.ui.components.MediaDetailsTopBarShortcut +import hu.bbara.purefin.core.data.navigation.Route + +internal sealed interface EpisodeTopBarShortcut { + val label: String + val onClick: () -> Unit + + data class Series(override val onClick: () -> Unit) : EpisodeTopBarShortcut { + override val label: String = "Series" + } +} + +internal fun episodeTopBarShortcut( + previousRoute: Route?, + onSeriesClick: () -> Unit +): EpisodeTopBarShortcut? { + return when (previousRoute) { + Route.Home -> EpisodeTopBarShortcut.Series(onClick = onSeriesClick) + else -> null + } +} @Composable internal fun EpisodeTopBar( onBack: () -> Unit, + shortcut: EpisodeTopBarShortcut? = null, modifier: Modifier = Modifier, downFocusRequester: FocusRequester? = null ) { MediaDetailsTopBar( onBack = onBack, + shortcut = shortcut?.let { MediaDetailsTopBarShortcut(label = it.label, onClick = it.onClick) }, modifier = modifier, downFocusRequester = downFocusRequester ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index b066b8e..2368fc8 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -36,6 +36,7 @@ import hu.bbara.purefin.common.ui.components.MediaHero import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.core.data.navigation.EpisodeDto +import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack import hu.bbara.purefin.core.data.navigation.LocalNavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.model.Episode @@ -48,6 +49,8 @@ fun EpisodeScreen( modifier: Modifier = Modifier ) { val navigationManager = LocalNavigationManager.current + val backStack = LocalNavigationBackStack.current + val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) } LaunchedEffect(episode) { viewModel.selectEpisode( @@ -67,6 +70,12 @@ fun EpisodeScreen( EpisodeScreenInternal( episode = selectedEpisode, + topBarShortcut = remember(previousRoute, viewModel) { + episodeTopBarShortcut( + previousRoute = previousRoute, + onSeriesClick = viewModel::onSeriesClick + ) + }, onBack = viewModel::onBack, onPlay = remember(selectedEpisode.id, navigationManager) { { @@ -83,6 +92,7 @@ fun EpisodeScreen( @Composable private fun EpisodeScreenInternal( episode: Episode, + topBarShortcut: EpisodeTopBarShortcut?, onBack: () -> Unit, onPlay: () -> Unit, modifier: Modifier = Modifier, @@ -191,6 +201,7 @@ private fun EpisodeScreenInternal( } EpisodeTopBar( onBack = onBack, + shortcut = topBarShortcut, downFocusRequester = playFocusRequester, modifier = Modifier.align(Alignment.TopStart) ) diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt index 603acd7..ef2becd 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaDetailsTopBar.kt @@ -1,25 +1,52 @@ package hu.bbara.purefin.common.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.outlined.Cast import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +internal data class MediaDetailsTopBarShortcut( + val label: String, + val onClick: () -> Unit +) @Composable -fun MediaDetailsTopBar( +internal fun MediaDetailsTopBar( onBack: () -> Unit, modifier: Modifier = Modifier, + shortcut: MediaDetailsTopBarShortcut? = null, onCastClick: () -> Unit = {}, onMoreClick: () -> Unit = {}, downFocusRequester: FocusRequester? = null @@ -38,12 +65,21 @@ fun MediaDetailsTopBar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - GhostIconButton( - icon = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - onClick = onBack, - modifier = downModifier - ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GhostIconButton( + icon = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + onClick = onBack, + modifier = downModifier + ) + if (shortcut != null) { + GhostTextButton( + text = shortcut.label, + onClick = shortcut.onClick, + modifier = downModifier + ) + } + } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { GhostIconButton( icon = Icons.Outlined.Cast, @@ -60,3 +96,43 @@ fun MediaDetailsTopBar( } } } + +@Composable +private fun GhostTextButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + height: Dp = 52.dp +) { + val scheme = MaterialTheme.colorScheme + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(targetValue = if (isFocused) 1.05f else 1.0f, label = "scale") + val borderColor by animateColorAsState( + targetValue = if (isFocused) scheme.primary else Color.Transparent, + label = "border" + ) + + Box( + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } + .height(height) + .border( + width = if (isFocused) 2.5.dp else 0.dp, + color = borderColor, + shape = RoundedCornerShape(percent = 50) + ) + .clip(RoundedCornerShape(percent = 50)) + .background(if (isFocused) scheme.primary.copy(alpha = 0.25f) else scheme.background.copy(alpha = 0.65f)) + .onFocusChanged { isFocused = it.isFocused } + .clickable { onClick() } + .padding(horizontal = 20.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = scheme.onBackground, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } +} diff --git a/app-tv/src/test/java/hu/bbara/purefin/app/content/episode/EpisodeTopBarShortcutTest.kt b/app-tv/src/test/java/hu/bbara/purefin/app/content/episode/EpisodeTopBarShortcutTest.kt new file mode 100644 index 0000000..2da1be6 --- /dev/null +++ b/app-tv/src/test/java/hu/bbara/purefin/app/content/episode/EpisodeTopBarShortcutTest.kt @@ -0,0 +1,37 @@ +package hu.bbara.purefin.app.content.episode + +import hu.bbara.purefin.core.data.navigation.Route +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpisodeTopBarShortcutTest { + + @Test + fun `home route exposes series shortcut`() { + var clicked = false + + val shortcut = episodeTopBarShortcut(Route.Home) { + clicked = true + } + + assertNotNull(shortcut) + assertEquals("Series", shortcut?.label) + + shortcut?.onClick?.invoke() + + assertTrue(clicked) + } + + @Test + fun `non home route hides series shortcut`() { + val shortcut = episodeTopBarShortcut( + previousRoute = Route.PlayerRoute(mediaId = "episode-1"), + onSeriesClick = {} + ) + + assertNull(shortcut) + } +}