Extract shared Compose UI into core module

This commit is contained in:
2026-04-15 21:21:10 +02:00
parent 29747a1031
commit 9591b72273
70 changed files with 1369 additions and 2455 deletions

View File

@@ -53,6 +53,7 @@ kotlin {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:image")) implementation(project(":core:image"))
implementation(project(":core:ui"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:navigation")) implementation(project(":core:navigation"))
implementation(project(":core:download")) implementation(project(":core:download"))

View File

@@ -14,7 +14,7 @@
android:required="true" /> android:required="true" />
<application <application
android:name="hu.bbara.purefin.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="hu.bbara.purefin.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,4 +1,4 @@
package hu.bbara.purefin package hu.bbara.purefin.tv
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle

View File

@@ -1,4 +1,4 @@
package hu.bbara.purefin package hu.bbara.purefin.tv
import android.app.Application import android.app.Application
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp

View File

@@ -1,55 +0,0 @@
package hu.bbara.purefin.ui.common.button
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,55 +0,0 @@
package hu.bbara.purefin.ui.common.button
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,41 +1,13 @@
package hu.bbara.purefin.ui.common.card package hu.bbara.purefin.ui.common.card
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.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.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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.feature.browse.home.FocusableItem import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.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
@Composable @Composable
fun PosterCard( fun PosterCard(
@@ -51,106 +23,66 @@ fun PosterCard(
onSeriesSelected: (UUID) -> Unit, onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit, onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) { ) {
val scheme = MaterialTheme.colorScheme PosterCardContent(
var isFocused by remember { mutableStateOf(false) } model = item.toPosterCardModel(),
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale") 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
)
}
fun openItem(posterItem: PosterItem) { private fun PosterItem.toPosterCardModel(): PosterCardModel {
when (posterItem.type) { return PosterCardModel(
MediaKind.MOVIE -> onMovieSelected(posterItem.id) title = title,
MediaKind.SERIES -> onSeriesSelected(posterItem.id) secondaryText = secondaryText,
MediaKind.EPISODE -> { imageUrl = imageUrl,
val ep = posterItem.episode!! mediaKind = type,
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id) badge = when (type) {
} MediaKind.MOVIE -> {
else -> {} val movie = requireNotNull(movie)
} PosterCardBadge.WatchState(
} watched = movie.watched,
started = (movie.progress ?: 0.0) > 0.0
Column(
modifier = modifier
.width(posterWidth)
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
) {
Box {
PurefinAsyncImage(
model = item.imageUrl,
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(14.dp)
)
.background(scheme.surfaceVariant)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocusedItem(item)
}
}
.clickable(onClick = { openItem(item) }),
contentScale = ContentScale.Crop
)
when (item.type) {
MediaKind.MOVIE -> {
val m = item.movie!!
WatchStateBadge(
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = m.watched,
started = (m.progress ?: 0.0) > 0
)
}
MediaKind.EPISODE -> {
val ep = item.episode!!
WatchStateBadge(
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = ep.watched,
started = (ep.progress ?: 0.0) > 0
)
}
MediaKind.SERIES -> UnwatchedEpisodeBadge(
size = indicatorSize,
modifier = Modifier.align(Alignment.TopEnd)
.padding(indicatorPadding),
unwatchedCount = item.series!!.unwatchedEpisodeCount
) )
else -> {}
} }
}
Column( MediaKind.EPISODE -> {
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp) val episode = requireNotNull(episode)
) { PosterCardBadge.WatchState(
Text( watched = episode.watched,
text = item.title, started = (episode.progress ?: 0.0) > 0.0
color = scheme.onBackground, )
fontSize = 13.sp, }
fontWeight = FontWeight.Medium,
maxLines = 1, MediaKind.SERIES -> PosterCardBadge.UnwatchedEpisodes(
overflow = TextOverflow.Ellipsis count = requireNotNull(series).unwatchedEpisodeCount
) )
item.secondaryText
.takeIf { showSecondaryText } else -> PosterCardBadge.None
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
)
}
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,38 +0,0 @@
package hu.bbara.purefin.ui.common.image
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,131 +0,0 @@
package hu.bbara.purefin.ui.common.media
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.ui.common.image.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

@@ -54,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 {
@@ -78,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(
@@ -93,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,103 +0,0 @@
package hu.bbara.purefin.ui.common.media
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,68 +0,0 @@
package hu.bbara.purefin.ui.common.textfield
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.ui.common.textfield
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

@@ -2,8 +2,6 @@ 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.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.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

@@ -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

@@ -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,16 +7,8 @@ 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.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.login.LoginViewModel import hu.bbara.purefin.feature.login.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -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,
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

@@ -2,8 +2,6 @@ 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.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.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,23 +1,8 @@
package hu.bbara.purefin.ui.screen.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
@@ -30,84 +15,17 @@ internal fun TvPlayerLoadingErrorEndCard(
onDismissError: () -> Unit, onDismissError: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scheme = MaterialTheme.colorScheme PlayerLoadingErrorEndCardContent(
uiState = uiState,
Box(modifier = modifier) { onRetry = onRetry,
AnimatedVisibility(visible = uiState.isBuffering && uiState.error == null) { onNext = onNext,
CircularProgressIndicator(color = scheme.primary) onReplay = onReplay,
} onDismissError = onDismissError,
modifier = modifier,
AnimatedVisibility(visible = uiState.error != null) { showBufferWhileError = false,
Column( cardPadding = 24.dp,
modifier = Modifier cardSpacing = 16.dp,
.clip(RoundedCornerShape(16.dp)) backgroundAlpha = 0.92f,
.background(scheme.background.copy(alpha = 0.92f)) headlineStyle = MaterialTheme.typography.titleMedium
.padding(24.dp), )
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")
}
Button(
onClick = onDismissError,
colors = ButtonDefaults.buttonColors(
containerColor = scheme.surface
)
) {
Text("Dismiss", color = scheme.onSurface)
}
}
}
}
AnimatedVisibility(visible = uiState.isEnded && uiState.error == null && !uiState.isBuffering) {
val nextUp = uiState.queue.getOrNull(
uiState.queue.indexOfFirst { it.isCurrent }
.takeIf { it >= 0 }
?.plus(1) ?: -1
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.92f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onNext) {
Text("Play next")
}
} else {
Text(
text = "Playback finished",
color = scheme.onBackground,
style = MaterialTheme.typography.titleMedium
)
Button(onClick = onReplay) {
Text("Replay")
}
}
}
}
}
} }

