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", name = "Movies",
type = LibraryKind.MOVIES, type = LibraryKind.MOVIES,
posterUrl = "", posterUrl = "",
size = 1,
isEmpty = false isEmpty = false
) )
) )

View File

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

View File

@@ -12,9 +12,9 @@
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_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:label="@string/app_name"
android:roundIcon="@mipmap/purefin_logo_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Purefin"> android:theme="@style/Theme.Purefin">
<activity <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.login.LoginScreen
import hu.bbara.purefin.ui.screen.AppScreen import hu.bbara.purefin.ui.screen.AppScreen
import hu.bbara.purefin.ui.screen.episode.EpisodeScreen 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.movie.MovieScreen
import hu.bbara.purefin.ui.screen.series.SeriesScreen import hu.bbara.purefin.ui.screen.series.SeriesScreen
@@ -23,7 +23,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) { ) {
HomeSearchFullScreen() SearchScreen()
} }
} }
entry<Route.MovieRoute> { 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.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -26,6 +27,8 @@ fun SectionHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onActionClick: () -> Unit = {} onActionClick: () -> Unit = {}
) { ) {
val headerColor = MaterialTheme.colorScheme.onSurface
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -36,10 +39,14 @@ fun SectionHeader(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
color = headerColor
) )
if (actionLabel != null) { if (actionLabel != null) {
TextButton(onClick = onActionClick) { TextButton(
onClick = onActionClick,
colors = ButtonDefaults.textButtonColors(contentColor = headerColor)
) {
Text(text = actionLabel) Text(text = actionLabel)
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Icon( Icon(

View File

@@ -1,32 +1,21 @@
package hu.bbara.purefin.ui.common.image package hu.bbara.purefin.ui.common.image
import android.graphics.BitmapFactory import androidx.compose.material3.Icon
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalContext
import hu.bbara.purefin.R import hu.bbara.purefin.R
@Composable @Composable
fun PurefinLogo( fun PurefinLogo(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentDescription: String? = null, color: Color = Color(0xFF7C42F0)
contentScale: ContentScale = ContentScale.Fit,
) { ) {
val resources = LocalContext.current.resources Icon(
val logoBitmap = remember(resources) { painter = painterResource(id = R.drawable.purefin_logo_tintable),
BitmapFactory.decodeResource(resources, R.raw.logo_raw)?.asImageBitmap() contentDescription = "Purefin logo",
}
logoBitmap?.let { bitmap ->
Image(
bitmap = bitmap,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier, 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.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope 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.padding
import androidx.compose.foundation.layout.size 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.Icons
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.ui.common.image.PurefinLogo import hu.bbara.purefin.ui.common.image.PurefinLogo
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DefaultTopBar( fun DefaultTopBar(
actions: @Composable RowScope.() -> Unit leftActions: (@Composable RowScope.() -> Unit)? = null,
rightActions: (@Composable RowScope.() -> Unit)? = null,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
TopAppBar( Surface(
title = { 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( Row(
modifier = Modifier, 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( PurefinLogo(
contentDescription = "Purefin", modifier = Modifier.size(84.dp),
modifier = Modifier.size(48.dp),
contentScale = ContentScale.Fit,
) )
Text( Text(
text = "PureFin", text = "PureFin",
fontSize = 32.sp, fontSize = 32.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
color = scheme.onSecondary color = scheme.primary
) )
} }
}, if (rightActions != null) {
actions = actions, Row(
colors = TopAppBarDefaults.largeTopAppBarColors( verticalAlignment = Alignment.CenterVertically,
containerColor = scheme.background, content = rightActions
navigationIconContentColor = scheme.onSurface,
actionIconContentColor = scheme.onSurface,
titleContentColor = scheme.onSurface
),
modifier = Modifier.padding(end = 12.dp)
) )
}
}
}
}
@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
.size(50.dp)
.clip(CircleShape),
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(30.dp),
)
}
} }
@OptIn(ExperimentalSharedTransitionApi::class) @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.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -18,7 +13,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
@@ -28,32 +22,17 @@ fun HomeTopBar(
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit, onLogoutClick: () -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme
var isProfileMenuExpanded by remember { mutableStateOf(false) } var isProfileMenuExpanded by remember { mutableStateOf(false) }
DefaultTopBar() DefaultTopBar(
{ rightActions = {
DefaultTopBarSearchButton(onClick = onSearchClick) DefaultTopBarSearchButton(onClick = onSearchClick)
Spacer(modifier = Modifier.size(12.dp)) Spacer(modifier = Modifier.size(12.dp))
IconButton( DefaultTopBarIconButton(
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, imageVector = Icons.Outlined.Person,
contentDescription = "Profile", contentDescription = "Profile",
modifier = Modifier.size(30.dp), onClick = { isProfileMenuExpanded = true },
) )
}
DropdownMenu( DropdownMenu(
expanded = isProfileMenuExpanded, expanded = isProfileMenuExpanded,
onDismissRequest = { isProfileMenuExpanded = false }, onDismissRequest = { isProfileMenuExpanded = false },
@@ -81,4 +60,5 @@ fun HomeTopBar(
) )
} }
} }
)
} }

View File

@@ -1,33 +1,21 @@
package hu.bbara.purefin.ui.screen.home.components.continuewatching package hu.bbara.purefin.ui.screen.home.components.continuewatching
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues
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.width 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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 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.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.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable @Composable
internal fun ContinueWatchingCard( internal fun ContinueWatchingCard(
@@ -41,26 +29,18 @@ internal fun ContinueWatchingCard(
onMediaSelected(item) onMediaSelected(item)
} }
Card( MediaImageCard(
imageUrl = item.primaryImageUrl,
title = item.primaryText,
subtitle = item.secondaryText,
onClick = onClick, onClick = onClick,
shape = RoundedCornerShape(26.dp), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer), shapeSize = 26.dp,
imageAspectRatio = 16f / 9f,
titleStyle = MaterialTheme.typography.titleMedium,
textPadding = PaddingValues(horizontal = 16.dp, vertical = 14.dp),
modifier = modifier.width(280.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( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
@@ -83,25 +63,4 @@ internal fun ContinueWatchingCard(
) )
} }
} }
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
)
}
}
}
} }

