Compare commits
21 Commits
0f0c86dcf9
...
ae4c2b34f4
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4c2b34f4 | |||
| f957098369 | |||
| 8bf3f08df0 | |||
| efd7593646 | |||
| 0edac36ca8 | |||
| 53bc6246e4 | |||
| 2753a45645 | |||
| fbcba6fbfc | |||
| dbac1b56c5 | |||
| 1f0c192460 | |||
| a0b573cbfe | |||
| 1a2687f1a2 | |||
| 803edcb80c | |||
| e55bd320d1 | |||
| fe1d449fe0 | |||
| f626c9533e | |||
| 6433225014 | |||
| 36e89f35d9 | |||
| 217e8df417 | |||
| 672d8e1d64 | |||
| c6fe84654f |
@@ -392,6 +392,7 @@ class TvHomeContentTest {
|
|||||||
name = "Movies",
|
name = "Movies",
|
||||||
type = LibraryKind.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = false
|
isEmpty = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,6 +110,7 @@ class TvHomeHeroStateTest {
|
|||||||
name = "Movies",
|
name = "Movies",
|
||||||
type = LibraryKind.MOVIES,
|
type = LibraryKind.MOVIES,
|
||||||
posterUrl = "",
|
posterUrl = "",
|
||||||
|
size = 1,
|
||||||
isEmpty = isEmpty
|
isEmpty = isEmpty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
modifier = modifier,
|
||||||
|
tint = color
|
||||||
logoBitmap?.let { bitmap ->
|
)
|
||||||
Image(
|
|
||||||
bitmap = bitmap,
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
contentScale = contentScale,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultTopBarIconButton(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = IconButtonColors(
|
||||||
|
containerColor = scheme.surface,
|
||||||
|
contentColor = scheme.onSurface,
|
||||||
|
disabledContainerColor = scheme.surface,
|
||||||
|
disabledContentColor = scheme.onSurface
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(end = 12.dp)
|
modifier = Modifier
|
||||||
)
|
.size(50.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVector,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.size(30.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
imageVector = Icons.Outlined.Person,
|
||||||
|
contentDescription = "Profile",
|
||||||
onClick = { isProfileMenuExpanded = true },
|
onClick = { isProfileMenuExpanded = true },
|
||||||
colors = IconButtonColors(
|
)
|
||||||
containerColor = scheme.surface,
|
|
||||||
contentColor = scheme.onSurface,
|
|
||||||
disabledContainerColor = scheme.surface,
|
|
||||||
disabledContentColor = scheme.onSurface
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(50.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Person,
|
|
||||||
contentDescription = "Profile",
|
|
||||||
modifier = Modifier.size(30.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = isProfileMenuExpanded,
|
expanded = isProfileMenuExpanded,
|
||||||
onDismissRequest = { isProfileMenuExpanded = false },
|
onDismissRequest = { isProfileMenuExpanded = false },
|
||||||
@@ -81,4 +60,5 @@ fun HomeTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,67 +29,38 @@ 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(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.matchParentSize()
|
||||||
.homeMediaSharedBoundsSource(sharedBoundsKey)
|
.background(
|
||||||
.fillMaxWidth()
|
Brush.verticalGradient(
|
||||||
.aspectRatio(16f / 9f)
|
colors = listOf(
|
||||||
.background(scheme.surfaceContainer)
|
Color.Transparent,
|
||||||
) {
|
Color.Black.copy(alpha = 0.08f),
|
||||||
PurefinAsyncImage(
|
Color.Black.copy(alpha = 0.38f)
|
||||||
model = item.primaryImageUrl,
|
|
||||||
contentDescription = item.primaryText,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.08f),
|
|
||||||
Color.Black.copy(alpha = 0.38f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
item.progress?.let { progress ->
|
|
||||||
MediaProgressBar(
|
|
||||||
progress = progress,
|
|
||||||
foregroundColor = scheme.primary,
|
|
||||||
backgroundColor = Color.White.copy(alpha = 0.24f),
|
|
||||||
modifier = Modifier.align(Alignment.BottomStart)
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = item.primaryText,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
Text(
|
)
|
||||||
text = item.secondaryText,
|
item.progress?.let { progress ->
|
||||||
style = MaterialTheme.typography.bodySmall,
|
MediaProgressBar(
|
||||||
color = scheme.onSurfaceVariant,
|
progress = progress,
|
||||||
maxLines = 1,
|
foregroundColor = scheme.primary,
|
||||||
overflow = TextOverflow.Ellipsis
|
backgroundColor = Color.White.copy(alpha = 0.24f),
|
||||||
)
|
modifier = Modifier.align(Alignment.BottomStart)
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -74,7 +74,7 @@ internal fun SuggestionCard(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -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,64 +21,31 @@ 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()) {
|
when (uiModel) {
|
||||||
Box(
|
is MovieUiModel, is EpisodeUiModel -> {
|
||||||
modifier = Modifier
|
WatchStateBadge(
|
||||||
.homeMediaSharedBoundsSource(sharedBoundsKey)
|
size = 28,
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.aspectRatio(16f / 10f)
|
.align(Alignment.TopEnd)
|
||||||
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
.padding(8.dp),
|
||||||
.background(scheme.surface)
|
watched = uiModel.watched,
|
||||||
) {
|
started = (uiModel.progress ?: 0f) > 0f
|
||||||
PurefinAsyncImage(
|
|
||||||
model = uiModel.primaryImageUrl,
|
|
||||||
contentDescription = uiModel.primaryText,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
when (uiModel) {
|
|
||||||
is MovieUiModel, is EpisodeUiModel -> {
|
|
||||||
WatchStateBadge(
|
|
||||||
size = 28,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.padding(8.dp),
|
|
||||||
watched = uiModel.watched,
|
|
||||||
started = (uiModel.progress ?: 0f) > 0f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = uiModel.primaryText,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = uiModel.secondaryText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = scheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,64 +22,33 @@ 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(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.matchParentSize()
|
||||||
.homeMediaSharedBoundsSource(sharedBoundsKey)
|
.background(
|
||||||
.fillMaxWidth()
|
Brush.verticalGradient(
|
||||||
.aspectRatio(16f / 10f)
|
colors = listOf(
|
||||||
.background(scheme.surface)
|
Color.Transparent,
|
||||||
) {
|
Color.Transparent,
|
||||||
PurefinAsyncImage(
|
Color.Black.copy(alpha = 0.26f)
|
||||||
model = uiModel.primaryImageUrl,
|
|
||||||
contentDescription = uiModel.primaryText,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Transparent,
|
|
||||||
Color.Black.copy(alpha = 0.26f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,20 +17,21 @@ fun LibrariesContent(
|
|||||||
onLibrarySelected: (LibraryUiModel) -> Unit,
|
onLibrarySelected: (LibraryUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
BoxWithConstraints(modifier = modifier) {
|
||||||
modifier = modifier,
|
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 160.dp
|
||||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
LazyVerticalGrid(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
columns = GridCells.Adaptive(minSize = minCellSize),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
items(items, key = { it.id }) { item ->
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
LibraryListItem(
|
) {
|
||||||
uiModel = item,
|
items(items, key = { it.id }) { item ->
|
||||||
modifier = Modifier.clickable {
|
LibraryListItem(
|
||||||
onLibrarySelected(item)
|
uiModel = item,
|
||||||
}
|
onClick = { onLibrarySelected(item) }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
BoxWithConstraints(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
|
||||||
columns = GridCells.Adaptive(minSize = 120.dp),
|
val minCellSize = if (maxWidth >= 600.dp) 220.dp else 120.dp
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
LazyVerticalGrid(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
columns = GridCells.Adaptive(minSize = minCellSize),
|
||||||
modifier = modifier.background(MaterialTheme.colorScheme.background)
|
contentPadding = PaddingValues(16.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
items(libraryItems, key = { item -> item.id }) { item ->
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
PosterCard(
|
) {
|
||||||
item = item,
|
items(libraryItems, key = { item -> item.id }) { item ->
|
||||||
onMovieSelected = viewModel::onMovieSelected,
|
MediaImageCard(
|
||||||
onSeriesSelected = viewModel::onSeriesSelected,
|
imageUrl = item.primaryImageUrl,
|
||||||
onEpisodeSelected = { _, _, _ -> }
|
title = item.primaryText,
|
||||||
)
|
subtitle = item.secondaryText,
|
||||||
|
onClick = {
|
||||||
|
when (item) {
|
||||||
|
is MovieUiModel -> viewModel.onMovieSelected(item.id)
|
||||||
|
is SeriesUiModel -> viewModel.onSeriesSelected(item.id)
|
||||||
|
is EpisodeUiModel -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when (item) {
|
||||||
|
is MovieUiModel, is EpisodeUiModel -> {
|
||||||
|
WatchStateBadge(
|
||||||
|
size = 28,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(8.dp),
|
||||||
|
watched = item.watched,
|
||||||
|
started = (item.progress ?: 0f) > 0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
21
app/src/main/res/drawable/purefin_logo_launcher.xml
Normal 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>
|
||||||
21
app/src/main/res/drawable/purefin_logo_tintable.xml
Normal 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>
|
||||||
4
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 242 KiB |
@@ -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>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="purefin_logo_background">#201337</color>
|
|
||||||
</resources>
|
|
||||||
5
core-model/src/main/java/hu/bbara/purefin/model/Genre.kt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package hu.bbara.purefin.model
|
||||||
|
|
||||||
|
data class Genre(
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?>
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package hu.bbara.purefin.data
|
|
||||||
|
|
||||||
interface MediaRepository : MediaCatalogReader, MediaProgressWriter {
|
|
||||||
}
|
|
||||||
12
core/src/main/java/hu/bbara/purefin/data/SearchManager.kt
Normal 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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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,29 +88,28 @@ 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
|
runCatching {
|
||||||
}
|
Log.d(TAG, "Refreshing home data")
|
||||||
val job = scope.launch {
|
if (!networkMonitor.isOnline.first()) {
|
||||||
runCatching {
|
return@runCatching
|
||||||
Log.d(TAG, "Refreshing home data")
|
}
|
||||||
if (!networkMonitor.isOnline.first()) {
|
loadLibraries()
|
||||||
return@runCatching
|
loadSuggestions()
|
||||||
|
loadContinueWatching()
|
||||||
|
loadNextUp()
|
||||||
|
loadLatestLibraryContent()
|
||||||
|
loadGenres()
|
||||||
|
Log.d(TAG, "Home refresh successful")
|
||||||
|
persistHomeCache()
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.w(TAG, "Home refresh failed; keeping cached content", error)
|
||||||
}
|
}
|
||||||
loadLibraries()
|
}.also { refreshJob = it }
|
||||||
loadSuggestions()
|
|
||||||
loadContinueWatching()
|
|
||||||
loadNextUp()
|
|
||||||
loadLatestLibraryContent()
|
|
||||||
Log.d(TAG, "Home refresh successful")
|
|
||||||
persistHomeCache()
|
|
||||||
}.onFailure { error ->
|
|
||||||
Log.w(TAG, "Home refresh failed; keeping cached content", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }
|
||||||
}
|
}
|
||||||
@@ -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] }
|
||||||
}
|
}
|
||||||
@@ -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() ?: "—"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||