Compare commits

..

11 Commits

21 changed files with 581 additions and 251 deletions

70
AGENTS.md Normal file
View File

@@ -0,0 +1,70 @@
# Purefin AGENTS.md
Follow these instructions before making changes:
* Understand the relevant files before editing.
* Always follow official Android recommendations and best practices.
* Always include the pages you have read in your reply, even in planning mode.
* Prefer small, targeted changes over broad refactors.
* Preserve existing architecture and naming unless the task requires a change.
* Do not introduce new dependencies without a clear reason.
* Ask for clarification or leave a note when requirements are ambiguous.
---
## Repository Expectations
* Always use the currently used syntax in the codebase. Include examples for any naming you introduce.
* Avoid touching unrelated files.
* Do not fix unrelated existing problems in the codebase.
* Prefer consistency with the existing codebase over idealized patterns.
* When modifying a file, match its local style and conventions.
* Favor readable, maintainable code over clever code.
---
## Testing
* Running a build is sufficient to verify your implementation.
* Do not write tests unless explicitly asked to.
---
## Git and Commit Rules
When asked to prepare a commit:
* Use **Conventional Commits** format.
* Format:
```
<type>(<scope>): <description>
```
* The description should not be too long, but must include any major change.
* Use imperative mood.
* Keep the subject concise.
**Common types:**
* `feat` new feature
* `fix` bug fix
* `refactor` code change without feature or fix
* `test` adding or updating tests
* `docs` documentation changes
* `chore` maintenance tasks
* `build` build system changes
* `ci` CI/CD changes
---
## Preferred Workflow
1. Read the task carefully.
2. Read relevant official Google/Android documentation.
3. Inspect the relevant files and nearby code.
4. Choose the simplest solution aligned with Googles standards.
5. Check if the code can be simplified:
* If yes, simplify it.
* Do not reduce readability.
6. Summarize the result, including anything not verified.

View File

