mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
Extract shared Compose UI into core module
This commit is contained in:
@@ -53,6 +53,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:image"))
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:navigation"))
|
||||
implementation(project(":core:download"))
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:name="hu.bbara.purefin.TvApplication"
|
||||
android:name="hu.bbara.purefin.tv.TvApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/banner"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -25,7 +25,7 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<activity
|
||||
android:name="hu.bbara.purefin.TvActivity"
|
||||
android:name="hu.bbara.purefin.tv.TvActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Purefin">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin
|
||||
package hu.bbara.purefin.tv
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
@@ -1,4 +1,4 @@
|
||||
package hu.bbara.purefin
|
||||
package hu.bbara.purefin.tv
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,13 @@
|
||||
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.ui.common.image.PurefinAsyncImage
|
||||
import hu.bbara.purefin.ui.common.badge.UnwatchedEpisodeBadge
|
||||
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
import hu.bbara.purefin.feature.browse.home.FocusableItem
|
||||
import hu.bbara.purefin.feature.browse.home.PosterItem
|
||||
import java.util.UUID
|
||||
import hu.bbara.purefin.core.model.MediaKind
|
||||
|
||||
@Composable
|
||||
fun PosterCard(
|
||||
@@ -51,106 +23,66 @@ fun PosterCard(
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.07f else 1.0f, label = "scale")
|
||||
PosterCardContent(
|
||||
model = item.toPosterCardModel(),
|
||||
onClick = { item.open(onMovieSelected, onSeriesSelected, onEpisodeSelected) },
|
||||
modifier = modifier,
|
||||
imageModifier = imageModifier,
|
||||
posterWidth = posterWidth,
|
||||
showSecondaryText = showSecondaryText,
|
||||
indicatorSize = indicatorSize,
|
||||
indicatorPadding = indicatorPadding,
|
||||
onFocused = { onFocusedItem(item) },
|
||||
focusedScale = 1.07f,
|
||||
focusedBorderWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
fun openItem(posterItem: PosterItem) {
|
||||
when (posterItem.type) {
|
||||
MediaKind.MOVIE -> onMovieSelected(posterItem.id)
|
||||
MediaKind.SERIES -> onSeriesSelected(posterItem.id)
|
||||
MediaKind.EPISODE -> {
|
||||
val ep = posterItem.episode!!
|
||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private fun PosterItem.toPosterCardModel(): PosterCardModel {
|
||||
return PosterCardModel(
|
||||
title = title,
|
||||
secondaryText = secondaryText,
|
||||
imageUrl = imageUrl,
|
||||
mediaKind = type,
|
||||
badge = when (type) {
|
||||
MediaKind.MOVIE -> {
|
||||
val movie = requireNotNull(movie)
|
||||
PosterCardBadge.WatchState(
|
||||
watched = movie.watched,
|
||||
started = (movie.progress ?: 0.0) > 0.0
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
color = scheme.onBackground,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
||||
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
|
||||
)
|
||||
item.secondaryText
|
||||
.takeIf { showSecondaryText }
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { text ->
|
||||
Text(
|
||||
text = text,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
else -> PosterCardBadge.None
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun PosterItem.open(
|
||||
onMovieSelected: (UUID) -> Unit,
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
when (type) {
|
||||
MediaKind.MOVIE -> onMovieSelected(id)
|
||||
MediaKind.SERIES -> onSeriesSelected(id)
|
||||
MediaKind.EPISODE -> {
|
||||
val ep = requireNotNull(episode)
|
||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ internal fun MediaDetailsTopBar(
|
||||
backFocusRequester: FocusRequester? = null,
|
||||
downFocusRequester: FocusRequester? = null
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val downModifier = if (downFocusRequester != null) {
|
||||
Modifier.focusProperties { down = downFocusRequester }
|
||||
} else {
|
||||
@@ -78,7 +79,11 @@ internal fun MediaDetailsTopBar(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
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) {
|
||||
GhostTextButton(
|
||||
@@ -93,13 +98,21 @@ internal fun MediaDetailsTopBar(
|
||||
icon = Icons.Outlined.Cast,
|
||||
contentDescription = "Cast",
|
||||
onClick = onCastClick,
|
||||
modifier = downModifier
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
GhostIconButton(
|
||||
icon = Icons.Outlined.MoreVert,
|
||||
contentDescription = "More",
|
||||
onClick = onMoreClick,
|
||||
modifier = downModifier
|
||||
modifier = downModifier,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.primary,
|
||||
focusedBackgroundColor = scheme.primary.copy(alpha = 0.25f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package hu.bbara.purefin.ui.screen.episode.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.unit.dp
|
||||
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.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
|
||||
|
||||
internal const val EpisodePlayButtonTag = "episode-play-button"
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun TvEpisodeHeroSection(
|
||||
episode: Episode,
|
||||
@@ -71,33 +70,26 @@ internal fun TvEpisodeHeroSection(
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
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.35f),
|
||||
textColor = scheme.primary
|
||||
)
|
||||
}
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||
highlightedItem = episode.format
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = episode.playButtonText(),
|
||||
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||
progress = mediaPlaybackProgress(episode.progress),
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.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"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ private fun TvLibraryTopBar(
|
||||
title: String,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -74,7 +75,11 @@ private fun TvLibraryTopBar(
|
||||
PurefinIconButton(
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack
|
||||
onClick = onBack,
|
||||
focusedScale = 1.1f,
|
||||
focusedBorderWidth = 2.5.dp,
|
||||
focusedBorderColor = scheme.onPrimary,
|
||||
focusedBackgroundColor = scheme.primary
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -31,16 +7,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.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 kotlinx.coroutines.launch
|
||||
|
||||
@@ -49,166 +17,52 @@ fun LoginScreen(
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe ViewModel state
|
||||
val serverUrl by viewModel.url.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
var isLoggingIn by remember { mutableStateOf(false) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
// Logo Section
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(scheme.primary, RoundedCornerShape(24.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Movie,
|
||||
contentDescription = "Logo",
|
||||
tint = scheme.onPrimary,
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Jellyfin",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "PERSONAL MEDIA SYSTEM",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
// Form Section
|
||||
Text(
|
||||
text = "Connect to Server",
|
||||
color = scheme.onBackground,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.align(Alignment.Start)
|
||||
)
|
||||
Text(
|
||||
text = "Enter your details to access your library",
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Start)
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = scheme.error,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
scheme.errorContainer,
|
||||
RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Server URL",
|
||||
value = serverUrl,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUrl(it)
|
||||
},
|
||||
placeholder = "http://192.168.1.100:8096",
|
||||
leadingIcon = Icons.Default.Storage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinComplexTextField(
|
||||
label = "Username",
|
||||
value = username,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
placeholder = "Enter your username",
|
||||
leadingIcon = Icons.Default.Person
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
PurefinPasswordField(
|
||||
label = "Password",
|
||||
value = password,
|
||||
onValueChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
placeholder = "••••••••",
|
||||
leadingIcon = Icons.Default.Lock,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PurefinTextButton(
|
||||
content = { Text("Connect") },
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
isLoggingIn = true
|
||||
try {
|
||||
viewModel.login()
|
||||
} finally {
|
||||
isLoggingIn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
// Footer Links
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = {}) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Search, contentDescription = null, tint = scheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
Text(" Discover Servers", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {}) {
|
||||
Text("Need Help?", color = scheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
val state = remember(serverUrl, username, password, errorMessage) {
|
||||
LoginContentState(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
password = password,
|
||||
errorMessage = errorMessage
|
||||
)
|
||||
}
|
||||
val callbacks = remember(viewModel, coroutineScope) {
|
||||
LoginContentCallbacks(
|
||||
onServerUrlChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUrl(it)
|
||||
},
|
||||
onUsernameChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
onPasswordChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
onConnect = {
|
||||
coroutineScope.launch {
|
||||
isLoggingIn = true
|
||||
try {
|
||||
viewModel.login()
|
||||
} finally {
|
||||
isLoggingIn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LoginContent(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
isLoggingIn = isLoggingIn,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package hu.bbara.purefin.ui.screen.movie.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.unit.dp
|
||||
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.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
|
||||
|
||||
internal const val MoviePlayButtonTag = "movie-play-button"
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun TvMovieHeroSection(
|
||||
movie: Movie,
|
||||
@@ -51,33 +50,26 @@ internal fun TvMovieHeroSection(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||
highlightedItem = movie.format
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = movie.playButtonText(),
|
||||
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = onPlay,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.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"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@@ -30,84 +15,17 @@ internal fun TvPlayerLoadingErrorEndCard(
|
||||
onDismissError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedVisibility(visible = uiState.isBuffering && uiState.error == null) {
|
||||
CircularProgressIndicator(color = scheme.primary)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = uiState.error != null) {
|
||||
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)
|
||||
) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerLoadingErrorEndCardContent(
|
||||
uiState = uiState,
|
||||
onRetry = onRetry,
|
||||
onNext = onNext,
|
||||
onReplay = onReplay,
|
||||
onDismissError = onDismissError,
|
||||
modifier = modifier,
|
||||
showBufferWhileError = false,
|
||||
cardPadding = 24.dp,
|
||||
cardSpacing = 16.dp,
|
||||
backgroundAlpha = 0.92f,
|
||||
headlineStyle = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package hu.bbara.purefin.ui.screen.player.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.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.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.MarkerType
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
|
||||
@Composable
|
||||
@@ -43,13 +39,8 @@ internal fun TvPlayerSeekBar(
|
||||
focusRequester: FocusRequester = remember { FocusRequester() },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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) }
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
@@ -90,52 +81,17 @@ internal fun TvPlayerSeekBar(
|
||||
.focusable(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 2.dp, vertical = 10.dp)
|
||||
) {
|
||||
val trackHeight = if (isFocused) 6f else 4f
|
||||
val trackTop = size.height / 2 - trackHeight / 2
|
||||
drawRect(
|
||||
color = scheme.onSurface.copy(alpha = 0.2f),
|
||||
size = Size(width = size.width, height = trackHeight),
|
||||
topLeft = Offset(0f, trackTop)
|
||||
)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = position,
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isFocused = isFocused,
|
||||
thumbRadius = 7.dp,
|
||||
focusedThumbRadius = 9.dp,
|
||||
focusedThumbHaloRadiusDelta = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@@ -54,11 +52,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.button.MediaResumeButton
|
||||
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
|
||||
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.model.CastMember
|
||||
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 SeriesFirstSeasonTabTag = "series-first-season-tab"
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun TvSeriesMetaChips(series: Series) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MediaMetaChip(text = series.year)
|
||||
MediaMetaChip(text = "${series.seasonCount} Seasons")
|
||||
}
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(series.year, "${series.seasonCount} Seasons")
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -241,14 +235,21 @@ internal fun TvSeriesHeroSection(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
MediaResumeButton(
|
||||
text = nextUpEpisode.playButtonText(),
|
||||
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
|
||||
progress = mediaPlaybackProgress(nextUpEpisode.progress),
|
||||
onClick = { onPlayEpisode(nextUpEpisode) },
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 216.dp, maxWidth = 240.dp)
|
||||
.focusRequester(playFocusRequester)
|
||||
.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 {
|
||||
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 {
|
||||
return if ((progress ?: 0.0) > 0.0 && !watched) "Continue Watching" else "Up Next"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(project(":core:model"))
|
||||
implementation(project(":core:image"))
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:data"))
|
||||
implementation(project(":core:navigation"))
|
||||
implementation(project(":core:download"))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -1,34 +1,10 @@
|
||||
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.ui.Alignment
|
||||
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.feature.browse.home.PosterItem
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun PosterCard(
|
||||
@@ -38,83 +14,58 @@ fun PosterCard(
|
||||
onSeriesSelected: (UUID) -> Unit,
|
||||
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
PosterCardContent(
|
||||
model = item.toPosterCardModel(),
|
||||
onClick = { item.open(onMovieSelected, onSeriesSelected, onEpisodeSelected) },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
val posterWidth = 144.dp
|
||||
val posterHeight = posterWidth * 3 / 2
|
||||
|
||||
fun openItem(posterItem: PosterItem) {
|
||||
when (posterItem.type) {
|
||||
MediaKind.MOVIE -> onMovieSelected(posterItem.id)
|
||||
MediaKind.SERIES -> onSeriesSelected(posterItem.id)
|
||||
MediaKind.EPISODE -> {
|
||||
val ep = posterItem.episode!!
|
||||
onEpisodeSelected(ep.seriesId, ep.seasonId, ep.id)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
internal fun PosterItem.toPosterCardModel(): PosterCardModel {
|
||||
return PosterCardModel(
|
||||
title = title,
|
||||
secondaryText = secondaryText,
|
||||
imageUrl = imageUrl,
|
||||
mediaKind = type,
|
||||
badge = when (type) {
|
||||
MediaKind.MOVIE -> {
|
||||
val movie = requireNotNull(movie)
|
||||
PosterCardBadge.WatchState(
|
||||
watched = movie.watched,
|
||||
started = (movie.progress ?: 0.0) > 0.0
|
||||
)
|
||||
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,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp, end = 4.dp, bottom = 8.dp),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,8 @@ import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.sp
|
||||
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.common.media.MediaHero
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
@@ -211,25 +208,12 @@ private fun EpisodeHeroSection(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EpisodeMetaChips(episode: Episode) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
FlowRow(
|
||||
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
|
||||
)
|
||||
}
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(episode.releaseDate, episode.rating, episode.runtime, episode.format),
|
||||
highlightedItem = episode.format
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
||||
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.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
|
||||
@@ -134,8 +136,8 @@ internal fun EpisodeDetails(
|
||||
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = if (episode.progress == null) "Play" else "Resume",
|
||||
progress = episode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(episode.progress, episode.watched),
|
||||
progress = mediaPlaybackProgress(episode.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp)
|
||||
)
|
||||
|
||||
@@ -23,12 +23,12 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.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.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
|
||||
internal fun HomeBrowseCard(
|
||||
@@ -94,7 +94,7 @@ internal fun HomeBrowseCard(
|
||||
when (item.type) {
|
||||
MediaKind.MOVIE -> {
|
||||
val movie = item.movie!!
|
||||
WatchStateIndicator(
|
||||
WatchStateBadge(
|
||||
size = 28,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
@@ -106,7 +106,7 @@ internal fun HomeBrowseCard(
|
||||
|
||||
MediaKind.EPISODE -> {
|
||||
val episode = item.episode!!
|
||||
WatchStateIndicator(
|
||||
WatchStateBadge(
|
||||
size = 28,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
@@ -117,7 +117,7 @@ internal fun HomeBrowseCard(
|
||||
}
|
||||
|
||||
MediaKind.SERIES -> {
|
||||
UnwatchedEpisodeIndicator(
|
||||
UnwatchedEpisodeBadge(
|
||||
size = 28,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -31,16 +7,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import hu.bbara.purefin.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 kotlinx.coroutines.launch
|
||||
|
||||
@@ -49,166 +17,52 @@ fun LoginScreen(
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe ViewModel state
|
||||
val serverUrl by viewModel.url.collectAsState()
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
var isLoggingIn by remember { mutableStateOf(false) }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (isLoggingIn) {
|
||||
PurefinWaitingScreen(modifier = modifier)
|
||||
} else {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(0.5f))
|
||||
|
||||
// Logo Section
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.background(scheme.primary, RoundedCornerShape(24.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Movie, // 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 state = remember(serverUrl, username, password, errorMessage) {
|
||||
LoginContentState(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
password = password,
|
||||
errorMessage = errorMessage
|
||||
)
|
||||
}
|
||||
val callbacks = remember(viewModel, coroutineScope) {
|
||||
LoginContentCallbacks(
|
||||
onServerUrlChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUrl(it)
|
||||
},
|
||||
onUsernameChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setUsername(it)
|
||||
},
|
||||
onPasswordChange = {
|
||||
viewModel.clearError()
|
||||
viewModel.setPassword(it)
|
||||
},
|
||||
onConnect = {
|
||||
coroutineScope.launch {
|
||||
isLoggingIn = true
|
||||
try {
|
||||
viewModel.login()
|
||||
} finally {
|
||||
isLoggingIn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LoginContent(
|
||||
state = state,
|
||||
callbacks = callbacks,
|
||||
isLoggingIn = isLoggingIn,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,8 @@ import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.sp
|
||||
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.common.media.MediaHero
|
||||
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
|
||||
import hu.bbara.purefin.core.download.DownloadState
|
||||
import hu.bbara.purefin.core.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.MovieDto
|
||||
@@ -170,20 +168,10 @@ fun MediaHeroSection(
|
||||
lineHeight = 38.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(movie.year, movie.rating, movie.runtime, movie.format),
|
||||
highlightedItem = movie.format
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import hu.bbara.purefin.ui.common.media.MediaCastRow
|
||||
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.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.media.MediaPlaybackSettings
|
||||
@@ -92,8 +94,8 @@ internal fun MovieDetails(
|
||||
|
||||
Row() {
|
||||
MediaResumeButton(
|
||||
text = if (movie.progress == null) "Play" else "Resume",
|
||||
progress = movie.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(movie.progress, movie.watched),
|
||||
progress = mediaPlaybackProgress(movie.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp)
|
||||
)
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.PlayerUiState
|
||||
|
||||
@Composable
|
||||
@@ -30,73 +14,14 @@ fun PlayerLoadingErrorEndCard(
|
||||
onReplay: () -> Unit,
|
||||
onDismissError: () -> Unit
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(modifier = modifier) {
|
||||
AnimatedVisibility(visible = uiState.isBuffering) {
|
||||
CircularProgressIndicator(color = scheme.primary)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = uiState.error != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerLoadingErrorEndCardContent(
|
||||
uiState = uiState,
|
||||
onRetry = onRetry,
|
||||
onNext = onNext,
|
||||
onReplay = onReplay,
|
||||
onDismissError = onDismissError,
|
||||
modifier = modifier,
|
||||
showBufferWhileError = true,
|
||||
headlineStyle = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,11 +17,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import hu.bbara.purefin.core.player.model.MarkerType
|
||||
import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
|
||||
@Composable
|
||||
@@ -36,15 +33,11 @@ fun PlayerSeekBar(
|
||||
onScrubFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
val currentPosition = positionMs.coerceIn(0, safeDuration)
|
||||
var sliderPosition by remember { mutableFloatStateOf(currentPosition.toFloat()) }
|
||||
var isScrubbing by remember { mutableStateOf(false) }
|
||||
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(
|
||||
modifier = modifier
|
||||
@@ -53,50 +46,18 @@ fun PlayerSeekBar(
|
||||
.height(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 2.dp, vertical = 10.dp)
|
||||
) {
|
||||
val trackHeight = 4f
|
||||
val trackTop = size.height / 2 - trackHeight / 2
|
||||
// Buffered bar
|
||||
val bufferWidth = bufferRatio * size.width
|
||||
drawRect(
|
||||
color = scheme.onSurface.copy(alpha = 0.2f),
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
PlayerSeekBarTrack(
|
||||
positionMs = sliderValue.toLong(),
|
||||
durationMs = safeDuration,
|
||||
bufferedMs = bufferedMs,
|
||||
chapterMarkers = chapterMarkers,
|
||||
adMarkers = adMarkers,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
isFocused = false,
|
||||
focusedTrackHeight = 4.dp,
|
||||
focusedThumbRadius = 6.dp,
|
||||
focusedThumbHaloRadiusDelta = 0.dp
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { newValue ->
|
||||
|
||||
@@ -8,8 +8,6 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@@ -58,13 +56,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.MediaActionButton
|
||||
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
|
||||
import hu.bbara.purefin.ui.common.button.MediaResumeButton
|
||||
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.image.ImageUrlBuilder
|
||||
import hu.bbara.purefin.core.navigation.EpisodeDto
|
||||
@@ -102,24 +102,11 @@ internal fun SeriesTopBar(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SeriesMetaChips(series: Series) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
FlowRow(
|
||||
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
|
||||
// )
|
||||
}
|
||||
MediaMetadataFlowRow(
|
||||
items = listOf(series.year, "${series.seasonCount} Seasons")
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -157,8 +144,8 @@ internal fun SeriesActionButtons(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (playAction != null && nextUpEpisode != null) {
|
||||
MediaResumeButton(
|
||||
text = if ((nextUpEpisode.progress ?: 0.0) > 0.0 && !nextUpEpisode.watched) "Resume" else "Play",
|
||||
progress = nextUpEpisode.progress?.div(100)?.toFloat() ?: 0f,
|
||||
text = mediaPlayButtonText(nextUpEpisode.progress, nextUpEpisode.watched),
|
||||
progress = mediaPlaybackProgress(nextUpEpisode.progress),
|
||||
onClick = playAction,
|
||||
modifier = Modifier.sizeIn(maxWidth = 200.dp)
|
||||
)
|
||||
@@ -388,7 +375,7 @@ private fun EpisodeCard(
|
||||
.align(Alignment.BottomStart)
|
||||
)
|
||||
} else {
|
||||
WatchStateIndicator(
|
||||
WatchStateBadge(
|
||||
watched = episode.watched,
|
||||
started = (episode.progress ?: 0.0) > 0.0,
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
46
core/ui/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -43,4 +43,4 @@ fun UnwatchedEpisodeBadge(
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,7 @@ fun WatchStateBadge(
|
||||
size: Int = 24,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
||||
if (watched.not() && started.not()) {
|
||||
if (!watched && !started) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +38,6 @@ fun WatchStateBadge(
|
||||
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
|
||||
@@ -64,7 +62,7 @@ fun WatchStateBadge(
|
||||
@Preview
|
||||
@Composable
|
||||
private fun WatchStateBadgePreview() {
|
||||
Column() {
|
||||
Column {
|
||||
WatchStateBadge(
|
||||
watched = false,
|
||||
started = false
|
||||
@@ -16,14 +16,6 @@ import androidx.compose.ui.graphics.Color
|
||||
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
|
||||
fun MediaProgressBar(
|
||||
progress: Float,
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -22,35 +21,55 @@ 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 GhostIconButton(
|
||||
internal fun CircularIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
contentDescription: String?,
|
||||
containerColor: Color,
|
||||
iconColor: Color,
|
||||
size: Dp,
|
||||
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) }
|
||||
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")
|
||||
val scale by animateFloatAsState(
|
||||
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(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(52.dp)
|
||||
.border(if (isFocused) 2.5.dp else 0.dp, borderColor, CircleShape)
|
||||
.size(size)
|
||||
.border(
|
||||
width = if (isFocused) focusedBorderWidth else focusedBorderWidth * 0,
|
||||
color = borderColor,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(if (isFocused) scheme.primary.copy(alpha = 0.25f) else scheme.background.copy(alpha = 0.65f))
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.background(backgroundColor)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = scheme.onBackground
|
||||
tint = iconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@@ -43,45 +43,57 @@ fun MediaResumeButton(
|
||||
text: String,
|
||||
progress: Float = 0f,
|
||||
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 primaryColor = scheme.primary
|
||||
val onPrimaryColor = scheme.onPrimary
|
||||
val focusShape = RoundedCornerShape(50)
|
||||
val shape = RoundedCornerShape(50)
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(targetValue = if (isFocused) 1.08f else 1.0f, label = "scale")
|
||||
val focusBorderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.onBackground else Color.Transparent,
|
||||
label = "focus-border"
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) focusedScale else 1f,
|
||||
label = "scale"
|
||||
)
|
||||
val focusHaloColor by animateColorAsState(
|
||||
targetValue = if (isFocused) scheme.primary.copy(alpha = 0.22f) else Color.Transparent,
|
||||
val haloColor by animateColorAsState(
|
||||
targetValue = if (isFocused) focusHaloColor else Color.Transparent,
|
||||
label = "focus-halo"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = if (isFocused) focusBorderColor else Color.Transparent,
|
||||
label = "focus-border"
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.height(52.dp)
|
||||
.background(focusHaloColor, focusShape)
|
||||
.border(3.dp, focusBorderColor, focusShape)
|
||||
.clip(focusShape)
|
||||
.background(haloColor, shape)
|
||||
.border(
|
||||
width = if (isFocused) focusBorderWidth else 0.dp,
|
||||
color = borderColor,
|
||||
shape = shape
|
||||
)
|
||||
.clip(shape)
|
||||
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
|
||||
.clickable(onClick = onClick)
|
||||
.focusable()
|
||||
.then(if (focusable) Modifier.focusable() else Modifier)
|
||||
) {
|
||||
// Bottom layer: inverted colors (visible for the remaining %)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(onPrimaryColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ButtonContent(text = text, color = primaryColor)
|
||||
ResumeButtonContent(text = text, color = primaryColor)
|
||||
}
|
||||
|
||||
// Top layer: primary colors, clipped to the progress %
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -99,21 +111,21 @@ fun MediaResumeButton(
|
||||
.background(primaryColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ButtonContent(text = text, color = onPrimaryColor)
|
||||
ResumeButtonContent(text = text, color = onPrimaryColor)
|
||||
}
|
||||
|
||||
if (isFocused) {
|
||||
if (isFocused && overlayBorderWidth > 0.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(2.dp, scheme.primary.copy(alpha = 0.95f), focusShape)
|
||||
.border(overlayBorderWidth, overlayBorderColor, shape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonContent(text: String, color: Color) {
|
||||
private fun ResumeButtonContent(text: String, color: Color) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ fun PurefinTextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit // Slot API
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
@@ -23,4 +23,4 @@ fun PurefinTextButton(
|
||||
),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,32 +20,24 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
fun PurefinAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
fallbackIcon: ImageVector = Icons.Outlined.BrokenImage
|
||||
fallbackIcon: ImageVector? = Icons.Outlined.BrokenImage
|
||||
) {
|
||||
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
|
||||
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
// Convert empty string to null to properly trigger fallback
|
||||
val effectiveModel = if (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) }
|
||||
val placeholderPainter = ColorPainter(surfaceVariant)
|
||||
val effectiveModel = when {
|
||||
model is String && model.isEmpty() -> null
|
||||
else -> model
|
||||
}
|
||||
var showFallbackIcon by remember(effectiveModel, fallbackIcon) {
|
||||
mutableStateOf(effectiveModel == null && fallbackIcon != null)
|
||||
}
|
||||
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
@@ -53,14 +45,14 @@ fun PurefinAsyncImage(
|
||||
model = effectiveModel,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
placeholder = ColorPainter(surfaceVariant),
|
||||
error = ColorPainter(surfaceVariant),
|
||||
fallback = ColorPainter(surfaceVariant),
|
||||
placeholder = placeholderPainter,
|
||||
error = placeholderPainter,
|
||||
fallback = placeholderPainter,
|
||||
onLoading = { showFallbackIcon = false },
|
||||
onSuccess = { showFallbackIcon = false },
|
||||
onError = { showFallbackIcon = true },
|
||||
onError = { showFallbackIcon = fallbackIcon != null },
|
||||
)
|
||||
if (showFallbackIcon) {
|
||||
if (showFallbackIcon && fallbackIcon != null) {
|
||||
Icon(
|
||||
imageVector = fallbackIcon,
|
||||
contentDescription = null,
|
||||
@@ -105,7 +105,8 @@ fun MediaCastRow(
|
||||
model = member.imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.Crop,
|
||||
fallbackIcon = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -38,8 +38,7 @@ fun MediaPlaybackSettings(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
MediaSettingDropdown(
|
||||
backgroundColor = backgroundColor,
|
||||
@@ -67,9 +66,7 @@ private fun MediaSettingDropdown(
|
||||
value: String,
|
||||
icon: ImageVector
|
||||
) {
|
||||
Row (
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
color = foregroundColor,
|
||||
@@ -88,7 +85,9 @@ private fun MediaSettingDropdown(
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(text = value, color = foregroundColor, fontSize = 14.sp)
|
||||
@@ -100,4 +99,4 @@ private fun MediaSettingDropdown(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,14 @@ fun PurefinComplexTextField(
|
||||
placeholder = { Text(placeholder, color = scheme.onSurfaceVariant) },
|
||||
leadingIcon = if (leadingIcon != null) {
|
||||
{ Icon(leadingIcon, contentDescription = null, tint = scheme.onSurfaceVariant) }
|
||||
} else null,
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingIcon = if (trailingIcon != null) {
|
||||
{ Icon(Icons.Default.Visibility, contentDescription = null, tint = scheme.onSurfaceVariant) }
|
||||
} else null,
|
||||
} else {
|
||||
null
|
||||
},
|
||||
visualTransformation = visualTransformation,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = scheme.surfaceVariant,
|
||||
@@ -63,6 +67,7 @@ fun PurefinComplexTextField(
|
||||
cursorColor = scheme.primary,
|
||||
focusedTextColor = scheme.onSurface,
|
||||
unfocusedTextColor = scheme.onSurface
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ fun PurefinPasswordField(
|
||||
leadingIcon: ImageVector,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val showField = remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -53,15 +52,20 @@ fun PurefinPasswordField(
|
||||
.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(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showField.value = !showField.value }) {
|
||||
Icon(
|
||||
imageVector = 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,
|
||||
@@ -12,16 +12,6 @@ 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?,
|
||||
@@ -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
|
||||
fun <T> ValueChangeTimedVisibility(
|
||||
value: T,
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ include(":app")
|
||||
include(":app-tv")
|
||||
include(":core:model")
|
||||
include(":core:image")
|
||||
include(":core:ui")
|
||||
include(":core:data")
|
||||
include(":core:navigation")
|
||||
include(":core:download")
|
||||
|
||||
Reference in New Issue
Block a user