View File

@@ -1,6 +1,5 @@
package hu.bbara.purefin.ui.screen.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

@@ -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
@@ -54,11 +52,13 @@ 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.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaMetaChip
import hu.bbara.purefin.ui.common.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.WatchStateBadge import hu.bbara.purefin.ui.common.badge.WatchStateBadge
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.image.ImageUrlBuilder 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
@@ -70,17 +70,11 @@ 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(
@@ -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

@@ -52,6 +52,7 @@ kotlin {
dependencies { dependencies {
implementation(project(":core:model")) implementation(project(":core:model"))
implementation(project(":core:image")) implementation(project(":core:image"))
implementation(project(":core:ui"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":core:navigation")) implementation(project(":core:navigation"))
implementation(project(":core:download")) implementation(project(":core:download"))

View File

@@ -1,46 +0,0 @@
package hu.bbara.purefin.ui.common.badge
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.ui.common.badge
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,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,26 +0,0 @@
package hu.bbara.purefin.ui.common.button
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,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.browse.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

@@ -1,75 +0,0 @@
package hu.bbara.purefin.ui.common.media
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,87 +0,0 @@
package hu.bbara.purefin.ui.common.visual
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

@@ -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,9 +31,9 @@ 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.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
@@ -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,6 +36,8 @@ 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
@@ -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

@@ -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.browse.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

@@ -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,16 +7,8 @@ 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.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.login.LoginViewModel import hu.bbara.purefin.feature.login.LoginViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -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,9 +30,9 @@ 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.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.MovieDto import hu.bbara.purefin.core.navigation.MovieDto
@@ -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,6 +31,8 @@ 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
@@ -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)
) )

View File

@@ -1,24 +1,8 @@
package hu.bbara.purefin.ui.screen.player.components package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable @Composable
@@ -30,73 +14,14 @@ fun PlayerLoadingErrorEndCard(
onReplay: () -> Unit, onReplay: () -> Unit,
onDismissError: () -> Unit onDismissError: () -> Unit
) { ) {
val scheme = MaterialTheme.colorScheme PlayerLoadingErrorEndCardContent(
Box(modifier = modifier) { uiState = uiState,
AnimatedVisibility(visible = uiState.isBuffering) { onRetry = onRetry,
CircularProgressIndicator(color = scheme.primary) onNext = onNext,
} onReplay = onReplay,
onDismissError = onDismissError,
AnimatedVisibility(visible = uiState.error != null) { modifier = modifier,
Column( showBufferWhileError = true,
modifier = Modifier headlineStyle = MaterialTheme.typography.bodyLarge
.clip(RoundedCornerShape(16.dp)) )
.background(scheme.background.copy(alpha = 0.9f))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")
}
Button(
onClick = onDismissError,
colors = ButtonDefaults.buttonColors(containerColor = scheme.surface)
) {
Text("Dismiss", color = scheme.onSurface)
}
}
}
}
AnimatedVisibility(visible = uiState.isEnded && uiState.error == null && !uiState.isBuffering) {
val nextUp = uiState.queue.getOrNull(
uiState.queue.indexOfFirst { it.isCurrent }.takeIf { it >= 0 }?.plus(1) ?: -1
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = 0.9f))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold
)
Button(onClick = onNext) {
Text("Play next")
}
} else {
Text(text = "Playback finished", color = scheme.onBackground)
Button(onClick = onReplay) {
Text("Replay")
}
}
}
}
}
} }

View File

