Compare commits

...

21 Commits

Author SHA1 Message Date
ae4c2b34f4 feat(search): update genre selection to support multiple selections in SearchScreen 2026-05-02 21:38:56 +02:00
f957098369 feat(search): update genre selection to support multiple selections in SearchScreen 2026-05-02 21:25:32 +02:00
8bf3f08df0 feat(search): implement back navigation in SearchScreen and SearchViewModel 2026-05-02 21:12:54 +02:00
efd7593646 feat(topbar): create DefaultTopBarIconButton for reusable icon button implementation 2026-05-02 21:04:58 +02:00
0edac36ca8 feat(search): rename SearchFullScreen to SearchScreen for consistency and clarity 2026-05-02 20:56:54 +02:00
53bc6246e4 feat(libraries): add size property to Library and related models for better content representation 2026-05-02 20:56:05 +02:00
2753a45645 feat(card): introduce MediaImageCard for consistent media display across components 2026-05-02 20:47:35 +02:00
fbcba6fbfc feat(libraries): refactor LibraryListItem and LibraryScreen for improved click handling and layout 2026-05-02 20:28:54 +02:00
dbac1b56c5 feat(topbar): enhance DefaultTopBar to support left and right actions for improved layout flexibility 2026-05-02 20:22:59 +02:00
1f0c192460 feat(colors): update color values for improved contrast and visibility 2026-05-02 19:51:13 +02:00
a0b573cbfe feat(sectionheader): update header color and button text color for improved visibility 2026-05-02 19:48:21 +02:00
1a2687f1a2 feat(topbar): refactor DefaultTopBar to use Surface and improve layout structure 2026-05-02 19:47:33 +02:00
803edcb80c feat(logo): increase logo size for better visibility in top bar and search screen 2026-05-02 19:35:53 +02:00
e55bd320d1 clean(app): delete unused asset files 2026-05-02 19:31:42 +02:00
fe1d449fe0 feat(branding): update app icons and logo for improved visual consistency 2026-05-02 19:28:47 +02:00
f626c9533e refactor: update color definitions for dark theme consistency 2026-05-02 19:06:05 +02:00
6433225014 feat(search): add Jellyfin-backed search 2026-05-02 17:40:56 +02:00
36e89f35d9 refactor: rename media repository files for consistency 2026-05-02 16:53:48 +02:00
217e8df417 refactor: merge media catalog reader into repository 2026-05-02 16:51:55 +02:00
672d8e1d64 feat(search): wire full-screen search with genre browsing 2026-05-02 15:17:06 +02:00
c6fe84654f refactor(search): simplify search component names 2026-05-02 14:20:20 +02:00
77 changed files with 1318 additions and 1137 deletions

View File

@@ -392,6 +392,7 @@ class TvHomeContentTest {
name = "Movies",
type = LibraryKind.MOVIES,
posterUrl = "",
size = 1,
isEmpty = false
)
)

View File

@@ -25,6 +25,7 @@ class TvLibrariesOverviewScreenTest {
name = "Movies",
type = LibraryKind.MOVIES,
posterUrl = "",
size = 1,
isEmpty = false
),
LibraryUiModel(
@@ -32,6 +33,7 @@ class TvLibrariesOverviewScreenTest {
name = "Shows",
type = LibraryKind.SERIES,
posterUrl = "",
size = 1,
isEmpty = false
)
)

View File

@@ -1,73 +0,0 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
@Composable
fun MediaPlayButton(
backgroundColor: Color,
foregroundColor: Color,
text: String = "Play",
subText: String? = null,
size: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.height(size)
.clip(CircleShape)
.background(backgroundColor)
.padding(start = 16.dp, end = 32.dp)
.clickable { onClick() },
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = foregroundColor,
modifier = Modifier.size(42.dp)
)
Column() {
Text(
text = text,
color = foregroundColor,
fontSize = TextUnit(
value = 16f,
type = TextUnitType.Sp
)
)
subText?.let {
Text(
text = subText,
color = foregroundColor.copy(alpha = 0.7f),
fontSize = TextUnit(
value = 14f,
type = TextUnitType.Sp
)
)
}
}
}
}

View File

@@ -110,6 +110,7 @@ class TvHomeHeroStateTest {
name = "Movies",
type = LibraryKind.MOVIES,
posterUrl = "",
size = 1,
isEmpty = isEmpty
)
}

View File

@@ -12,9 +12,9 @@
android:screenOrientation="portrait"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/purefin_logo"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/purefin_logo_round"
android:supportsRtl="true"
android:theme="@style/Theme.Purefin">
<activity

View File

