Compare commits

..

4 Commits

254 changed files with 2761 additions and 3475 deletions

View File

@@ -52,9 +52,20 @@ kotlin {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:image"))
implementation(project(":core:ui"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:navigation"))
implementation(project(":core:download"))
implementation(project(":core:player")) implementation(project(":core:player"))
implementation(project(":feature:shared")) implementation(project(":data:jellyfin"))
implementation(project(":data:offline"))
implementation(project(":data:catalog"))
implementation(project(":feature:browse"))
implementation(project(":feature:search"))
implementation(project(":feature:content"))
implementation(project(":feature:downloads"))
implementation(project(":feature:login"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -83,8 +94,6 @@ dependencies {
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.ui.common.media
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.episode package hu.bbara.purefin.ui.screen.episode
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
@@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.screen.episode.components.EpisodePlayButtonTag
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,15 +17,15 @@ import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -298,7 +298,7 @@ class TvHomeContentTest {
private fun sampleContinueWatching(): List<ContinueWatchingItem> { private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf( return listOf(
ContinueWatchingItem( ContinueWatchingItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = sampleMovie( movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111", id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049", title = "Blade Runner 2049",
@@ -311,7 +311,7 @@ class TvHomeContentTest {
private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> { private fun sampleContinueWatchingRow(): List<ContinueWatchingItem> {
return listOf( return listOf(
ContinueWatchingItem( ContinueWatchingItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = sampleMovie( movie = sampleMovie(
id = "11111111-1111-1111-1111-111111111111", id = "11111111-1111-1111-1111-111111111111",
title = "Blade Runner 2049", title = "Blade Runner 2049",
@@ -319,7 +319,7 @@ class TvHomeContentTest {
) )
), ),
ContinueWatchingItem( ContinueWatchingItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = sampleMovie( movie = sampleMovie(
id = "55555555-5555-5555-5555-555555555555", id = "55555555-5555-5555-5555-555555555555",
title = "Mad Max: Fury Road", title = "Mad Max: Fury Road",
@@ -363,7 +363,7 @@ class TvHomeContentTest {
LibraryItem( LibraryItem(
id = sampleLibraryId(), id = sampleLibraryId(),
name = "Movies", name = "Movies",
type = CollectionType.MOVIES, type = LibraryKind.MOVIES,
posterUrl = "", posterUrl = "",
isEmpty = false isEmpty = false
) )
@@ -376,7 +376,7 @@ class TvHomeContentTest {
return mapOf( return mapOf(
libraryId to listOf( libraryId to listOf(
PosterItem( PosterItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = sampleMovie( movie = sampleMovie(
id = "44444444-4444-4444-4444-444444444444", id = "44444444-4444-4444-4444-444444444444",
title = "Arrival", title = "Arrival",

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box

View File

@@ -1,12 +1,12 @@
package hu.bbara.purefin.tv.library.ui package hu.bbara.purefin.ui.screen.libraries
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -23,14 +23,14 @@ class TvLibrariesOverviewScreenTest {
LibraryItem( LibraryItem(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"), id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
name = "Movies", name = "Movies",
type = CollectionType.MOVIES, type = LibraryKind.MOVIES,
posterUrl = "", posterUrl = "",
isEmpty = false isEmpty = false
), ),
LibraryItem( LibraryItem(
id = UUID.fromString("22222222-2222-2222-2222-222222222222"), id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
name = "Shows", name = "Shows",
type = CollectionType.TVSHOWS, type = LibraryKind.SERIES,
posterUrl = "", posterUrl = "",
isEmpty = false isEmpty = false
) )

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.movie package hu.bbara.purefin.ui.screen.movie
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
@@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.Key
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.screen.movie.components.MoviePlayButtonTag
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -44,10 +44,10 @@ import hu.bbara.purefin.core.player.model.PlayerUiState
import hu.bbara.purefin.core.player.model.QueueItemUi import hu.bbara.purefin.core.player.model.QueueItemUi
import hu.bbara.purefin.core.player.model.TrackOption import hu.bbara.purefin.core.player.model.TrackOption
import hu.bbara.purefin.core.player.model.TrackType import hu.bbara.purefin.core.player.model.TrackType
import hu.bbara.purefin.tv.player.TV_HIDDEN_STOP_FEEDBACK_MS import hu.bbara.purefin.ui.screen.player.TV_HIDDEN_STOP_FEEDBACK_MS
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedback import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedback
import hu.bbara.purefin.tv.player.TvPlayerHiddenStopFeedbackTag import hu.bbara.purefin.ui.screen.player.TvPlayerHiddenStopFeedbackTag
import hu.bbara.purefin.tv.player.handleTvPlayerRootKeyEvent import hu.bbara.purefin.ui.screen.player.handleTvPlayerRootKeyEvent
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.junit.Rule import org.junit.Rule

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.series package hu.bbara.purefin.ui.screen.series
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
@@ -16,6 +16,8 @@ import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
import hu.bbara.purefin.ui.screen.series.components.SeriesPlayButtonTag
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test

View File

@@ -14,7 +14,7 @@
android:required="true" /> android:required="true" />
<application <application
android:name=".TvApplication" android:name="hu.bbara.purefin.tv.TvApplication"
android:allowBackup="true" android:allowBackup="true"
android:banner="@drawable/banner" android:banner="@drawable/banner"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -25,7 +25,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Purefin"> android:theme="@style/Theme.Purefin">
<activity <activity
android:name=".TvActivity" android:name="hu.bbara.purefin.tv.TvActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.Purefin"> android:theme="@style/Theme.Purefin">
<intent-filter> <intent-filter>

View File

@@ -1,131 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
import hu.bbara.purefin.core.model.CastMember
@Composable
fun MediaMetaChip(
text: String,
background: Color = MaterialTheme.colorScheme.surfaceVariant,
border: Color = Color.Transparent,
textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.height(28.dp)
.wrapContentHeight(Alignment.CenterVertically)
.clip(RoundedCornerShape(6.dp))
.background(background)
.border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp))
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = textColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
fun MediaCastRow(
cast: List<CastMember>,
modifier: Modifier = Modifier,
cardWidth: Dp = 96.dp,
nameSize: TextUnit = 12.sp,
roleSize: TextUnit = 10.sp
) {
val scheme = MaterialTheme.colorScheme
val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f)
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(cast) { member ->
Column(modifier = Modifier.width(cardWidth)) {
Box(
modifier = Modifier
.aspectRatio(4f / 5f)
.clip(RoundedCornerShape(12.dp))
.background(scheme.surfaceVariant)
) {
if (member.imageUrl == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(scheme.surfaceVariant.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Person,
contentDescription = null,
tint = mutedStrong
)
}
} else {
PurefinAsyncImage(
model = member.imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = scheme.onBackground,
fontSize = nameSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = mutedStrong,
fontSize = roleSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,75 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
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.TextUnit
import androidx.compose.ui.unit.TextUnit.Companion.Unspecified
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun MediaSynopsis(
synopsis: String,
modifier: Modifier = Modifier,
title: String = "Synopsis",
titleColor: Color = MaterialTheme.colorScheme.onBackground,
bodyColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
titleFontSize: TextUnit = 18.sp,
bodyFontSize: TextUnit = 15.sp,
bodyLineHeight: TextUnit? = 22.sp,
titleSpacing: Dp = 12.dp,
collapsedLines: Int = 3,
collapseInitially: Boolean = true
) {
var isExpanded by remember(synopsis) { mutableStateOf(!collapseInitially) }
var isOverflowing by remember(synopsis) { mutableStateOf(false) }
val containerModifier = if (isOverflowing) {
modifier.clickable(role = Role.Button) { isExpanded = !isExpanded }
} else {
modifier
}
Column(modifier = containerModifier) {
Text(
text = title,
color = titleColor,
fontSize = titleFontSize,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(titleSpacing))
Text(
text = synopsis,
color = bodyColor,
fontSize = bodyFontSize,
lineHeight = bodyLineHeight ?: Unspecified,
maxLines = if (isExpanded) Int.MAX_VALUE else collapsedLines,
overflow = if (isExpanded) TextOverflow.Clip else TextOverflow.Ellipsis,
onTextLayout = { result ->
val overflowed = if (isExpanded) {
result.lineCount > collapsedLines
} else {
result.hasVisualOverflow || result.lineCount > collapsedLines
}
if (overflowed != isOverflowing) {
isOverflowing = overflowed
}
}
)
}
}

View File

@@ -1,68 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinComplexTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector? = null,
trailingIcon: ImageVector? = null,
visualTransformation: VisualTransformation = VisualTransformation.None
) {
val scheme = MaterialTheme.colorScheme
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
leadingIcon = if (leadingIcon != null) {
{ Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }
} else null,
trailingIcon = if (trailingIcon != null) {
{ Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) }
} else null,
visualTransformation = visualTransformation,
colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceVariant,
unfocusedContainerColor = scheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface
))
}
}

View File

@@ -1,76 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinPasswordField(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
leadingIcon: ImageVector,
) {
val scheme = MaterialTheme.colorScheme
val showField = remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) },
trailingIcon =
{
IconButton(
onClick = { showField.value = !showField.value },
) {
Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant)
}
},
visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(),
colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceVariant,
unfocusedContainerColor = scheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface
)
)
}
}

View File

@@ -1,26 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun PurefinTextButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit // Slot API
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
content = content
)
}

View File

@@ -1,199 +0,0 @@
package hu.bbara.purefin.common.ui
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PurefinWaitingScreen(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val accentColor = scheme.primary
val backgroundColor = scheme.background
val surfaceColor = scheme.surface
val textPrimary = scheme.onSurface
val textSecondary = scheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "waiting-pulse")
val pulseScale = transition.animateFloat(
initialValue = 0.9f,
targetValue = 1.15f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse-scale"
)
val pulseAlpha = transition.animateFloat(
initialValue = 0.2f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulse-alpha"
)
val gradient = Brush.radialGradient(
colors = listOf(
accentColor.copy(alpha = 0.28f),
backgroundColor
)
)
Box(
modifier = modifier
.fillMaxSize()
.background(gradient)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.background(surfaceColor.copy(alpha = 0.92f))
.padding(horizontal = 28.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(86.dp)
.graphicsLayer {
scaleX = pulseScale.value
scaleY = pulseScale.value
}
.alpha(pulseAlpha.value)
.border(
width = 2.dp,
color = accentColor.copy(alpha = 0.6f),
shape = RoundedCornerShape(26.dp)
)
)
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(22.dp))
.background(accentColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Movie,
contentDescription = null,
tint = scheme.onPrimary,
modifier = Modifier.size(40.dp)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Just a moment",
color = textPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "I am doing all I can...",
color = textSecondary,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(24.dp))
WaitingDots(accentColor = accentColor)
}
}
}
@Composable
private fun WaitingDots(accentColor: Color, modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "waiting-dots")
val firstAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 0, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-1"
)
val secondAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 140, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-2"
)
val thirdAlpha = transition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 700, delayMillis = 280, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot-3"
)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
WaitingDot(alpha = firstAlpha.value, color = accentColor)
WaitingDot(alpha = secondAlpha.value, color = accentColor)
WaitingDot(alpha = thirdAlpha.value, color = accentColor)
}
}
@Composable
private fun WaitingDot(alpha: Float, color: Color) {
Box(
modifier = Modifier
.size(10.dp)
.graphicsLayer {
val scale = 0.7f + (alpha * 0.3f)
scaleX = scale
scaleY = scale
}
.alpha(alpha)
.background(color, CircleShape)
)
}

View File

@@ -1,55 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun MediaActionButton(
backgroundColor: Color,
iconColor: Color,
icon: ImageVector,
modifier: Modifier = Modifier,
height: Dp,
onClick: () -> Unit = {},
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(height)
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.6f))
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
}
}

View File

@@ -1,103 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ClosedCaption
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun MediaPlaybackSettings(
backgroundColor: Color,
foregroundColor: Color,
audioTrack: String,
subtitles: String,
audioIcon: ImageVector = Icons.Outlined.VolumeUp,
subtitleIcon: ImageVector = Icons.Outlined.ClosedCaption,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
) {
MediaSettingDropdown(
backgroundColor = backgroundColor,
foregroundColor = foregroundColor,
label = "Audio Track",
value = audioTrack,
icon = audioIcon
)
Spacer(modifier = Modifier.height(12.dp))
MediaSettingDropdown(
backgroundColor = backgroundColor,
foregroundColor = foregroundColor,
label = "Subtitles",
value = subtitles,
icon = subtitleIcon
)
}
}
@Composable
private fun MediaSettingDropdown(
backgroundColor: Color,
foregroundColor: Color,
label: String,
value: String,
icon: ImageVector
) {
Row (
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = foregroundColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Spacer(modifier = Modifier.width(12.dp))
Row(
modifier = Modifier
.height(38.dp)
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.width(10.dp))
Text(text = value, color = foregroundColor, fontSize = 14.sp)
}
Icon(
imageVector = Icons.Outlined.ExpandMore,
contentDescription = null,
tint = foregroundColor
)
}
}
}

View File

@@ -1,38 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
/**
* Async image that falls back to theme-synced color blocks so loading/error states
* stay aligned with PurefinTheme's colorScheme.
*/
@Composable
fun PurefinAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop
) {
val placeholderPainter = ColorPainter(MaterialTheme.colorScheme.surfaceVariant)
// Convert empty string to null to properly trigger fallback
val effectiveModel = when {
model is String && model.isEmpty() -> null
else -> model
}
AsyncImage(
model = effectiveModel,
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
placeholder = placeholderPainter,
error = placeholderPainter,
fallback = placeholderPainter
)
}

View File

@@ -1,55 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun PurefinIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Int = 52
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale")
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale }
.size(size.dp)
.border(if (isFocused) 2.5.dp else 0.dp, if (isFocused) scheme.onPrimary else Color.Transparent, CircleShape)
.clip(CircleShape)
.background(if (isFocused) scheme.primary else scheme.secondary)
.onFocusChanged { isFocused = it.isFocused }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onSecondary
)
}
}

View File

@@ -1,87 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import kotlinx.coroutines.delay
/**
* A composable that displays content for a specified duration after the value becomes null.
*
* @param value The value to display. When set to a non-null value, it will be shown immediately.
* When set to null, the previously shown value will remain visible for [hideAfterMillis]
* before being hidden.
* @param hideAfterMillis The duration in milliseconds to keep showing the last value after [value] becomes null.
* Defaults to 1000ms (1 second).
* @param content The composable content to display, receiving the current non-null value.
*/
@Composable
fun <T> EmptyValueTimedVisibility(
value: T?,
hideAfterMillis: Long = 1_000,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
val shownValue = remember { mutableStateOf<T?>(null) }
LaunchedEffect(value) {
if (value == null) {
delay(hideAfterMillis)
shownValue.value = null
}
shownValue.value = value
}
shownValue.value?.let {
content(it)
}
}
/**
* Displays [content] whenever [value] changes and hides it after [hideAfterMillis]
* milliseconds without further updates.
*
* @param value The value whose changes should trigger visibility.
* @param hideAfterMillis Duration in milliseconds after which the content will be hidden
* if [value] has not changed again.
* @param content The composable to render while visible.
*/
@Composable
fun <T> ValueChangeTimedVisibility(
value: T,
hideAfterMillis: Long = 1_000,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
var displayedValue by remember { mutableStateOf(value) }
var isVisible by remember { mutableStateOf(false) }
var hasInitialValue by remember { mutableStateOf(false) }
LaunchedEffect(value) {
displayedValue = value
if (!hasInitialValue) {
hasInitialValue = true
return@LaunchedEffect
}
isVisible = true
delay(hideAfterMillis)
isVisible = false
}
AnimatedVisibility(
visible = isVisible,
modifier = modifier,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
content(displayedValue)
}
}

View File

@@ -1,46 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun UnwatchedEpisodeIndicator(
unwatchedCount: Int,
foregroundColor: Color = MaterialTheme.colorScheme.onPrimary,
backgroundColor: Color = MaterialTheme.colorScheme.primary,
size: Int = 24,
modifier: Modifier = Modifier
) {
if (unwatchedCount == 0) {
return
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.border(1.dp, backgroundColor.copy(alpha = 0.8f), CircleShape)
.background(backgroundColor.copy(alpha = 0.8f), CircleShape)
.size(size.dp)
.clip(CircleShape)
) {
Text(
text = if (unwatchedCount > 9) "9+" else unwatchedCount.toString(),
color = foregroundColor.copy(alpha = 0.8f),
fontWeight = FontWeight.W900,
fontSize = 15.sp
)
}
}

View File

@@ -1,81 +0,0 @@
package hu.bbara.purefin.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.outlined.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun WatchStateIndicator(
watched: Boolean,
started: Boolean,
watchedColor: Color = MaterialTheme.colorScheme.onPrimary,
watchedBackgroundColor: Color = MaterialTheme.colorScheme.primary,
startedColor: Color = MaterialTheme.colorScheme.onSecondary,
startedBackgroundColor: Color = MaterialTheme.colorScheme.secondary,
size: Int = 24,
modifier: Modifier = Modifier
) {
if (watched.not() && started.not()) {
return
}
val foregroundColor = if (watched) watchedColor.copy(alpha = 0.8f) else startedColor.copy(alpha = 0.3f)
val backgroundColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.3f)
val borderColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.8f)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.border(1.dp, borderColor, CircleShape)
.background(backgroundColor, CircleShape)
.size(size.dp)
.clip(CircleShape)
) {
if (watched) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = "Check",
tint = foregroundColor,
modifier = Modifier
.padding(1.dp)
.matchParentSize()
)
}
}
}
@Preview
@Composable
private fun WatchStateIndicatorPreview() {
Column() {
WatchStateIndicator(
watched = false,
started = false
)
WatchStateIndicator(
watched = true,
started = false
)
WatchStateIndicator(
watched = false,
started = true
)
}
}

