refactor: rename ui-common module to core-ui module

This commit is contained in:
2026-04-24 17:43:33 +02:00
parent 7232352014
commit ab7700d202
27 changed files with 3 additions and 3 deletions

View File

@@ -0,0 +1,46 @@
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 UnwatchedEpisodeBadge(
unwatchedCount: Int,
foregroundColor: Color = MaterialTheme.colorScheme.onPrimary,
backgroundColor: Color = MaterialTheme.colorScheme.primary,
size: Int = 24,
modifier: Modifier = Modifier
) {
if (unwatchedCount == 0) {
return
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.border(1.dp, backgroundColor.copy(alpha = 0.8f), CircleShape)
.background(backgroundColor.copy(alpha = 0.8f), CircleShape)
.size(size.dp)
.clip(CircleShape)
) {
Text(
text = if (unwatchedCount > 9) "9+" else unwatchedCount.toString(),
color = foregroundColor.copy(alpha = 0.8f),
fontWeight = FontWeight.W900,
fontSize = 15.sp
)
}
}

View File

@@ -0,0 +1,79 @@
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 WatchStateBadge(
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 && !started) {
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 WatchStateBadgePreview() {
Column {
WatchStateBadge(
watched = false,
started = false
)
WatchStateBadge(
watched = true,
started = false
)
WatchStateBadge(
watched = false,
started = true
)
}
}

View File

@@ -0,0 +1,45 @@
package hu.bbara.purefin.ui.common.bar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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
import androidx.compose.ui.unit.dp
@Composable
fun MediaProgressBar(
progress: Float,
foregroundColor: Color = MaterialTheme.colorScheme.onSurface,
backgroundColor: Color = MaterialTheme.colorScheme.primary,
contentPadding: PaddingValues = PaddingValues(start = 8.dp, end = 8.dp, bottom = 8.dp),
barHeight: Dp = 4.dp,
cornerRadius: Dp = 24.dp,
modifier: Modifier
) {
if (progress <= 0f) return
Box(
modifier = modifier
.padding(contentPadding)
.clip(RoundedCornerShape(cornerRadius))
.fillMaxWidth()
.height(barHeight)
.background(backgroundColor.copy(alpha = 0.2f))
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(progress)
.background(foregroundColor)
)
}
}

View File

@@ -0,0 +1,75 @@
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.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
internal fun CircularIconButton(
icon: ImageVector,
contentDescription: String?,
containerColor: Color,
iconColor: Color,
size: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedScale: Float = 1f,
focusedBorderWidth: Dp,
focusedBorderColor: Color,
focusedBackgroundColor: Color = containerColor
) {
var isFocused by remember { mutableStateOf(false) }
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(size)
.border(
width = if (isFocused) focusedBorderWidth else focusedBorderWidth * 0,
color = borderColor,
shape = CircleShape
)
.clip(CircleShape)
.background(backgroundColor)
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = iconColor
)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
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.focusable
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.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.draw.drawWithContent
import androidx.compose.ui.focus.onFocusChanged
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
@Composable
fun MediaResumeButton(
text: String,
progress: Float = 0f,
onClick: () -> Unit,
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 shape = RoundedCornerShape(50)
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1f,
label = "scale"
)
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(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)
.then(if (focusable) Modifier.focusable() else Modifier)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(onPrimaryColor),
contentAlignment = Alignment.Center
) {
ResumeButtonContent(text = text, color = primaryColor)
}
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
) {
ResumeButtonContent(text = text, color = onPrimaryColor)
}
if (isFocused && overlayBorderWidth > 0.dp) {
Box(
modifier = Modifier
.fillMaxSize()
.border(overlayBorderWidth, overlayBorderColor, shape)
)
}
}
}
@Composable
private fun ResumeButtonContent(text: String, color: Color) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(text, color = color, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(Modifier.width(8.dp))
Icon(Icons.Filled.PlayArrow, null, tint = color)
}
}

View File

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

View File

@@ -0,0 +1,26 @@
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
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
content = content
)
}

View File

@@ -0,0 +1,64 @@
package hu.bbara.purefin.ui.common.image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BrokenImage
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.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@Composable
fun PurefinAsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
fallbackIcon: ImageVector? = Icons.Outlined.BrokenImage
) {
val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
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(
modifier = Modifier.fillMaxSize(),
model = effectiveModel,
contentDescription = contentDescription,
contentScale = contentScale,
placeholder = placeholderPainter,
error = placeholderPainter,
fallback = placeholderPainter,
onLoading = { showFallbackIcon = false },
onSuccess = { showFallbackIcon = false },
onError = { showFallbackIcon = fallbackIcon != null },
)
if (showFallbackIcon && fallbackIcon != null) {
Icon(
imageVector = fallbackIcon,
contentDescription = null,
tint = onSurfaceVariant,
modifier = Modifier.size(32.dp)
)
}
}
}

