From 9591b72273571585f0d1fb3b349d1afb589bc2be Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Wed, 15 Apr 2026 21:21:10 +0200 Subject: [PATCH] Extract shared Compose UI into core module --- app-tv/build.gradle.kts | 1 + app-tv/src/main/AndroidManifest.xml | 4 +- .../hu/bbara/purefin/{ => tv}/TvActivity.kt | 2 +- .../bbara/purefin/{ => tv}/TvApplication.kt | 2 +- .../ui/common/button/MediaActionButton.kt | 55 ----- .../ui/common/button/PurefinIconButton.kt | 55 ----- .../purefin/ui/common/card/PosterCard.kt | 182 +++++--------- .../ui/common/image/PurefinAsyncImage.kt | 38 --- .../ui/common/media/MediaDetailComponents.kt | 131 ---------- .../ui/common/media/MediaDetailsTopBar.kt | 19 +- .../ui/common/media/MediaPlaybackSettings.kt | 103 -------- .../textfield/PurefinComplexTextField.kt | 68 ------ .../common/textfield/PurefinPasswordField.kt | 76 ------ .../episode/components/TvEpisodeComponents.kt | 42 ++-- .../ui/screen/library/TvLibraryScreen.kt | 7 +- .../purefin/ui/screen/login/LoginScreen.kt | 226 ++++-------------- .../movie/components/TvMovieComponents.kt | 42 ++-- .../components/TvPlayerLoadingErrorEndCard.kt | 108 +-------- .../player/components/TvPlayerSeekBar.kt | 68 +----- .../series/components/TvSeriesComponents.kt | 35 ++- app/build.gradle.kts | 1 + .../ui/common/badge/UnwatchedEpisodeBadge.kt | 46 ---- .../ui/common/badge/WatchStateBadge.kt | 81 ------- .../purefin/ui/common/bar/MediaProgressBar.kt | 48 ---- .../ui/common/button/GhostIconButton.kt | 40 ---- .../ui/common/button/MediaActionButton.kt | 36 --- .../ui/common/button/MediaResumeButton.kt | 89 ------- .../ui/common/button/PurefinIconButton.kt | 41 ---- .../ui/common/button/PurefinTextButton.kt | 26 -- .../purefin/ui/common/card/PosterCard.kt | 151 ++++-------- .../purefin/ui/common/media/MediaSynopsis.kt | 75 ------ .../ui/common/visual/TimedVisibility.kt | 87 ------- .../ui/screen/episode/EpisodeScreen.kt | 26 +- .../episode/components/EpisodeComponents.kt | 6 +- .../home/components/library/HomeBrowseCard.kt | 16 +- .../purefin/ui/screen/login/LoginScreen.kt | 226 ++++-------------- .../purefin/ui/screen/movie/MovieScreen.kt | 22 +- .../movie/components/MovieComponents.kt | 6 +- .../components/PlayerLoadingErrorEndCard.kt | 95 +------- .../screen/player/components/PlayerSeekBar.kt | 63 +---- .../series/components/SeriesComponents.kt | 33 +-- .../ui/screen/waiting/WaitingScreen.kt | 199 --------------- .../java/hu/bbara/purefin/ExampleUnitTest.kt | 17 -- .../ui/common/card/PosterCardMappingTest.kt | 111 +++++++++ core/ui/build.gradle.kts | 46 ++++ .../ui/common/badge/UnwatchedEpisodeBadge.kt | 2 +- .../ui/common/badge/WatchStateBadge.kt | 6 +- .../purefin/ui/common/bar/MediaProgressBar.kt | 8 - .../ui/common/button/CircularIconButton.kt | 45 +++- .../ui/common/button/GhostIconButton.kt | 37 +++ .../ui/common/button/MediaActionButton.kt | 36 +++ .../ui/common/button/MediaResumeButton.kt | 52 ++-- .../ui/common/button/PurefinIconButton.kt | 37 +++ .../ui/common/button/PurefinTextButton.kt | 4 +- .../ui/common/card/PosterCardContent.kt | 161 +++++++++++++ .../ui/common/image/PurefinAsyncImage.kt | 36 ++- .../ui/common/media/MediaDetailComponents.kt | 3 +- .../ui/common/media/MediaMetadataFlowRow.kt | 40 ++++ .../ui/common/media/MediaPlayButtonLabel.kt | 9 + .../ui/common/media/MediaPlaybackSettings.kt | 13 +- .../purefin/ui/common/media/MediaSynopsis.kt | 0 .../textfield/PurefinComplexTextField.kt | 11 +- .../common/textfield/PurefinPasswordField.kt | 24 +- .../ui/common/visual/TimedVisibility.kt | 19 -- .../purefin/ui/screen/login/LoginContent.kt | 201 ++++++++++++++++ .../PlayerLoadingErrorEndCardContent.kt | 117 +++++++++ .../player/components/PlayerSeekBarTrack.kt | 88 +++++++ .../ui/screen/waiting/WaitingScreen.kt | 0 .../common/media/MediaPlayButtonLabelTest.kt | 23 ++ settings.gradle.kts | 1 + 70 files changed, 1369 insertions(+), 2455 deletions(-) rename app-tv/src/main/java/hu/bbara/purefin/{ => tv}/TvActivity.kt (99%) rename app-tv/src/main/java/hu/bbara/purefin/{ => tv}/TvApplication.kt (81%) delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt delete mode 100644 app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt delete mode 100644 app/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt delete mode 100644 app/src/test/java/hu/bbara/purefin/ExampleUnitTest.kt create mode 100644 app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt create mode 100644 core/ui/build.gradle.kts rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt (99%) rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt (97%) rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt (78%) rename app-tv/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt => core/ui/src/main/java/hu/bbara/purefin/ui/common/button/CircularIconButton.kt (55%) create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt (72%) create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt (92%) create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt rename {app => core/ui}/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt (63%) rename {app => core/ui}/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt (97%) create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaMetadataFlowRow.kt create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt rename {app => core/ui}/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt (92%) rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt (100%) rename {app => core/ui}/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt (95%) rename {app => core/ui}/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt (82%) rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt (64%) create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCardContent.kt create mode 100644 core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBarTrack.kt rename {app-tv => core/ui}/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt (100%) create mode 100644 core/ui/src/test/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabelTest.kt diff --git a/app-tv/build.gradle.kts b/app-tv/build.gradle.kts index 30b99790..a6be6317 100644 --- a/app-tv/build.gradle.kts +++ b/app-tv/build.gradle.kts @@ -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")) diff --git a/app-tv/src/main/AndroidManifest.xml b/app-tv/src/main/AndroidManifest.xml index 792264fc..61514307 100644 --- a/app-tv/src/main/AndroidManifest.xml +++ b/app-tv/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:required="true" /> diff --git a/app-tv/src/main/java/hu/bbara/purefin/TvActivity.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt similarity index 99% rename from app-tv/src/main/java/hu/bbara/purefin/TvActivity.kt rename to app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt index 03dc22b1..199cf977 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/TvActivity.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvActivity.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin +package hu.bbara.purefin.tv import android.content.pm.ApplicationInfo import android.os.Bundle diff --git a/app-tv/src/main/java/hu/bbara/purefin/TvApplication.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt similarity index 81% rename from app-tv/src/main/java/hu/bbara/purefin/TvApplication.kt rename to app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt index bc45b462..2d94057f 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/TvApplication.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/TvApplication.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin +package hu.bbara.purefin.tv import android.app.Application import dagger.hilt.android.HiltAndroidApp diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt deleted file mode 100644 index bbb40395..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt +++ /dev/null @@ -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) - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt deleted file mode 100644 index f2292d80..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt index 0cc180ca..8a08bd99 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt @@ -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 } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt deleted file mode 100644 index 292e6e36..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt +++ /dev/null @@ -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 - ) -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt deleted file mode 100644 index e91f610c..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt +++ /dev/null @@ -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, - 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 - ) - } - } - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailsTopBar.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailsTopBar.kt index 2aea8cd2..bd2092be 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailsTopBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailsTopBar.kt @@ -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) ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt deleted file mode 100644 index 2812fa81..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt +++ /dev/null @@ -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 - ) - } - } -} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt deleted file mode 100644 index ae1e2188..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt +++ /dev/null @@ -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 - )) - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt deleted file mode 100644 index 96b8150d..00000000 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt +++ /dev/null @@ -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 - ) - ) - } -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/episode/components/TvEpisodeComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/episode/components/TvEpisodeComponents.kt index ba37be33..5755a450 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/episode/components/TvEpisodeComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/episode/components/TvEpisodeComponents.kt @@ -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" -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt index 2a2f1984..490bbf10 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/library/TvLibraryScreen.kt @@ -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, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt index 646c07d6..deac9ad7 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt @@ -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 + ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/movie/components/TvMovieComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/movie/components/TvMovieComponents.kt index 1eff8980..6d114b67 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/movie/components/TvMovieComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/movie/components/TvMovieComponents.kt @@ -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" -} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerLoadingErrorEndCard.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerLoadingErrorEndCard.kt index 89cfe640..b0c098f1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerLoadingErrorEndCard.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerLoadingErrorEndCard.kt @@ -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 + ) } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerSeekBar.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerSeekBar.kt index f320bfeb..fbe62453 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerSeekBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/player/components/TvPlayerSeekBar.kt @@ -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 + ) } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt index b8b9c29e..b8c37acc 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/series/components/TvSeriesComponents.kt @@ -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, 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" } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5aba915..390b59c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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")) diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt b/app/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt deleted file mode 100644 index 015ab1cc..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt +++ /dev/null @@ -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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt b/app/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt deleted file mode 100644 index 539b1321..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt +++ /dev/null @@ -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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt b/app/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt deleted file mode 100644 index 30a5d962..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt +++ /dev/null @@ -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) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt b/app/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt deleted file mode 100644 index 2c0b423f..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt +++ /dev/null @@ -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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt b/app/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt deleted file mode 100644 index 8561f5ee..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt b/app/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt deleted file mode 100644 index 42251fe7..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt b/app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt deleted file mode 100644 index 0be04aa4..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt b/app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt deleted file mode 100644 index b24fed49..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt +++ /dev/null @@ -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 - ) -} \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt b/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt index d9f86bf2..6d9dc303 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/common/card/PosterCard.kt @@ -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 } } diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt b/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt deleted file mode 100644 index da86d97f..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt +++ /dev/null @@ -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 - } - } - ) - } -} diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt b/app/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt deleted file mode 100644 index 2a48151f..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt +++ /dev/null @@ -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 EmptyValueTimedVisibility( - value: T?, - hideAfterMillis: Long = 1_000, - modifier: Modifier = Modifier, - content: @Composable (T) -> Unit -) { - val shownValue = remember { mutableStateOf(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 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) - } -} diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt index 7cb61457..7956b278 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/EpisodeScreen.kt @@ -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) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt index 1754efd7..5cb4da8c 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/episode/components/EpisodeComponents.kt @@ -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) ) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/library/HomeBrowseCard.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/library/HomeBrowseCard.kt index d41a9966..5ca77e8b 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/library/HomeBrowseCard.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/home/components/library/HomeBrowseCard.kt @@ -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) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt index f9cc06fe..deac9ad7 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/login/LoginScreen.kt @@ -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 + ) } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt index 17aa5e3b..b5517d3d 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/MovieScreen.kt @@ -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 + ) } } } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt index 14081212..4458fc2d 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/movie/components/MovieComponents.kt @@ -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) ) diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCard.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCard.kt index 992da239..d058f4e5 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCard.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCard.kt @@ -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 + ) } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt index 33b7bf74..fded0b1b 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBar.kt @@ -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 -> diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt index a1780ba4..55fa69fd 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/series/components/SeriesComponents.kt @@ -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 diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt deleted file mode 100644 index 7545732e..00000000 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt +++ /dev/null @@ -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) - ) -} diff --git a/app/src/test/java/hu/bbara/purefin/ExampleUnitTest.kt b/app/src/test/java/hu/bbara/purefin/ExampleUnitTest.kt deleted file mode 100644 index 45819cf3..00000000 --- a/app/src/test/java/hu/bbara/purefin/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt b/app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt new file mode 100644 index 00000000..2aa6a124 --- /dev/null +++ b/app/src/test/java/hu/bbara/purefin/ui/common/card/PosterCardMappingTest.kt @@ -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) + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 00000000..cc621e6b --- /dev/null +++ b/core/ui/build.gradle.kts @@ -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) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt similarity index 99% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt index 3037ccca..9aee9ce4 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/badge/UnwatchedEpisodeBadge.kt @@ -43,4 +43,4 @@ fun UnwatchedEpisodeBadge( fontSize = 15.sp ) } -} \ No newline at end of file +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt similarity index 97% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt index 049aad00..ee4c789f 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/badge/WatchStateBadge.kt @@ -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 diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt similarity index 78% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt index a6cc6e93..dc6997dd 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/bar/MediaProgressBar.kt @@ -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, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/CircularIconButton.kt similarity index 55% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/button/CircularIconButton.kt index 8873eeea..721a1444 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/CircularIconButton.kt @@ -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 ) } } diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt new file mode 100644 index 00000000..dbc736c7 --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/GhostIconButton.kt @@ -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 + ) +} diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt new file mode 100644 index 00000000..009b9539 --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaActionButton.kt @@ -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 + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt similarity index 72% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt index 4d9249f5..53008e1d 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/MediaResumeButton.kt @@ -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 diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt new file mode 100644 index 00000000..4851ffed --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinIconButton.kt @@ -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 + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt similarity index 92% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt index b24fed49..d8de0106 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/button/PurefinTextButton.kt @@ -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 ) -} \ No newline at end of file +} diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt new file mode 100644 index 00000000..3baad735 --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/card/PosterCardContent.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt similarity index 63% rename from app/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt index 9c5ae3a6..1f29085a 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/image/PurefinAsyncImage.kt @@ -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, diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt similarity index 97% rename from app/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt index 66e6be70..f9e75b84 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaDetailComponents.kt @@ -105,7 +105,8 @@ fun MediaCastRow( model = member.imageUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, + fallbackIcon = null ) } } diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaMetadataFlowRow.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaMetadataFlowRow.kt new file mode 100644 index 00000000..93625f3a --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaMetadataFlowRow.kt @@ -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, + 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) + } + } + } +} diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt new file mode 100644 index 00000000..d23cbb14 --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabel.kt @@ -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 +} diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt similarity index 92% rename from app/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt index 2812fa81..a9fab100 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaPlaybackSettings.kt @@ -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( ) } } -} \ No newline at end of file +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt similarity index 100% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/media/MediaSynopsis.kt diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt similarity index 95% rename from app/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt index ae1e2188..4fd9e19d 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinComplexTextField.kt @@ -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 - )) + ) + ) } } diff --git a/app/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt similarity index 82% rename from app/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt index 96b8150d..76b9734c 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/textfield/PurefinPasswordField.kt @@ -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, diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt similarity index 64% rename from app-tv/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt index 2a48151f..a66a4c29 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/common/visual/TimedVisibility.kt @@ -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 EmptyValueTimedVisibility( value: T?, @@ -44,15 +34,6 @@ fun 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 ValueChangeTimedVisibility( value: T, diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt new file mode 100644 index 00000000..b4909c7c --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/login/LoginContent.kt @@ -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)) + } +} diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCardContent.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCardContent.kt new file mode 100644 index 00000000..7ddf51e7 --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerLoadingErrorEndCardContent.kt @@ -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") + } + } + } + } + } +} diff --git a/core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBarTrack.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBarTrack.kt new file mode 100644 index 00000000..26496e9a --- /dev/null +++ b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/player/components/PlayerSeekBarTrack.kt @@ -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, + adMarkers: List, + 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) + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt b/core/ui/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt similarity index 100% rename from app-tv/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt rename to core/ui/src/main/java/hu/bbara/purefin/ui/screen/waiting/WaitingScreen.kt diff --git a/core/ui/src/test/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabelTest.kt b/core/ui/src/test/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabelTest.kt new file mode 100644 index 00000000..9d32647b --- /dev/null +++ b/core/ui/src/test/java/hu/bbara/purefin/ui/common/media/MediaPlayButtonLabelTest.kt @@ -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) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0b3c6954..0bcfaff7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")