View File

@@ -1,214 +0,0 @@
package hu.bbara.purefin.login.ui
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
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PurefinComplexTextField
import hu.bbara.purefin.common.ui.PurefinPasswordField
import hu.bbara.purefin.common.ui.PurefinTextButton
import hu.bbara.purefin.common.ui.PurefinWaitingScreen
import hu.bbara.purefin.feature.shared.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
// Observe ViewModel state
val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
} else {
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
// Logo Section
Box(
modifier = Modifier
.size(100.dp)
.background(scheme.primary, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Movie,
contentDescription = "Logo",
tint = scheme.onPrimary,
modifier = Modifier.size(60.dp)
)
}
Text(
text = "Jellyfin",
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = scheme.onSurfaceVariant,
fontSize = 12.sp,
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.height(48.dp))
// Form Section
Text(
text = "Connect to Server",
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your details to access your library",
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = scheme.error,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.background(
scheme.errorContainer,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
PurefinComplexTextField(
label = "Server URL",
value = serverUrl,
onValueChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinComplexTextField(
label = "Username",
value = username,
onValueChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp))
PurefinPasswordField(
label = "Password",
value = password,
onValueChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
Spacer(modifier = Modifier.weight(0.5f))
// Footer Links
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Search, contentDescription = null, tint = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(" Discover Servers", color = scheme.onSurfaceVariant)
}
}
TextButton(onClick = {}) {
Text("Need Help?", color = scheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.navigation package hu.bbara.purefin.modules
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import dagger.Module import dagger.Module
@@ -6,7 +6,14 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.tvEpisodeSection
import hu.bbara.purefin.navigation.tvHomeSection
import hu.bbara.purefin.navigation.tvLibrarySection
import hu.bbara.purefin.navigation.tvLoginSection
import hu.bbara.purefin.navigation.tvMovieSection
import hu.bbara.purefin.navigation.tvPlayerSection
import hu.bbara.purefin.navigation.tvSeriesSection
@Module @Module
@InstallIn(ActivityRetainedComponent::class) @InstallIn(ActivityRetainedComponent::class)

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.tv.di package hu.bbara.purefin.modules
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View File

@@ -1,10 +1,10 @@
package hu.bbara.purefin.tv.navigation package hu.bbara.purefin.navigation
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.feature.shared.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> = val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") } staticCompositionLocalOf { error("NavigationManager not provided") }

View File

@@ -1,14 +1,14 @@
package hu.bbara.purefin.tv.navigation package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.app.content.episode.TvEpisodeScreen import hu.bbara.purefin.ui.screen.episode.TvEpisodeScreen
import hu.bbara.purefin.app.content.movie.TvMovieScreen import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
import hu.bbara.purefin.app.content.series.TvSeriesScreen import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.tv.TvAppScreen import hu.bbara.purefin.ui.screen.TvAppScreen
import hu.bbara.purefin.tv.library.ui.TvLibraryScreen import hu.bbara.purefin.ui.screen.library.TvLibraryScreen
import hu.bbara.purefin.tv.player.TvPlayerScreen import hu.bbara.purefin.ui.screen.player.TvPlayerScreen
fun EntryProviderScope<Route>.tvHomeSection() { fun EntryProviderScope<Route>.tvHomeSection() {
entry<Route.Home> { entry<Route.Home> {

View File

@@ -36,16 +36,16 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.core.data.client.JellyfinApiClient
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.feature.shared.navigation.NavigationCommand import hu.bbara.purefin.core.navigation.NavigationCommand
import hu.bbara.purefin.feature.shared.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.data.jellyfin.client.JellyfinAuthInterceptor
import hu.bbara.purefin.tv.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.tv.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark import hu.bbara.purefin.ui.theme.backgroundDark
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -66,7 +66,7 @@ class TvActivity : ComponentActivity() {
lateinit var navigationManager: NavigationManager lateinit var navigationManager: NavigationManager
@Inject @Inject
lateinit var jellyfinApiClient: JellyfinApiClient lateinit var sessionBootstrapper: SessionBootstrapper
@Inject @Inject
lateinit var authInterceptor: JellyfinAuthInterceptor lateinit var authInterceptor: JellyfinAuthInterceptor
@@ -94,7 +94,7 @@ class TvActivity : ComponentActivity() {
} }
private suspend fun init() { private suspend fun init() {
jellyfinApiClient.updateApiClient() sessionBootstrapper.initialize()
} }
private fun configureImageLoader() { private fun configureImageLoader() {

View File

@@ -1,56 +0,0 @@
package hu.bbara.purefin.tv.di
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.download.DownloadState
import hu.bbara.purefin.core.data.download.MediaDownloadController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import java.util.UUID
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object TvPlaybackModule {
@Provides
@Singleton
fun providePlaybackDataSourceFactory(
okHttpDataSourceFactory: OkHttpDataSource.Factory
): DataSource.Factory {
return okHttpDataSourceFactory
}
@Provides
@Singleton
fun provideMediaDownloadController(): MediaDownloadController {
return TvNoOpMediaDownloadController
}
}
internal object TvNoOpMediaDownloadController : MediaDownloadController {
private val notDownloaded = MutableStateFlow<DownloadState>(DownloadState.NotDownloaded)
override fun observeActiveDownloads(): Flow<Map<String, Float>> = flowOf(emptyMap())
override fun observeDownloadState(contentId: String) = notDownloaded
override suspend fun downloadMovie(movieId: UUID) = Unit
override suspend fun cancelDownload(movieId: UUID) = Unit
override suspend fun downloadEpisode(episodeId: UUID) = Unit
override suspend fun downloadEpisodes(episodeIds: List<UUID>) = Unit
override suspend fun cancelEpisodeDownload(episodeId: UUID) = Unit
override suspend fun enableSmartDownload(seriesId: UUID) = Unit
override suspend fun syncSmartDownloads() = Unit
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable

View File

@@ -0,0 +1,88 @@
package hu.bbara.purefin.ui.common.card
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
@Composable
fun PosterCard(
item: PosterItem,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocusedItem: (FocusableItem) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
PosterCardContent(
model = item.toPosterCardModel(),
onClick = { item.open(onMovieSelected, onSeriesSelected, onEpisodeSelected) },
modifier = modifier,
imageModifier = imageModifier,
posterWidth = posterWidth,
showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding,
onFocused = { onFocusedItem(item) },
focusedScale = 1.07f,
focusedBorderWidth = 2.dp
)
}
private fun PosterItem.toPosterCardModel(): PosterCardModel {
return PosterCardModel(
title = title,
secondaryText = secondaryText,
imageUrl = imageUrl,
mediaKind = type,
badge = when (type) {
MediaKind.MOVIE -> {
val movie = requireNotNull(movie)
PosterCardBadge.WatchState(
watched = movie.watched,
started = (movie.progress ?: 0.0) > 0.0
)
}
MediaKind.EPISODE -> {
val episode = requireNotNull(episode)
PosterCardBadge.WatchState(
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0.0
)
}
MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes(
count = requireNotNull(series).unwatchedEpisodeCount
)
else -> PosterCardBadge.None
}
)
}
private fun PosterItem.open(
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
when (type) {
MediaKind.MOVIE -> onMovieSelected(id)
MediaKind.SERIES -> onSeriesSelected(id)
MediaKind.EPISODE -> {
val ep = requireNotNull(episode)
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
}
else -> Unit
}
}

View File

@@ -1,6 +1,6 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.BringIntoViewSpec import androidx.compose.foundation.gestures.BringIntoViewSpec
@@ -31,9 +31,9 @@ import androidx.compose.ui.platform.LocalConfiguration
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
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.image.ImageUrlBuilder
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
internal val MediaDetailHorizontalPadding = 48.dp internal val MediaDetailHorizontalPadding = 48.dp
private val MediaDetailHeaderTopPadding = 104.dp private val MediaDetailHeaderTopPadding = 104.dp
@@ -151,8 +151,8 @@ internal fun TvMediaDetailBodyBox(
} }
internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String { internal fun tvMediaDetailBackgroundImageUrl(imageUrlPrefix: String?): String {
val primaryImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) val primaryImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP) val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
return backdropImageUrl.ifBlank { primaryImageUrl } return backdropImageUrl.ifBlank { primaryImageUrl }
} }

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.common.ui.components package hu.bbara.purefin.ui.common.media
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.button.GhostIconButton
internal data class MediaDetailsTopBarShortcut( internal data class MediaDetailsTopBarShortcut(
val label: String, val label: String,
@@ -53,6 +54,7 @@ internal fun MediaDetailsTopBar(
backFocusRequester: FocusRequester? = null, backFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null downFocusRequester: FocusRequester? = null
) { ) {
val scheme = MaterialTheme.colorScheme
val downModifier = if (downFocusRequester != null) { val downModifier = if (downFocusRequester != null) {
Modifier.focusProperties { down = downFocusRequester } Modifier.focusProperties { down = downFocusRequester }
} else { } else {
@@ -77,7 +79,11 @@ internal fun MediaDetailsTopBar(
icon = Icons.AutoMirrored.Outlined.ArrowBack, icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
onClick = onBack, onClick = onBack,
modifier = backModifier.then(downModifier) modifier = backModifier.then(downModifier),
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.primary,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
) )
if (shortcut != null) { if (shortcut != null) {
GhostTextButton( GhostTextButton(
@@ -92,13 +98,21 @@ internal fun MediaDetailsTopBar(
icon = Icons.Outlined.Cast, icon = Icons.Outlined.Cast,
contentDescription = "Cast", contentDescription = "Cast",
onClick = onCastClick, onClick = onCastClick,
modifier = downModifier modifier = downModifier,
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.primary,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
) )
GhostIconButton( GhostIconButton(
icon = Icons.Outlined.MoreVert, icon = Icons.Outlined.MoreVert,
contentDescription = "More", contentDescription = "More",
onClick = onMoreClick, onClick = onMoreClick,
modifier = downModifier modifier = downModifier,
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.primary,
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
) )
} }
} }

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv package hu.bbara.purefin.ui.screen
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -15,12 +15,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.feature.shared.home.AppViewModel import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.tv.home.TvHomeScreen import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.tv.home.ui.TvDrawerDestination import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestination
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen import hu.bbara.purefin.ui.screen.libraries.TvLibrariesOverviewScreen
import hu.bbara.purefin.core.model.LibraryKind import hu.bbara.purefin.core.model.LibraryKind
@Composable @Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.episode package hu.bbara.purefin.ui.screen.episode
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -13,20 +13,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.common.ui.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.ui.screen.episode.components.TvEpisodeHeroSection
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.tv.navigation.LocalNavigationManager import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
@Composable @Composable
fun TvEpisodeScreen( fun TvEpisodeScreen(
@@ -87,7 +88,7 @@ internal fun TvEpisodeScreenContent(
) { ) {
item { item {
TvMediaDetailBodyBox( TvMediaDetailBodyBox(
backgroundImageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY), backgroundImageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
modifier = it modifier = it
) { ) {
TvEpisodeHeroSection( TvEpisodeHeroSection(

View File

@@ -1,9 +1,7 @@
package hu.bbara.purefin.app.content.episode package hu.bbara.purefin.ui.screen.episode.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -20,13 +18,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
internal const val EpisodePlayButtonTag = "episode-play-button" internal const val EpisodePlayButtonTag = "episode-play-button"
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun TvEpisodeHeroSection( internal fun TvEpisodeHeroSection(
episode: Episode, episode: Episode,
@@ -71,33 +70,26 @@ internal fun TvEpisodeHeroSection(
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
FlowRow( MediaMetadataFlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
verticalArrangement = Arrangement.spacedBy(8.dp) highlightedItem = episode.format
) { )
MediaMetaChip(text = episode.releaseDate)
MediaMetaChip(text = episode.rating)
MediaMetaChip(text = episode.runtime)
MediaMetaChip(
text = episode.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.35f),
textColor = scheme.primary
)
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
MediaResumeButton( MediaResumeButton(
text = episode.playButtonText(), text = mediaPlayButtonText(episode.progress, episode.watched),
progress = episode.progress?.div(100)?.toFloat() ?: 0f, progress = mediaPlaybackProgress(episode.progress),
onClick = onPlay, onClick = onPlay,
modifier = Modifier modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp) .sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.focusRequester(playFocusRequester) .focusRequester(playFocusRequester)
.testTag(EpisodePlayButtonTag) .testTag(EpisodePlayButtonTag),
focusedScale = 1.08f,
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
focusBorderWidth = 3.dp,
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
focusable = true
) )
} }
} }
private fun Episode.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home package hu.bbara.purefin.ui.screen.home
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -9,13 +9,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.tv.home.ui.TvFocusedItemHero import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.tv.home.ui.TvHomeContent import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.tv.home.ui.rememberTvHomeHeroState import hu.bbara.purefin.ui.screen.home.components.rememberTvHomeHeroState
import java.util.UUID import java.util.UUID
private const val TvHomeHeroHeightFraction = 0.32f private const val TvHomeHeroHeightFraction = 0.32f

View File

@@ -1,16 +1,16 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
import kotlin.math.roundToInt import kotlin.math.roundToInt
internal data class TvFocusedHeroModel( internal data class TvFocusedHeroModel(
@@ -42,7 +42,7 @@ internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
} }
internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String { internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String {
val backdropImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.BACKDROP) val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
return backdropImageUrl.ifBlank { fallbackImageUrl } return backdropImageUrl.ifBlank { fallbackImageUrl }
} }
@@ -51,7 +51,7 @@ private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
id = id, id = id,
backdropImageUrl = backdropImageUrl( backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
), ),
title = title, title = title,
metadata = listOf(year, rating, runtime, format).compactMetadata(), metadata = listOf(year, rating, runtime, format).compactMetadata(),
@@ -63,7 +63,7 @@ private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
id = id, id = id,
backdropImageUrl = backdropImageUrl( backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
), ),
title = title, title = title,
metadata = listOf( metadata = listOf(
@@ -87,7 +87,7 @@ private fun Series.toTvFocusedHeroModel(): TvFocusedHeroModel {
id = id, id = id,
backdropImageUrl = backdropImageUrl( backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix, imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = JellyfinImageHelper.finishImageUrl(imageUrlPrefix, ImageType.PRIMARY) fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
), ),
title = name, title = name,
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(), metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -23,7 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
internal const val TvHomeHeroTitleTag = "tv-home-hero-title" internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label" internal const val TvHomeHeroProgressLabelTag = "tv-home-hero-progress-label"

View File

@@ -1,6 +1,6 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.gestures.BringIntoViewSpec import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp

View File

@@ -1,6 +1,6 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
@@ -24,11 +24,11 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item" internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"

View File

@@ -1,15 +1,15 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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 hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID import java.util.UUID
internal data class TvHomeItemRegistry( internal data class TvHomeItemRegistry(

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -44,17 +44,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp) private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
private val TvHomeSectionsHorizontalPadding = 32.dp private val TvHomeSectionsHorizontalPadding = 32.dp
@@ -93,14 +93,14 @@ fun TvContinueWatchingSection(
title = item.primaryText, title = item.primaryText,
supporting = item.secondaryText, supporting = item.secondaryText,
imageUrl = when (item.type) { imageUrl = when (item.type) {
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl( MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix, prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY artworkKind = ArtworkKind.PRIMARY
) )
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl( MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix, prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY artworkKind = ArtworkKind.PRIMARY
) )
else -> null else -> null

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.library.ui package hu.bbara.purefin.ui.screen.libraries
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -43,8 +43,8 @@ import androidx.compose.ui.platform.testTag
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.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.core.model.LibraryKind import hu.bbara.purefin.core.model.LibraryKind
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-" internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.library.ui 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
@@ -22,11 +22,11 @@ import androidx.compose.runtime.collectAsState
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.common.ui.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.feature.shared.navigation.LibraryDto import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel import hu.bbara.purefin.feature.browse.library.LibraryViewModel
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -64,6 +64,7 @@ private fun TvLibraryTopBar(
title: String, title: String,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -74,7 +75,11 @@ private fun TvLibraryTopBar(
PurefinIconButton( PurefinIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack, icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
onClick = onBack onClick = onBack,
focusedScale = 1.1f,
focusedBorderWidth = 2.5.dp,
focusedBorderColor = scheme.onPrimary,
focusedBackgroundColor = scheme.primary
) )
Text( Text(
text = title, text = title,

View File

@@ -0,0 +1,68 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.login.LoginViewModel
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val state = remember(serverUrl, username, password, errorMessage) {
LoginContentState(
serverUrl = serverUrl,
username = username,
password = password,
errorMessage = errorMessage
)
}
val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks(
onServerUrlChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
}
LoginContent(
state = state,
callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier
)
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.movie package hu.bbara.purefin.ui.screen.movie
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -13,17 +13,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.common.ui.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailPlaybackSection import hu.bbara.purefin.ui.common.media.MediaDetailPlaybackSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.feature.shared.navigation.MovieDto import hu.bbara.purefin.ui.screen.movie.components.TvMovieHeroSection
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
@Composable @Composable
fun TvMovieScreen( fun TvMovieScreen(

View File

@@ -1,9 +1,7 @@
package hu.bbara.purefin.app.content.movie package hu.bbara.purefin.ui.screen.movie.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -20,13 +18,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
internal const val MoviePlayButtonTag = "movie-play-button" internal const val MoviePlayButtonTag = "movie-play-button"
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun TvMovieHeroSection( internal fun TvMovieHeroSection(
movie: Movie, movie: Movie,
@@ -51,33 +50,26 @@ internal fun TvMovieHeroSection(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
FlowRow( MediaMetadataFlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
verticalArrangement = Arrangement.spacedBy(8.dp) highlightedItem = movie.format
) { )
MediaMetaChip(text = movie.year)
MediaMetaChip(text = movie.rating)
MediaMetaChip(text = movie.runtime)
MediaMetaChip(
text = movie.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.35f),
textColor = scheme.primary
)
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
MediaResumeButton( MediaResumeButton(
text = movie.playButtonText(), text = mediaPlayButtonText(movie.progress, movie.watched),
progress = movie.progress?.div(100)?.toFloat() ?: 0f, progress = mediaPlaybackProgress(movie.progress),
onClick = onPlay, onClick = onPlay,
modifier = Modifier modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp) .sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.focusRequester(playFocusRequester) .focusRequester(playFocusRequester)
.testTag(MoviePlayButtonTag) .testTag(MoviePlayButtonTag),
focusedScale = 1.08f,
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
focusBorderWidth = 3.dp,
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
focusable = true
) )
} }
} }
private fun Movie.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.player package hu.bbara.purefin.ui.screen.player
import android.app.Activity import android.app.Activity
import android.view.WindowManager import android.view.WindowManager
@@ -50,10 +50,10 @@ import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker import hu.bbara.purefin.core.player.viewmodel.ControlsAutoHideBlocker
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
import hu.bbara.purefin.tv.player.components.TvPlayerControlsOverlay import hu.bbara.purefin.ui.screen.player.components.TvPlayerControlsOverlay
import hu.bbara.purefin.tv.player.components.TvPlayerLoadingErrorEndCard import hu.bbara.purefin.ui.screen.player.components.TvPlayerLoadingErrorEndCard
import hu.bbara.purefin.tv.player.components.TvTrackPanelType import hu.bbara.purefin.ui.screen.player.components.TvTrackPanelType
import hu.bbara.purefin.tv.player.components.TvTrackSelectionPanel import hu.bbara.purefin.ui.screen.player.components.TvTrackSelectionPanel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L private const val TV_CONTROLS_AUTO_HIDE_MS = 5_000L

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically

View File

@@ -0,0 +1,31 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable
internal fun TvPlayerLoadingErrorEndCard(
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
PlayerLoadingErrorEndCardContent(
uiState = uiState,
onRetry = onRetry,
onNext = onNext,
onReplay = onReplay,
onDismissError = onDismissError,
modifier = modifier,
showBufferWhileError = false,
cardPadding = 24.dp,
cardSpacing = 16.dp,
backgroundAlpha = 0.92f,
headlineStyle = MaterialTheme.typography.titleMedium
)
}

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -18,15 +17,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.MarkerType
import hu.bbara.purefin.core.player.model.TimedMarker import hu.bbara.purefin.core.player.model.TimedMarker
@Composable @Composable
@@ -43,13 +39,8 @@ internal fun TvPlayerSeekBar(
focusRequester: FocusRequester = remember { FocusRequester() }, focusRequester: FocusRequester = remember { FocusRequester() },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
val position = positionMs.coerceIn(0, safeDuration) val position = positionMs.coerceIn(0, safeDuration)
val bufferRatio = (bufferedMs.toFloat() / safeDuration).coerceIn(0f, 1f)
val progressRatio = (position.toFloat() / safeDuration).coerceIn(0f, 1f)
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } +
adMarkers.map { it.copy(type = MarkerType.AD) }
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
Box( Box(
@@ -90,52 +81,17 @@ internal fun TvPlayerSeekBar(
.focusable(), .focusable(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Canvas( PlayerSeekBarTrack(
modifier = Modifier positionMs = position,
.fillMaxSize() durationMs = safeDuration,
.padding(horizontal = 2.dp, vertical = 10.dp) bufferedMs = bufferedMs,
) { chapterMarkers = chapterMarkers,
val trackHeight = if (isFocused) 6f else 4f adMarkers = adMarkers,
val trackTop = size.height / 2 - trackHeight / 2 modifier = Modifier.fillMaxSize(),
drawRect( isFocused = isFocused,
color = scheme.onSurface.copy(alpha = 0.2f), thumbRadius = 7.dp,
size = Size(width = size.width, height = trackHeight), focusedThumbRadius = 9.dp,
topLeft = Offset(0f, trackTop) focusedThumbHaloRadiusDelta = 4.dp
) )
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferRatio * size.width, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val progressWidth = progressRatio * size.width
drawRect(
color = scheme.primary,
size = Size(width = progressWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
val thumbRadius = if (isFocused) 9.dp.toPx() else 7.dp.toPx()
val thumbCenter = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
drawCircle(
color = scheme.primary,
radius = thumbRadius,
center = thumbCenter
)
if (isFocused) {
drawCircle(
color = scheme.primary.copy(alpha = 0.3f),
radius = thumbRadius + 4.dp.toPx(),
center = thumbCenter
)
}
combinedMarkers.forEach { marker ->
val x = (marker.positionMs.toFloat() / safeDuration) * size.width
val color = if (marker.type == MarkerType.AD) scheme.secondary else scheme.primary
drawRect(
color = color,
topLeft = Offset(x - 1f, size.height / 2 - 6f),
size = Size(width = 2f, height = 12f)
)
}
}
} }
} }

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.tv.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.series package hu.bbara.purefin.ui.screen.series
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -16,16 +16,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.common.ui.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.common.ui.components.MediaDetailOverviewSection import hu.bbara.purefin.ui.common.media.MediaDetailOverviewSection
import hu.bbara.purefin.common.ui.components.MediaDetailSectionTitle import hu.bbara.purefin.ui.common.media.MediaDetailSectionTitle
import hu.bbara.purefin.common.ui.components.TvMediaDetailBodyBox import hu.bbara.purefin.ui.common.media.TvMediaDetailBodyBox
import hu.bbara.purefin.common.ui.components.TvMediaDetailScaffold import hu.bbara.purefin.ui.common.media.TvMediaDetailScaffold
import hu.bbara.purefin.common.ui.components.tvMediaDetailBackgroundImageUrl import hu.bbara.purefin.ui.common.media.tvMediaDetailBackgroundImageUrl
import hu.bbara.purefin.feature.shared.navigation.SeriesDto import hu.bbara.purefin.ui.screen.series.components.CastRow
import hu.bbara.purefin.ui.screen.series.components.SeriesFirstSeasonTabTag
import hu.bbara.purefin.ui.screen.series.components.TvEpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.TvSeasonTabs
import hu.bbara.purefin.ui.screen.series.components.TvSeriesHeroSection
import hu.bbara.purefin.core.navigation.SeriesDto
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.content.series.SeriesViewModel
import java.util.UUID import java.util.UUID
@Composable @Composable

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin.app.content.series package hu.bbara.purefin.ui.screen.series.components
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -8,8 +8,6 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement 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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@@ -53,34 +51,30 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.common.ui.components.WatchStateIndicator import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.feature.content.series.SeriesViewModel
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
internal const val SeriesPlayButtonTag = "series-play-button" internal const val SeriesPlayButtonTag = "series-play-button"
internal const val SeriesFirstSeasonTabTag = "series-first-season-tab" internal const val SeriesFirstSeasonTabTag = "series-first-season-tab"
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun TvSeriesMetaChips(series: Series) { internal fun TvSeriesMetaChips(series: Series) {
val scheme = MaterialTheme.colorScheme MediaMetadataFlowRow(
FlowRow( items = listOf(series.year, "${series.seasonCount} Seasons")
horizontalArrangement = Arrangement.spacedBy(8.dp), )
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MediaMetaChip(text = series.year)
MediaMetaChip(text = "${series.seasonCount} Seasons")
}
} }
@Composable @Composable
@@ -241,14 +235,21 @@ internal fun TvSeriesHeroSection(
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
MediaResumeButton( MediaResumeButton(
text = nextUpEpisode.playButtonText(), text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f, progress = mediaPlaybackProgress(nextUpEpisode.progress),
onClick = { onPlayEpisode(nextUpEpisode) }, onClick = { onPlayEpisode(nextUpEpisode) },
modifier = Modifier modifier = Modifier
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp) .sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
.focusRequester(playFocusRequester) .focusRequester(playFocusRequester)
.focusProperties { down = firstContentFocusRequester } .focusProperties { down = firstContentFocusRequester }
.testTag(SeriesPlayButtonTag) .testTag(SeriesPlayButtonTag),
focusedScale = 1.08f,
focusHaloColor = scheme.primary.copy(alpha = 0.22f),
focusBorderWidth = 3.dp,
focusBorderColor = scheme.onBackground,
overlayBorderWidth = 2.dp,
overlayBorderColor = scheme.primary.copy(alpha = 0.95f),
focusable = true
) )
} else { } else {
Text( Text(
@@ -296,7 +297,7 @@ private fun TvEpisodeCard(
) )
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY), model = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -335,7 +336,7 @@ private fun TvEpisodeCard(
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
) )
} else { } else {
WatchStateIndicator( WatchStateBadge(
watched = episode.watched, watched = episode.watched,
started = (episode.progress ?: 0.0) > 0.0, started = (episode.progress ?: 0.0) > 0.0,
modifier = Modifier modifier = Modifier
@@ -376,10 +377,6 @@ internal fun CastRow(cast: List<CastMember>, modifier: Modifier = Modifier) {
) )
} }
private fun Episode.playButtonText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}
private fun Episode.heroStatusText(): String { private fun Episode.heroStatusText(): String {
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next" return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
} }

View File

@@ -1,37 +0,0 @@
package hu.bbara.purefin.app.content.episode
import hu.bbara.purefin.core.data.navigation.Route
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EpisodeTopBarShortcutTest {
@Test
fun `home route exposes series shortcut`() {
var clicked = false
val shortcut = episodeTopBarShortcut(Route.Home) {
clicked = true
}
assertNotNull(shortcut)
assertEquals("Series", shortcut?.label)
shortcut?.onClick?.invoke()
assertTrue(clicked)
}
@Test
fun `non home route hides series shortcut`() {
val shortcut = episodeTopBarShortcut(
previousRoute = Route.PlayerRoute(mediaId = "episode-1"),
onSeriesClick = {}
)
assertNull(shortcut)
}
}

View File

@@ -1,19 +0,0 @@
package hu.bbara.purefin.tv.di
import hu.bbara.purefin.core.data.download.DownloadState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class TvNoOpMediaDownloadControllerTest {
@Test
fun `tv no-op download controller always reports no downloads`() = runBlocking {
assertEquals(emptyMap<String, Float>(), TvNoOpMediaDownloadController.observeActiveDownloads().first())
assertEquals(
DownloadState.NotDownloaded,
TvNoOpMediaDownloadController.observeDownloadState("movie-1").value
)
}
}

View File

@@ -1,39 +1,34 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind import hu.bbara.purefin.core.image.ArtworkKind
import org.jellyfin.sdk.model.api.ImageType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
class TvFocusedItemHeroTest { class TvFocusedItemHeroTest {
@Test @Test
fun movieItem_mapsProgressMetadataAndBackdrop() { fun movieItem_mapsMetadataAndBackdrop() {
val movie = sampleMovie(progress = 42.0) val movie = sampleMovie(progress = 42.0)
val item = ContinueWatchingItem( val item = ContinueWatchingItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = movie movie = movie
) )
val hero = item.toTvFocusedHeroModel() val hero = item.toTvFocusedHeroModel()
assertEquals("Continue watching", hero.eyebrowText)
assertEquals("Blade Runner 2049", hero.title) assertEquals("Blade Runner 2049", hero.title)
assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText) assertEquals("2017 • 16+ • 164m • 4K", hero.metadataText)
assertEquals(0.42f, hero.progressFraction!!, 0.0001f)
assertEquals("42%", hero.progressLabel)
assertNull(hero.watchedText)
assertEquals( assertEquals(
JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.BACKDROP), ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.BACKDROP),
hero.backdropImageUrl hero.backdropImageUrl
) )
} }
@@ -50,7 +45,7 @@ class TvFocusedItemHeroTest {
} }
@Test @Test
fun episodeItem_prefersWatchedStateOverProgress() { fun episodeItem_mapsEpisodeMetadata() {
val item = NextUpItem( val item = NextUpItem(
episode = sampleEpisode( episode = sampleEpisode(
progress = 96.0, progress = 96.0,
@@ -60,29 +55,21 @@ class TvFocusedItemHeroTest {
val hero = item.toTvFocusedHeroModel() val hero = item.toTvFocusedHeroModel()
assertEquals("Next up", hero.eyebrowText)
assertEquals("The Very Pulse of the Machine", hero.title) assertEquals("The Very Pulse of the Machine", hero.title)
assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText) assertEquals("Episode 3 • 2022-05-20 • 16m • 12+ • HD", hero.metadataText)
assertEquals("Watched", hero.watchedText)
assertNull(hero.progressFraction)
assertNull(hero.progressLabel)
} }
@Test @Test
fun seriesItem_usesSeasonAndUnwatchedMetadataWithoutProgress() { fun seriesItem_usesSeasonAndUnwatchedMetadata() {
val item = PosterItem( val item = PosterItem(
type = BaseItemKind.SERIES, type = MediaKind.SERIES,
series = sampleSeries() series = sampleSeries()
) )
val hero = item.toTvFocusedHeroModel() val hero = item.toTvFocusedHeroModel()
assertEquals("Series", hero.eyebrowText)
assertEquals("Love, Death & Robots", hero.title) assertEquals("Love, Death & Robots", hero.title)
assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText) assertEquals("2019 • 3 seasons • 5 unwatched", hero.metadataText)
assertNull(hero.watchedText)
assertNull(hero.progressFraction)
assertNull(hero.progressLabel)
} }
private fun sampleMovie(progress: Double? = 42.0): Movie { private fun sampleMovie(progress: Double? = 42.0): Movie {

View File

@@ -1,13 +1,13 @@
package hu.bbara.purefin.tv.home.ui package hu.bbara.purefin.ui.screen.home.components
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -108,7 +108,7 @@ class TvHomeHeroStateTest {
return LibraryItem( return LibraryItem(
id = id, id = id,
name = "Movies", name = "Movies",
type = CollectionType.MOVIES, type = LibraryKind.MOVIES,
posterUrl = "", posterUrl = "",
isEmpty = isEmpty isEmpty = isEmpty
) )
@@ -116,7 +116,7 @@ class TvHomeHeroStateTest {
private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem { private fun continueWatchingItem(id: String, title: String): ContinueWatchingItem {
return ContinueWatchingItem( return ContinueWatchingItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = sampleMovie(id = id, title = title) movie = sampleMovie(id = id, title = title)
) )
} }
@@ -144,7 +144,7 @@ class TvHomeHeroStateTest {
private fun posterItem(id: String, libraryId: UUID): PosterItem { private fun posterItem(id: String, libraryId: UUID): PosterItem {
return PosterItem( return PosterItem(
type = BaseItemKind.MOVIE, type = MediaKind.MOVIE,
movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId) movie = sampleMovie(id = id, title = "Arrival", libraryId = libraryId)
) )
} }

View File

@@ -51,10 +51,20 @@ kotlin {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:image"))
implementation(project(":core:ui"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":feature:download")) implementation(project(":core:navigation"))
implementation(project(":core:download"))
implementation(project(":core:player")) implementation(project(":core:player"))
implementation(project(":feature:shared")) implementation(project(":data:jellyfin"))
implementation(project(":data:offline"))
implementation(project(":data:catalog"))
implementation(project(":feature:browse"))
implementation(project(":feature:search"))
implementation(project(":feature:content"))
implementation(project(":feature:downloads"))
implementation(project(":feature:login"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -83,8 +93,6 @@ dependencies {
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -33,7 +33,7 @@
android:exported="false" android:exported="false"
android:theme="@style/Theme.Purefin" /> android:theme="@style/Theme.Purefin" />
<service <service
android:name="hu.bbara.purefin.feature.download.PurefinDownloadService" android:name="hu.bbara.purefin.core.download.PurefinDownloadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
</application> </application>

View File

@@ -36,15 +36,15 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade import coil3.request.crossfade
import coil3.util.DebugLogger import coil3.util.DebugLogger
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.core.data.SessionBootstrapper
import hu.bbara.purefin.core.data.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.client.JellyfinAuthInterceptor
import hu.bbara.purefin.core.data.client.JellyfinAuthInterceptor
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.feature.shared.navigation.NavigationCommand
import hu.bbara.purefin.feature.shared.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route
import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.core.navigation.NavigationCommand
import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import hu.bbara.purefin.ui.theme.backgroundDark import hu.bbara.purefin.ui.theme.backgroundDark
@@ -65,7 +65,7 @@ class PurefinActivity : ComponentActivity() {
lateinit var navigationManager: NavigationManager lateinit var navigationManager: NavigationManager
@Inject @Inject
lateinit var jellyfinApiClient: JellyfinApiClient lateinit var sessionBootstrapper: SessionBootstrapper
@Inject @Inject
lateinit var authInterceptor: JellyfinAuthInterceptor lateinit var authInterceptor: JellyfinAuthInterceptor
@@ -93,7 +93,7 @@ class PurefinActivity : ComponentActivity() {
} }
private suspend fun init() { private suspend fun init() {
jellyfinApiClient.updateApiClient() sessionBootstrapper.initialize()
} }
private fun configureImageLoader() { private fun configureImageLoader() {

View File

@@ -6,7 +6,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.navigation.appRouteEntryBuilder import hu.bbara.purefin.navigation.appRouteEntryBuilder
@Module @Module

View File

@@ -4,7 +4,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import hu.bbara.purefin.core.data.client.PlaybackProfileFamily import hu.bbara.purefin.core.data.PlaybackProfileFamily
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View File

@@ -3,8 +3,8 @@ package hu.bbara.purefin.navigation
import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import hu.bbara.purefin.feature.shared.navigation.NavigationManager import hu.bbara.purefin.core.navigation.NavigationManager
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> = val LocalNavigationManager: ProvidableCompositionLocal<NavigationManager> =
staticCompositionLocalOf { error("NavigationManager not provided") } staticCompositionLocalOf { error("NavigationManager not provided") }

View File

@@ -1,7 +1,7 @@
package hu.bbara.purefin.navigation package hu.bbara.purefin.navigation
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.ui.screen.library.LibraryScreen 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

View File

@@ -27,7 +27,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.shared.search.SearchViewModel import hu.bbara.purefin.feature.search.SearchViewModel
@Composable @Composable
fun DefaultTopBar( fun DefaultTopBar(

View File

@@ -1,48 +0,0 @@
package hu.bbara.purefin.ui.common.bar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A progress bar component for displaying media playback progress.
*
* @param progress The progress value between 0f and 1f, where 0f is no progress and 1f is complete.
* @param foregroundColor The color of the progress indicator.
* @param backgroundColor The color of the background/unfilled portion of the progress bar.
* @param modifier The modifier to be applied to the Box. Modifier should contain the Alignment.
*/
@Composable
fun MediaProgressBar(
progress: Float,
foregroundColor: Color = MaterialTheme.colorScheme.onSurface,
backgroundColor: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier
) {
if (progress == 0f) return
Box(
modifier = modifier
.padding(bottom = 8.dp, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.height(4.dp)
.background(backgroundColor.copy(alpha = 0.2f))
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(progress)
.background(foregroundColor)
)
}
}

View File

@@ -1,40 +0,0 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.size(52.dp)
.clip(CircleShape)
.background(scheme.background.copy(alpha = 0.65f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onBackground
)
}
}

View File

@@ -1,36 +0,0 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
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.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
@Composable
fun MediaActionButton(
backgroundColor: Color,
iconColor: Color,
icon: ImageVector,
modifier: Modifier = Modifier,
height: Dp,
onClick: () -> Unit = {},
) {
Box(
modifier = modifier
.size(height)
.clip(CircleShape)
.background(backgroundColor.copy(alpha = 0.6f))
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(imageVector = icon, contentDescription = null, tint = iconColor)
}
}

View File

@@ -1,89 +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.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun MediaResumeButton(
text: String,
progress: Float = 0f,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val primaryColor = MaterialTheme.colorScheme.primary
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary
BoxWithConstraints(
modifier = modifier
.height(52.dp)
.clip(RoundedCornerShape(50))
.clickable(onClick = onClick)
) {
// Bottom layer: inverted colors (visible for the remaining %)
Box(
modifier = Modifier
.fillMaxSize()
.background(onPrimaryColor),
contentAlignment = Alignment.Center
) {
ButtonContent(text = text, color = Color.White)
}
// Top layer: primary colors, clipped to the progress %
Box(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
val clipWidth = size.width * progress
clipRect(
left = 0f,
top = 0f,
right = clipWidth,
bottom = size.height
) {
this@drawWithContent.drawContent()
}
}
.background(primaryColor),
contentAlignment = Alignment.Center
) {
ButtonContent(text = text, color = Color.White)
}
}
}
@Composable
private fun ButtonContent(text: String, color: Color) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(text, color = color, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(Modifier.width(8.dp))
Icon(Icons.Filled.PlayArrow, null, tint = color)
}
}

View File

@@ -1,41 +0,0 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun PurefinIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Int = 52
) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.size(size.dp)
.clip(CircleShape)
.background(scheme.secondary)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = scheme.onSecondary
)
}
}

View File

@@ -1,34 +1,10 @@
package hu.bbara.purefin.ui.common.card package hu.bbara.purefin.ui.common.card
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.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.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
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.sp
import coil3.request.ImageRequest
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeIndicator
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
@Composable @Composable
fun PosterCard( fun PosterCard(
@@ -38,83 +14,58 @@ fun PosterCard(
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme PosterCardContent(
val context = LocalContext.current model = item.toPosterCardModel(),
val density = LocalDensity.current onClick = { item.open(onMovieSelected, onSeriesSelected, onEpisodeSelected) },
modifier = modifier
)
}
val posterWidth = 144.dp internal fun PosterItem.toPosterCardModel(): PosterCardModel {
val posterHeight = posterWidth * 3 / 2 return PosterCardModel(
title = title,
fun openItem(posterItem: PosterItem) { secondaryText = secondaryText,
when (posterItem.type) { imageUrl = imageUrl,
MediaKind.MOVIE -> onMovieSelected(posterItem.id) mediaKind = type,
MediaKind.SERIES -> onSeriesSelected(posterItem.id) badge = when (type) {
MediaKind.EPISODE -> { MediaKind.MOVIE -> {
val ep = posterItem.episode!! val movie = requireNotNull(movie)
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) PosterCardBadge.WatchState(
} watched = movie.watched,
else -> {} started = (movie.progress ?: 0.0) > 0.0
}
}
val imageRequest = ImageRequest.Builder(context)
.data(item.imageUrl)
.size(with(density) { posterWidth.roundToPx() }, with(density) { posterHeight.roundToPx() })
.build()
Column(
modifier = Modifier
.width(posterWidth)
) {
Box() {
PurefinAsyncImage(
model = imageRequest,
contentDescription = null,
modifier = Modifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(1.dp, scheme.outlineVariant.copy(alpha = 0.3f), RoundedCornerShape(14.dp))
.background(scheme.surfaceVariant)
.clickable(onClick = { openItem(item) }),
contentScale = ContentScale.Crop
)
when (item.type) {
MediaKind.MOVIE -> {
val m = item.movie!!
WatchStateIndicator(
size = 28,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),
watched = m.watched,
started = (m.progress ?: 0.0) > 0
)
}
MediaKind.EPISODE -> {
val ep = item.episode!!
WatchStateIndicator(
size = 28,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),
watched = ep.watched,
started = (ep.progress ?: 0.0) > 0
)
}
MediaKind.SERIES -> UnwatchedEpisodeIndicator(
size = 28,
modifier = Modifier.align(Alignment.TopEnd)
.padding(8.dp),
unwatchedCount = item.series!!.unwatchedEpisodeCount
) )
else -> {}
} }
MediaKind.EPISODE -> {
val episode = requireNotNull(episode)
PosterCardBadge.WatchState(
watched = episode.watched,
started = (episode.progress ?: 0.0) > 0.0
)
}
MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes(
count = requireNotNull(series).unwatchedEpisodeCount
)
else -> PosterCardBadge.None
} }
Text( )
text = item.title, }
color = scheme.onBackground,
fontSize = 13.sp, private fun PosterItem.open(
fontWeight = FontWeight.Medium, onMovieSelected: (UUID) -> Unit,
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp), onSeriesSelected: (UUID) -> Unit,
maxLines = 1, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
overflow = TextOverflow.Ellipsis ) {
) when (type) {
MediaKind.MOVIE -> onMovieSelected(id)
MediaKind.SERIES -> onSeriesSelected(id)
MediaKind.EPISODE -> {
val ep = requireNotNull(episode)
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
}
else -> Unit
} }
} }

View File

@@ -10,7 +10,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.feature.shared.home.AppViewModel import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.download.DownloadsScreen import hu.bbara.purefin.ui.screen.download.DownloadsScreen
import hu.bbara.purefin.ui.screen.home.HomeScreen import hu.bbara.purefin.ui.screen.home.HomeScreen
import hu.bbara.purefin.ui.screen.home.components.HomeNavItem import hu.bbara.purefin.ui.screen.home.components.HomeNavItem

View File

@@ -27,7 +27,7 @@ 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.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.download.ActiveDownloadItem import hu.bbara.purefin.feature.downloads.ActiveDownloadItem
@Composable @Composable
internal fun DownloadingItemRow( internal fun DownloadingItemRow(

View File

@@ -24,7 +24,7 @@ import androidx.compose.ui.text.font.FontWeight
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.ui.common.card.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.feature.shared.download.DownloadsViewModel import hu.bbara.purefin.feature.downloads.DownloadsViewModel
@Composable @Composable
fun DownloadsContent( fun DownloadsContent(

View File

@@ -5,11 +5,8 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,22 +31,22 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.feature.shared.navigation.EpisodeDto import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto
import hu.bbara.purefin.navigation.LocalNavigationBackStack import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.feature.shared.navigation.Route import hu.bbara.purefin.core.navigation.Route
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.feature.content.episode.EpisodeScreenViewModel
import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails import hu.bbara.purefin.ui.screen.episode.components.EpisodeDetails
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBar
import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut import hu.bbara.purefin.ui.screen.episode.components.EpisodeTopBarShortcut
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -167,7 +164,7 @@ private fun EpisodeHeroSection(
.height(sectionHeight) .height(sectionHeight)
) { ) {
MediaHero( MediaHero(
imageUrl = JellyfinImageHelper.finishImageUrl(episode.imageUrlPrefix, ImageType.PRIMARY), imageUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundColor = scheme.background, backgroundColor = scheme.background,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -211,25 +208,12 @@ private fun EpisodeHeroSection(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun EpisodeMetaChips(episode: Episode) { private fun EpisodeMetaChips(episode: Episode) {
val scheme = MaterialTheme.colorScheme MediaMetadataFlowRow(
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
FlowRow( highlightedItem = episode.format
horizontalArrangement = Arrangement.spacedBy(8.dp), )
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MediaMetaChip(text = episode.releaseDate)
MediaMetaChip(text = episode.rating)
MediaMetaChip(text = episode.runtime)
MediaMetaChip(
text = episode.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.3f),
textColor = scheme.primary
)
}
} }
@Preview(showBackground = true) @Preview(showBackground = true)

View File

@@ -36,11 +36,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.media.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaSynopsis import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.button.GhostIconButton import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
@@ -134,8 +136,8 @@ internal fun EpisodeDetails(
Row() { Row() {
MediaResumeButton( MediaResumeButton(
text = if (episode.progress == null) "Play" else "Resume", text = mediaPlayButtonText(episode.progress, episode.watched),
progress = episode.progress?.div(100)?.toFloat() ?: 0f, progress = mediaPlaybackProgress(episode.progress),
onClick = playAction, onClick = playAction,
modifier = Modifier.sizeIn(maxWidth = 200.dp) modifier = Modifier.sizeIn(maxWidth = 200.dp)
) )

View File

@@ -16,11 +16,11 @@ import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.screen.home.components.HomeContent import hu.bbara.purefin.ui.screen.home.components.HomeContent
import hu.bbara.purefin.ui.screen.home.components.HomeTopBar import hu.bbara.purefin.ui.screen.home.components.HomeTopBar
import hu.bbara.purefin.ui.screen.home.components.search.HomeSearchOverlay import hu.bbara.purefin.ui.screen.home.components.search.HomeSearchOverlay
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.feature.browse.home.SuggestedItem
import hu.bbara.purefin.ui.screen.AppBottomBar import hu.bbara.purefin.ui.screen.AppBottomBar
import java.util.UUID import java.util.UUID

View File

@@ -22,11 +22,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.feature.browse.home.SuggestedItem
import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection import hu.bbara.purefin.ui.screen.home.components.continuewatching.ContinueWatchingSection
import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection import hu.bbara.purefin.ui.screen.home.components.featured.SuggestionsSection
import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection import hu.bbara.purefin.ui.screen.home.components.library.LibraryPosterSection

View File

@@ -25,11 +25,11 @@ 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.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.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import java.util.UUID import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
@Composable @Composable
internal fun ContinueWatchingCard( internal fun ContinueWatchingCard(
@@ -53,13 +53,13 @@ internal fun ContinueWatchingCard(
else -> "" else -> ""
} }
val imageUrl = when (item.type) { val imageUrl = when (item.type) {
MediaKind.MOVIE -> JellyfinImageHelper.finishImageUrl( MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix, prefixImageUrl = item.movie?.imageUrlPrefix,
imageType = ImageType.PRIMARY artworkKind = ArtworkKind.PRIMARY
) )
MediaKind.EPISODE -> JellyfinImageHelper.finishImageUrl( MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix, prefixImageUrl = item.episode?.imageUrlPrefix,
imageType = ImageType.PRIMARY artworkKind = ArtworkKind.PRIMARY
) )
else -> null else -> null
} }

View File

@@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import java.util.UUID import java.util.UUID
@Composable @Composable

View File

@@ -26,7 +26,7 @@ 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.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.feature.browse.home.SuggestedItem
@Composable @Composable
internal fun SuggestionCard( internal fun SuggestionCard(

View File

@@ -22,7 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.SuggestedItem import hu.bbara.purefin.feature.browse.home.SuggestedItem
@Composable @Composable
fun SuggestionsSection( fun SuggestionsSection(

View File

@@ -23,12 +23,12 @@ 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.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeIndicator
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator
import hu.bbara.purefin.feature.shared.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable @Composable
internal fun HomeBrowseCard( internal fun HomeBrowseCard(
@@ -94,7 +94,7 @@ internal fun HomeBrowseCard(
when (item.type) { when (item.type) {
MediaKind.MOVIE -> { MediaKind.MOVIE -> {
val movie = item.movie!! val movie = item.movie!!
WatchStateIndicator( WatchStateBadge(
size = 28, size = 28,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
@@ -106,7 +106,7 @@ internal fun HomeBrowseCard(
MediaKind.EPISODE -> { MediaKind.EPISODE -> {
val episode = item.episode!! val episode = item.episode!!
WatchStateIndicator( WatchStateBadge(
size = 28, size = 28,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
@@ -117,7 +117,7 @@ internal fun HomeBrowseCard(
} }
MediaKind.SERIES -> { MediaKind.SERIES -> {
UnwatchedEpisodeIndicator( UnwatchedEpisodeBadge(
size = 28, size = 28,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)

View File

@@ -9,8 +9,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.LibraryItem import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import java.util.UUID import java.util.UUID

View File

@@ -23,10 +23,10 @@ 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.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import java.util.UUID import java.util.UUID
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
@Composable @Composable
internal fun NextUpCard( internal fun NextUpCard(
@@ -57,7 +57,7 @@ internal fun NextUpCard(
.background(scheme.surface) .background(scheme.surface)
) { ) {
PurefinAsyncImage( PurefinAsyncImage(
model = JellyfinImageHelper.finishImageUrl(item.episode.imageUrlPrefix, ImageType.PRIMARY), model = ImageUrlBuilder.finishImageUrl(item.episode.imageUrlPrefix, ArtworkKind.PRIMARY),
contentDescription = item.primaryText, contentDescription = item.primaryText,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop

View File

@@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.feature.shared.home.NextUpItem import hu.bbara.purefin.feature.browse.home.NextUpItem
import java.util.UUID import java.util.UUID
@Composable @Composable

View File

@@ -35,7 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.feature.shared.search.SearchViewModel import hu.bbara.purefin.feature.search.SearchViewModel
import java.util.UUID import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind

View File

@@ -18,7 +18,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaKind import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.shared.search.SearchResult import hu.bbara.purefin.feature.search.SearchResult
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@Composable @Composable

View File

@@ -23,9 +23,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.card.PosterCard import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.ui.common.button.PurefinIconButton import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.feature.shared.navigation.LibraryDto import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel import hu.bbara.purefin.feature.browse.library.LibraryViewModel
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(

View File

@@ -1,29 +1,5 @@
package hu.bbara.purefin.ui.screen.login package hu.bbara.purefin.ui.screen.login
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
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -31,17 +7,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.textfield.PurefinComplexTextField import hu.bbara.purefin.feature.login.LoginViewModel
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.feature.shared.login.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -49,166 +17,52 @@ fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(), viewModel: LoginViewModel = hiltViewModel(),
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme
// Observe ViewModel state
val serverUrl by viewModel.url.collectAsState() val serverUrl by viewModel.url.collectAsState()
val username by viewModel.username.collectAsState() val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState() val password by viewModel.password.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState()
var isLoggingIn by remember { mutableStateOf(false) } var isLoggingIn by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
if (isLoggingIn) { val state = remember(serverUrl, username, password, errorMessage) {
PurefinWaitingScreen(modifier = modifier) LoginContentState(
} else { serverUrl = serverUrl,
Column( username = username,
modifier = modifier password = password,
.fillMaxSize() errorMessage = errorMessage
.background(scheme.background) )
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
// Logo Section
Box(
modifier = Modifier
.size(100.dp)
.background(scheme.primary, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Movie, // Replace with actual logo resource
contentDescription = "Logo",
tint = scheme.onPrimary,
modifier = Modifier.size(60.dp)
)
}
Text(
text = "Jellyfin",
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = scheme.onSurfaceVariant,
fontSize = 12.sp,
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.height(48.dp))
// Form Section
Text(
text = "Connect to Server",
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your details to access your library",
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
if (errorMessage != null) {
Text(
text = errorMessage!!,
color = scheme.error,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.background(
scheme.errorContainer,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
PurefinComplexTextField(
label = "Server URL",
value = serverUrl,
onValueChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinComplexTextField(
label = "Username",
value = username,
onValueChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp))
PurefinPasswordField(
label = "Password",
value = password,
onValueChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
Spacer(modifier = Modifier.weight(0.5f))
// Footer Links
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {}) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Search, contentDescription = null, tint = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(" Discover Servers", color = scheme.onSurfaceVariant)
}
}
TextButton(onClick = {}) {
Text("Need Help?", color = scheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
} }
val callbacks = remember(viewModel, coroutineScope) {
LoginContentCallbacks(
onServerUrlChange = {
viewModel.clearError()
viewModel.setUrl(it)
},
onUsernameChange = {
viewModel.clearError()
viewModel.setUsername(it)
},
onPasswordChange = {
viewModel.clearError()
viewModel.setPassword(it)
},
onConnect = {
coroutineScope.launch {
isLoggingIn = true
try {
viewModel.login()
} finally {
isLoggingIn = false
}
}
}
)
}
LoginContent(
state = state,
callbacks = callbacks,
isLoggingIn = isLoggingIn,
modifier = modifier
)
} }

View File

@@ -5,10 +5,8 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
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.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,19 +30,19 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.feature.shared.navigation.MovieDto import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.MovieDto
import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.feature.content.movie.MovieScreenViewModel
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.theme.AppTheme import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.ImageType import hu.bbara.purefin.core.image.ArtworkKind
import java.util.UUID import java.util.UUID
@Composable @Composable
@@ -139,7 +137,7 @@ fun MediaHeroSection(
.height(sectionHeight) .height(sectionHeight)
) { ) {
MediaHero( MediaHero(
imageUrl = JellyfinImageHelper.finishImageUrl(movie.imageUrlPrefix, ImageType.PRIMARY), imageUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY),
backgroundColor = MaterialTheme.colorScheme.background, backgroundColor = MaterialTheme.colorScheme.background,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -170,20 +168,10 @@ fun MediaHeroSection(
lineHeight = 38.sp lineHeight = 38.sp
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
FlowRow( MediaMetadataFlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
verticalArrangement = Arrangement.spacedBy(8.dp) highlightedItem = movie.format
) { )
MediaMetaChip(text = movie.year)
MediaMetaChip(text = movie.rating)
MediaMetaChip(text = movie.runtime)
MediaMetaChip(
text = movie.format,
background = scheme.primary.copy(alpha = 0.2f),
border = scheme.primary.copy(alpha = 0.3f),
textColor = scheme.primary
)
}
} }
} }
} }

View File

@@ -31,11 +31,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.media.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaSynopsis import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.mediaPlaybackProgress
import hu.bbara.purefin.ui.common.media.mediaPlayButtonText
import hu.bbara.purefin.ui.common.button.GhostIconButton import hu.bbara.purefin.ui.common.button.GhostIconButton
import hu.bbara.purefin.ui.common.button.MediaActionButton import hu.bbara.purefin.ui.common.button.MediaActionButton
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.player.PlayerActivity import hu.bbara.purefin.player.PlayerActivity
@@ -92,8 +94,8 @@ internal fun MovieDetails(
Row() { Row() {
MediaResumeButton( MediaResumeButton(
text = if (movie.progress == null) "Play" else "Resume", text = mediaPlayButtonText(movie.progress, movie.watched),
progress = movie.progress?.div(100)?.toFloat() ?: 0f, progress = mediaPlaybackProgress(movie.progress),
onClick = playAction, onClick = playAction,
modifier = Modifier.sizeIn(maxWidth = 200.dp) modifier = Modifier.sizeIn(maxWidth = 200.dp)
) )

Some files were not shown because too many files have changed in this diff Show More