@@ -17,11 +17,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
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
@@ -36,15 +33,11 @@ fun PlayerSeekBar(
onScrubFinished: () -> Unit, onScrubFinished: () -> Unit,
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 currentPosition = positionMs.coerceIn(0, safeDuration) val currentPosition = positionMs.coerceIn(0, safeDuration)
var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) } var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) }
var isScrubbing by remember { mutableStateOf(false) } var isScrubbing by remember { mutableStateOf(false) }
val sliderValue = if (isScrubbing) sliderPosition else currentPosition.toFloat() val sliderValue = if (isScrubbing) sliderPosition else currentPosition.toFloat()
val bufferRatio = (bufferedMs.toFloat() / safeDuration).coerceIn(0f, 1f)
val combinedMarkers = chapterMarkers.map { it.copy(type = MarkerType.CHAPTER) } + adMarkers.map { it.copy(type = MarkerType.AD) }
val progressRatio = (sliderValue / safeDuration).coerceIn(0f, 1f)
Box( Box(
modifier = modifier modifier = modifier
@@ -53,50 +46,18 @@ fun PlayerSeekBar(
.height(32.dp), .height(32.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Canvas( PlayerSeekBarTrack(
modifier = Modifier positionMs = sliderValue.toLong(),
.fillMaxSize() durationMs = safeDuration,
.padding(horizontal = 2.dp, vertical = 10.dp) bufferedMs = bufferedMs,
) { chapterMarkers = chapterMarkers,
val trackHeight = 4f adMarkers = adMarkers,
val trackTop = size.height / 2 - trackHeight / 2 modifier = Modifier.fillMaxSize(),
// Buffered bar isFocused = false,
val bufferWidth = bufferRatio * size.width focusedTrackHeight = 4.dp,
drawRect( focusedThumbRadius = 6.dp,
color = scheme.onSurface.copy(alpha = 0.2f), focusedThumbHaloRadiusDelta = 0.dp
size = Size(width = size.width, height = trackHeight), )
topLeft = Offset(0f, trackTop)
)
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
// Primary progress sits on top of the buffer line to match its width
val progressWidth = progressRatio * size.width
drawRect(
color = scheme.primary,
size = Size(width = progressWidth, height = trackHeight),
topLeft = Offset(0f, trackTop)
)
// Draw a compact dot indicator instead of the default thumb
val thumbRadius = 6.dp.toPx()
drawCircle(
color = scheme.primary,
radius = thumbRadius,
center = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
)
// Markers
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)
)
}
}
Slider( Slider(
value = sliderValue, value = sliderValue,
onValueChange = { newValue -> onValueChange = { newValue ->

View File

@@ -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
@@ -58,13 +56,15 @@ 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.MediaCastRow import hu.bbara.purefin.ui.common.media.MediaCastRow
import hu.bbara.purefin.ui.common.media.MediaMetaChip 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.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.bar.MediaProgressBar import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.button.MediaResumeButton import hu.bbara.purefin.ui.common.button.MediaResumeButton
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.badge.WatchStateIndicator import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.core.download.DownloadState import hu.bbara.purefin.core.download.DownloadState
import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.navigation.EpisodeDto import hu.bbara.purefin.core.navigation.EpisodeDto
@@ -102,24 +102,11 @@ internal fun SeriesTopBar(
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
internal fun SeriesMetaChips(series: Series) { internal fun SeriesMetaChips(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.rating)
MediaMetaChip(text = "${series.seasonCount} Seasons")
// MediaMetaChip(
// text = series.,
// background = scheme.primary.copy(alpha = 0.2f),
// border = scheme.primary.copy(alpha = 0.3f),
// textColor = scheme.primary
// )
}
} }
@Composable @Composable
@@ -157,8 +144,8 @@ internal fun SeriesActionButtons(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (playAction != null && nextUpEpisode != null) { if (playAction != null && nextUpEpisode != null) {
MediaResumeButton( MediaResumeButton(
text = if ((nextUpEpisode.progress ?: 0.0) > 0.0 && !nextUpEpisode.watched) "Resume" else "Play", text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f, progress = mediaPlaybackProgress(nextUpEpisode.progress),
onClick = playAction, onClick = playAction,
modifier = Modifier.sizeIn(maxWidth = 200.dp) modifier = Modifier.sizeIn(maxWidth = 200.dp)
) )
@@ -388,7 +375,7 @@ private fun EpisodeCard(
.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

View File

@@ -1,199 +0,0 @@
package hu.bbara.purefin.ui.screen.waiting
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,17 +0,0 @@
package hu.bbara.purefin
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,111 @@
package hu.bbara.purefin.ui.common.card
import hu.bbara.purefin.core.model.CastMember
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.Season
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PosterCardMappingTest {
@Test
fun `maps movie poster item to watch-state card model`() {
val movie = Movie(
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
libraryId = UUID.fromString("22222222-2222-2222-2222-222222222222"),
title = "Blade Runner 2049",
progress = 18.0,
watched = false,
year = "2017",
rating = "16+",
runtime = "2h 44m",
format = "Dolby Vision",
synopsis = "Synthetic future noir.",
imageUrlPrefix = "https://example.test/movie/",
audioTrack = "English 5.1",
subtitles = "English CC",
cast = emptyList()
)
val model = PosterItem(type = MediaKind.MOVIE, movie = movie).toPosterCardModel()
assertEquals("Blade Runner 2049", model.title)
assertEquals("2017", model.secondaryText)
assertEquals("https://example.test/movie/Primary", model.imageUrl)
assertEquals(MediaKind.MOVIE, model.mediaKind)
assertTrue(model.badge is PosterCardBadge.WatchState)
val badge = model.badge as PosterCardBadge.WatchState
assertEquals(false, badge.watched)
assertEquals(true, badge.started)
}
@Test
fun `maps series poster item to unwatched badge card model`() {
val series = Series(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
libraryId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
name = "Severance",
synopsis = "Corporate sci-fi.",
year = "2025",
imageUrlPrefix = "https://example.test/series/",
unwatchedEpisodeCount = 12,
seasonCount = 2,
seasons = listOf(
Season(
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seriesId = UUID.fromString("33333333-3333-3333-3333-333333333333"),
name = "Season 1",
index = 1,
unwatchedEpisodeCount = 9,
episodeCount = 9,
episodes = emptyList()
)
),
cast = listOf(CastMember("Adam Scott", "Mark", null))
)
val model = PosterItem(type = MediaKind.SERIES, series = series).toPosterCardModel()
assertEquals("Severance", model.title)
assertEquals("2025", model.secondaryText)
assertEquals(MediaKind.SERIES, model.mediaKind)
assertTrue(model.badge is PosterCardBadge.UnwatchedEpisodes)
val badge = model.badge as PosterCardBadge.UnwatchedEpisodes
assertEquals(12, badge.count)
}
@Test
fun `maps episode poster item to watch-state card model`() {
val episode = Episode(
id = UUID.fromString("66666666-6666-6666-6666-666666666666"),
seriesId = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
index = 4,
title = "The You You Are",
synopsis = "Office mystery.",
releaseDate = "2025",
rating = "16+",
runtime = "53m",
progress = 0.0,
watched = true,
format = "4K",
imageUrlPrefix = "https://example.test/episode/",
cast = emptyList()
)
val model = PosterItem(type = MediaKind.EPISODE, episode = episode).toPosterCardModel()
assertEquals("The You You Are", model.title)
assertEquals("2025", model.secondaryText)
assertEquals(MediaKind.EPISODE, model.mediaKind)
assertTrue(model.badge is PosterCardBadge.WatchState)
val badge = model.badge as PosterCardBadge.WatchState
assertEquals(true, badge.watched)
assertEquals(false, badge.started)
}
}

46
core/ui/build.gradle.kts Normal file
View File

@@ -0,0 +1,46 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "hu.bbara.purefin.core.ui"
compileSdk = 36
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:player"))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.foundation)
implementation(libs.coil.compose)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
}

View File

@@ -30,8 +30,7 @@ fun WatchStateBadge(
size: Int = 24, size: Int = 24,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (!watched && !started) {
if (watched.not() && started.not()) {
return return
} }
@@ -39,7 +38,6 @@ fun WatchStateBadge(
val backgroundColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.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) val borderColor = if (watched) watchedBackgroundColor.copy(alpha = 0.8f) else startedBackgroundColor.copy(alpha = 0.8f)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = modifier modifier = modifier
@@ -64,7 +62,7 @@ fun WatchStateBadge(
@Preview @Preview
@Composable @Composable
private fun WatchStateBadgePreview() { private fun WatchStateBadgePreview() {
Column() { Column {
WatchStateBadge( WatchStateBadge(
watched = false, watched = false,
started = false started = false

View File

@@ -16,14 +16,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp 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 @Composable
fun MediaProgressBar( fun MediaProgressBar(
progress: Float, progress: Float,

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
@@ -22,35 +21,55 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp
@Composable @Composable
fun GhostIconButton( internal fun CircularIconButton(
icon: ImageVector, icon: ImageVector,
contentDescription: String, contentDescription: String?,
containerColor: Color,
iconColor: Color,
size: Dp,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusedBorderWidth: Dp,
focusedBorderColor: Color,
focusedBackgroundColor: Color = containerColor
) { ) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.1f else 1.0f, label = "scale") val scale by animateFloatAsState(
val borderColor by animateColorAsState(targetValue = if (isFocused) scheme.primary else Color.Transparent, label = "border") targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
val borderColor by animateColorAsState(
targetValue = if (isFocused) focusedBorderColor else Color.Transparent,
label = "border"
)
val backgroundColor by animateColorAsState(
targetValue = if (isFocused) focusedBackgroundColor else containerColor,
label = "background"
)
Box( Box(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
.size(52.dp) .size(size)
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape) .border(
width = if (isFocused) focusedBorderWidth else focusedBorderWidth * 0,
color = borderColor,
shape = CircleShape
)
.clip(CircleShape) .clip(CircleShape)
.background(if (isFocused) scheme.primary.copy(alpha = 0.25f) else scheme.background.copy(alpha = 0.65f)) .background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.clickable { onClick() }, .clickable { onClick() },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = scheme.onBackground tint = iconColor
) )
} }
} }

View File

@@ -0,0 +1,37 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun GhostIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 0.dp,
focusedBorderColor: Color = Color.Transparent,
focusedBackgroundColor: Color? = null
) {
val scheme = MaterialTheme.colorScheme
val backgroundColor = scheme.background.copy(alpha = 0.65f)
CircularIconButton(
icon = icon,
contentDescription = contentDescription,
containerColor = backgroundColor,
focusedBackgroundColor = focusedBackgroundColor ?: backgroundColor,
iconColor = scheme.onBackground,
size = 52.dp,
onClick = onClick,
modifier = modifier,
focusedScale = focusedScale,
focusedBorderWidth = focusedBorderWidth,
focusedBorderColor = focusedBorderColor
)
}

View File

@@ -0,0 +1,36 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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 = {},
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 0.dp,
focusedBorderColor: Color = Color.Transparent,
focusedBackgroundColor: Color? = null
) {
CircularIconButton(
icon = icon,
contentDescription = null,
containerColor = backgroundColor.copy(alpha = 0.6f),
focusedBackgroundColor = focusedBackgroundColor ?: backgroundColor.copy(alpha = 0.6f),
iconColor = iconColor,
size = height,
onClick = onClick,
modifier = modifier,
focusedScale = focusedScale,
focusedBorderWidth = focusedBorderWidth,
focusedBorderColor = focusedBorderColor
)
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -35,6 +34,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -43,45 +43,57 @@ fun MediaResumeButton(
text: String, text: String,
progress: Float = 0f, progress: Float = 0f,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusHaloColor: Color = Color.Transparent,
focusBorderWidth: Dp = 0.dp,
focusBorderColor: Color = Color.Transparent,
overlayBorderWidth: Dp = 0.dp,
overlayBorderColor: Color = Color.Transparent,
focusable: Boolean = false
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val primaryColor = scheme.primary val primaryColor = scheme.primary
val onPrimaryColor = scheme.onPrimary val onPrimaryColor = scheme.onPrimary
val focusShape = RoundedCornerShape(50) val shape = RoundedCornerShape(50)
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.08f else 1.0f, label = "scale") val scale by animateFloatAsState(
val focusBorderColor by animateColorAsState( targetValue = if (isFocused) focusedScale else 1f,
targetValue = if (isFocused) scheme.onBackground else Color.Transparent, label = "scale"
label = "focus-border"
) )
val focusHaloColor by animateColorAsState( val haloColor by animateColorAsState(
targetValue = if (isFocused) scheme.primary.copy(alpha = 0.22f) else Color.Transparent, targetValue = if (isFocused) focusHaloColor else Color.Transparent,
label = "focus-halo" label = "focus-halo"
) )
val borderColor by animateColorAsState(
targetValue = if (isFocused) focusBorderColor else Color.Transparent,
label = "focus-border"
)
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer { scaleX = scale; scaleY = scale }
.height(52.dp) .height(52.dp)
.background(focusHaloColor, focusShape) .background(haloColor, shape)
.border(3.dp, focusBorderColor, focusShape) .border(
.clip(focusShape) width = if (isFocused) focusBorderWidth else 0.dp,
color = borderColor,
shape = shape
)
.clip(shape)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus } .onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.clickable(onClick = onClick) .clickable(onClick = onClick)
.focusable() .then(if (focusable) Modifier.focusable() else Modifier)
) { ) {
// Bottom layer: inverted colors (visible for the remaining %)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(onPrimaryColor), .background(onPrimaryColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
ButtonContent(text = text, color = primaryColor) ResumeButtonContent(text = text, color = primaryColor)
} }
// Top layer: primary colors, clipped to the progress %
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -99,21 +111,21 @@ fun MediaResumeButton(
.background(primaryColor), .background(primaryColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
ButtonContent(text = text, color = onPrimaryColor) ResumeButtonContent(text = text, color = onPrimaryColor)
} }
if (isFocused) { if (isFocused && overlayBorderWidth > 0.dp) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.border(2.dp, scheme.primary.copy(alpha = 0.95f), focusShape) .border(overlayBorderWidth, overlayBorderColor, shape)
) )
} }
} }
} }
@Composable @Composable
private fun ButtonContent(text: String, color: Color) { private fun ResumeButtonContent(text: String, color: Color) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center

View File

@@ -0,0 +1,37 @@
package hu.bbara.purefin.ui.common.button
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun PurefinIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Int = 52,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 0.dp,
focusedBorderColor: Color = Color.Transparent,
focusedBackgroundColor: Color? = null
) {
val scheme = MaterialTheme.colorScheme
CircularIconButton(
icon = icon,
contentDescription = contentDescription,
containerColor = scheme.secondary,
focusedBackgroundColor = focusedBackgroundColor ?: scheme.secondary,
iconColor = scheme.onSecondary,
size = size.dp,
onClick = onClick,
modifier = modifier,
focusedScale = focusedScale,
focusedBorderWidth = focusedBorderWidth,
focusedBorderColor = focusedBorderColor
)
}

View File

@@ -12,7 +12,7 @@ fun PurefinTextButton(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
content: @Composable RowScope.() -> Unit // Slot API content: @Composable RowScope.() -> Unit
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,

View File

@@ -0,0 +1,161 @@
package hu.bbara.purefin.ui.common.card
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.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.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.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
data class PosterCardModel(
val title: String,
val secondaryText: String = "",
val imageUrl: String?,
val mediaKind: MediaKind,
val badge: PosterCardBadge = PosterCardBadge.None
)
sealed interface PosterCardBadge {
data object None : PosterCardBadge
data class WatchState(
val watched: Boolean,
val started: Boolean
) : PosterCardBadge
data class UnwatchedEpisodes(
val count: Int
) : PosterCardBadge
}
@Composable
fun PosterCardContent(
model: PosterCardModel,
onClick: () -> Unit,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {},
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
Column(
modifier = modifier
.width(posterWidth)
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = focusedTransformOrigin
}
) {
Box {
PurefinAsyncImage(
model = model.imageUrl,
contentDescription = null,
modifier = imageModifier
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(14.dp))
.border(
width = if (isFocused) focusedBorderWidth else 1.dp,
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.3f),
shape = RoundedCornerShape(14.dp)
)
.background(scheme.surfaceVariant)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
onFocused()
}
}
.clickable(onClick = onClick),
contentScale = ContentScale.Crop
)
when (val badge = model.badge) {
is PosterCardBadge.WatchState -> {
WatchStateBadge(
size = indicatorSize,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
watched = badge.watched,
started = badge.started
)
}
is PosterCardBadge.UnwatchedEpisodes -> {
UnwatchedEpisodeBadge(
size = indicatorSize,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(indicatorPadding),
unwatchedCount = badge.count
)
}
PosterCardBadge.None -> Unit
}
}
Column(
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp)
) {
Text(
text = model.title,
color = scheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
model.secondaryText
.takeIf { showSecondaryText }
?.takeIf { it.isNotBlank() }
?.let { text ->
Text(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -20,32 +20,24 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
/**
* Async image that falls back to theme-synced color blocks so loading/error states
* stay aligned with PurefinTheme's colorScheme.
*
* Uses plain [AsyncImage] (no SubcomposeLayout) so it is safe inside lazy lists and
* composables that query intrinsic measurements (e.g. [ListItem]).
*
* - Loading: solid surfaceVariant block
* - Error / missing URL: surfaceVariant block with a centered [fallbackIcon]
*/
@Composable @Composable
fun PurefinAsyncImage( fun PurefinAsyncImage(
model: Any?, model: Any?,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop, contentScale: ContentScale = ContentScale.Crop,
fallbackIcon: ImageVector = Icons.Outlined.BrokenImage fallbackIcon: ImageVector? = Icons.Outlined.BrokenImage
) { ) {
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
val placeholderPainter = ColorPainter(surfaceVariant)
// Convert empty string to null to properly trigger fallback val effectiveModel = when {
val effectiveModel = if (model is String && model.isEmpty()) null else model model is String && model.isEmpty() -> null
else -> model
// Show icon immediately for null model (no request will be made); callbacks update it otherwise. }
var showFallbackIcon by remember(effectiveModel) { mutableStateOf(effectiveModel == null) } var showFallbackIcon by remember(effectiveModel, fallbackIcon) {
mutableStateOf(effectiveModel == null && fallbackIcon != null)
}
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
AsyncImage( AsyncImage(
@@ -53,14 +45,14 @@ fun PurefinAsyncImage(
model = effectiveModel, model = effectiveModel,
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = contentScale, contentScale = contentScale,
placeholder = ColorPainter(surfaceVariant), placeholder = placeholderPainter,
error = ColorPainter(surfaceVariant), error = placeholderPainter,
fallback = ColorPainter(surfaceVariant), fallback = placeholderPainter,
onLoading = { showFallbackIcon = false }, onLoading = { showFallbackIcon = false },
onSuccess = { showFallbackIcon = false }, onSuccess = { showFallbackIcon = false },
onError = { showFallbackIcon = true }, onError = { showFallbackIcon = fallbackIcon != null },
) )
if (showFallbackIcon) { if (showFallbackIcon && fallbackIcon != null) {
Icon( Icon(
imageVector = fallbackIcon, imageVector = fallbackIcon,
contentDescription = null, contentDescription = null,

View File

@@ -105,7 +105,8 @@ fun MediaCastRow(
model = member.imageUrl, model = member.imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop,
fallbackIcon = null
) )
} }
} }

View File

@@ -0,0 +1,40 @@
package hu.bbara.purefin.ui.common.media
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.MaterialTheme
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MediaMetadataFlowRow(
items: List<String>,
modifier: Modifier = Modifier,
highlightedItem: String? = null,
highlightedBackground: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
highlightedBorder: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
highlightedTextColor: Color = MaterialTheme.colorScheme.primary
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items.filter { it.isNotBlank() }.forEach { item ->
if (item == highlightedItem) {
MediaMetaChip(
text = item,
background = highlightedBackground,
border = highlightedBorder,
textColor = highlightedTextColor
)
} else {
MediaMetaChip(text = item)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package hu.bbara.purefin.ui.common.media
fun mediaPlayButtonText(progressPercent: Double?, watched: Boolean): String {
return if ((progressPercent ?: 0.0) > 0.0 && !watched) "Resume" else "Play"
}
fun mediaPlaybackProgress(progressPercent: Double?): Float {
return progressPercent?.div(100)?.toFloat() ?: 0f
}

View File

@@ -38,8 +38,7 @@ fun MediaPlaybackSettings(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
modifier = modifier modifier = modifier.fillMaxWidth()
.fillMaxWidth()
) { ) {
MediaSettingDropdown( MediaSettingDropdown(
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
@@ -67,9 +66,7 @@ private fun MediaSettingDropdown(
value: String, value: String,
icon: ImageVector icon: ImageVector
) { ) {
Row ( Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = label, text = label,
color = foregroundColor, color = foregroundColor,
@@ -88,7 +85,9 @@ private fun MediaSettingDropdown(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onBackground
) )
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Text(text = value, color = foregroundColor, fontSize = 14.sp) Text(text = value, color = foregroundColor, fontSize = 14.sp)

View File

@@ -50,10 +50,14 @@ fun PurefinComplexTextField(
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) }, placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
leadingIcon = if (leadingIcon != null) { leadingIcon = if (leadingIcon != null) {
{ Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) } { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }
} else null, } else {
null
},
trailingIcon = if (trailingIcon != null) { trailingIcon = if (trailingIcon != null) {
{ Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) } { Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) }
} else null, } else {
null
},
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceVariant, focusedContainerColor = scheme.surfaceVariant,
@@ -63,6 +67,7 @@ fun PurefinComplexTextField(
cursorColor = scheme.primary, cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface, focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface unfocusedTextColor = scheme.onSurface
)) )
)
} }
} }

View File

@@ -34,7 +34,6 @@ fun PurefinPasswordField(
leadingIcon: ImageVector, leadingIcon: ImageVector,
) { ) {
val scheme = MaterialTheme.colorScheme val scheme = MaterialTheme.colorScheme
val showField = remember { mutableStateOf(false) } val showField = remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
@@ -53,15 +52,20 @@ fun PurefinPasswordField(
.clip(RoundedCornerShape(12.dp)), .clip(RoundedCornerShape(12.dp)),
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) }, placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }, leadingIcon = { Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) },
trailingIcon = trailingIcon = {
{ IconButton(onClick = { showField.value = !showField.value }) {
IconButton( Icon(
onClick = { showField.value = !showField.value }, imageVector = Icons.Default.Visibility,
) { contentDescription = null,
Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) tint = scheme.onSurfaceVariant
} )
}, }
visualTransformation = if (showField.value) VisualTransformation.None else PasswordVisualTransformation(), },
visualTransformation = if (showField.value) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedContainerColor = scheme.surfaceVariant, focusedContainerColor = scheme.surfaceVariant,
unfocusedContainerColor = scheme.surfaceVariant, unfocusedContainerColor = scheme.surfaceVariant,

