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 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.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.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.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.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel 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 import java.util.UUID
@Composable @Composable
@@ -50,51 +75,102 @@ fun PosterCard(
) )
} }
private fun PosterItem.toPosterCardModel(): PosterCardModel { @Composable
return PosterCardModel( fun PosterCardContent(
title = title, model: MediaUiModel,
secondaryText = secondaryText, onClick: () -> Unit,
imageUrl = imageUrl, modifier: Modifier = Modifier,
mediaKind = type, imageModifier: Modifier = Modifier,
badge = when (type) { posterWidth: Dp = 144.dp,
MediaKind.MOVIE -> { contentScale: Float = 1f,
val movie = requireNotNull(movie) showSecondaryText: Boolean = false,
PosterCardBadge.WatchState( indicatorSize: Int = 28,
watched = movie.watched, indicatorPadding: Dp = 8.dp,
started = (movie.progress ?: 0.0) > 0.0 onFocused: () -> Unit = {},
) focusedScale: Float = 1f,
} focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
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,
) { ) {
when (type) { val scheme = MaterialTheme.colorScheme
MediaKind.MOVIE -> onMovieSelected(id) var isFocused by remember { mutableStateOf(false) }
MediaKind.SERIES -> onSeriesSelected(id) val scale by animateFloatAsState(
MediaKind.EPISODE -> { targetValue = if (isFocused) focusedScale else 1f,
val ep = requireNotNull(episode) label = "scale"
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) )
}
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 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, libraryContent = latestLibraryContent,
continueWatching = continueWatching, continueWatching = continueWatching,
nextUp = nextUp, nextUp = nextUp,
onMovieSelected = viewModel::onMovieSelected, onMediaSelected = viewModel::onMediaSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,40 @@
package hu.bbara.purefin.ui.common.card 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.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.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.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel 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 import java.util.UUID
@Composable @Composable
@@ -24,58 +52,105 @@ fun PosterCard(
when (item) { when (item) {
is MovieUiModel -> onMovieSelected(item.id) is MovieUiModel -> onMovieSelected(item.id)
is SeriesUiModel -> onSeriesSelected(item.id) is SeriesUiModel -> onSeriesSelected(item.id)
else -> Unit is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
} }
}, },
modifier = modifier modifier = modifier
) )
} }
internal fun PosterItem.toPosterCardModel(): PosterCardModel { @Composable
return PosterCardModel( fun PosterCardContent(
title = title, model: MediaUiModel,
secondaryText = secondaryText, onClick: () -> Unit,
imageUrl = imageUrl, modifier: Modifier = Modifier,
mediaKind = type, imageModifier: Modifier = Modifier,
badge = when (type) { posterWidth: Dp = 144.dp,
MediaKind.MOVIE -> { showSecondaryText: Boolean = false,
val movie = requireNotNull(movie) indicatorSize: Int = 28,
PosterCardBadge.WatchState( indicatorPadding: Dp = 8.dp,
watched = movie.watched, onFocused: () -> Unit = {},
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,
) { ) {
when (type) { val scheme = MaterialTheme.colorScheme
MediaKind.MOVIE -> onMovieSelected(id) var isFocused by remember { mutableStateOf(false) }
MediaKind.SERIES -> onSeriesSelected(id) val scale by animateFloatAsState(
MediaKind.EPISODE -> { targetValue = if (isFocused) 1.07f else 1f,
val ep = requireNotNull(episode) label = "scale"
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) )
}
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 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 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.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect 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.feature.browse.home.AppViewModel
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.download.DownloadsScreen import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen 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 @Composable
fun AppScreen( fun AppScreen(
viewModel: AppViewModel = hiltViewModel(), viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var selectedTab by remember { mutableIntStateOf(0) }
val libraries by viewModel.libraries.collectAsState() val libraries by viewModel.libraries.collectAsState()
val libraryContent by viewModel.latestLibraryContent.collectAsState() val libraryContent by viewModel.latestLibraryContent.collectAsState()
val suggestions by viewModel.suggestions.collectAsState() val suggestions by viewModel.suggestions.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState() val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState() val nextUp by viewModel.nextUp.collectAsState()
val isRefreshing by viewModel.isRefreshing.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 { val libraryNavItems = libraries.map {
HomeNavItem( HomeNavItem(
@@ -43,8 +61,18 @@ fun AppScreen(
onPauseOrDispose { } onPauseOrDispose { }
} }
when (selectedTab) { val onTabSelected = remember(backStack) {
0 -> HomeScreen( { 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, libraries = libraries,
libraryContent = libraryContent, libraryContent = libraryContent,
suggestions = suggestions, suggestions = suggestions,
@@ -52,9 +80,7 @@ fun AppScreen(
nextUp = nextUp, nextUp = nextUp,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onMovieSelected = viewModel::onMovieSelected, onMediaSelected = viewModel::onMediaSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
onLibrarySelected = { library -> onLibrarySelected = { library ->
viewModel.onLibrarySelected( viewModel.onLibrarySelected(
library.id, library.id,
@@ -65,23 +91,101 @@ fun AppScreen(
onSettingsClick = {}, onSettingsClick = {},
onLogoutClick = viewModel::logout, onLogoutClick = viewModel::logout,
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = onTabSelected,
modifier = modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
1 -> LibrariesScreen( }
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
LibrariesScreen(
items = libraryNavItems, items = libraryNavItems,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) }, onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
onProfileClick = {}, onProfileClick = {},
onSettingsClick = {}, onSettingsClick = {},
onLogoutClick = viewModel::logout, onLogoutClick = viewModel::logout,
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = onTabSelected,
modifier = modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
2 -> DownloadsScreen( }
entry<AppTabRoute.Downloads>(metadata = appTabMetadata(AppTabRoute.Downloads)) {
DownloadsScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = onTabSelected,
modifier = modifier.fillMaxSize() 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 androidx.compose.ui.Modifier
import hu.bbara.purefin.core.ui.model.MediaUiModel import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem 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.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.HomeContent import hu.bbara.purefin.ui.screen.home.components.HomeContent
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
@@ -26,14 +25,12 @@ import java.util.UUID
fun HomeScreen( fun HomeScreen(
libraries: List<LibraryItem>, libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<MediaUiModel>>, libraryContent: Map<UUID, List<MediaUiModel>>,
suggestions: List<SuggestedItem>, suggestions: List<MediaUiModel>,
continueWatching: List<MediaUiModel>, continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>, nextUp: List<MediaUiModel>,
isRefreshing: Boolean, isRefreshing: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onMovieSelected: (UUID) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit, onLibrarySelected: (LibraryItem) -> Unit,
onProfileClick: () -> Unit, onProfileClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
@@ -73,9 +70,7 @@ fun HomeScreen(
nextUp = nextUp, nextUp = nextUp,
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = onRefresh, onRefresh = onRefresh,
onMovieSelected = onMovieSelected, onMediaSelected = onMediaSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
onLibrarySelected = onLibrarySelected, onLibrarySelected = onLibrarySelected,
onBrowseLibrariesClick = { onTabSelected(1) }, onBrowseLibrariesClick = { onTabSelected(1) },
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
@@ -86,11 +81,12 @@ fun HomeScreen(
onDismiss = { isSearchVisible = false }, onDismiss = { isSearchVisible = false },
onMovieSelected = { onMovieSelected = {
isSearchVisible = false isSearchVisible = false
onMovieSelected(it) //TODO use MediaUiModel as well
//onMovieSelected(it)
}, },
onSeriesSelected = { onSeriesSelected = {
isSearchVisible = false isSearchVisible = false
onSeriesSelected(it) //onSeriesSelected(it)
}, },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

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

View File

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

View File

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

View File

@@ -24,13 +24,13 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.browse.home.SuggestedItem
@Composable @Composable
internal fun SuggestionCard( internal fun SuggestionCard(
item: SuggestedItem, item: MediaUiModel,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -51,8 +51,8 @@ internal fun SuggestionCard(
.aspectRatio(16f / 11f) .aspectRatio(16f / 11f)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = item.imageUrl, model = item.primaryImageUrl,
contentDescription = item.title, contentDescription = item.primaryText,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -77,23 +77,20 @@ internal fun SuggestionCard(
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text( Text(
text = item.title, text = item.primaryText,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (item.metadata.isNotEmpty()) {
Text( Text(
text = item.metadata.joinToString(""), text = item.secondaryText,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.88f), color = Color.White.copy(alpha = 0.88f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
if (description.isNotBlank()) {
Text( Text(
text = description, text = description,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -104,7 +101,6 @@ internal fun SuggestionCard(
) )
} }
} }
}
if (item.progress != null && item.progress!! > 0f) { if (item.progress != null && item.progress!! > 0f) {
MediaProgressBar( MediaProgressBar(
progress = item.progress!!.coerceIn(0f, 1f), 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.browse.home.SuggestedItem import hu.bbara.purefin.core.ui.model.MediaUiModel
@Composable @Composable
fun SuggestionsSection( fun SuggestionsSection(
items: List<SuggestedItem>, items: List<MediaUiModel>,
onItemOpen: (SuggestedItem) -> Unit, onItemOpen: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (items.isEmpty()) return 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.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel 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.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable @Composable
internal fun HomeBrowseCard( internal fun HomeBrowseCard(
uiModel: MediaUiModel, uiModel: MediaUiModel,
onMovieSelected: (UUID) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
@@ -49,14 +45,7 @@ internal fun HomeBrowseCard(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable { onMediaSelected(uiModel) }
//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)
}
}
) { ) {
Box( Box(
modifier = Modifier 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.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID
@Composable @Composable
fun LibraryPosterSection( fun LibraryPosterSection(
library: LibraryItem, library: LibraryItem,
items: List<MediaUiModel>, items: List<MediaUiModel>,
onLibrarySelected: (LibraryItem) -> Unit, onLibrarySelected: (LibraryItem) -> Unit,
onMovieSelected: (UUID) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (items.isEmpty()) return if (items.isEmpty()) return
@@ -43,9 +40,7 @@ fun LibraryPosterSection(
items(items = items, key = { item -> item.id }) { item -> items(items = items, key = { item -> item.id }) { item ->
HomeBrowseCard( HomeBrowseCard(
uiModel = item, uiModel = item,
onMovieSelected = onMovieSelected, onMediaSelected = onMediaSelected
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
) )
} }
} }

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ fun PlayerScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(Color.Black)
) { ) {
AndroidView( AndroidView(
factory = { ctx -> 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.MediaCatalogReader
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.download.MediaDownloadController 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.LibraryKind
import hu.bbara.purefin.core.model.Media 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.EpisodeDto
import hu.bbara.purefin.core.navigation.LibraryDto import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.core.navigation.MovieDto import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.navigation.SeriesDto 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -72,13 +73,13 @@ class AppViewModel @Inject constructor(
list.mapNotNull { media -> list.mapNotNull { media ->
when (media) { when (media) {
is Media.MovieMedia -> moviesMap[media.movieId]?.let { is Media.MovieMedia -> moviesMap[media.movieId]?.let {
SuggestedMovie(movie = it) MovieUiModel(movie = it)
} }
is Media.SeriesMedia -> seriesMap[media.seriesId]?.let { is Media.SeriesMedia -> seriesMap[media.seriesId]?.let {
SuggestedSeries(series = it) SeriesUiModel(series = it)
} }
is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let { is Media.EpisodeMedia -> episodesMap[media.episodeId]?.let {
SuggestedEpisode(episode = it) EpisodeUiModel(episode = it)
} }
else -> null 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( navigationManager.navigate(Route.MovieRoute(
MovieDto( MovieDto(
id = movieId, id = movieId,
@@ -173,7 +182,7 @@ class AppViewModel @Inject constructor(
)) ))
} }
fun onSeriesSelected(seriesId: UUID) { private fun onSeriesSelected(seriesId: UUID) {
viewModelScope.launch { viewModelScope.launch {
navigationManager.navigate(Route.SeriesRoute( navigationManager.navigate(Route.SeriesRoute(
SeriesDto( 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 { viewModelScope.launch {
navigationManager.navigate(Route.EpisodeRoute( navigationManager.navigate(Route.EpisodeRoute(
EpisodeDto( EpisodeDto(

View File

@@ -9,85 +9,6 @@ import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import java.util.UUID 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 { sealed interface FocusableItem {
val imageUrl: String val imageUrl: String
val primaryText: String val primaryText: String