Compare commits

..

5 Commits

Author SHA1 Message Date
d0e5e978be Refactor app navigation to use Navigation3
Integrate `navigation3` into `AppScreen` to manage tab transitions and
backstack state. Replaced manual tab state management with `NavDisplay`
and `NavBackStack`, and added horizontal slide animations for switching
between Home, Libraries, and Downloads screens.
2026-04-20 20:23:40 +02:00
a39581682f Refactor media selection logic to use a unified onMediaSelected callback
Consolidates specific media selection callbacks (`onMovieSelected`, `onSeriesSelected`, `onEpisodeSelected`) into a single `onMediaSelected` handler that accepts a `MediaUiModel`. This simplifies the interface between the ViewModel and UI components across both mobile and TV screens, moving the type-checking logic into `AppViewModel`.
2026-04-20 20:08:38 +02:00
a9b0f771f8 refactor: Use MediaUiModel instead of SuggestedItem 2026-04-20 19:51:13 +02:00
18e352eb1e refactor: change background color of PlayerScreen to black 2026-04-20 18:13:46 +02:00
9545e4d619 refactor: remove forced common PosterCardContent and inline them in the corresponding app ui module 2026-04-20 18:13:25 +02:00
22 changed files with 477 additions and 659 deletions

View File

@@ -1,15 +1,40 @@
package hu.bbara.purefin.ui.common.card
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.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaKind
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
@@ -50,51 +75,102 @@ fun PosterCard(
)
}
private fun PosterItem.toPosterCardModel(): PosterCardModel {
return PosterCardModel(
title = title,
secondaryText = secondaryText,
imageUrl = imageUrl,
mediaKind = type,
badge = when (type) {
MediaKind.MOVIE -> {
val movie = requireNotNull(movie)
PosterCardBadge.WatchState(
watched = movie.watched,
started = (movie.progress ?: 0.0) > 0.0
)
}
MediaKind.EPISODE -> {
val episode = requireNotNull(episode)
PosterCardBadge.WatchState(
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0.0
)
}
MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes(
count = requireNotNull(series).unwatchedEpisodeCount
)
else -> PosterCardBadge.None
}
)
}
private fun PosterItem.open(
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@Composable
fun PosterCardContent(
model: MediaUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {},
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
) {
when (type) {
MediaKind.MOVIE -> onMovieSelected(id)
MediaKind.SERIES -> onSeriesSelected(id)
MediaKind.EPISODE -> {
val ep = requireNotNull(episode)
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
}
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
Column(
modifier = modifier
.width(posterWidth)
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = focusedTransformOrigin
}
) {
Box {
PurefinAsyncImage(
model = model.primaryImageUrl,
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(
width = if (isFocused) focusedBorderWidth else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(14.dp)
)
.background(scheme.surfaceVariant)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocused()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop
)
when (model) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = indicatorSize,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = model.watched,
started = (model.progress ?: 0f) > 0f
)
}
else -> Unit
}
}
Column(
modifier = Modifier.padding(
top = 8.dp,
start = 4.dp,
end = 4.dp,
bottom = 8.dp
)
) {
Text(
text = model.primaryText,
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
model.secondaryText
.takeIf { showSecondaryText }
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -76,9 +76,7 @@ fun TvAppScreen(
libraryContent = latestLibraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
onMediaSelected = viewModel::onMediaSelected,
modifier = Modifier.fillMaxSize()
)
}

View File

@@ -24,9 +24,7 @@ fun TvHomeScreen(
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
@@ -53,9 +51,7 @@ fun TvHomeScreen(
onMediaFocused = {
focusedMediaUiModel.value = it
},
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
onMediaSelected = onMediaSelected,
modifier = Modifier
.weight(1f)
.fillMaxWidth()

View File

@@ -37,9 +37,7 @@ fun TvHomeContent(
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
onMediaFocused: (MediaUiModel) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
contentPadding: PaddingValues = PaddingValues(bottom = 32.dp),
modifier: Modifier = Modifier,
) {
@@ -81,8 +79,7 @@ fun TvHomeContent(
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected,
onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester,
firstItemTestTag = TvHomeInitialFocusTag,
rowTestTag = TvHomeContinueWatchingRowTag
@@ -95,7 +92,7 @@ fun TvHomeContent(
TvNextUpSection(
items = nextUp,
onFocusedItem = onMediaFocused,
onEpisodeSelected = onEpisodeSelected,
onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
rowTestTag = TvHomeNextUpRowTag
@@ -115,9 +112,7 @@ fun TvHomeContent(
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
},
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}

View File

@@ -44,10 +44,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.ui.common.card.PosterCardContent
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
import kotlin.math.roundToInt
@@ -83,8 +82,7 @@ internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
fun TvContinueWatchingSection(
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
@@ -120,13 +118,7 @@ fun TvContinueWatchingSection(
}
),
onFocusedItem = { onFocusedItem(item) },
onClick = {
when (item) {
//TODO fix this shit
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
else -> Unit
}
}
onClick = { onMediaSelected(item) }
)
}
}
@@ -136,7 +128,7 @@ fun TvContinueWatchingSection(
fun TvNextUpSection(
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
@@ -171,12 +163,7 @@ fun TvNextUpSection(
}
),
onFocusedItem = { onFocusedItem(item) },
onClick = {
//TODO FIX
(item as? EpisodeUiModel)?.let { episode ->
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
}
onClick = { onMediaSelected(item) }
)
}
}
@@ -191,9 +178,7 @@ fun TvLibraryPosterSection(
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
) {
TvSectionHeader(
title = title,
@@ -203,8 +188,9 @@ fun TvLibraryPosterSection(
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
PosterCard(
item = item,
PosterCardContent(
model = item,
onClick = { onMediaSelected(item) },
posterWidth = TvHomePosterCardWidth,
contentScale = TvHomeMediaCardScale,
showSecondaryText = true,
@@ -225,10 +211,9 @@ fun TvLibraryPosterSection(
Modifier
}
),
onFocusedItem = onFocusedItem,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
onFocused = { onFocusedItem(item) },
focusedScale = 1.07f,
focusedBorderWidth = 2.dp
)
}
}

View File

@@ -1,12 +1,40 @@
package hu.bbara.purefin.ui.common.card
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.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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 hu.bbara.purefin.core.model.MediaKind
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
@@ -24,58 +52,105 @@ fun PosterCard(
when (item) {
is MovieUiModel -> onMovieSelected(item.id)
is SeriesUiModel -> onSeriesSelected(item.id)
else -> Unit
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
}
},
modifier = modifier
)
}
internal fun PosterItem.toPosterCardModel(): PosterCardModel {
return PosterCardModel(
title = title,
secondaryText = secondaryText,
imageUrl = imageUrl,
mediaKind = type,
badge = when (type) {
MediaKind.MOVIE -> {
val movie = requireNotNull(movie)
PosterCardBadge.WatchState(
watched = movie.watched,
started = (movie.progress ?: 0.0) > 0.0
)
}
MediaKind.EPISODE -> {
val episode = requireNotNull(episode)
PosterCardBadge.WatchState(
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0.0
)
}
MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes(
count = requireNotNull(series).unwatchedEpisodeCount
)
else -> PosterCardBadge.None
}
)
}
private fun PosterItem.open(
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@Composable
fun PosterCardContent(
model: MediaUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {},
) {
when (type) {
MediaKind.MOVIE -> onMovieSelected(id)
MediaKind.SERIES -> onSeriesSelected(id)
MediaKind.EPISODE -> {
val ep = requireNotNull(episode)
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
}
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.07f else 1f,
label = "scale"
)
Column(
modifier = modifier
.width(posterWidth)
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box {
PurefinAsyncImage(
model = model.primaryImageUrl,
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(14.dp)
)
.background(scheme.surfaceVariant)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocused()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop
)
when (model) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = indicatorSize,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = model.watched,
started = (model.progress ?: 0f) > 0f
)
}
else -> Unit
}
}
Column(
modifier = Modifier.padding(
top = 8.dp,
start = 4.dp,
end = 4.dp,
bottom = 8.dp
)
) {
Text(
text = model.primaryText,
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
model.secondaryText
.takeIf { showSecondaryText }
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,34 +1,52 @@
package hu.bbara.purefin.ui.screen
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
import kotlinx.serialization.Serializable
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@Composable
fun AppScreen(
viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
var selectedTab by remember { mutableIntStateOf(0) }
val libraries by viewModel.libraries.collectAsState()
val libraryContent by viewModel.latestLibraryContent.collectAsState()
val suggestions by viewModel.suggestions.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val navigationManager = LocalNavigationManager.current
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(AppTabRoute.Home) as NavBackStack<AppTabRoute>
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
val selectedTab = currentRoute.toTabIndex()
val libraryNavItems = libraries.map {
HomeNavItem(
@@ -43,8 +61,18 @@ fun AppScreen(
onPauseOrDispose { }
}
when (selectedTab) {
0 -> HomeScreen(
val onTabSelected = remember(backStack) {
{ selectedIndex: Int ->
val route = selectedIndex.toAppTabRoute()
if (backStack.lastOrNull() != route) {
backStack.add(route)
}
}
}
val tabEntryProvider = entryProvider {
entry<AppTabRoute.Home>(metadata = appTabMetadata(AppTabRoute.Home)) {
HomeScreen(
libraries = libraries,
libraryContent = libraryContent,
suggestions = suggestions,
@@ -52,9 +80,7 @@ fun AppScreen(
nextUp = nextUp,
isRefreshing = isRefreshing,
onRefresh = viewModel::onRefresh,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
onMediaSelected = viewModel::onMediaSelected,
onLibrarySelected = { library ->
viewModel.onLibrarySelected(
library.id,
@@ -65,23 +91,101 @@ fun AppScreen(
onSettingsClick = {},
onLogoutClick = viewModel::logout,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
modifier = modifier.fillMaxSize()
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
1 -> LibrariesScreen(
}
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
LibrariesScreen(
items = libraryNavItems,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
onProfileClick = {},
onSettingsClick = {},
onLogoutClick = viewModel::logout,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
modifier = modifier.fillMaxSize()
)
2 -> DownloadsScreen(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
modifier = modifier.fillMaxSize()
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
}
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
DownloadsScreen(
selectedTab = selectedTab,
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)
}
}
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
navigationManager.pop()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator()
),
transitionSpec = { appTabTransition() },
popTransitionSpec = { appTabTransition() },
predictivePopTransitionSpec = { _ -> appTabTransition() },
entryProvider = tabEntryProvider,
modifier = modifier.fillMaxSize()
)
}
@Serializable
private sealed interface AppTabRoute : NavKey {
@Serializable
data object Home : AppTabRoute
@Serializable
data object Libraries : AppTabRoute
@Serializable
data object Downloads : AppTabRoute
}
private fun AppTabRoute.toTabIndex(): Int = when (this) {
AppTabRoute.Home -> 0
AppTabRoute.Libraries -> 1
AppTabRoute.Downloads -> 2
}
private fun Int.toAppTabRoute(): AppTabRoute = when (this) {
0 -> AppTabRoute.Home
1 -> AppTabRoute.Libraries
2 -> AppTabRoute.Downloads
else -> AppTabRoute.Home
}
private fun AnimatedContentTransitionScope<Scene<AppTabRoute>>.appTabTransition(): ContentTransform {
val initialIndex = initialState.metadata.appTabIndex()
val targetIndex = targetState.metadata.appTabIndex()
val animationSpec = tween<IntOffset>(durationMillis = 140)
return when {
targetIndex > initialIndex -> {
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth }
}
targetIndex < initialIndex -> {
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth }
}
else -> {
slideInHorizontally(animationSpec = animationSpec) { 0 } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { 0 }
}
}
}
private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
mapOf(APP_TAB_INDEX_METADATA to route.toTabIndex())
private fun Map<String, Any>.appTabIndex(): Int =
this[APP_TAB_INDEX_METADATA] as? Int ?: 0
private const val APP_TAB_INDEX_METADATA = "app_tab_index"

View File

@@ -14,7 +14,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.SuggestedItem
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.HomeContent
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
@@ -26,14 +25,12 @@ import java.util.UUID
fun HomeScreen(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
suggestions: List<SuggestedItem>,
suggestions: List<MediaUiModel>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
isRefreshing: Boolean,
onRefresh: () -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit,
onProfileClick: () -> Unit,
onSettingsClick: () -> Unit,
@@ -73,9 +70,7 @@ fun HomeScreen(
nextUp = nextUp,
isRefreshing = isRefreshing,
onRefresh = onRefresh,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
onMediaSelected = onMediaSelected,
onLibrarySelected = onLibrarySelected,
onBrowseLibrariesClick = { onTabSelected(1) },
modifier = Modifier.padding(innerPadding)
@@ -86,11 +81,12 @@ fun HomeScreen(
onDismiss = { isSearchVisible = false },
onMovieSelected = {
isSearchVisible = false
onMovieSelected(it)
//TODO use MediaUiModel as well
//onMovieSelected(it)
},
onSeriesSelected = {
isSearchVisible = false
onSeriesSelected(it)
//onSeriesSelected(it)
},
modifier = Modifier
.fillMaxSize()

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.ui.screen.home.components
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -22,10 +21,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.SuggestedItem
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
@@ -37,14 +34,12 @@ import java.util.UUID
fun HomeContent(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
suggestions: List<SuggestedItem>,
suggestions: List<MediaUiModel>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
isRefreshing: Boolean,
onRefresh: () -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit,
onBrowseLibrariesClick: () -> Unit,
modifier: Modifier = Modifier
@@ -101,16 +96,7 @@ fun HomeContent(
item(key = "featured") {
SuggestionsSection(
items = suggestions,
onItemOpen = { item ->
when (item.type) {
MediaKind.MOVIE -> onMovieSelected(item.id)
MediaKind.SERIES -> onSeriesSelected(item.id)
MediaKind.EPISODE -> onEpisodeSelected(item.id, item.id, item.id)
else -> {
Log.e("HomeContent", "Unsupported item type: ${item.type}")
}
}
}
onItemOpen = { item -> onMediaSelected(item) }
)
}
}
@@ -119,8 +105,7 @@ fun HomeContent(
item(key = "continue-watching") {
ContinueWatchingSection(
items = continueWatching,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}
}
@@ -129,7 +114,7 @@ fun HomeContent(
item(key = "next-up") {
NextUpSection(
items = nextUp,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}
}
@@ -142,9 +127,7 @@ fun HomeContent(
library = library,
items = libraryContent[library.id].orEmpty(),
onLibrarySelected = onLibrarySelected,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}

View File

@@ -23,18 +23,14 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
internal fun ContinueWatchingCard(
item: MediaUiModel,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -47,14 +43,7 @@ internal fun ContinueWatchingCard(
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
// TODO fix this shit as well
when (item) {
is MovieUiModel -> onMovieSelected(item.id)
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
else -> Unit
}
}
.clickable { onMediaSelected(item) }
) {
Box(
modifier = Modifier

View File

@@ -11,13 +11,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID
@Composable
fun ContinueWatchingSection(
items: List<MediaUiModel>,
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
@@ -35,8 +33,7 @@ fun ContinueWatchingSection(
items(items = items, key = { item -> item.id }) { item ->
ContinueWatchingCard(
item = item,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}
}

View File

@@ -24,13 +24,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.browse.home.SuggestedItem
@Composable
internal fun SuggestionCard(
item: SuggestedItem,
item: MediaUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
@@ -51,8 +51,8 @@ internal fun SuggestionCard(
.aspectRatio(16f / 11f)
) {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = item.title,
model = item.primaryImageUrl,
contentDescription = item.primaryText,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
@@ -77,23 +77,20 @@ internal fun SuggestionCard(
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = item.title,
text = item.primaryText,
style = MaterialTheme.typography.headlineMedium,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (item.metadata.isNotEmpty()) {
Text(
text = item.metadata.joinToString(""),
text = item.secondaryText,
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.88f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (description.isNotBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
@@ -104,7 +101,6 @@ internal fun SuggestionCard(
)
}
}
}
if (item.progress != null && item.progress!! > 0f) {
MediaProgressBar(
progress = item.progress!!.coerceIn(0f, 1f),

View File

@@ -22,12 +22,12 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.browse.home.SuggestedItem
import hu.bbara.purefin.core.ui.model.MediaUiModel
@Composable
fun SuggestionsSection(
items: List<SuggestedItem>,
onItemOpen: (SuggestedItem) -> Unit,
items: List<MediaUiModel>,
onItemOpen: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return

View File

@@ -26,17 +26,13 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
internal fun HomeBrowseCard(
uiModel: MediaUiModel,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -49,14 +45,7 @@ internal fun HomeBrowseCard(
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
//TODO fix this shit
when (uiModel) {
is MovieUiModel -> onMovieSelected(uiModel.id)
is SeriesUiModel -> onSeriesSelected(uiModel.id)
is EpisodeUiModel -> onEpisodeSelected(uiModel.seriesId, uiModel.seasonId, uiModel.id)
}
}
.clickable { onMediaSelected(uiModel) }
) {
Box(
modifier = Modifier

View File

@@ -12,16 +12,13 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID
@Composable
fun LibraryPosterSection(
library: LibraryItem,
items: List<MediaUiModel>,
onLibrarySelected: (LibraryItem) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
if (items.isEmpty()) return
@@ -43,9 +40,7 @@ fun LibraryPosterSection(
items(items = items, key = { item -> item.id }) { item ->
HomeBrowseCard(
uiModel = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}
}

View File

@@ -22,15 +22,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
internal fun NextUpCard(
uiModel: MediaUiModel,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -43,13 +41,7 @@ internal fun NextUpCard(
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
// TODO fix this shit
when (uiModel) {
is EpisodeUiModel -> onEpisodeSelected(uiModel.seriesId, uiModel.seasonId, uiModel.id)
else -> Unit
}
}
.clickable { onMediaSelected(uiModel) }
) {
Box(
modifier = Modifier

View File

@@ -11,12 +11,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID
@Composable
fun NextUpSection(
items: List<MediaUiModel>,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
@@ -34,7 +33,7 @@ fun NextUpSection(
items(items = items, key = { item -> item.id }) { item ->
NextUpCard(
uiModel = item,
onEpisodeSelected = onEpisodeSelected
onMediaSelected = onMediaSelected
)
}
}

View File

@@ -78,7 +78,7 @@ fun PlayerScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.background(Color.Black)
) {
AndroidView(
factory = { ctx ->

View File

@@ -1,111 +0,0 @@
package hu.bbara.purefin.ui.common.card
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PosterCardMappingTest {
@Test
fun `maps movie poster item to watch-state card model`() {
val movie = Movie(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"),
title = "Blade Runner 2049",
progress = 18.0,
watched = false,
year = "2017",
rating = "16+",
runtime = "2h 44m",
format = "Dolby Vision",
synopsis = "Synthetic future noir.",
imageUrlPrefix = "https://example.test/movie/",
audioTrack = "English 5.1",
subtitles = "English CC",
cast = emptyList()
)
val model = PosterItem(type = MediaKind.MOVIE, movie = movie).toPosterCardModel()
assertEquals("Blade Runner 2049", model.title)
assertEquals("2017", model.secondaryText)
assertEquals("https://example.test/movie/Primary", model.imageUrl)
assertEquals(MediaKind.MOVIE, model.mediaKind)
assertTrue(model.badge is PosterCardBadge.WatchState)
val badge = model.badge as PosterCardBadge.WatchState
assertEquals(false, badge.watched)
assertEquals(true, badge.started)
}
@Test
fun `maps series poster item to unwatched badge card model`() {
val series = Series(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
libraryId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
name = "Severance",
synopsis = "Corporate sci-fi.",
year = "2025",
imageUrlPrefix = "https://example.test/series/",
unwatchedEpisodeCount = 12,
seasonCount = 2,
seasons = listOf(
Season(
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seriesId = UUID.fromString("33333333-3333-3333-3333-333333333333"),
name = "Season 1",
index = 1,
unwatchedEpisodeCount = 9,
episodeCount = 9,
episodes = emptyList()
)
),
cast = listOf(CastMember("Adam Scott", "Mark", null))
)
val model = PosterItem(type = MediaKind.SERIES, series = series).toPosterCardModel()
assertEquals("Severance", model.title)
assertEquals("2025", model.secondaryText)
assertEquals(MediaKind.SERIES, model.mediaKind)
assertTrue(model.badge is PosterCardBadge.UnwatchedEpisodes)
val badge = model.badge as PosterCardBadge.UnwatchedEpisodes
assertEquals(12, badge.count)
}
@Test
fun `maps episode poster item to watch-state card model`() {
val episode = Episode(
id = UUID.fromString("66666666-6666-6666-6666-666666666666"),
seriesId = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
index = 4,
title = "The You You Are",
synopsis = "Office mystery.",
releaseDate = "2025",
rating = "16+",
runtime = "53m",
progress = 0.0,
watched = true,
format = "4K",
imageUrlPrefix = "https://example.test/episode/",
cast = emptyList()
)
val model = PosterItem(type = MediaKind.EPISODE, episode = episode).toPosterCardModel()
assertEquals("The You You Are", model.title)
assertEquals("2025", model.secondaryText)
assertEquals(MediaKind.EPISODE, model.mediaKind)
assertTrue(model.badge is PosterCardBadge.WatchState)
val badge = model.badge as PosterCardBadge.WatchState
assertEquals(true, badge.watched)
assertEquals(false, badge.started)
}
}

View File

@@ -1,166 +0,0 @@
package hu.bbara.purefin.ui.common.card
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.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
data class PosterCardModel(
val title: String,
val secondaryText: String = "",
val imageUrl: String?,
val mediaKind: MediaKind,
val badge: PosterCardBadge = PosterCardBadge.None
)
sealed interface PosterCardBadge {
data object None : PosterCardBadge
data class WatchState(
val watched: Boolean,
val started: Boolean
) : PosterCardBadge
data class UnwatchedEpisodes(
val count: Int
) : PosterCardBadge
}
@Composable
fun PosterCardContent(
model: MediaUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {},
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape((14f * contentScale).dp)
val unfocusedBorderWidth = (1f * contentScale).dp
val scaledFocusedBorderWidth = focusedBorderWidth * contentScale
val contentTopPadding = (8f * contentScale).dp
val contentHorizontalPadding = (4f * contentScale).dp
val contentBottomPadding = (8f * contentScale).dp
val titleFontSize = (13f * contentScale).sp
val secondaryFontSize = (11f * contentScale).sp
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
Column(
modifier = modifier
.width(posterWidth)
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = focusedTransformOrigin
}
) {
Box {
PurefinAsyncImage(
model = model.primaryImageUrl,
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(shape)
.border(
width = if (isFocused) scaledFocusedBorderWidth else unfocusedBorderWidth,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = shape
)
.background(scheme.surfaceVariant)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocused()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop
)
when (model) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = indicatorSize,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = model.watched,
started = (model.progress ?: 0f) > 0f
)
}
else -> Unit
}
}
Column(
modifier = Modifier.padding(
top = contentTopPadding,
start = contentHorizontalPadding,
end = contentHorizontalPadding,
bottom = contentBottomPadding
)
) {
Text(
text = model.primaryText,
color = scheme.onBackground,
fontSize = titleFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
model.secondaryText
.takeIf { showSecondaryText }
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = secondaryFontSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -7,17 +7,18 @@ import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.MediaCatalogReader
import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.download.MediaDownloadController
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -72,13 +73,13 @@ class AppViewModel @Inject constructor(
list.mapNotNull { media ->
when (media) {
is Media.MovieMedia -> moviesMap[media.movieId]?.let {
SuggestedMovie(movie = it)
MovieUiModel(movie = it)
}
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
SuggestedSeries(series = it)
SeriesUiModel(series = it)
}
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
SuggestedEpisode(episode = it)
EpisodeUiModel(episode = it)
}
else -> null
}
@@ -165,7 +166,15 @@ class AppViewModel @Inject constructor(
}
}
fun onMovieSelected(movieId: UUID) {
fun onMediaSelected(mediaUiModel : MediaUiModel) {
when (mediaUiModel) {
is MovieUiModel -> onMovieSelected(mediaUiModel.id)
is SeriesUiModel -> onSeriesSelected(mediaUiModel.id)
is EpisodeUiModel -> onEpisodeSelected(mediaUiModel.seriesId, mediaUiModel.seasonId, mediaUiModel.id)
}
}
private fun onMovieSelected(movieId: UUID) {
navigationManager.navigate(Route.MovieRoute(
MovieDto(
id = movieId,
@@ -173,7 +182,7 @@ class AppViewModel @Inject constructor(
))
}
fun onSeriesSelected(seriesId: UUID) {
private fun onSeriesSelected(seriesId: UUID) {
viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute(
SeriesDto(
@@ -183,7 +192,7 @@ class AppViewModel @Inject constructor(
}
}
fun onEpisodeSelected(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
private fun onEpisodeSelected(seriesId: UUID, seasonId: UUID, episodeId: UUID) {
viewModelScope.launch {
navigationManager.navigate(Route.EpisodeRoute(
EpisodeDto(

View File

@@ -9,85 +9,6 @@ import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import java.util.UUID
sealed interface SuggestedItem {
val id: UUID
val badge: String
val title: String
val supportingText: String
val description: String
val metadata: List<String>
val imageUrl: String
val ctaLabel: String
val progress: Float?
val type: MediaKind
}
data class SuggestedEpisode (
val episode: Episode,
override val badge: String = "",
override val supportingText: String = listOf("Episode ${episode.index}", episode.runtime)
.filter { it.isNotBlank() }
.joinToString(""),
override val metadata: List<String> =
listOf(episode.releaseDate, episode.runtime, episode.rating, episode.format)
.filter { it.isNotBlank() },
override val ctaLabel: String = "Open",
override val progress: Float? = episode.progress?.toFloat(),
override val type: MediaKind = MediaKind.EPISODE,
override val id: UUID = episode.id,
override val title: String = episode.title,
override val description: String = episode.synopsis,
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
) : SuggestedItem
data class SuggestedSeries (
val series: Series,
override val badge: String = "",
override val supportingText: String =
if (series.unwatchedEpisodeCount > 0) {
"${series.unwatchedEpisodeCount} unwatched episodes"
} else {
"${series.seasonCount} seasons"
},
override val metadata: List<String> =
listOf(series.year, "${series.seasonCount} seasons").filter { it.isNotBlank() },
override val ctaLabel: String = "Open",
override val progress: Float? = null,
override val type: MediaKind = MediaKind.SERIES,
override val id: UUID = series.id,
override val title: String = series.name,
override val description: String = series.synopsis,
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = series.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
) : SuggestedItem
data class SuggestedMovie (
val movie: Movie,
override val badge: String = "",
override val supportingText: String = listOf(movie.year, movie.runtime)
.filter { it.isNotBlank() }
.joinToString(""),
override val metadata: List<String> =
listOf(movie.year, movie.runtime, movie.rating, movie.format)
.filter { it.isNotBlank() },
override val ctaLabel: String = "Open",
override val progress: Float? = movie.progress?.toFloat(),
override val type: MediaKind = MediaKind.MOVIE,
override val id: UUID = movie.id,
override val title: String = movie.title,
override val description: String = movie.synopsis,
override val imageUrl: String = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
) : SuggestedItem
sealed interface FocusableItem {
val imageUrl: String
val primaryText: String