View File

@@ -12,16 +12,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.delay 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 @Composable
fun <T> EmptyValueTimedVisibility( fun <T> EmptyValueTimedVisibility(
value: T?, value: T?,
@@ -44,15 +34,6 @@ fun <T> EmptyValueTimedVisibility(
} }
} }
/**
* 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 @Composable
fun <T> ValueChangeTimedVisibility( fun <T> ValueChangeTimedVisibility(
value: T, value: T,

View File

@@ -0,0 +1,201 @@
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.Immutable
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 hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.common.textfield.PurefinComplexTextField
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Immutable
data class LoginContentState(
val serverUrl: String,
val username: String,
val password: String,
val errorMessage: String? = null
)
class LoginContentCallbacks(
val onServerUrlChange: (String) -> Unit,
val onUsernameChange: (String) -> Unit,
val onPasswordChange: (String) -> Unit,
val onConnect: () -> Unit,
val onDiscoverServers: () -> Unit = {},
val onNeedHelp: () -> Unit = {}
)
@Composable
fun LoginContent(
state: LoginContentState,
callbacks: LoginContentCallbacks,
isLoggingIn: Boolean,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
if (isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
return
}
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
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))
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)
)
state.errorMessage?.let { errorMessage ->
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 = state.serverUrl,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinComplexTextField(
label = "Username",
value = state.username,
onValueChange = callbacks.onUsernameChange,
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp))
PurefinPasswordField(
label = "Password",
value = state.password,
onValueChange = callbacks.onPasswordChange,
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = callbacks.onConnect
)
Spacer(modifier = Modifier.weight(0.5f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = callbacks.onDiscoverServers) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = scheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
Text(" Discover Servers", color = scheme.onSurfaceVariant)
}
}
TextButton(onClick = callbacks.onNeedHelp) {
Text("Need Help?", color = scheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

@@ -0,0 +1,117 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.animation.AnimatedVisibility
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
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.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.player.model.PlayerUiState
@Composable
fun PlayerLoadingErrorEndCardContent(
uiState: PlayerUiState,
onRetry: () -> Unit,
onNext: () -> Unit,
onReplay: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
showBufferWhileError: Boolean = false,
cardPadding: Dp = 16.dp,
cardSpacing: Dp = 12.dp,
backgroundAlpha: Float = 0.9f,
headlineStyle: TextStyle = MaterialTheme.typography.bodyLarge
) {
val scheme = MaterialTheme.colorScheme
Box(modifier = modifier) {
AnimatedVisibility(visible = uiState.isBuffering && (showBufferWhileError || uiState.error == null)) {
CircularProgressIndicator(color = scheme.primary)
}
AnimatedVisibility(visible = uiState.error != null) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = backgroundAlpha))
.padding(cardPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(cardSpacing)
) {
Text(
text = uiState.error ?: "Playback error",
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = headlineStyle
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onRetry) {
Text("Retry")
}
Button(
onClick = onDismissError,
colors = ButtonDefaults.buttonColors(containerColor = scheme.surface)
) {
Text("Dismiss", color = scheme.onSurface)
}
}
}
}
AnimatedVisibility(visible = uiState.isEnded && uiState.error == null && !uiState.isBuffering) {
val nextUp = uiState.queue.getOrNull(
uiState.queue.indexOfFirst { it.isCurrent }
.takeIf { it >= 0 }
?.plus(1) ?: -1
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(scheme.background.copy(alpha = backgroundAlpha))
.padding(cardPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(cardSpacing)
) {
if (nextUp != null) {
Text(
text = "Up next",
color = scheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = nextUp.title,
color = scheme.onBackground,
fontWeight = FontWeight.Bold,
style = headlineStyle
)
Button(onClick = onNext) {
Text("Play next")
}
} else {
Text(
text = "Playback finished",
color = scheme.onBackground,
style = headlineStyle
)
Button(onClick = onReplay) {
Text("Replay")
}
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
package hu.bbara.purefin.ui.screen.player.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
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
@Composable
fun PlayerSeekBarTrack(
positionMs: Long,
durationMs: Long,
bufferedMs: Long,
chapterMarkers: List<TimedMarker>,
adMarkers: List<TimedMarker>,
modifier: Modifier = Modifier,
isFocused: Boolean = false,
trackHeight: Dp = 4.dp,
focusedTrackHeight: Dp = 6.dp,
thumbRadius: Dp = 6.dp,
focusedThumbRadius: Dp = 9.dp,
focusedThumbHaloRadiusDelta: Dp = 4.dp
) {
val scheme = MaterialTheme.colorScheme
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
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) }
Canvas(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 2.dp, vertical = 10.dp)
) {
val activeTrackHeight = if (isFocused) focusedTrackHeight.toPx() else trackHeight.toPx()
val trackTop = size.height / 2 - activeTrackHeight / 2
drawRect(
color = scheme.onSurface.copy(alpha = 0.2f),
size = Size(width = size.width, height = activeTrackHeight),
topLeft = Offset(0f, trackTop)
)
drawRect(
color = scheme.onSurface.copy(alpha = 0.4f),
size = Size(width = bufferRatio * size.width, height = activeTrackHeight),
topLeft = Offset(0f, trackTop)
)
val progressWidth = progressRatio * size.width
drawRect(
color = scheme.primary,
size = Size(width = progressWidth, height = activeTrackHeight),
topLeft = Offset(0f, trackTop)
)
val activeThumbRadius = if (isFocused) focusedThumbRadius.toPx() else thumbRadius.toPx()
val thumbCenter = Offset(progressWidth.coerceIn(0f, size.width), size.height / 2)
drawCircle(
color = scheme.primary,
radius = activeThumbRadius,
center = thumbCenter
)
if (isFocused) {
drawCircle(
color = scheme.primary.copy(alpha = 0.3f),
radius = activeThumbRadius + focusedThumbHaloRadiusDelta.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

@@ -0,0 +1,23 @@
package hu.bbara.purefin.ui.common.media
import org.junit.Assert.assertEquals
import org.junit.Test
class MediaPlayButtonLabelTest {
@Test
fun `returns Resume when playback started and item not watched`() {
assertEquals("Resume", mediaPlayButtonText(progressPercent = 37.5, watched = false))
}
@Test
fun `returns Play when playback missing or item watched`() {
assertEquals("Play", mediaPlayButtonText(progressPercent = null, watched = false))
assertEquals("Play", mediaPlayButtonText(progressPercent = 37.5, watched = true))
}
@Test
fun `converts percent to normalized progress`() {
assertEquals(0.375f, mediaPlaybackProgress(37.5), 0.0001f)
assertEquals(0f, mediaPlaybackProgress(null), 0.0001f)
}
}

View File

@@ -27,6 +27,7 @@ include(":app")
include(":app-tv") include(":app-tv")
include(":core:model") include(":core:model")
include(":core:image") include(":core:image")
include(":core:ui")
include(":core:data") include(":core:data")
include(":core:navigation") include(":core:navigation")
include(":core:download") include(":core:download")