View File

@@ -0,0 +1,135 @@
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.model.CastMember
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
@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(
items = cast,
key = { member -> "${member.name}:${member.role}:${member.imageUrl.orEmpty()}" }
) { 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,
fallbackIcon = null
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = member.name,
color = scheme.onBackground,
fontSize = nameSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = member.role,
color = mutedStrong,
fontSize = roleSize,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
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(
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,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = scheme.primary,
focusedTextColor = scheme.onSurface,
unfocusedTextColor = scheme.onSurface
)
)
}
}

View File

@@ -0,0 +1,68 @@
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
@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)
}
}
@Composable
fun <T> ValueChangeTimedVisibility(
value: T,
hideAfterMillis: Long = 1_000,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
var displayedValue by remember { mutableStateOf(value) }
var isVisible by remember { mutableStateOf(false) }
var hasInitialValue by remember { mutableStateOf(false) }
LaunchedEffect(value) {
displayedValue = value
if (!hasInitialValue) {
hasInitialValue = true
return@LaunchedEffect
}
isVisible = true
delay(hideAfterMillis)
isVisible = false
}
AnimatedVisibility(
visible = isVisible,
modifier = modifier,
enter = EnterTransition.None,
exit = ExitTransition.None
) {
content(displayedValue)
}
}

View File

@@ -0,0 +1,201 @@
package hu.bbara.purefin.ui.screen.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.button.PurefinTextButton
import hu.bbara.purefin.ui.common.textfield.PurefinComplexTextField
import hu.bbara.purefin.ui.common.textfield.PurefinPasswordField
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@Immutable
data class LoginContentState(
val serverUrl: String,
val username: String,
val password: String,
val errorMessage: String? = null
)
class LoginContentCallbacks(
val onServerUrlChange: (String) -> Unit,
val onUsernameChange: (String) -> Unit,
val onPasswordChange: (String) -> Unit,
val onConnect: () -> Unit,
val onDiscoverServers: () -> Unit = {},
val onNeedHelp: () -> Unit = {}
)
@Composable
fun LoginContent(
state: LoginContentState,
callbacks: LoginContentCallbacks,
isLoggingIn: Boolean,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
if (isLoggingIn) {
PurefinWaitingScreen(modifier = modifier)
return
}
Column(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.5f))
Box(
modifier = Modifier
.size(100.dp)
.background(scheme.primary, RoundedCornerShape(24.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Movie,
contentDescription = "Logo",
tint = scheme.onPrimary,
modifier = Modifier.size(60.dp)
)
}
Text(
text = "Jellyfin",
color = scheme.onBackground,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = "PERSONAL MEDIA SYSTEM",
color = scheme.onSurfaceVariant,
fontSize = 12.sp,
letterSpacing = 2.sp
)
Spacer(modifier = Modifier.height(48.dp))
Text(
text = "Connect to Server",
color = scheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Text(
text = "Enter your details to access your library",
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
modifier = Modifier
.align(Alignment.Start)
.padding(bottom = 24.dp)
)
state.errorMessage?.let { errorMessage ->
Text(
text = errorMessage,
color = scheme.error,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.background(
scheme.errorContainer,
RoundedCornerShape(8.dp)
)
.padding(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
PurefinComplexTextField(
label = "Server URL",
value = state.serverUrl,
onValueChange = callbacks.onServerUrlChange,
placeholder = "http://192.168.1.100:8096",
leadingIcon = Icons.Default.Storage
)
Spacer(modifier = Modifier.height(16.dp))
PurefinComplexTextField(
label = "Username",
value = state.username,
onValueChange = callbacks.onUsernameChange,
placeholder = "Enter your username",
leadingIcon = Icons.Default.Person
)
Spacer(modifier = Modifier.height(16.dp))
PurefinPasswordField(
label = "Password",
value = state.password,
onValueChange = callbacks.onPasswordChange,
placeholder = "••••••••",
leadingIcon = Icons.Default.Lock,
)
Spacer(modifier = Modifier.height(32.dp))
PurefinTextButton(
content = { Text("Connect") },
onClick = callbacks.onConnect
)
Spacer(modifier = Modifier.weight(0.5f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = callbacks.onDiscoverServers) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = scheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
Text(" Discover Servers", color = scheme.onSurfaceVariant)
}
}
TextButton(onClick = callbacks.onNeedHelp) {
Text("Need Help?", color = scheme.onSurfaceVariant)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

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

View File

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

View File

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

View File

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