@@ -22,7 +22,7 @@ import hu.bbara.purefin.model.Movie
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.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
@@ -385,9 +385,9 @@ class TvHomeContentTest {
)
}
private fun sampleLibraries(): List<LibraryItem> {
private fun sampleLibraries(): List<LibraryUiModel> {
return listOf(
LibraryItem(
LibraryUiModel(
id = sampleLibraryId(),
name = "Movies",
type = LibraryKind.MOVIES,

View File

@@ -5,7 +5,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.theme.AppTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -20,14 +20,14 @@ class TvLibrariesOverviewScreenTest {
@Test
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
val libraries = listOf(
LibraryItem(
LibraryUiModel(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
name = "Movies",
type = LibraryKind.MOVIES,
posterUrl = "",
isEmpty = false
),
LibraryItem(
LibraryUiModel(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
name = "Shows",
type = LibraryKind.SERIES,
@@ -35,7 +35,7 @@ class TvLibrariesOverviewScreenTest {
isEmpty = false
)
)
var openedLibrary: LibraryItem? = null
var openedLibrary: LibraryUiModel? = null
composeRule.setContent {
AppTheme {

View File

@@ -13,14 +13,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import java.util.UUID
@Composable
fun TvHomeScreen(
libraries: List<LibraryItem>,
libraries: List<LibraryUiModel>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,

View File

@@ -24,7 +24,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@@ -32,7 +32,7 @@ internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
@Composable
fun TvHomeContent(
libraries: List<LibraryItem>,
libraries: List<LibraryUiModel>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,

View File

@@ -1,11 +1,9 @@
package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
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.Row
import androidx.compose.foundation.layout.Spacer
@@ -13,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -38,9 +35,6 @@ import androidx.tv.material3.MaterialTheme as TvMaterialTheme
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
internal const val TvDrawerTitleTag = "tv-drawer-title"
private val TvDrawerCollapsedWidth = 92.dp
private val TvDrawerExpandedWidth = 280.dp
@Composable
fun TvNavigationDrawer(
destinations: List<TvDrawerDestinationItem>,
@@ -52,9 +46,9 @@ fun TvNavigationDrawer(
ProvideTvDrawerTheme {
NavigationDrawer(
modifier = modifier.fillMaxSize(),
drawerContent = {
drawerContent = { drawerValue ->
TvNavigationDrawerRail(
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
drawerValue = drawerValue,
destinations = destinations,
selectedDestination = selectedDestination,
onDestinationSelected = onDestinationSelected
@@ -137,54 +131,42 @@ private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
onDestinationSelected: (Route) -> Unit,
) {
val expanded = drawerValue == DrawerValue.Open
val drawerWidth = animateDpAsState(
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
label = "tv-drawer-width"
)
val scheme = MaterialTheme.colorScheme
Box(
Column(
modifier = Modifier
.width(drawerWidth.value)
.fillMaxHeight()
.background(scheme.surface.copy(alpha = 0.96f))
.padding(horizontal = 12.dp, vertical = 24.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 24.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TvDrawerHeader(
expanded = expanded,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
)
destinations.forEachIndexed { index, destination ->
val isSelected = destination.destination == selectedDestination
NavigationDrawerItem(
selected = isSelected,
onClick = { onDestinationSelected(destination.destination) },
modifier = Modifier
.testTag("$TvDrawerItemTagPrefix$index")
.semantics { selected = isSelected },
leadingContent = {
Icon(
imageVector = destination.icon,
contentDescription = destination.label
)
}
) {
if (expanded) {
Text(
text = destination.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TvDrawerHeader(
expanded = expanded,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
)
destinations.forEachIndexed { index, destination ->
val isSelected = destination.destination == selectedDestination
NavigationDrawerItem(
selected = isSelected,
onClick = { onDestinationSelected(destination.destination) },
modifier = Modifier
.testTag("$TvDrawerItemTagPrefix$index")
.semantics { selected = isSelected },
leadingContent = {
Icon(
imageVector = destination.icon,
contentDescription = destination.label
)
}
) {
Text(
text = destination.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.weight(1f))
}
}

View File

@@ -43,7 +43,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.model.LibraryKind
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
@@ -52,8 +52,8 @@ private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
@Composable
fun TvLibrariesOverviewScreen(
libraries: List<LibraryItem>,
onLibrarySelected: (LibraryItem) -> Unit,
libraries: List<LibraryUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
@@ -117,7 +117,7 @@ fun TvLibrariesOverviewScreen(
@Composable
private fun TvLibraryOverviewCard(
item: LibraryItem,
item: LibraryUiModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {

View File

@@ -5,7 +5,7 @@ import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import org.junit.Assert.assertEquals
@@ -104,8 +104,8 @@ class TvHomeHeroStateTest {
assertEquals(continueWatching, registry.firstAvailableItem)
}
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryItem {
return LibraryItem(
private fun sampleLibrary(id: UUID, isEmpty: Boolean = false): LibraryUiModel {
return LibraryUiModel(
id = id,
name = "Movies",
type = LibraryKind.MOVIES,

View File

@@ -1,10 +1,8 @@
package hu.bbara.purefin.ui.screen
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -12,7 +10,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.navigation3.runtime.NavBackStack
@@ -20,13 +17,11 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.libraries.LibrariesScreen
import kotlinx.serialization.Serializable
@@ -48,14 +43,6 @@ fun AppScreen(
val currentRoute = backStack.lastOrNull() ?: AppTabRoute.Home
val selectedTab = currentRoute.toTabIndex()
val libraryNavItems = libraries.map {
HomeNavItem(
id = it.id,
label = it.name,
posterUrl = it.posterUrl
)
}
LifecycleResumeEffect(Unit) {
viewModel.onResumed()
onPauseOrDispose { }
@@ -97,8 +84,8 @@ fun AppScreen(
}
entry<AppTabRoute.Libraries>(metadata = appTabMetadata(AppTabRoute.Libraries)) {
LibrariesScreen(
items = libraryNavItems,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
items = libraries,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.name) },
selectedTab = selectedTab,
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
@@ -125,9 +112,9 @@ fun AppScreen(
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator()
),
transitionSpec = { appTabTransition() },
popTransitionSpec = { appTabTransition() },
predictivePopTransitionSpec = { _ -> appTabTransition() },
transitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
popTransitionSpec = { fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
predictivePopTransitionSpec = { _ -> fadeIn(tween(220)) togetherWith fadeOut(tween(220)) },
entryProvider = tabEntryProvider,
modifier = modifier.fillMaxSize()
)
@@ -158,27 +145,6 @@ private fun Int.toAppTabRoute(): AppTabRoute = when (this) {
else -> AppTabRoute.Home
}
private fun AnimatedContentTransitionScope<Scene<AppTabRoute>>.appTabTransition(): ContentTransform {
val initialIndex = initialState.metadata.appTabIndex()
val targetIndex = targetState.metadata.appTabIndex()
val animationSpec = tween<IntOffset>(durationMillis = 140)
return when {
targetIndex > initialIndex -> {
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth }
}
targetIndex < initialIndex -> {
slideInHorizontally(animationSpec = animationSpec) { fullWidth -> -fullWidth } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { fullWidth -> fullWidth }
}
else -> {
slideInHorizontally(animationSpec = animationSpec) { 0 } togetherWith
slideOutHorizontally(animationSpec = animationSpec) { 0 }
}
}
}
private fun appTabMetadata(route: AppTabRoute): Map<String, Any> =
mapOf(APP_TAB_INDEX_METADATA to route.toTabIndex())

View File

@@ -12,7 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.HomeContent
@@ -23,7 +23,7 @@ import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
libraries: List<LibraryItem>,
libraries: List<LibraryUiModel>,
libraryContent: Map<UUID, List<MediaUiModel>>,
suggestions: List<MediaUiModel>,
continueWatching: List<MediaUiModel>,
@@ -31,7 +31,7 @@ fun HomeScreen(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit,
onProfileClick: () -> Unit,
onSettingsClick: () -> Unit,
onLogoutClick: () -> Unit,

View File

@@ -22,7 +22,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection
@@ -32,7 +32,7 @@ import java.util.UUID
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeContent(
libraries: List<LibraryItem>,
libraries: List<LibraryUiModel>,
libraryContent: Map<UUID, List<MediaUiModel>>,
suggestions: List<MediaUiModel>,
continueWatching: List<MediaUiModel>,
@@ -40,7 +40,7 @@ fun HomeContent(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
onLibrarySelected: (LibraryItem) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit,
onBrowseLibrariesClick: () -> Unit,
modifier: Modifier = Modifier
) {

View File

@@ -1,15 +0,0 @@
package hu.bbara.purefin.ui.screen.home.components
import java.util.UUID
data class HomeNavItem(
val id: UUID,
val label: String,
val posterUrl: String,
val selected: Boolean = false
)
data class HomeUser(
val name: String,
val plan: String
)

View File

@@ -12,14 +12,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.common.header.SectionHeader
@Composable
fun LibraryPosterSection(
library: LibraryItem,
library: LibraryUiModel,
items: List<MediaUiModel>,
onLibrarySelected: (LibraryItem) -> Unit,
onLibrarySelected: (LibraryUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
) {

View File

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

View File

@@ -6,15 +6,15 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.screen.libraries.components.LibrariesContent
import hu.bbara.purefin.ui.screen.library.components.LibraryTopBar
@Composable
fun LibrariesScreen(
items: List<HomeNavItem>,
onLibrarySelected: (HomeNavItem) -> Unit,
items: List<LibraryUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit,
selectedTab: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier

View File

@@ -9,12 +9,12 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.model.LibraryUiModel
@Composable
fun LibrariesContent(
items: List<HomeNavItem>,
onLibrarySelected: (HomeNavItem) -> Unit,
items: List<LibraryUiModel>,
onLibrarySelected: (LibraryUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
LazyVerticalGrid(
@@ -26,7 +26,7 @@ fun LibrariesContent(
) {
items(items, key = { it.id }) { item ->
LibraryListItem(
item = item,
uiModel = item,
modifier = Modifier.clickable {
onLibrarySelected(item)
}

View File

@@ -11,18 +11,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.LibraryUiModel
@Composable
fun LibraryListItem(
item: HomeNavItem,
uiModel: LibraryUiModel,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.fillMaxWidth()) {
PurefinAsyncImage(
model = item.posterUrl,
contentDescription = item.label,
model = uiModel.posterUrl,
contentDescription = uiModel.name,
contentScale = ContentScale.Inside,
modifier = Modifier
.fillMaxWidth()
@@ -30,7 +30,7 @@ fun LibraryListItem(
.clip(RoundedCornerShape(24.dp))
)
Text(
text = item.label,
text = uiModel.name,
style = MaterialTheme.typography.displaySmall
)
}

View File

@@ -16,6 +16,7 @@ import hu.bbara.purefin.navigation.NavigationManager
import hu.bbara.purefin.navigation.Route
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.ui.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel
@@ -51,12 +52,12 @@ class AppViewModel @Inject constructor(
val libraries = homeRepository.libraries.map { libraries ->
libraries.map {
LibraryItem(
LibraryUiModel(
id = it.id,
name = it.name,
type = it.type,
posterUrl = it.posterUrl,
isEmpty = when(it.type) {
isEmpty = when (it.type) {
LibraryKind.MOVIES -> mediaCatalogReader.movies.value.isEmpty()
LibraryKind.SERIES -> mediaCatalogReader.series.value.isEmpty()
}

View File

@@ -1,106 +0,0 @@
package hu.bbara.purefin.feature.browse.home
import hu.bbara.purefin.image.ArtworkKind
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.model.Episode
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.MediaKind
import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.model.Series
import java.util.UUID
sealed interface FocusableItem {
val imageUrl: String
val primaryText: String
val secondaryText: String
val description: String
val id: UUID
val type: MediaKind
}
data class NextUpItem(
val episode: Episode
) : FocusableItem {
override val id: UUID = episode.id
override val type: MediaKind
get() = MediaKind.EPISODE
override val imageUrl: String
get() = ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
override val primaryText: String = episode.title
override val secondaryText: String = episode.releaseDate
override val description: String
get() = episode.synopsis
}
data class LibraryItem(
val id: UUID,
val name: String,
val type: LibraryKind,
val posterUrl: String,
val isEmpty: Boolean
)
data class PosterItem(
override val type: MediaKind,
val movie: Movie? = null,
val series: Series? = null,
val episode: Episode? = null
) : FocusableItem {
override val id: UUID = when (type) {
MediaKind.MOVIE -> movie!!.id
MediaKind.EPISODE -> episode!!.id
MediaKind.SERIES -> series!!.id
else -> throw IllegalArgumentException("Invalid type: $type")
}
val title: String = when (type) {
MediaKind.MOVIE -> movie!!.title
MediaKind.EPISODE -> episode!!.title
MediaKind.SERIES -> series!!.name
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val imageUrl: String = when (type) {
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = movie!!.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = episode!!.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.SERIES -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = series!!.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val primaryText: String
get() = when (type) {
MediaKind.MOVIE -> movie!!.title
MediaKind.EPISODE -> episode!!.title
MediaKind.SERIES -> series!!.name
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val secondaryText: String
get() = when (type) {
MediaKind.MOVIE -> movie!!.year
MediaKind.EPISODE -> episode!!.releaseDate
MediaKind.SERIES -> series!!.year
else -> throw IllegalArgumentException("Invalid type: $type")
}
override val description: String
get() = when (type) {
MediaKind.MOVIE -> movie!!.synopsis
MediaKind.EPISODE -> episode!!.synopsis
MediaKind.SERIES -> series!!.synopsis
else -> throw IllegalArgumentException("Invalid type: $type")
}
fun watched() = when (type) {
MediaKind.MOVIE -> movie!!.watched
MediaKind.EPISODE -> episode!!.watched
else -> throw IllegalArgumentException("Invalid type: $type")
}
}

View File

@@ -0,0 +1,12 @@
package hu.bbara.purefin.ui.model
import hu.bbara.purefin.model.LibraryKind
import java.util.UUID
data class LibraryUiModel(
val id: UUID,
val name: String,
val type: LibraryKind,
val posterUrl: String,
val isEmpty: Boolean
)

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
@@ -250,6 +251,18 @@ class JellyfinApiClient @Inject constructor(
nextUpEpisodes
}
suspend fun getGenres(id: UUID? = null) : List<BaseItemDto> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) {
return@withContext emptyList()
}
val result = api.genresApi.getGenres(
userId = getUserId(),
parentId = id,
)
Log.d("getGenres", result.toString())
result.content.items
}
suspend fun getMediaSources(mediaId: UUID): List<MediaSourceInfo> = withContext(Dispatchers.IO) {
if (!ensureConfigured()) {
return@withContext emptyList()