@@ -7,7 +7,7 @@ import hu.bbara.purefin.ui.screen.library.LibraryScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.AppScreen
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen
import hu.bbara.purefin.ui.screen.home.components.search.HomeSearchFullScreen
import hu.bbara.purefin.ui.screen.home.components.search.SearchScreen
import hu.bbara.purefin.ui.screen.movie.MovieScreen
import hu.bbara.purefin.ui.screen.series.SeriesScreen
@@ -23,7 +23,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
HomeSearchFullScreen()
SearchScreen()
}
}
entry<Route.MovieRoute> {

View File

@@ -0,0 +1,93 @@
package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
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.ui.common.image.PurefinAsyncImage
@Composable
fun MediaImageCard(
imageUrl: String?,
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
imageModifier: Modifier = Modifier,
shapeSize: Dp = 12.dp,
imageAspectRatio: Float = 16f / 10f,
titleStyle: TextStyle = MaterialTheme.typography.bodyLarge,
subtitleStyle: TextStyle = MaterialTheme.typography.bodySmall,
textPadding: PaddingValues = PaddingValues(8.dp),
imageOverlay: @Composable BoxScope.() -> Unit = {}
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(shapeSize)
Card(
onClick = onClick,
shape = shape,
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = imageModifier
.fillMaxWidth()
.aspectRatio(imageAspectRatio)
.clip(RoundedCornerShape(topStart = shapeSize, topEnd = shapeSize))
.background(scheme.surface)
) {
PurefinAsyncImage(
model = imageUrl,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
imageOverlay()
}
Column(modifier = Modifier.padding(textPadding)) {
Text(
text = title,
style = titleStyle,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
subtitle
?.takeIf { it.isNotBlank() }
?.let { text ->
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
style = subtitleStyle,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -26,6 +27,8 @@ fun SectionHeader(
modifier: Modifier = Modifier,
onActionClick: () -> Unit = {}
) {
val headerColor = MaterialTheme.colorScheme.onSurface
Row(
modifier = modifier
.fillMaxWidth()
@@ -36,10 +39,14 @@ fun SectionHeader(
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
color = headerColor
)
if (actionLabel != null) {
TextButton(onClick = onActionClick) {
TextButton(
onClick = onActionClick,
colors = ButtonDefaults.textButtonColors(contentColor = headerColor)
) {
Text(text = actionLabel)
Spacer(modifier = Modifier.width(4.dp))
Icon(

View File

@@ -1,32 +1,21 @@
package hu.bbara.purefin.ui.common.image
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import hu.bbara.purefin.R
@Composable
fun PurefinLogo(
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
color: Color = Color(0xFF7C42F0)
) {
val resources = LocalContext.current.resources
val logoBitmap = remember(resources) {
BitmapFactory.decodeResource(resources, R.raw.logo_raw)?.asImageBitmap()
}
logoBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier,
)
}
Icon(
painter = painterResource(id = R.drawable.purefin_logo_tintable),
contentDescription = "Purefin logo",
modifier = modifier,
tint = color
)
}

View File

@@ -7,22 +7,25 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -32,43 +35,88 @@ import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.common.image.PurefinLogo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DefaultTopBar(
actions: @Composable RowScope.() -> Unit
leftActions: (@Composable RowScope.() -> Unit)? = null,
rightActions: (@Composable RowScope.() -> Unit)? = null,
) {
val scheme = MaterialTheme.colorScheme
TopAppBar(
title = {
Surface(
color = scheme.background,
contentColor = scheme.onSurface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.statusBarsPadding()
.height(84.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (leftActions != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
content = leftActions
)
}
Row(
modifier = Modifier,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = when {
leftActions == null && rightActions != null -> Arrangement.Start
leftActions != null && rightActions == null -> Arrangement.Center
else -> Arrangement.Center
}
) {
PurefinLogo(
contentDescription = "Purefin",
modifier = Modifier.size(48.dp),
contentScale = ContentScale.Fit,
modifier = Modifier.size(84.dp),
)
Text(
text = "PureFin",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
color = scheme.onSecondary
color = scheme.primary
)
}
},
actions = actions,
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = scheme.background,
navigationIconContentColor = scheme.onSurface,
actionIconContentColor = scheme.onSurface,
titleContentColor = scheme.onSurface
if (rightActions != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
content = rightActions
)
}
}
}
}
@Composable
fun DefaultTopBarIconButton(
imageVector: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
IconButton(
onClick = onClick,
colors = IconButtonColors(
containerColor = scheme.surface,
contentColor = scheme.onSurface,
disabledContainerColor = scheme.surface,
disabledContentColor = scheme.onSurface
),
modifier = Modifier.padding(end = 12.dp)
)
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(30.dp),
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)

View File

@@ -2,15 +2,10 @@ package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -18,7 +13,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
@Composable
@@ -28,32 +22,17 @@ fun HomeTopBar(
onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit,
) {
val scheme = MaterialTheme.colorScheme
var isProfileMenuExpanded by remember { mutableStateOf(false) }
DefaultTopBar()
{
DefaultTopBar(
rightActions = {
DefaultTopBarSearchButton(onClick = onSearchClick)
Spacer(modifier = Modifier.size(12.dp))
IconButton(
DefaultTopBarIconButton(
imageVector = Icons.Outlined.Person,
contentDescription = "Profile",
onClick = { isProfileMenuExpanded = true },
colors = IconButtonColors(
containerColor = scheme.surface,
contentColor = scheme.onSurface,
disabledContainerColor = scheme.surface,
disabledContentColor = scheme.onSurface
),
modifier = Modifier
.size(50.dp)
.clip(CircleShape),
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = "Profile",
modifier = Modifier.size(30.dp),
)
}
)
DropdownMenu(
expanded = isProfileMenuExpanded,
onDismissRequest = { isProfileMenuExpanded = false },
@@ -81,4 +60,5 @@ fun HomeTopBar(
)
}
}
)
}

View File

@@ -1,33 +1,21 @@
package hu.bbara.purefin.ui.screen.home.components.continuewatching
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
internal fun ContinueWatchingCard(
@@ -41,67 +29,38 @@ internal fun ContinueWatchingCard(
onMediaSelected(item)
}
Card(
MediaImageCard(
imageUrl = item.primaryImageUrl,
title = item.primaryText,
subtitle = item.secondaryText,
onClick = onClick,
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 26.dp,
imageAspectRatio = 16f / 9f,
titleStyle = MaterialTheme.typography.titleMedium,
textPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp),
modifier = modifier.width(280.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(scheme.surfaceContainer)
) {
PurefinAsyncImage(
model = item.primaryImageUrl,
contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.08f),
Color.Black.copy(alpha = 0.38f)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.08f),
Color.Black.copy(alpha = 0.38f)
)
)
item.progress?.let { progress ->
MediaProgressBar(
progress = progress,
foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.24f),
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = item.primaryText,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = item.secondaryText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
item.progress?.let { progress ->
MediaProgressBar(
progress = progress,
foregroundColor = scheme.primary,
backgroundColor = Color.White.copy(alpha = 0.24f),
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}

View File

@@ -23,11 +23,11 @@ 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.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
internal fun SuggestionCard(
@@ -74,7 +74,7 @@ internal fun SuggestionCard(
)
)
)
)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier

View File

@@ -1,30 +1,13 @@
package hu.bbara.purefin.ui.screen.home.components.library
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.EpisodeUiModel
@@ -38,64 +21,31 @@ internal fun HomeBrowseCard(
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel)
}
Card(
MediaImageCard(
imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText,
subtitle = uiModel.secondaryText,
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
imageAspectRatio = 15f / 16f,
modifier = modifier.width(188.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
.background(scheme.surface)
) {
PurefinAsyncImage(
model = uiModel.primaryImageUrl,
contentDescription = uiModel.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
when (uiModel) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = uiModel.watched,
started = (uiModel.progress ?: 0f) > 0f
)
}
else -> Unit
}
}
Spacer(modifier = Modifier.height(10.dp))
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = uiModel.primaryText,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = uiModel.secondaryText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
when (uiModel) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = uiModel.watched,
started = (uiModel.progress ?: 0f) > 0f
)
}
else -> Unit
}
}
}

View File

@@ -1,31 +1,19 @@
package hu.bbara.purefin.ui.screen.home.components.nextup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
internal fun NextUpCard(
@@ -34,64 +22,33 @@ internal fun NextUpCard(
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel)
}
Card(
MediaImageCard(
imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText,
subtitle = uiModel.secondaryText,
onClick = onClick,
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
shapeSize = 24.dp,
titleStyle = MaterialTheme.typography.titleSmall,
textPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp),
modifier = modifier.width(256.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 10f)
.background(scheme.surface)
) {
PurefinAsyncImage(
model = uiModel.primaryImageUrl,
contentDescription = uiModel.primaryText,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
Color.Black.copy(alpha = 0.26f)
)
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Transparent,
Color.Black.copy(alpha = 0.26f)
)
)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Text(
text = uiModel.primaryText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = uiModel.secondaryText,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
}

View File

@@ -1,435 +0,0 @@
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocalFireDepartment
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.TrendingUp
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.image.PurefinLogo
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.theme.AppTheme
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun HomeSearchFullScreen(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
val sharedBoundsModifier = if (
sharedTransitionScope != null &&
animatedVisibilityScope != null
) {
with(sharedTransitionScope) {
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(
key = HOME_SEARCH_SHARED_BOUNDS_KEY
),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
)
}
} else {
Modifier
}
Column(
modifier = modifier
.then(sharedBoundsModifier)
.fillMaxSize()
.background(scheme.background)
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp)
) {
SearchHeader(modifier = Modifier.padding(top = 12.dp))
Spacer(modifier = Modifier.height(24.dp))
SearchField()
Spacer(modifier = Modifier.height(30.dp))
SectionTitle(
text = "Trending Searches",
icon = {
Icon(
imageVector = Icons.Outlined.LocalFireDepartment,
contentDescription = null,
tint = scheme.primary,
modifier = Modifier.size(26.dp)
)
}
)
Spacer(modifier = Modifier.height(16.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
previewTrendingSearches.forEach { title ->
TrendingSearchChip(title = title)
}
}
Spacer(modifier = Modifier.height(34.dp))
SectionTitle(text = "Browse Categories")
Spacer(modifier = Modifier.height(18.dp))
CategoryGrid(categories = previewCategories)
Spacer(modifier = Modifier.height(24.dp))
OutlinedButton(
onClick = {},
shape = RoundedCornerShape(28.dp),
border = BorderStroke(1.dp, scheme.primary.copy(alpha = 0.52f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = scheme.primary),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(
text = "View All Genres",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)
)
}
}
}
@Composable
private fun SearchHeader(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth()
) {
IconButton(onClick = {}) {
Icon(
imageVector = Icons.Outlined.Menu,
contentDescription = "Menu",
tint = scheme.primary,
modifier = Modifier.size(30.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
PurefinLogo(
contentDescription = "PureFin",
contentScale = ContentScale.Fit,
modifier = Modifier.size(38.dp)
)
Text(
text = "PureFin",
color = scheme.primary,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic
)
}
Spacer(modifier = Modifier.weight(1f))
Surface(
shape = CircleShape,
color = scheme.surfaceContainer,
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.28f)),
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = "Profile",
tint = scheme.onSurfaceVariant,
modifier = Modifier.size(26.dp)
)
}
}
}
}
@Composable
private fun SearchField(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(24.dp),
color = scheme.surfaceContainer,
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.16f)),
modifier = modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(68.dp)
.padding(horizontal = 20.dp)
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = scheme.onSurfaceVariant,
modifier = Modifier.size(30.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Search movies, shows, genres...",
style = MaterialTheme.typography.titleMedium,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier
.height(34.dp)
.width(1.dp)
.background(scheme.outlineVariant.copy(alpha = 0.18f))
)
Spacer(modifier = Modifier.width(14.dp))
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = "Filter",
tint = scheme.primary,
modifier = Modifier.size(30.dp)
)
}
}
}
@Composable
private fun SectionTitle(
text: String,
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null
) {
val scheme = MaterialTheme.colorScheme
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth()
) {
icon?.invoke()
Text(
text = text,
style = MaterialTheme.typography.headlineSmall,
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun TrendingSearchChip(
title: String,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(24.dp),
color = scheme.surfaceContainer,
border = BorderStroke(1.dp, scheme.outlineVariant.copy(alpha = 0.14f)),
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 11.dp)
) {
Icon(
imageVector = Icons.Outlined.TrendingUp,
contentDescription = null,
tint = scheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = scheme.onSurface,
fontWeight = FontWeight.SemiBold
)
}
}
}
@Composable
private fun CategoryGrid(
categories: List<SearchCategory>,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxWidth()
) {
categories.chunked(2).forEach { rowItems ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
rowItems.forEach { category ->
CategoryCard(
category = category,
modifier = Modifier.weight(1f)
)
}
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun CategoryCard(
category: SearchCategory,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(18.dp)
Surface(
shape = shape,
color = scheme.surfaceContainer,
modifier = modifier
.clip(shape)
.aspectRatio(1.45f)
) {
Box(modifier = Modifier.fillMaxSize()) {
PurefinAsyncImage(
model = category.imageUrl,
contentDescription = category.name,
fallbackIcon = Icons.Outlined.Movie,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.18f),
Color.Black.copy(alpha = 0.42f),
Color.Black.copy(alpha = 0.76f)
)
)
)
)
Text(
text = category.name,
style = MaterialTheme.typography.titleLarge,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun HomeSearchFullScreenPreview() {
AppTheme {
HomeSearchFullScreen(modifier = Modifier.fillMaxSize())
}
}
private data class SearchCategory(
val name: String,
val imageUrl: String
)
private val previewTrendingSearches = listOf(
"Dune",
"Severance",
"The Last of Us",
"Blade Runner",
"Foundation"
)
private val previewCategories = listOf(
SearchCategory(
name = "Action",
imageUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580"
),
SearchCategory(
name = "Adventure",
imageUrl = "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee"
),
SearchCategory(
name = "Comedy",
imageUrl = "https://images.unsplash.com/photo-1527224857830-43a7acc85260"
),
SearchCategory(
name = "Fantasy",
imageUrl = "https://images.unsplash.com/photo-1518709268805-4e9042af2176"
),
SearchCategory(
name = "Romance",
imageUrl = "https://images.unsplash.com/photo-1516589178581-6cd7833ae3b2"
),
SearchCategory(
name = "Sci-Fi",
imageUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa"
)
)

View File

@@ -1,71 +0,0 @@
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.model.MediaKind
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable
internal fun HomeSearchResultCard(
item: SearchResult,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Surface(
shape = RoundedCornerShape(22.dp),
color = scheme.surfaceContainer,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.clickable(onClick = onClick)
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(12.dp)
) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.clip(RoundedCornerShape(18.dp))
)
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = when (item.type) {
MediaKind.MOVIE -> "Movie"
MediaKind.SERIES -> "Series"
else -> "Title"
},
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant
)
}
}
}