View File

@@ -23,11 +23,11 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable @Composable
internal fun SuggestionCard( internal fun SuggestionCard(

View File

@@ -1,30 +1,13 @@
package hu.bbara.purefin.ui.screen.home.components.library 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.padding
import androidx.compose.foundation.layout.width 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.EpisodeUiModel import hu.bbara.purefin.ui.model.EpisodeUiModel
@@ -38,32 +21,19 @@ internal fun HomeBrowseCard(
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) { val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel) onMediaSelected(uiModel)
} }
Card( MediaImageCard(
imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText,
subtitle = uiModel.secondaryText,
onClick = onClick, onClick = onClick,
shape = RoundedCornerShape(12.dp), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer), imageAspectRatio = 15f / 16f,
modifier = modifier.width(188.dp) 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) { when (uiModel) {
is MovieUiModel, is EpisodeUiModel -> { is MovieUiModel, is EpisodeUiModel -> {
WatchStateBadge( WatchStateBadge(
@@ -78,24 +48,4 @@ internal fun HomeBrowseCard(
else -> Unit 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
)
}
}
}
} }

View File

@@ -1,31 +1,19 @@
package hu.bbara.purefin.ui.screen.home.components.nextup package hu.bbara.purefin.ui.screen.home.components.nextup
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues
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.width 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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 androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.ui.common.card.MediaImageCard
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable @Composable
internal fun NextUpCard( internal fun NextUpCard(
@@ -34,31 +22,21 @@ internal fun NextUpCard(
onMediaSelected: (MediaUiModel) -> Unit, onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) { val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel) onMediaSelected(uiModel)
} }
Card( MediaImageCard(
imageUrl = uiModel.primaryImageUrl,
title = uiModel.primaryText,
subtitle = uiModel.secondaryText,
onClick = onClick, onClick = onClick,
shape = RoundedCornerShape(24.dp), imageModifier = Modifier.homeMediaSharedBoundsSource(sharedBoundsKey),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer), shapeSize = 24.dp,
titleStyle = MaterialTheme.typography.titleSmall,
textPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp),
modifier = modifier.width(256.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( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
@@ -73,25 +51,4 @@ internal fun NextUpCard(
) )
) )
} }
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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeSearchOverlay( fun SearchOverlay(
visible: Boolean, visible: Boolean,
topPadding: Dp, topPadding: Dp,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -138,7 +138,7 @@ fun HomeSearchOverlay(
modifier = Modifier.background(MaterialTheme.colorScheme.surface) modifier = Modifier.background(MaterialTheme.colorScheme.surface)
) { ) {
items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item -> items(searchResults, key = { result -> "${result.type}:${result.id}" }) { item ->
HomeSearchResultCard( SearchResultCard(
item = item, item = item,
onClick = { onClick = {
when (item.type) { 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.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent 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 @Composable
fun LibrariesScreen( fun LibrariesScreen(
@@ -25,7 +25,7 @@ fun LibrariesScreen(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
LibraryTopBar( LibrariesTopBar(
onSearchClick = onSearchClick, onSearchClick = onSearchClick,
) )
}, },

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.ui.screen.libraries.components package hu.bbara.purefin.ui.screen.libraries.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -17,9 +17,11 @@ fun LibrariesContent(
onLibrarySelected: (LibraryUiModel) -> Unit, onLibrarySelected: (LibraryUiModel) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints(modifier = modifier) {
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
LazyVerticalGrid( LazyVerticalGrid(
modifier = modifier, columns = GridCells.Adaptive(minSize = minCellSize),
columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -27,10 +29,9 @@ fun LibrariesContent(
items(items, key = { it.id }) { item -> items(items, key = { it.id }) { item ->
LibraryListItem( LibraryListItem(
uiModel = item, uiModel = item,
modifier = Modifier.clickable { onClick = { onLibrarySelected(item) }
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 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import hu.bbara.purefin.model.LibraryKind
import androidx.compose.ui.layout.ContentScale import hu.bbara.purefin.ui.common.card.MediaImageCard
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.LibraryUiModel import hu.bbara.purefin.ui.model.LibraryUiModel
@Composable @Composable
fun LibraryListItem( fun LibraryListItem(
uiModel: LibraryUiModel, uiModel: LibraryUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier = modifier.fillMaxWidth()) { MediaImageCard(
PurefinAsyncImage( imageUrl = uiModel.posterUrl,
model = uiModel.posterUrl, title = uiModel.name,
contentDescription = uiModel.name, subtitle = "${uiModel.size} ${if (uiModel.type == LibraryKind.SERIES) "series" else "movies"}",
contentScale = ContentScale.Inside, onClick = onClick,
modifier = Modifier modifier = modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(24.dp))
) )
Text(
text = uiModel.name,
style = MaterialTheme.typography.displaySmall
)
}
} }

View File

@@ -2,30 +2,32 @@ package hu.bbara.purefin.ui.screen.library
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.ui.common.button.PurefinIconButton import hu.bbara.purefin.navigation.LibraryDto
import hu.bbara.purefin.ui.common.card.PosterCard 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 @Composable
fun LibraryScreen( fun LibraryScreen(
@@ -40,56 +42,69 @@ fun LibraryScreen(
val libraryItems = viewModel.contents.collectAsState() val libraryItems = viewModel.contents.collectAsState()
Scaffold( Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { topBar = {
LibraryTopBar( LibraryTopBar(
onBack = { viewModel.onBack() } onBack = { viewModel.onBack() }
) )
} }
) { innerPadding -> ) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(innerPadding)
) {
LibraryPosterGrid(libraryItems = libraryItems.value) 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 @Composable
internal fun LibraryPosterGrid( internal fun LibraryPosterGrid(
libraryItems: List<MediaUiModel>, libraryItems: List<MediaUiModel>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: LibraryViewModel = hiltViewModel() viewModel: LibraryViewModel = hiltViewModel()
) { ) {
BoxWithConstraints(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 120.dp
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp), columns = GridCells.Adaptive(minSize = minCellSize),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.background(MaterialTheme.colorScheme.background)
) { ) {
items(libraryItems, key = { item -> item.id }) { item -> items(libraryItems, key = { item -> item.id }) { item ->
PosterCard( MediaImageCard(
item = item, imageUrl = item.primaryImageUrl,
onMovieSelected = viewModel::onMovieSelected, title = item.primaryText,
onSeriesSelected = viewModel::onSeriesSelected, subtitle = item.secondaryText,
onEpisodeSelected = { _, _, _ -> } 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 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 androidx.compose.runtime.Composable
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar 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 @Composable
fun LibraryTopBar( fun LibraryTopBar(
onSearchClick: () -> Unit, onBack: () -> Unit,
) { ) {
DefaultTopBar { DefaultTopBar(
DefaultTopBarSearchButton(onClick = onSearchClick) 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) Color.hsl(hue = hue, saturation = saturation / 100f, lightness = lightness / 100f)
val primaryDark = hslColor(255f, 70f, 70f) val primaryDark = hslColor(260f, 85f, 60f)
val onPrimaryDark = hslColor(255f, 60f, 12f) val onPrimaryDark = hslColor(255f, 0f, 100f)
val primaryContainerDark = hslColor(255f, 70f, 70f) val secondaryDark = hslColor(255f, 0f, 40f)
val onPrimaryContainerDark = hslColor(255f, 60f, 12f) val onSecondaryDark = hslColor(255f, 0f, 100f)
val secondaryDark = hslColor(255f, 40f, 40f) val tertiaryDark = hslColor(255f, 0f, 65f)
val onSecondaryDark = hslColor(255f, 40f, 88f) val onTertiaryDark = hslColor(255f, 0f, 12f)
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 errorDark = hslColor(7f, 100f, 84f) val errorDark = hslColor(7f, 100f, 84f)
val onErrorDark = hslColor(357f, 100f, 21f) val onErrorDark = hslColor(357f, 100f, 21f)
val errorContainerDark = hslColor(355f, 100f, 29f) val errorContainerDark = hslColor(355f, 100f, 29f)
val onErrorContainerDark = hslColor(6f, 100f, 92f) val onErrorContainerDark = hslColor(6f, 100f, 92f)
val backgroundDark = hslColor(255f, 60f, 8f) val backgroundDark = hslColor(255f, 10f, 8f)
val onBackgroundDark = hslColor(255f, 60f, 94f) val onBackgroundDark = hslColor(255f, 0f, 100f)
val surfaceDark = hslColor(255f, 40f, 12f) val surfaceDark = hslColor(255f, 10f, 16f)
val onSurfaceDark = hslColor(255f, 40f, 92f) val onSurfaceDark = hslColor(255f, 0f, 100f)
val surfaceContainerDark = hslColor(255f, 40f, 12f) 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"?> <?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 name: String,
val type: LibraryKind, val type: LibraryKind,
val posterUrl: String, val posterUrl: String,
val size: Int,
val series: List<Series>? = null, val series: List<Series>? = null,
val movies: List<Movie>? = null, val movies: List<Movie>? = null,
) { ) {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package hu.bbara.purefin.data
import hu.bbara.purefin.Offline import hu.bbara.purefin.Offline
import hu.bbara.purefin.Online import hu.bbara.purefin.Online
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -10,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow 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.StateFlow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -21,27 +22,31 @@ import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Singleton @Singleton
class CompositeMediaRepository @Inject constructor( class CompositeLocalMediaRepository @Inject constructor(
@Offline private val offlineRepository: MediaRepository, @Offline private val offlineRepository: LocalMediaRepository,
@Online private val onlineRepository: MediaRepository, @Online private val onlineRepository: LocalMediaRepository,
) : MediaCatalogReader, MediaProgressWriter { ) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) 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. // 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 override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies } .flatMapLatest { it.movies }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) .stateIn(scope, Eagerly, emptyMap())
override val series: StateFlow<Map<UUID, Series>> = activeRepository override val series: StateFlow<Map<UUID, Series>> = activeRepository
.flatMapLatest { it.series } .flatMapLatest { it.series }
.stateIn(scope, SharingStarted.Companion.Eagerly, emptyMap()) .stateIn(scope, Eagerly, emptyMap())
override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository override val episodes: StateFlow<Map<UUID, Episode>> = activeRepository
.flatMapLatest { it.episodes } .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?> { override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository return activeRepository

View File

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

View File

@@ -1,17 +1,18 @@
package hu.bbara.purefin.data package hu.bbara.purefin.data
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.UUID import java.util.UUID
//TODO rename MediaRepository interface LocalMediaRepository : MediaProgressWriter {
interface MediaCatalogReader {
val movies: StateFlow<Map<UUID, Movie>> val movies: StateFlow<Map<UUID, Movie>>
val series: StateFlow<Map<UUID, Series>> val series: StateFlow<Map<UUID, Series>>
val episodes: StateFlow<Map<UUID, Episode>> val episodes: StateFlow<Map<UUID, Episode>>
val genres: StateFlow<Set<Genre>>
suspend fun getMovie(id: UUID): Flow<Movie?> suspend fun getMovie(id: UUID): Flow<Movie?>
suspend fun getSeries(id: UUID): Flow<Series?> suspend fun getSeries(id: UUID): Flow<Series?>
suspend fun getEpisode(id: UUID): Flow<Episode?> 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 androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.HomeRepository 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.data.UserSessionRepository
import hu.bbara.purefin.download.MediaDownloadController import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
@@ -34,7 +34,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AppViewModel @Inject constructor( class AppViewModel @Inject constructor(
private val homeRepository: HomeRepository, private val homeRepository: HomeRepository,
private val mediaCatalogReader: MediaCatalogReader, private val mediaCatalogReader: LocalMediaRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
@@ -57,6 +57,7 @@ class AppViewModel @Inject constructor(
name = it.name, name = it.name,
type = it.type, type = it.type,
posterUrl = it.posterUrl, posterUrl = it.posterUrl,
size = it.size,
isEmpty = when (it.type) { isEmpty = when (it.type) {
LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty() LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty()
LibraryKind.SERIES -> mediaCatalogReader.series.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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.DownloadState
import hu.bbara.purefin.download.MediaDownloadController import hu.bbara.purefin.download.MediaDownloadController
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
@@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class EpisodeScreenViewModel @Inject constructor( class EpisodeScreenViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader, private val mediaCatalogReader: LocalMediaRepository,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val mediaDownloadManager: MediaDownloadController, private val mediaDownloadManager: MediaDownloadController,
): ViewModel() { ): ViewModel() {

View File

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

View File

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

View File

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

View File

@@ -1,61 +1,63 @@
package hu.bbara.purefin.feature.search package hu.bbara.purefin.feature.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import hu.bbara.purefin.data.MediaCatalogReader import hu.bbara.purefin.data.SearchManager
import hu.bbara.purefin.data.UserSessionRepository import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.image.ImageUrlBuilder 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 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
@OptIn(FlowPreview::class)
class SearchViewModel @Inject constructor( class SearchViewModel @Inject constructor(
private val mediaCatalogReader: MediaCatalogReader, private val searchManager: SearchManager,
private val userSessionRepository: UserSessionRepository, private val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private val _searchResult = MutableStateFlow<List<SearchResult>>(emptyList()) val genres = searchManager.genres
val searchResult = _searchResult.asStateFlow() val searchResult = searchManager.searchResult
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)
}
fun search(query: String) { fun search(query: String) {
this.query.value = query searchManager.setSearchTerm(query)
} }
private suspend fun createImageUrl(id: UUID) : String { fun setSelectedGenres(genreNames: Set<String>) {
return ImageUrlBuilder.toImageUrl(userSessionRepository.serverUrl.first(), id, searchManager.setGenres(genreNames)
ArtworkKind.PRIMARY)
} }
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.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.data.CompositeMediaRepository import hu.bbara.purefin.data.CompositeLocalMediaRepository
import hu.bbara.purefin.data.MediaCatalogReader
import hu.bbara.purefin.data.MediaProgressWriter import hu.bbara.purefin.data.MediaProgressWriter
import hu.bbara.purefin.data.LocalMediaRepository
@Module @Module
@@ -14,8 +14,8 @@ import hu.bbara.purefin.data.MediaProgressWriter
abstract class RepositoryModule { abstract class RepositoryModule {
@Binds @Binds
abstract fun bindMediaCatalogReader(impl: CompositeMediaRepository): MediaCatalogReader abstract fun bindMediaRepository(impl: CompositeLocalMediaRepository): LocalMediaRepository
@Binds @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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.PlayableMedia
@@ -31,7 +31,7 @@ import javax.inject.Inject
class PlayerViewModel @Inject constructor( class PlayerViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val playerManager: PlayerManager, private val playerManager: PlayerManager,
private val mediaCatalogReader: MediaCatalogReader, private val mediaCatalogReader: LocalMediaRepository,
private val progressManager: ProgressManager, private val progressManager: ProgressManager,
) : ViewModel() { ) : ViewModel() {
companion object { companion object {

View File

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

View File

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

View File

@@ -1,14 +1,16 @@
package hu.bbara.purefin.data.catalog 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.data.offline.room.offline.OfflineRoomMediaLocalDataSource
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series import hu.bbara.purefin.model.Series
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -18,9 +20,9 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class OfflineMediaRepository @Inject constructor( class OfflineLocalMediaRepository @Inject constructor(
private val localDataSource: OfflineRoomMediaLocalDataSource, private val localDataSource: OfflineRoomMediaLocalDataSource,
) : MediaRepository { ) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override val movies: StateFlow<Map<UUID, Movie>> = localDataSource.moviesFlow 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 override val episodes: StateFlow<Map<UUID, Episode>> = localDataSource.episodesFlow
.stateIn(scope, SharingStarted.Eagerly, emptyMap()) .stateIn(scope, SharingStarted.Eagerly, emptyMap())
override var genres: StateFlow<Set<Genre>> = MutableStateFlow(emptySet())
override suspend fun getMovie(id: UUID): Flow<Movie?> { override suspend fun getMovie(id: UUID): Flow<Movie?> {
return movies.map { it[id] } 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.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.Genre
import hu.bbara.purefin.model.Library import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Movie import hu.bbara.purefin.model.Movie
@@ -26,6 +27,7 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
artworkKind = ArtworkKind.PRIMARY artworkKind = ArtworkKind.PRIMARY
), ),
type = LibraryKind.MOVIES, type = LibraryKind.MOVIES,
size = childCount ?: 0,
movies = emptyList(), movies = emptyList(),
) )
CollectionType.TVSHOWS -> Library( CollectionType.TVSHOWS -> Library(
@@ -37,6 +39,7 @@ fun BaseItemDto.toLibrary(serverUrl: String): Library {
artworkKind = ArtworkKind.PRIMARY artworkKind = ArtworkKind.PRIMARY
), ),
type = LibraryKind.SERIES, type = LibraryKind.SERIES,
size = childCount ?: 0,
series = emptyList(), series = emptyList(),
) )
else -> throw UnsupportedOperationException("Unsupported library type: $collectionType") 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 { fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
if (date == null) { if (date == null) {
return fallbackYear?.toString() ?: "" return fallbackYear?.toString() ?: ""

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) { suspend fun getLibraries(): List<BaseItemDto> = withContext(Dispatchers.IO) {
logApiFailure("getLibraries") { logApiFailure("getLibraries") {
if (!ensureConfigured()) { if (!ensureConfigured()) {

View File

@@ -7,10 +7,12 @@ import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.Offline import hu.bbara.purefin.Offline
import hu.bbara.purefin.Online import hu.bbara.purefin.Online
import hu.bbara.purefin.data.HomeRepository 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.InMemoryAppContentRepository
import hu.bbara.purefin.data.catalog.InMemoryMediaRepository import hu.bbara.purefin.data.catalog.InMemoryLocalMediaRepository
import hu.bbara.purefin.data.catalog.OfflineMediaRepository import hu.bbara.purefin.data.catalog.OfflineLocalMediaRepository
import hu.bbara.purefin.data.jellyfin.SearchManagerImpl
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -18,12 +20,15 @@ abstract class MediaRepositoryModule {
@Binds @Binds
@Online @Online
abstract fun bindOnlineRepository(impl: InMemoryMediaRepository): MediaRepository abstract fun bindOnlineRepository(impl: InMemoryLocalMediaRepository): LocalMediaRepository
@Binds @Binds
@Offline @Offline
abstract fun bindOfflineRepository(impl: OfflineMediaRepository): MediaRepository abstract fun bindOfflineRepository(impl: OfflineLocalMediaRepository): LocalMediaRepository
@Binds @Binds
abstract fun bindHomeRepository(impl: InMemoryAppContentRepository): HomeRepository 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 name: String,
val type: String, val type: String,
val posterUrl: String, val posterUrl: String,
val size: Int = 0,
val series: List<CachedSeries>? = null, val series: List<CachedSeries>? = null,
val movies: List<CachedMovie>? = null, val movies: List<CachedMovie>? = null,
) )
@@ -100,6 +101,7 @@ fun Library.toCachedLibrary() = CachedLibrary(
name = name, name = name,
type = type.name, type = type.name,
posterUrl = posterUrl, posterUrl = posterUrl,
size = size,
series = series?.map { it.toCachedSeries() }, series = series?.map { it.toCachedSeries() },
movies = movies?.map { it.toCachedMovie() }, movies = movies?.map { it.toCachedMovie() },
) )
@@ -112,6 +114,7 @@ fun CachedLibrary.toLibrary(): Library? {
name = name, name = name,
type = LibraryKind.MOVIES, type = LibraryKind.MOVIES,
posterUrl = posterUrl, posterUrl = posterUrl,
size = size,
movies = movies?.mapNotNull { it.toMovie() } ?: emptyList(), movies = movies?.mapNotNull { it.toMovie() } ?: emptyList(),
) )
"SERIES" -> Library( "SERIES" -> Library(
@@ -119,6 +122,7 @@ fun CachedLibrary.toLibrary(): Library? {
name = name, name = name,
type = LibraryKind.SERIES, type = LibraryKind.SERIES,
posterUrl = posterUrl, posterUrl = posterUrl,
size = size,
series = series?.mapNotNull { it.toSeries() } ?: emptyList(), series = series?.mapNotNull { it.toSeries() } ?: emptyList(),
) )
else -> null else -> null