Add series shortcut to TV episode top bar

This commit is contained in:
2026-03-29 17:12:43 +02:00
parent 0afa7334be
commit 55e87d9d04
4 changed files with 154 additions and 7 deletions

View File

@@ -4,15 +4,38 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import hu.bbara.purefin.common.ui.components.MediaDetailsTopBar 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 @Composable
internal fun EpisodeTopBar( internal fun EpisodeTopBar(
onBack: () -> Unit, onBack: () -> Unit,
shortcut: EpisodeTopBarShortcut? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
downFocusRequester: FocusRequester? = null downFocusRequester: FocusRequester? = null
) { ) {
MediaDetailsTopBar( MediaDetailsTopBar(
onBack = onBack, onBack = onBack,
shortcut = shortcut?.let { MediaDetailsTopBarShortcut(label = it.label, onClick = it.onClick) },
modifier = modifier, modifier = modifier,
downFocusRequester = downFocusRequester downFocusRequester = downFocusRequester
) )

View File

@@ -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.MediaPlaybackSettings
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.MediaResumeButton
import hu.bbara.purefin.core.data.navigation.EpisodeDto 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.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
@@ -48,6 +49,8 @@ fun EpisodeScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val navigationManager = LocalNavigationManager.current val navigationManager = LocalNavigationManager.current
val backStack = LocalNavigationBackStack.current
val previousRoute = remember(backStack) { backStack.getOrNull(backStack.lastIndex - 1) }
LaunchedEffect(episode) { LaunchedEffect(episode) {
viewModel.selectEpisode( viewModel.selectEpisode(
@@ -67,6 +70,12 @@ fun EpisodeScreen(
EpisodeScreenInternal( EpisodeScreenInternal(
episode = selectedEpisode, episode = selectedEpisode,
topBarShortcut = remember(previousRoute, viewModel) {
episodeTopBarShortcut(
previousRoute = previousRoute,
onSeriesClick = viewModel::onSeriesClick
)
},
onBack = viewModel::onBack, onBack = viewModel::onBack,
onPlay = remember(selectedEpisode.id, navigationManager) { onPlay = remember(selectedEpisode.id, navigationManager) {
{ {
@@ -83,6 +92,7 @@ fun EpisodeScreen(
@Composable @Composable
private fun EpisodeScreenInternal( private fun EpisodeScreenInternal(
episode: Episode, episode: Episode,
topBarShortcut: EpisodeTopBarShortcut?,
onBack: () -> Unit, onBack: () -> Unit,
onPlay: () -> Unit, onPlay: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -191,6 +201,7 @@ private fun EpisodeScreenInternal(
} }
EpisodeTopBar( EpisodeTopBar(
onBack = onBack, onBack = onBack,
shortcut = topBarShortcut,
downFocusRequester = playFocusRequester, downFocusRequester = playFocusRequester,
modifier = Modifier.align(Alignment.TopStart) modifier = Modifier.align(Alignment.TopStart)
) )

View File

@@ -1,25 +1,52 @@
package hu.bbara.purefin.common.ui.components 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.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Cast import androidx.compose.material.icons.outlined.Cast
import androidx.compose.material.icons.outlined.MoreVert 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.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.Alignment
import androidx.compose.ui.Modifier 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.focusProperties 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.dp
import androidx.compose.ui.unit.sp
internal data class MediaDetailsTopBarShortcut(
val label: String,
val onClick: () -> Unit
)
@Composable @Composable
fun MediaDetailsTopBar( internal fun MediaDetailsTopBar(
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shortcut: MediaDetailsTopBarShortcut? = null,
onCastClick: () -> Unit = {}, onCastClick: () -> Unit = {},
onMoreClick: () -> Unit = {}, onMoreClick: () -> Unit = {},
downFocusRequester: FocusRequester? = null downFocusRequester: FocusRequester? = null
@@ -38,12 +65,21 @@ fun MediaDetailsTopBar(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton( GhostIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack, icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
onClick = onBack, onClick = onBack,
modifier = downModifier modifier = downModifier
) )
if (shortcut != null) {
GhostTextButton(
text = shortcut.label,
onClick = shortcut.onClick,
modifier = downModifier
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GhostIconButton( GhostIconButton(
icon = Icons.Outlined.Cast, 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
)
}
}

View File

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