View File

@@ -41,7 +41,7 @@ import hu.bbara.purefin.model.MediaKind
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeSearchOverlay(
fun SearchOverlay(
visible: Boolean,
topPadding: Dp,
onDismiss: () -> Unit,
@@ -138,7 +138,7 @@ fun HomeSearchOverlay(
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
) {
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
HomeSearchResultCard(
SearchResultCard(
item = item,
onClick = {
when (item.type) {

View File

@@ -0,0 +1,26 @@
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.ui.common.card.MediaImageCard
@Composable
internal fun SearchResultCard(
item: SearchResult,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
MediaImageCard(
imageUrl = item.posterUrl,
title = item.title,
subtitle = when (item.type) {
MediaKind.MOVIE -> "Movie"
MediaKind.SERIES -> "Series"
else -> "Title"
},
onClick = onClick,
modifier = modifier
)
}

View File

@@ -0,0 +1,522 @@
package hu.bbara.purefin.ui.screen.home.components.search
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.feature.search.SearchViewModel
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.navigation.HOME_SEARCH_SHARED_BOUNDS_KEY
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
import hu.bbara.purefin.ui.theme.AppTheme
import java.util.UUID
@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class)
@Composable
fun SearchScreen(
viewModel: SearchViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
val searchResults by viewModel.searchResult.collectAsStateWithLifecycle()
val genres by viewModel.genres.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var selectedGenreNames by rememberSaveable { mutableStateOf(emptySet<String>()) }
val sharedBoundsModifier = if (
sharedTransitionScope != null &&
animatedVisibilityScope != null
) {
with(sharedTransitionScope) {
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(
key = HOME_SEARCH_SHARED_BOUNDS_KEY
),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
)
}
} else {
Modifier
}
SearchFullScreenContent(
query = query,
searchResults = searchResults,
genres = genres.sortedBy { it.name },
selectedGenreNames = selectedGenreNames,
onQueryChange = {
query = it
viewModel.search(it)
},
onSearch = { viewModel.search(query) },
onResultClick = viewModel::onSearchResultSelected,
onGenreSelected = { genre ->
selectedGenreNames = if (genre.name in selectedGenreNames) {
selectedGenreNames - genre.name
} else {
selectedGenreNames + genre.name
}
viewModel.setSelectedGenres(selectedGenreNames)
},
onClearGenres = {
selectedGenreNames = emptySet()
viewModel.setSelectedGenres(selectedGenreNames)
},
onBack = viewModel::onBack,
modifier = modifier.then(sharedBoundsModifier)
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SearchFullScreenContent(
query: String,
searchResults: List<SearchResult>,
genres: List<Genre>,
selectedGenreNames: Set<String>,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit,
onResultClick: (SearchResult) -> Unit,
onGenreSelected: (Genre) -> Unit,
onClearGenres: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Scaffold(
topBar = { SearchHeader(
onBack = onBack
) },
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.statusBarsPadding()
.padding(top = 24.dp)
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp)
) {
SearchField(
query = query,
onQueryChange = onQueryChange,
onSearch = onSearch
)
Spacer(modifier = Modifier.height(30.dp))
if (query.isBlank()) {
SectionTitle(text = "Browse Genres")
Spacer(modifier = Modifier.height(18.dp))
GenreSelector(
genres = genres,
selectedGenreNames = selectedGenreNames,
onGenreSelected = onGenreSelected,
onClearGenres = onClearGenres
)
if (selectedGenreNames.isNotEmpty()) {
Spacer(modifier = Modifier.height(30.dp))
SectionTitle(text = "Search Results")
Spacer(modifier = Modifier.height(16.dp))
SearchResults(
searchResults = searchResults,
onResultClick = onResultClick
)
}
} else {
SectionTitle(text = "Search Results")
Spacer(modifier = Modifier.height(16.dp))
SearchResults(
searchResults = searchResults,
onResultClick = onResultClick
)
}
}
}
}
@Composable
private fun SearchResults(
searchResults: List<SearchResult>,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
if (searchResults.isEmpty()) {
SearchMessage(
title = "No matches",
body = "Try a different title or browse your libraries.",
modifier = modifier
)
} else {
SearchResultsGrid(
results = searchResults,
onResultClick = onResultClick,
modifier = modifier
)
}
}
@Composable
private fun SearchHeader(
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
DefaultTopBar(
leftActions = {
DefaultTopBarIconButton(
imageVector = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
}
)
}
@Composable
private fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.typography.titleMedium,
placeholder = {
Text(
text = "Search movies, shows, genres...",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = null,
tint = scheme.onSurfaceVariant,
modifier = Modifier.size(30.dp)
)
},
trailingIcon = {
if (query.isNotBlank()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = "Clear search",
tint = scheme.onSurface
)
}
}
},
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceContainer,
unfocusedContainerColor = scheme.surfaceContainer,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
onSearch()
keyboardController?.hide()
}
),
modifier = modifier
.fillMaxWidth()
.height(68.dp)
)
}
@Composable
private fun SearchResultsGrid(
results: List<SearchResult>,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxWidth()
) {
results.chunked(2).forEach { rowItems ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
rowItems.forEach { item ->
SearchResultCard(
item = item,
onClick = { onResultClick(item) },
modifier = Modifier.weight(1f)
)
}
if (rowItems.size == 1) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun SectionTitle(
text: String,
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null
) {
val scheme = MaterialTheme.colorScheme
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth()
) {
icon?.invoke()
Text(
text = text,
style = MaterialTheme.typography.headlineSmall,
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GenreSelector(
genres: List<Genre>,
selectedGenreNames: Set<String>,
onGenreSelected: (Genre) -> Unit,
onClearGenres: () -> Unit,
modifier: Modifier = Modifier
) {
var showGenres by rememberSaveable { mutableStateOf(false) }
val visibleGenres = (genres.filter { it.name in selectedGenreNames } + genres)
.distinctBy { it.name }
.take(19)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.fillMaxWidth()
.height(260.dp)
) {
FilterChip(
selected = false,
onClick = { showGenres = true },
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Tune,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
label = {
Text(
text = if (selectedGenreNames.isEmpty()) {
"All genres"
} else {
"${selectedGenreNames.size} selected"
},
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
visibleGenres.forEach { genre ->
GenreChip(
genre = genre,
selected = genre.name in selectedGenreNames,
onClick = { onGenreSelected(genre) }
)
}
}
if (showGenres) {
AlertDialog(
onDismissRequest = { showGenres = false },
title = {
Text(text = "Select genres")
},
text = {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
) {
genres.forEach { genre ->
GenreChip(
genre = genre,
selected = genre.name in selectedGenreNames,
onClick = { onGenreSelected(genre) }
)
}
}
},
confirmButton = {
TextButton(onClick = { showGenres = false }) {
Text(text = "Done")
}
},
dismissButton = {
if (selectedGenreNames.isNotEmpty()) {
TextButton(onClick = onClearGenres) {
Text(text = "Clear")
}
}
}
)
}
}
@Composable
private fun GenreChip(
genre: Genre,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
FilterChip(
selected = selected,
onClick = onClick,
leadingIcon = if (selected) {
{
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
} else {
null
},
label = {
Text(
text = genre.name,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
},
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
private fun SearchFullScreenPreview() {
var query by rememberSaveable { mutableStateOf("du") }
AppTheme {
SearchFullScreenContent(
query = query,
searchResults = previewSearchResults,
genres = previewGenres,
selectedGenreNames = setOf("Action", "Comedy"),
onQueryChange = { query = it },
onSearch = {},
onResultClick = {},
onGenreSelected = {},
onClearGenres = {},
onBack = {},
modifier = Modifier.fillMaxSize()
)
}
}
private val previewGenres = listOf(
Genre(name = "Action"),
Genre(name = "Adventure"),
Genre(name = "Comedy"),
Genre(name = "Fantasy"),
Genre(name = "Romance"),
Genre(name = "Sci-Fi")
)
private val previewSearchResults = listOf(
SearchResult(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
title = "Dune",
posterUrl = "https://images.unsplash.com/photo-1446776811953-b23d57bd21aa",
type = MediaKind.MOVIE
),
SearchResult(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
title = "Dune: Part Two",
posterUrl = "https://images.unsplash.com/photo-1535016120720-40c646be5580",
type = MediaKind.MOVIE
)
)

View File

@@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesTopBar
@Composable
fun LibrariesScreen(
@@ -25,7 +25,7 @@ fun LibrariesScreen(
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
LibraryTopBar(
LibrariesTopBar(
onSearchClick = onSearchClick,
)
},

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -17,20 +17,21 @@ fun LibrariesContent(
onLibrarySelected: (LibraryUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items, key = { it.id }) { item ->
LibraryListItem(
uiModel = item,
modifier = Modifier.clickable {
onLibrarySelected(item)
}
)
BoxWithConstraints(modifier = modifier) {
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = minCellSize),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items, key = { it.id }) { item ->
LibraryListItem(
uiModel = item,
onClick = { onLibrarySelected(item) }
)
}
}
}
}

View File

@@ -0,0 +1,16 @@
package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.runtime.Composable
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarSearchButton
@Composable
fun LibrariesTopBar(
onSearchClick: () -> Unit,
) {
DefaultTopBar(
rightActions = {
DefaultTopBarSearchButton(onClick = onSearchClick)
}
)
}

View File

@@ -1,37 +1,22 @@
package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.model.LibraryUiModel
@Composable
fun LibraryListItem(
uiModel: LibraryUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.fillMaxWidth()) {
PurefinAsyncImage(
model = uiModel.posterUrl,
contentDescription = uiModel.name,
contentScale = ContentScale.Inside,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(24.dp))
)
Text(
text = uiModel.name,
style = MaterialTheme.typography.displaySmall
)
}
MediaImageCard(
imageUrl = uiModel.posterUrl,
title = uiModel.name,
subtitle = "${uiModel.size} ${if (uiModel.type == LibraryKind.SERIES) "series" else "movies"}",
onClick = onClick,
modifier = modifier
)
}

View File

@@ -2,30 +2,32 @@ package hu.bbara.purefin.ui.screen.library
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
@Composable
fun LibraryScreen(
@@ -40,56 +42,69 @@ fun LibraryScreen(
val libraryItems = viewModel.contents.collectAsState()
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
LibraryTopBar(
onBack = { viewModel.onBack() }
)
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(innerPadding)
) {
LibraryPosterGrid(libraryItems = libraryItems.value)
}
}
}
@Composable
internal fun LibraryTopBar(
onBack: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(16.dp)
) {
PurefinIconButton(
icon = Icons.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
}
}
@Composable
internal fun LibraryPosterGrid(
libraryItems: List<MediaUiModel>,
modifier: Modifier = Modifier,
viewModel: LibraryViewModel = hiltViewModel()
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(MaterialTheme.colorScheme.background)
) {
items(libraryItems, key = { item -> item.id }) { item ->
PosterCard(
item = item,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = { _, _, _ -> }
)
BoxWithConstraints(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 120.dp
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = minCellSize),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(libraryItems, key = { item -> item.id }) { item ->
MediaImageCard(
imageUrl = item.primaryImageUrl,
title = item.primaryText,
subtitle = item.secondaryText,
onClick = {
when (item) {
is MovieUiModel -> viewModel.onMovieSelected(item.id)
is SeriesUiModel -> viewModel.onSeriesSelected(item.id)
is EpisodeUiModel -> Unit
}
}
) {
when (item) {
is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge(
size = 28,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
watched = item.watched,
started = (item.progress ?: 0f) > 0f
)
}
else -> Unit
}
}
}
}
}
}

View File

@@ -1,14 +1,30 @@
package hu.bbara.purefin.ui.screen.library.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.runtime.Composable
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarSearchButton
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarIconButton
@Composable
fun LibraryTopBar(
onSearchClick: () -> Unit,
onBack: () -> Unit,
) {
DefaultTopBar {
DefaultTopBarSearchButton(onClick = onSearchClick)
}
DefaultTopBar(
leftActions = {
DefaultTopBarIconButton(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack,
)
},
rightActions = {
DefaultTopBarIconButton(
imageVector = Icons.Outlined.FilterAlt,
contentDescription = "Search",
onClick = {},
)
}
)
}

View File

@@ -6,24 +6,24 @@ private fun hslColor(hue: Float, saturation: Float, lightness: Float): Color =
Color.hsl(hue = hue, saturation = saturation / 100f, lightness = lightness / 100f)
val primaryDark = hslColor(255f, 70f, 70f)
val onPrimaryDark = hslColor(255f, 60f, 12f)
val primaryContainerDark = hslColor(255f, 70f, 70f)
val onPrimaryContainerDark = hslColor(255f, 60f, 12f)
val secondaryDark = hslColor(255f, 40f, 40f)
val onSecondaryDark = hslColor(255f, 40f, 88f)
val secondaryContainerDark = hslColor(255f, 40f, 40f)
val onSecondaryContainerDark = hslColor(255f, 40f, 88f)
val tertiaryDark = hslColor(255f, 15f, 65f)
val onTertiaryDark = hslColor(255f, 15f, 12f)
val tertiaryContainerDark = hslColor(255f, 15f, 65.0f)
val onTertiaryContainerDark = hslColor(255f, 15f, 12f)
val primaryDark = hslColor(260f, 85f, 60f)
val onPrimaryDark = hslColor(255f, 0f, 100f)
val secondaryDark = hslColor(255f, 0f, 40f)
val onSecondaryDark = hslColor(255f, 0f, 100f)
val tertiaryDark = hslColor(255f, 0f, 65f)
val onTertiaryDark = hslColor(255f, 0f, 12f)
val errorDark = hslColor(7f, 100f, 84f)
val onErrorDark = hslColor(357f, 100f, 21f)
val errorContainerDark = hslColor(355f, 100f, 29f)
val onErrorContainerDark = hslColor(6f, 100f, 92f)
val backgroundDark = hslColor(255f, 60f, 8f)
val onBackgroundDark = hslColor(255f, 60f, 94f)
val surfaceDark = hslColor(255f, 40f, 12f)
val onSurfaceDark = hslColor(255f, 40f, 92f)
val surfaceContainerDark = hslColor(255f, 40f, 12f)
val backgroundDark = hslColor(255f, 10f, 8f)
val onBackgroundDark = hslColor(255f, 0f, 100f)
val surfaceDark = hslColor(255f, 10f, 16f)
val onSurfaceDark = hslColor(255f, 0f, 100f)
val primaryContainerDark = primaryDark
val onPrimaryContainerDark = onPrimaryDark
val secondaryContainerDark = secondaryDark
val onSecondaryContainerDark = onSecondaryDark
val tertiaryContainerDark = tertiaryDark
val onTertiaryContainerDark = onTertiaryDark
val surfaceContainerDark = surfaceDark

View File

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="600dp"
android:height="552dp"
android:viewportWidth="600"
android:viewportHeight="552">
<group
android:scaleX="0.075"
android:scaleY="-0.075"
android:translateX="75"
android:translateY="483">
<path
android:fillColor="#7c42f0"
android:pathData="M2068,4685c-72,-14 -140,-56 -183,-112 -67,-88 -65,-56 -65,-986l0,-840 68,5c80,7 95,10 104,23 4,6 8,375 7,822 0,884 -2,851 55,888 35,23 91,29 128,14 18,-7 123,-68 233,-135 110,-67 304,-185 430,-262 127,-77 231,-146 233,-154 2,-11 -9,-17 -40,-22 -67,-10 -236,-65 -298,-96 -150,-75 -322,-227 -376,-331 -25,-47 -29,-65 -29,-139 0,-64 5,-95 19,-123 26,-53 87,-112 146,-142 28,-14 50,-30 50,-35 0,-6 -14,-39 -31,-73 -21,-41 -59,-90 -113,-143 -121,-120 -260,-182 -466,-209 -25,-3 -63,-8 -85,-10 -258,-33 -399,-80 -520,-172 -57,-44 -143,-152 -184,-233 -36,-70 -75,-189 -66,-198 4,-4 10,-2 12,5 10,27 111,152 166,205 64,61 171,123 273,157 53,17 157,34 339,55 277,31 426,83 582,202 84,64 148,140 210,246 61,104 62,105 118,77 73,-37 74,-39 20,-132 -60,-103 -119,-180 -215,-280 -153,-161 -331,-266 -575,-342 -263,-81 -395,-137 -496,-211 -75,-54 -193,-186 -240,-265 -24,-41 -60,-117 -80,-169 -37,-94 -75,-248 -67,-272 3,-7 27,35 55,92 69,144 114,212 202,304 155,160 275,221 698,356 247,78 427,183 592,342 102,99 150,158 237,291 110,168 95,152 132,133 37,-19 102,-74 102,-87 0,-4 -32,-41 -71,-80 -94,-97 -163,-197 -263,-382 -105,-195 -146,-256 -207,-311 -94,-85 -172,-116 -409,-166 -80,-17 -167,-38 -195,-46 -82,-27 -198,-91 -256,-143 -53,-48 -137,-156 -128,-165 3,-3 27,13 54,35 122,98 253,141 495,164 198,19 216,21 305,47 165,48 287,143 390,303 18,28 60,105 93,170 120,236 287,445 355,445 25,0 99,-73 92,-91 -3,-8 -28,-30 -56,-49 -28,-19 -74,-60 -102,-90 -102,-112 -153,-215 -231,-470 -102,-332 -179,-434 -451,-595 -83,-48 -195,-114 -250,-147 -139,-83 -191,-90 -258,-37 -45,37 -57,84 -57,232 0,69 -4,128 -9,131 -11,7 -116,-29 -149,-50 -24,-16 -24,-17 -20,-153 3,-125 6,-141 31,-193 37,-76 119,-151 191,-173 109,-35 198,-10 400,109 50,30 145,85 211,123 189,109 283,189 367,311 42,60 101,191 133,292 75,238 91,281 131,360 24,47 65,108 91,136 51,55 136,114 163,114 9,0 37,-16 62,-35 177,-138 388,-82 487,129 111,235 125,557 37,805 -9,24 -16,47 -16,52 0,18 37,8 85,-24 28,-17 84,-52 125,-76 108,-64 444,-267 517,-313 92,-58 116,-128 72,-203 -16,-27 -51,-53 -143,-107 -66,-39 -175,-102 -241,-141 -66,-39 -145,-85 -175,-102 -30,-18 -100,-59 -155,-92 -55,-33 -143,-84 -195,-115 -52,-30 -183,-107 -290,-170 -107,-63 -249,-145 -316,-181l-120,-66 -18,-56c-14,-45 -51,-132 -84,-197 -9,-18 27,-5 83,28 33,21 114,68 180,107 66,38 183,107 260,152 259,153 679,398 845,493 468,269 493,287 541,387 57,119 32,253 -66,350 -22,22 -117,86 -210,141 -94,56 -343,206 -555,334 -212,128 -428,259 -480,290 -154,93 -587,356 -835,507 -583,354 -665,403 -692,414 -48,20 -118,26 -175,14zM3375,3729c381,-94 641,-440 634,-844 -4,-220 -69,-404 -160,-451 -56,-28 -87,-8 -284,185 -253,248 -359,330 -619,475 -49,27 -136,68 -195,92 -172,71 -214,95 -234,137 -29,58 -11,96 92,197 204,200 487,277 766,209z" />
<path
android:fillColor="#7C42F0"
android:pathData="M3754,3278c14,-45 28,-231 22,-298 -3,-42 -18,-116 -32,-164 -27,-94 -25,-118 11,-142 66,-43 121,70 119,251 -1,87 -30,208 -69,285 -26,51 -60,96 -51,68z" />
</group>
</vector>

View File

@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="494dp"
android:height="552dp"
android:viewportWidth="494"
android:viewportHeight="552">
<group
android:scaleX="0.1"
android:scaleY="-0.1"
android:translateX="-106"
android:translateY="552">
<path
android:fillColor="@android:color/white"
android:pathData="M2068,4685c-72,-14 -140,-56 -183,-112 -67,-88 -65,-56 -65,-986l0,-840 68,5c80,7 95,10 104,23 4,6 8,375 7,822 0,884 -2,851 55,888 35,23 91,29 128,14 18,-7 123,-68 233,-135 110,-67 304,-185 430,-262 127,-77 231,-146 233,-154 2,-11 -9,-17 -40,-22 -67,-10 -236,-65 -298,-96 -150,-75 -322,-227 -376,-331 -25,-47 -29,-65 -29,-139 0,-64 5,-95 19,-123 26,-53 87,-112 146,-142 28,-14 50,-30 50,-35 0,-6 -14,-39 -31,-73 -21,-41 -59,-90 -113,-143 -121,-120 -260,-182 -466,-209 -25,-3 -63,-8 -85,-10 -258,-33 -399,-80 -520,-172 -57,-44 -143,-152 -184,-233 -36,-70 -75,-189 -66,-198 4,-4 10,-2 12,5 10,27 111,152 166,205 64,61 171,123 273,157 53,17 157,34 339,55 277,31 426,83 582,202 84,64 148,140 210,246 61,104 62,105 118,77 73,-37 74,-39 20,-132 -60,-103 -119,-180 -215,-280 -153,-161 -331,-266 -575,-342 -263,-81 -395,-137 -496,-211 -75,-54 -193,-186 -240,-265 -24,-41 -60,-117 -80,-169 -37,-94 -75,-248 -67,-272 3,-7 27,35 55,92 69,144 114,212 202,304 155,160 275,221 698,356 247,78 427,183 592,342 102,99 150,158 237,291 110,168 95,152 132,133 37,-19 102,-74 102,-87 0,-4 -32,-41 -71,-80 -94,-97 -163,-197 -263,-382 -105,-195 -146,-256 -207,-311 -94,-85 -172,-116 -409,-166 -80,-17 -167,-38 -195,-46 -82,-27 -198,-91 -256,-143 -53,-48 -137,-156 -128,-165 3,-3 27,13 54,35 122,98 253,141 495,164 198,19 216,21 305,47 165,48 287,143 390,303 18,28 60,105 93,170 120,236 287,445 355,445 25,0 99,-73 92,-91 -3,-8 -28,-30 -56,-49 -28,-19 -74,-60 -102,-90 -102,-112 -153,-215 -231,-470 -102,-332 -179,-434 -451,-595 -83,-48 -195,-114 -250,-147 -139,-83 -191,-90 -258,-37 -45,37 -57,84 -57,232 0,69 -4,128 -9,131 -11,7 -116,-29 -149,-50 -24,-16 -24,-17 -20,-153 3,-125 6,-141 31,-193 37,-76 119,-151 191,-173 109,-35 198,-10 400,109 50,30 145,85 211,123 189,109 283,189 367,311 42,60 101,191 133,292 75,238 91,281 131,360 24,47 65,108 91,136 51,55 136,114 163,114 9,0 37,-16 62,-35 177,-138 388,-82 487,129 111,235 125,557 37,805 -9,24 -16,47 -16,52 0,18 37,8 85,-24 28,-17 84,-52 125,-76 108,-64 444,-267 517,-313 92,-58 116,-128 72,-203 -16,-27 -51,-53 -143,-107 -66,-39 -175,-102 -241,-141 -66,-39 -145,-85 -175,-102 -30,-18 -100,-59 -155,-92 -55,-33 -143,-84 -195,-115 -52,-30 -183,-107 -290,-170 -107,-63 -249,-145 -316,-181l-120,-66 -18,-56c-14,-45 -51,-132 -84,-197 -9,-18 27,-5 83,28 33,21 114,68 180,107 66,38 183,107 260,152 259,153 679,398 845,493 468,269 493,287 541,387 57,119 32,253 -66,350 -22,22 -117,86 -210,141 -94,56 -343,206 -555,334 -212,128 -428,259 -480,290 -154,93 -587,356 -835,507 -583,354 -665,403 -692,414 -48,20 -118,26 -175,14zM3375,3729c381,-94 641,-440 634,-844 -4,-220 -69,-404 -160,-451 -56,-28 -87,-8 -284,185 -253,248 -359,330 -619,475 -49,27 -136,68 -195,92 -172,71 -214,95 -234,137 -29,58 -11,96 92,197 204,200 487,277 766,209z" />
<path
android:fillColor="@android:color/white"
android:pathData="M3754,3278c14,-45 28,-231 22,-298 -3,-42 -18,-116 -32,-164 -27,-94 -25,-118 11,-142 66,-43 121,70 119,251 -1,87 -30,208 -69,285 -26,51 -60,96 -51,68z" />
</group>
</vector>

View File

@@ -0,0 +1,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/purefin_logo_launcher" />
</adaptive-icon>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/purefin_logo_background"/>
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
</adaptive-icon>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/purefin_logo_background"/>
<foreground android:drawable="@mipmap/purefin_logo_foreground"/>
<monochrome android:drawable="@mipmap/purefin_logo_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

View File

@@ -1,2 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<color name="ic_launcher_background">#131216</color>
</resources>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purefin_logo_background">#201337</color>
</resources>

View File

@@ -0,0 +1,5 @@
package hu.bbara.purefin.model
data class Genre(
val name: String
)

View File

@@ -7,6 +7,7 @@ data class Library(
val name: String,
val type: LibraryKind,
val posterUrl: String,
val size: Int,
val series: List<Series>? = null,
val movies: List<Movie>? = null,
) {

View File

@@ -59,7 +59,7 @@ fun MediaResumeButton(
) {
val scheme = MaterialTheme.colorScheme
val primaryColor = scheme.primary
val onPrimaryColor = scheme.onPrimary
val defaultColor = scheme.surface
val shape = RoundedCornerShape(50)
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
@@ -93,7 +93,7 @@ fun MediaResumeButton(
Box(
modifier = Modifier
.fillMaxSize()
.background(onPrimaryColor),
.background(defaultColor),
contentAlignment = Alignment.Center
) {
ResumeButtonContent(
@@ -123,7 +123,7 @@ fun MediaResumeButton(
) {
ResumeButtonContent(
text = text,
color = onPrimaryColor,
color = defaultColor,
textSize = textSize,
iconSize = iconSize
)

View File

@@ -24,8 +24,8 @@ fun PurefinIconButton(
CircularIconButton(
icon = icon,
contentDescription = contentDescription,
containerColor = scheme.secondary,
focusedBackgroundColor = focusedBackgroundColor ?: scheme.secondary,
containerColor = scheme.surface,
focusedBackgroundColor = focusedBackgroundColor ?: scheme.surface,
iconColor = scheme.onSecondary,
size = size.dp,
onClick = onClick,

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
import hu.bbara.purefin.Offline
import hu.bbara.purefin.Online
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope
@@ -10,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -21,27 +22,31 @@ import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
class CompositeMediaRepository @Inject constructor(
@Offline private val offlineRepository: MediaRepository,
@Online private val onlineRepository: MediaRepository,
) : MediaCatalogReader, MediaProgressWriter {
class CompositeLocalMediaRepository @Inject constructor(
@Offline private val offlineRepository: LocalMediaRepository,
@Online private val onlineRepository: LocalMediaRepository,
) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// TODO move this into the domain layer and there you can use NetworkMonitor. Data should be free of android stuff.
private val activeRepository: Flow<MediaCatalogReader> = flowOf(onlineRepository)
private val activeRepository: Flow<LocalMediaRepository> = flowOf(onlineRepository)
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
.stateIn(scope, Eagerly, emptyMap())
override val series: StateFlow<Map<UUID, Series>> = activeRepository
.flatMapLatest { it.series }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
.stateIn(scope, Eagerly, emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
.flatMapLatest { it.episodes }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap())
.stateIn(scope, Eagerly, emptyMap())
override val genres: StateFlow<Set<Genre>> = activeRepository
.flatMapLatest { it.genres }
.stateIn(scope, Eagerly, emptySet())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository

View File

@@ -12,5 +12,5 @@ interface HomeRepository {
val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
fun ensureReady()
fun refreshHomeData()
suspend fun refreshHomeData()
}

View File

@@ -1,17 +1,18 @@
package hu.bbara.purefin.data
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
//TODO rename MediaRepository
interface MediaCatalogReader {
interface LocalMediaRepository : MediaProgressWriter {
val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>>
val genres: StateFlow<Set<Genre>>
suspend fun getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?>

View File

@@ -1,4 +0,0 @@
package hu.bbara.purefin.data
interface MediaRepository : MediaCatalogReader, MediaProgressWriter {
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.data
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.model.Genre
import kotlinx.coroutines.flow.StateFlow
interface SearchManager {
val searchResult : StateFlow<List<SearchResult>>
val genres: StateFlow<Set<Genre>>
fun setGenres(genres : Set<String>)
fun setSearchTerm(searchTerm: String)
}

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.model.LibraryKind
@@ -34,7 +34,7 @@ import javax.inject.Inject
@HiltViewModel
class AppViewModel @Inject constructor(
private val homeRepository: HomeRepository,
private val mediaCatalogReader: MediaCatalogReader,
private val mediaCatalogReader: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
@@ -57,6 +57,7 @@ class AppViewModel @Inject constructor(
name = it.name,
type = it.type,
posterUrl = it.posterUrl,
size = it.size,
isEmpty = when (it.type) {
LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty()
LibraryKind.SERIES -> mediaCatalogReader.series.value.isEmpty()

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.feature.content.episode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.model.Episode
@@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel
class EpisodeScreenViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
private val mediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
): ViewModel() {

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.feature.content.movie
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.model.Movie
@@ -21,7 +21,7 @@ import javax.inject.Inject
@HiltViewModel
class MovieScreenViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
private val mediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
): ViewModel() {

View File

@@ -3,7 +3,7 @@ package hu.bbara.purefin.feature.content.series
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.model.Episode
@@ -25,7 +25,7 @@ import javax.inject.Inject
@HiltViewModel
class SeriesViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
private val mediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController,
) : ViewModel() {

View File

@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.Offline
import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
@@ -23,7 +23,7 @@ import javax.inject.Inject
@HiltViewModel
class DownloadsViewModel @Inject constructor(
@Offline private val offlineCatalogReader: MediaRepository,
@Offline private val offlineCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager,
private val downloadManager: MediaDownloadController,
) : ViewModel() {

View File

@@ -1,61 +1,63 @@
package hu.bbara.purefin.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto
import java.util.UUID
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import hu.bbara.purefin.image.ArtworkKind
import javax.inject.Inject
@HiltViewModel
@OptIn(FlowPreview::class)
class SearchViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader,
private val userSessionRepository: UserSessionRepository,
private val searchManager: SearchManager,
private val navigationManager: NavigationManager,
) : ViewModel() {
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList())
val searchResult = _searchResult.asStateFlow()
private val query = MutableStateFlow("")
init {
combine(
query.debounce(300).distinctUntilChanged(),
mediaCatalogReader.movies,
mediaCatalogReader.series
) { currentQuery, movies, series ->
val filteredMovies = movies.filter {
it.value.title.contains(currentQuery, ignoreCase = true)
}
val filteredSeries = series.filter {
it.value.name.contains(currentQuery, ignoreCase = true)
}
_searchResult.value = filteredMovies.values.map {
SearchResult.create(it, createImageUrl(it.id))
} + filteredSeries.values.map {
SearchResult.create(it, createImageUrl(it.id))
}
}.launchIn(viewModelScope)
}
val genres = searchManager.genres
val searchResult = searchManager.searchResult
fun search(query: String) {
this.query.value = query
searchManager.setSearchTerm(query)
}
private suspend fun createImageUrl(id: UUID) : String {
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id,
ArtworkKind.PRIMARY)
fun setSelectedGenres(genreNames: Set<String>) {
searchManager.setGenres(genreNames)
}
fun onBack() {
navigationManager.pop()
}
fun onSearchResultSelected(searchResult: SearchResult) {
when (searchResult.type) {
MediaKind.MOVIE -> onMovieSelected(searchResult.id)
MediaKind.SERIES -> onSeriesSelected(searchResult.id)
else -> Unit
}
}
private fun onMovieSelected(movieId: UUID) {
navigationManager.navigate(
Route.MovieRoute(
MovieDto(
id = movieId,
)
)
)
}
private fun onSeriesSelected(seriesId: UUID) {
navigationManager.navigate(
Route.SeriesRoute(
SeriesDto(
id = seriesId,
)
)
)
}
}

View File

@@ -4,9 +4,9 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.data.CompositeMediaRepository
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.CompositeLocalMediaRepository
import hu.bbara.purefin.data.MediaProgressWriter
import hu.bbara.purefin.data.LocalMediaRepository
@Module
@@ -14,8 +14,8 @@ import hu.bbara.purefin.data.MediaProgressWriter
abstract class RepositoryModule {
@Binds
abstract fun bindMediaCatalogReader(impl: CompositeMediaRepository): MediaCatalogReader
abstract fun bindMediaRepository(impl: CompositeLocalMediaRepository): LocalMediaRepository
@Binds
abstract fun bindMediaProgressWrite(impl: CompositeMediaRepository): MediaProgressWriter
}
abstract fun bindMediaProgressWrite(impl: CompositeLocalMediaRepository): MediaProgressWriter
}

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.PlayableMedia
@@ -31,7 +31,7 @@ import javax.inject.Inject
class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager,
private val mediaCatalogReader: MediaCatalogReader,
private val mediaCatalogReader: LocalMediaRepository,
private val progressManager: ProgressManager,
) : ViewModel() {
companion object {

View File

@@ -8,5 +8,6 @@ data class LibraryUiModel(
val name: String,
val type: LibraryKind,
val posterUrl: String,
val size: Int,
val isEmpty: Boolean
)

View File

@@ -6,6 +6,7 @@ import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.NetworkMonitor
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toGenre
import hu.bbara.purefin.data.converter.toLibrary
import hu.bbara.purefin.data.converter.toMovie
import hu.bbara.purefin.data.converter.toSeason
@@ -47,7 +48,7 @@ class InMemoryAppContentRepository @Inject constructor(
val userSessionRepository: UserSessionRepository,
val jellyfinApiClient: JellyfinApiClient,
private val homeCacheDataStore: DataStore<HomeCache>,
private val onlineMediaRepository: InMemoryMediaRepository,
private val onlineMediaRepository: InMemoryLocalMediaRepository,
private val networkMonitor: NetworkMonitor,
) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -87,29 +88,28 @@ class InMemoryAppContentRepository @Inject constructor(
scope.launch { refreshHomeData() }
}
@Synchronized
override fun refreshHomeData() {
if (refreshJob?.isActive == true) {
return
}
val job = scope.launch {
runCatching {
Log.d(TAG, "Refreshing home data")
if (!networkMonitor.isOnline.first()) {
return@runCatching
override suspend fun refreshHomeData() {
val job = synchronized(this) {
refreshJob?.takeIf { it.isActive } ?: scope.launch {
runCatching {
Log.d(TAG, "Refreshing home data")
if (!networkMonitor.isOnline.first()) {
return@runCatching
}
loadLibraries()
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
loadGenres()
Log.d(TAG, "Home refresh successful")
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
loadLibraries()
loadSuggestions()
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
Log.d(TAG, "Home refresh successful")
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
}.also { refreshJob = it }
}
refreshJob = job
job.join()
}
private suspend fun loadHomeCache() {
@@ -199,7 +199,7 @@ class InMemoryAppContentRepository @Inject constructor(
)
}
suspend fun loadLibraries() {
private suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load libraries", error)
@@ -221,7 +221,7 @@ class InMemoryAppContentRepository @Inject constructor(
onlineMediaRepository.upsertSeries(series)
}
suspend fun loadLibrary(library: Library): Library {
private suspend fun loadLibrary(library: Library): Library {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
.getOrElse { error ->
Log.w(TAG, "Unable to load library ${library.id}", error)
@@ -230,14 +230,16 @@ class InMemoryAppContentRepository @Inject constructor(
return when (library.type) {
LibraryKind.MOVIES -> library.copy(
movies = contentItem.map { it.toMovie(serverUrl()) },
size = contentItem.size,
)
LibraryKind.SERIES -> library.copy(
series = contentItem.map { it.toSeries(serverUrl()) },
size = contentItem.size,
)
}
}
suspend fun loadSuggestions() {
private suspend fun loadSuggestions() {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
Log.w(TAG, "Unable to load suggestions", error)
@@ -258,7 +260,7 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
suspend fun loadContinueWatching() {
private suspend fun loadContinueWatching() {
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error ->
Log.w(TAG, "Unable to load continue watching", error)
@@ -279,7 +281,7 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
suspend fun loadNextUp() {
private suspend fun loadNextUp() {
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error ->
Log.w(TAG, "Unable to load next up", error)
@@ -294,7 +296,7 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
suspend fun loadLatestLibraryContent() {
private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Log.w(TAG, "Unable to load latest library content", error)
@@ -337,6 +339,12 @@ class InMemoryAppContentRepository @Inject constructor(
latestLibraryContentState.value = latestLibraryContents
}
private suspend fun loadGenres() {
val baseItemDtos = jellyfinApiClient.getGenres()
val genres = baseItemDtos.map { it.toGenre() }
onlineMediaRepository.upsertGenres(genres.toSet())
}
private suspend fun serverUrl(): String {
return userSessionRepository.serverUrl.first()
}

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.data.catalog
import android.util.Log
import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.converter.toEpisode
import hu.bbara.purefin.data.converter.toMovie
@@ -9,6 +9,7 @@ import hu.bbara.purefin.data.converter.toSeason
import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CancellationException
@@ -30,10 +31,10 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InMemoryMediaRepository @Inject constructor(
class InMemoryLocalMediaRepository @Inject constructor(
private val userSessionRepository: UserSessionRepository,
private val jellyfinApiClient: JellyfinApiClient,
) : MediaRepository {
) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -48,6 +49,9 @@ class InMemoryMediaRepository @Inject constructor(
private val episodesState = MutableStateFlow<Map<UUID, Episode>>(emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = episodesState.asStateFlow()
private val genresState = MutableStateFlow<Set<Genre>>(emptySet())
override var genres: StateFlow<Set<Genre>> = genresState.asStateFlow()
override suspend fun getMovie(id: UUID): Flow<Movie?> {
if (!moviesState.value.containsKey(id)) {
jellyfinApiClient.getItemInfo(id)?.let { item ->
@@ -92,6 +96,10 @@ class InMemoryMediaRepository @Inject constructor(
return episodesState.map { it[id] }
}
fun upsertGenres(genres: Set<Genre>) {
genresState.update { current -> current + genres }
}
fun upsertMovies(movies: List<Movie>) {
moviesState.update { current -> current + movies.associateBy { it.id } }
}

View File

@@ -1,14 +1,16 @@
package hu.bbara.purefin.data.catalog
import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.offline.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
@@ -18,9 +20,9 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class OfflineMediaRepository @Inject constructor(
class OfflineLocalMediaRepository @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource,
) : MediaRepository {
) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow
@@ -32,6 +34,8 @@ class OfflineMediaRepository @Inject constructor(
override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
override var genres: StateFlow<Set<Genre>> = MutableStateFlow(emptySet())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return movies.map { it[id] }
}

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data.converter
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie
@@ -26,6 +27,7 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
artworkKind = ArtworkKind.PRIMARY
),
type = LibraryKind.MOVIES,
size = childCount ?: 0,
movies = emptyList(),
)
CollectionType.TVSHOWS -> Library(
@@ -37,6 +39,7 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
artworkKind = ArtworkKind.PRIMARY
),
type = LibraryKind.SERIES,
size = childCount ?: 0,
series = emptyList(),
)
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType")
@@ -112,6 +115,12 @@ fun BaseItemDto.toEpisode(serverUrl: String): Episode {
)
}
fun BaseItemDto.toGenre() : Genre {
return Genre(
name = name ?: "Unknown"
)
}
fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) {
return fallbackYear?.toString() ?: ""
@@ -126,4 +135,4 @@ private fun formatRuntime(ticks: Long?): String {
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m"
}
}

View File

@@ -0,0 +1,96 @@
package hu.bbara.purefin.data.jellyfin
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.MediaKind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SearchManagerImpl @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository
) : SearchManager {
private suspend fun getServerUrl(): String {
return userSessionRepository.serverUrl.first()
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val searchTerm = MutableStateFlow("")
private val selectedGenres = MutableStateFlow<Set<String>>(emptySet())
private val _genres = MutableStateFlow<Set<Genre>>(emptySet())
override val searchResult: StateFlow<List<SearchResult>> =
combine(searchTerm, selectedGenres) { searchTerm, genres ->
search(searchTerm, genres)
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val genres: StateFlow<Set<Genre>> = _genres.asStateFlow()
init {
scope.launch {
_genres.value = jellyfinApiClient.getGenres()
.mapNotNull { it.name }
.map { Genre(name = it) }
.toSet()
}
}
override fun setGenres(genres: Set<String>) {
selectedGenres.value = genres
}
override fun setSearchTerm(searchTerm: String) {
this.searchTerm.value = searchTerm
}
private suspend fun search(searchTerm: String, genres: Set<String>): List<SearchResult> {
if (searchTerm.isNotEmpty()) {
val searchBySearchTerm = jellyfinApiClient.searchBySearchTerm(searchTerm)
return searchBySearchTerm.map { it.toSearchResult() }
}
if (genres.isNotEmpty()) {
val searchByGenre = jellyfinApiClient.searchByGenre(genres)
return searchByGenre.map { it.toSearchResult() }
}
return emptyList()
}
private suspend fun BaseItemDto.toSearchResult(): SearchResult {
return SearchResult(
id = id,
title = name!!,
posterUrl = ImageUrlBuilder.toImageUrl(
url = getServerUrl(),
itemId = id,
artworkKind = ArtworkKind.PRIMARY
),
type = when (type) {
BaseItemKind.MOVIE -> MediaKind.MOVIE
BaseItemKind.SERIES -> MediaKind.SERIES
else -> throw UnsupportedOperationException("Unsupported media type: $type")
}
)
}
}

View File

@@ -107,6 +107,38 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun searchBySearchTerm(searchTerm: String): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("searchBySearchTerm") {
if (!ensureConfigured()) {
return@logApiFailure emptyList()
}
val response = api.itemsApi.getItems(
userId = getUserId(),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
searchTerm = searchTerm,
recursive = true
)
Log.d("searchBySearchTerm", response.content.toString())
response.content.items
}
}
suspend fun searchByGenre(genres: Set<String>): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("searchMovie") {
if (!ensureConfigured()) {
return@logApiFailure emptyList()
}
val response = api.itemsApi.getItems(
userId = getUserId(),
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
genres = genres,
recursive = true
)
Log.d("searchByGenre", response.content.toString())
response.content.items
}
}
suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getLibraries") {
if (!ensureConfigured()) {

View File

@@ -7,10 +7,12 @@ import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.Offline
import hu.bbara.purefin.Online
import hu.bbara.purefin.data.HomeRepository
import hu.bbara.purefin.data.MediaRepository
import hu.bbara.purefin.data.LocalMediaRepository
import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.catalog.InMemoryAppContentRepository
import hu.bbara.purefin.data.catalog.InMemoryMediaRepository
import hu.bbara.purefin.data.catalog.OfflineMediaRepository
import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
@Module
@InstallIn(SingletonComponent::class)
@@ -18,12 +20,15 @@ abstract class MediaRepositoryModule {
@Binds
@Online
abstract fun bindOnlineRepository(impl: InMemoryMediaRepository): MediaRepository
abstract fun bindOnlineRepository(impl: InMemoryLocalMediaRepository): LocalMediaRepository
@Binds
@Offline
abstract fun bindOfflineRepository(impl: OfflineMediaRepository): MediaRepository
abstract fun bindOfflineRepository(impl: OfflineLocalMediaRepository): LocalMediaRepository
@Binds
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository
@Binds
abstract fun bindSearchManager(impl: SearchManagerImpl): SearchManager
}

View File

@@ -79,6 +79,7 @@ data class CachedLibrary(
val name: String,
val type: String,
val posterUrl: String,
val size: Int = 0,
val series: List<CachedSeries>? = null,
val movies: List<CachedMovie>? = null,
)
@@ -100,6 +101,7 @@ fun Library.toCachedLibrary() = CachedLibrary(
name = name,
type = type.name,
posterUrl = posterUrl,
size = size,
series = series?.map { it.toCachedSeries() },
movies = movies?.map { it.toCachedMovie() },
)
@@ -112,6 +114,7 @@ fun CachedLibrary.toLibrary(): Library? {
name = name,
type = LibraryKind.MOVIES,
posterUrl = posterUrl,
size = size,
movies = movies?.mapNotNull { it.toMovie() } ?: emptyList(),
)
"SERIES" -> Library(
@@ -119,6 +122,7 @@ fun CachedLibrary.toLibrary(): Library? {
name = name,
type = LibraryKind.SERIES,
posterUrl = posterUrl,
size = size,
series = series?.mapNotNull { it.toSeries() } ?: emptyList(),
)
else -> null