diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt new file mode 100644 index 0000000..19939e1 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -0,0 +1,152 @@ +package hu.bbara.purefin.app.content.episode + +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.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.sizeIn +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +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.common.ui.MediaCastRow +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis +import hu.bbara.purefin.common.ui.components.GhostIconButton +import hu.bbara.purefin.common.ui.components.MediaActionButton +import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings +import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.core.model.Episode + +@Composable +internal fun EpisodeTopBar( + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + GhostIconButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back", + onClick = onBack + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) + GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun EpisodeDetails( + episode: Episode, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Column(modifier = modifier) { + Text( + text = episode.title, + color = scheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Episode ${episode.index}", + color = scheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(16.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.3f), + textColor = scheme.primary + ) + } + Spacer(modifier = Modifier.height(24.dp)) + + MediaSynopsis( + synopsis = episode.synopsis + ) + Spacer(modifier = Modifier.height(24.dp)) + + Row() { + MediaResumeButton( + text = if (episode.progress == null) "Play" else "Resume", + progress = episode.progress?.div(100)?.toFloat() ?: 0f, + onClick = {}, + modifier = Modifier.sizeIn(maxWidth = 200.dp) + ) + VerticalDivider( + color = MaterialTheme.colorScheme.secondary, + thickness = 4.dp, + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Row() { + MediaActionButton( + backgroundColor = MaterialTheme.colorScheme.secondary, + iconColor = MaterialTheme.colorScheme.onSecondary, + icon = Icons.Outlined.Add, + height = 48.dp + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + + MediaPlaybackSettings( + backgroundColor = MaterialTheme.colorScheme.surface, + foregroundColor = MaterialTheme.colorScheme.onSurface, + audioTrack = "ENG", + subtitles = "ENG" + ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow( + cast = emptyList() + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt new file mode 100644 index 0000000..919d5c3 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -0,0 +1,89 @@ +package hu.bbara.purefin.app.content.episode + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.core.data.navigation.EpisodeDto +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel + +@Composable +fun EpisodeScreen( + episode: EpisodeDto, + viewModel: EpisodeScreenViewModel = hiltViewModel(), + modifier: Modifier = Modifier +) { + + LaunchedEffect(episode) { + viewModel.selectEpisode( + seriesId = episode.seriesId, + seasonId = episode.seasonId, + episodeId = episode.id + ) + } + + val episode = viewModel.episode.collectAsState() + + if (episode.value == null) { + PurefinWaitingScreen() + return + } + + EpisodeScreenInternal( + episode = episode.value!!, + onBack = viewModel::onBack, + modifier = modifier + ) +} + +@Composable +private fun EpisodeScreenInternal( + episode: Episode, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.background, + topBar = { + EpisodeTopBar( + onBack = onBack, + modifier = Modifier + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MediaHero( + imageUrl = episode.heroImageUrl, + backgroundColor = MaterialTheme.colorScheme.background, + heightFraction = 0.30f, + modifier = Modifier.fillMaxWidth() + ) + EpisodeDetails( + episode = episode, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = innerPadding.calculateBottomPadding()) + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt new file mode 100644 index 0000000..d945ec7 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -0,0 +1,146 @@ +package hu.bbara.purefin.app.content.movie + +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.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.sizeIn +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +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.common.ui.MediaCastRow +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.MediaSynopsis +import hu.bbara.purefin.common.ui.components.GhostIconButton +import hu.bbara.purefin.common.ui.components.MediaActionButton +import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings +import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel + +@Composable +internal fun MovieTopBar( + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + Row( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + GhostIconButton( + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back", + onClick = onBack + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) + GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun MovieDetails( + movie: MovieUiModel, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + + Column(modifier = modifier) { + Text( + text = movie.title, + color = scheme.onBackground, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + 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 + ) + } + Spacer(modifier = Modifier.height(24.dp)) + + MediaSynopsis( + synopsis = movie.synopsis + ) + Spacer(modifier = Modifier.height(24.dp)) + + Row() { + MediaResumeButton( + text = if (movie.progress == null) "Play" else "Resume", + progress = movie.progress?.div(100)?.toFloat() ?: 0f, + onClick = {}, + modifier = Modifier.sizeIn(maxWidth = 200.dp) + ) + VerticalDivider( + color = MaterialTheme.colorScheme.secondary, + thickness = 4.dp, + modifier = Modifier + .height(48.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + Row() { + MediaActionButton( + backgroundColor = MaterialTheme.colorScheme.secondary, + iconColor = MaterialTheme.colorScheme.onSecondary, + icon = Icons.Outlined.Add, + height = 48.dp + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + + MediaPlaybackSettings( + backgroundColor = MaterialTheme.colorScheme.surface, + foregroundColor = MaterialTheme.colorScheme.onSurface, + audioTrack = movie.audioTrack, + subtitles = movie.subtitles + ) + + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaCastRow( + cast = emptyList() + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt new file mode 100644 index 0000000..9b4275c --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -0,0 +1,80 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.core.data.navigation.MovieDto +import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel +import hu.bbara.purefin.feature.shared.content.movie.MovieUiModel + +@Composable +fun MovieScreen( + movie: MovieDto, viewModel: MovieScreenViewModel = hiltViewModel(), modifier: Modifier = Modifier +) { + LaunchedEffect(movie.id) { + viewModel.selectMovie(movie.id) + } + + val movieItem = viewModel.movie.collectAsState() + + if (movieItem.value != null) { + MovieScreenInternal( + movie = movieItem.value!!, + onBack = viewModel::onBack, + modifier = modifier + ) + } else { + PurefinWaitingScreen() + } +} + +@Composable +private fun MovieScreenInternal( + movie: MovieUiModel, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.background, + topBar = { + MovieTopBar( + onBack = onBack, + modifier = Modifier + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MediaHero( + imageUrl = movie.heroImageUrl, + backgroundColor = MaterialTheme.colorScheme.background, + heightFraction = 0.30f, + modifier = Modifier.fillMaxWidth() + ) + MovieDetails( + movie = movie, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = innerPadding.calculateBottomPadding()) + ) + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt new file mode 100644 index 0000000..dbdf7e3 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -0,0 +1,291 @@ +package hu.bbara.purefin.app.content.series + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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 +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.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.PlayCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.sp +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.MediaCastRow +import hu.bbara.purefin.common.ui.MediaMetaChip +import hu.bbara.purefin.common.ui.components.GhostIconButton +import hu.bbara.purefin.common.ui.components.MediaActionButton +import hu.bbara.purefin.common.ui.components.MediaProgressBar +import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.common.ui.components.WatchStateIndicator +import hu.bbara.purefin.core.model.CastMember +import hu.bbara.purefin.core.model.Episode +import hu.bbara.purefin.core.model.Season +import hu.bbara.purefin.core.model.Series +import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel + +@Composable +internal fun SeriesTopBar( + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + GhostIconButton( + onClick = onBack, + icon = Icons.Outlined.ArrowBack, + contentDescription = "Back") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast", onClick = { }) + GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More", onClick = { }) + } + } +} + +@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.seasonCount} Seasons") + } +} + +@Composable +internal fun SeriesActionButtons(modifier: Modifier = Modifier) { + Row() { + MediaActionButton( + backgroundColor = MaterialTheme.colorScheme.secondary, + iconColor = MaterialTheme.colorScheme.onSecondary, + icon = Icons.Outlined.Add, + height = 32.dp + ) + } +} + +@Composable +internal fun SeasonTabs( + seasons: List, + selectedSeason: Season?, + modifier: Modifier = Modifier, + onSelect: (Season) -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + seasons.forEach { season -> + SeasonTab( + name = season.name, + isSelected = season == selectedSeason, + modifier = Modifier.clickable { onSelect(season) } + ) + } + } +} + +@Composable +private fun SeasonTab( + name: String, + isSelected: Boolean, + modifier: Modifier = Modifier +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) + val color = if (isSelected) scheme.primary else mutedStrong + val borderColor = if (isSelected) scheme.primary else Color.Transparent + Column( + modifier = modifier + .padding(bottom = 8.dp) + ) { + Text( + text = name, + color = color, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .height(2.dp) + .width(52.dp) + .background(borderColor) + ) + } +} + +@Composable +internal fun EpisodeCarousel(episodes: List, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + + LaunchedEffect(episodes) { + val firstUnwatchedIndex = episodes.indexOfFirst { !it.watched }.let { if (it == -1) 0 else it } + if (firstUnwatchedIndex != 0) { + listState.animateScrollToItem(firstUnwatchedIndex) + } else { + listState.scrollToItem(0) + } + } + + LazyRow( + state = listState, + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(episodes) { episode -> + EpisodeCard(episode = episode) + } + } +} + +@Composable +private fun EpisodeCard( + viewModel: SeriesViewModel = hiltViewModel(), + episode: Episode +) { + val scheme = MaterialTheme.colorScheme + val mutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) + Column( + modifier = Modifier + .width(260.dp) + .clickable { viewModel.onSelectEpisode( + seriesId = episode.seriesId, + seasonId = episode.seasonId, + episodeId = episode.id + ) }, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)) + .background(scheme.surface) + .border(1.dp, scheme.outlineVariant, RoundedCornerShape(12.dp)) + ) { + PurefinAsyncImage( + model = episode.heroImageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .matchParentSize() + .background(scheme.background.copy(alpha = 0.2f)) + ) + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + tint = scheme.onBackground, + modifier = Modifier + .align(Alignment.Center) + .size(32.dp) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp) + .background(scheme.background.copy(alpha = 0.8f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = episode.runtime, + color = scheme.onBackground, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + if (episode.watched.not() && (episode.progress ?: 0.0) > 0) { + MediaProgressBar( + progress = (episode.progress ?: 0.0).toFloat().div(100), + modifier = Modifier + .align(Alignment.BottomStart) + ) + } else { + WatchStateIndicator( + watched = episode.watched, + started = (episode.progress ?: 0.0) > 0.0, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) + } + } + Column( + ) { + Text( + text = episode.title, + color = scheme.onBackground, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Episode ${episode.index}", + color = mutedStrong, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +internal fun CastRow(cast: List, modifier: Modifier = Modifier) { + MediaCastRow( + cast = cast, + modifier = modifier, + cardWidth = 84.dp, + nameSize = 11.sp, + roleSize = 10.sp + ) +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt new file mode 100644 index 0000000..36c079a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -0,0 +1,145 @@ +package hu.bbara.purefin.app.content.series + +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import hu.bbara.purefin.common.ui.MediaSynopsis +import hu.bbara.purefin.common.ui.PurefinWaitingScreen +import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.core.data.navigation.SeriesDto +import hu.bbara.purefin.core.model.Season +import hu.bbara.purefin.core.model.Series +import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel + +@Composable +fun SeriesScreen( + series: SeriesDto, + modifier: Modifier = Modifier, + viewModel: SeriesViewModel = hiltViewModel() +) { + LaunchedEffect(series.id) { + viewModel.selectSeries(series.id) + } + + val series = viewModel.series.collectAsState() + + val seriesData = series.value + if (seriesData != null && seriesData.seasons.isNotEmpty()) { + SeriesScreenInternal( + series = seriesData, + onBack = viewModel::onBack, + modifier = modifier + ) + } else { + PurefinWaitingScreen() + } +} + +@Composable +private fun SeriesScreenInternal( + series: Series, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + val textMutedStrong = scheme.onSurfaceVariant.copy(alpha = 0.7f) + + fun getDefaultSeason() : Season { + for (season in series.seasons) { + val firstUnwatchedEpisode = season.episodes.firstOrNull { + it.watched.not() + } + if (firstUnwatchedEpisode != null) { + return season + } + } + return series.seasons.first() + } + val selectedSeason = remember { mutableStateOf(getDefaultSeason()) } + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.background, + topBar = { + SeriesTopBar( + onBack = onBack, + modifier = Modifier + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MediaHero( + imageUrl = series.heroImageUrl, + heightFraction = 0.30f, + backgroundColor = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth() + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = innerPadding.calculateBottomPadding()) + ) { + Text( + text = series.name, + color = scheme.onBackground, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + lineHeight = 36.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + SeriesMetaChips(series = series) + Spacer(modifier = Modifier.height(24.dp)) + SeriesActionButtons() + Spacer(modifier = Modifier.height(24.dp)) + MediaSynopsis( + synopsis = series.synopsis, + bodyColor = textMutedStrong, + bodyFontSize = 13.sp, + bodyLineHeight = null, + titleSpacing = 8.dp + ) + Spacer(modifier = Modifier.height(24.dp)) + SeasonTabs( + seasons = series.seasons, + selectedSeason = selectedSeason.value, + onSelect = { selectedSeason.value = it } + ) + EpisodeCarousel( + episodes = selectedSeason.value.episodes, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Cast", + color = scheme.onBackground, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + CastRow(cast = series.cast) + } + } + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt new file mode 100644 index 0000000..e3edf62 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/MediaDetailComponents.kt @@ -0,0 +1,131 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import hu.bbara.purefin.common.ui.components.PurefinAsyncImage +import hu.bbara.purefin.core.model.CastMember + +@Composable +fun MediaMetaChip( + text: String, + background: Color = MaterialTheme.colorScheme.surfaceVariant, + border: Color = Color.Transparent, + textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(28.dp) + .wrapContentHeight(Alignment.CenterVertically) + .clip(RoundedCornerShape(6.dp)) + .background(background) + .border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp)) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = textColor, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +fun MediaCastRow( + cast: List, + 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/common/ui/MediaSynopsis.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/MediaSynopsis.kt new file mode 100644 index 0000000..729931d --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/MediaSynopsis.kt @@ -0,0 +1,75 @@ +package hu.bbara.purefin.common.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnit.Companion.Unspecified +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun MediaSynopsis( + synopsis: String, + modifier: Modifier = Modifier, + title: String = "Synopsis", + titleColor: Color = MaterialTheme.colorScheme.onBackground, + bodyColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + titleFontSize: TextUnit = 18.sp, + bodyFontSize: TextUnit = 15.sp, + bodyLineHeight: TextUnit? = 22.sp, + titleSpacing: Dp = 12.dp, + collapsedLines: Int = 3, + collapseInitially: Boolean = true +) { + var isExpanded by remember(synopsis) { mutableStateOf(!collapseInitially) } + var isOverflowing by remember(synopsis) { mutableStateOf(false) } + + val containerModifier = if (isOverflowing) { + modifier.clickable(role = Role.Button) { isExpanded = !isExpanded } + } else { + modifier + } + + Column(modifier = containerModifier) { + Text( + text = title, + color = titleColor, + fontSize = titleFontSize, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(titleSpacing)) + Text( + text = synopsis, + color = bodyColor, + fontSize = bodyFontSize, + lineHeight = bodyLineHeight ?: Unspecified, + maxLines = if (isExpanded) Int.MAX_VALUE else collapsedLines, + overflow = if (isExpanded) TextOverflow.Clip else TextOverflow.Ellipsis, + onTextLayout = { result -> + val overflowed = if (isExpanded) { + result.lineCount > collapsedLines + } else { + result.hasVisualOverflow || result.lineCount > collapsedLines + } + if (overflowed != isOverflowing) { + isOverflowing = overflowed + } + } + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt index 0e54d08..fa0725c 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/PurefinTextButton.kt @@ -12,7 +12,7 @@ fun PurefinTextButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - content: @Composable RowScope.() -> Unit + content: @Composable RowScope.() -> Unit // Slot API ) { Button( onClick = onClick, @@ -23,4 +23,4 @@ fun PurefinTextButton( ), content = content ) -} +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt new file mode 100644 index 0000000..64ad605 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/GhostIconButton.kt @@ -0,0 +1,40 @@ +package hu.bbara.purefin.common.ui.components + +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-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt new file mode 100644 index 0000000..8bcf97d --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaActionButton.kt @@ -0,0 +1,36 @@ +package hu.bbara.purefin.common.ui.components + +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-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHero.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHero.kt new file mode 100644 index 0000000..8134404 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaHero.kt @@ -0,0 +1,50 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp + +@Composable +fun MediaHero( + imageUrl: String, + backgroundColor: Color, + heightFraction: Float = 0.4f, + modifier: Modifier = Modifier, +) { + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val heroHeight = screenHeight * heightFraction + + Box( + modifier = modifier + .height(heroHeight) + .background(backgroundColor) + ) { + PurefinAsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + backgroundColor.copy(alpha = 0.5f), + backgroundColor + ) + ) + ) + ) + } +} diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaPlayButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaPlayButton.kt new file mode 100644 index 0000000..1d70ab5 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaPlayButton.kt @@ -0,0 +1,73 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +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.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp + +@Composable +fun MediaPlayButton( + backgroundColor: Color, + foregroundColor: Color, + text: String = "Play", + subText: String? = null, + size: Dp, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .height(size) + .clip(CircleShape) + .background(backgroundColor) + .padding(start = 16.dp, end = 32.dp) + .clickable { onClick() }, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = foregroundColor, + modifier = Modifier.size(42.dp) + ) + Column() { + Text( + text = text, + color = foregroundColor, + fontSize = TextUnit( + value = 16f, + type = TextUnitType.Sp + ) + ) + subText?.let { + Text( + text = subText, + color = foregroundColor.copy(alpha = 0.7f), + fontSize = TextUnit( + value = 14f, + type = TextUnitType.Sp + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaPlaybackSettings.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaPlaybackSettings.kt new file mode 100644 index 0000000..0d5053a --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaPlaybackSettings.kt @@ -0,0 +1,103 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ClosedCaption +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.VolumeUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun MediaPlaybackSettings( + backgroundColor: Color, + foregroundColor: Color, + audioTrack: String, + subtitles: String, + audioIcon: ImageVector = Icons.Outlined.VolumeUp, + subtitleIcon: ImageVector = Icons.Outlined.ClosedCaption, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + ) { + MediaSettingDropdown( + backgroundColor = backgroundColor, + foregroundColor = foregroundColor, + label = "Audio Track", + value = audioTrack, + icon = audioIcon + ) + Spacer(modifier = Modifier.height(12.dp)) + MediaSettingDropdown( + backgroundColor = backgroundColor, + foregroundColor = foregroundColor, + label = "Subtitles", + value = subtitles, + icon = subtitleIcon + ) + } +} + +@Composable +private fun MediaSettingDropdown( + backgroundColor: Color, + foregroundColor: Color, + label: String, + value: String, + icon: ImageVector +) { + Row ( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + color = foregroundColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.width(12.dp)) + Row( + modifier = Modifier + .height(38.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.width(10.dp)) + Text(text = value, color = foregroundColor, fontSize = 14.sp) + } + Icon( + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null, + tint = foregroundColor + ) + } + } +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt index 2d17543..7e8d9f0 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaProgressBar.kt @@ -45,4 +45,4 @@ fun MediaProgressBar( .background(foregroundColor) ) } -} +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt new file mode 100644 index 0000000..3b1c862 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/MediaResumeButton.kt @@ -0,0 +1,89 @@ +package hu.bbara.purefin.common.ui.components + +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.fillMaxWidth +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.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 = primaryColor) + } + + // 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 = onPrimaryColor) + } + } +} + +@Composable +private fun ButtonContent(text: String, color: androidx.compose.ui.graphics.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-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt index f606d75..092f9e7 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/SearchField.kt @@ -45,4 +45,4 @@ fun SearchField( focusedTextColor = textColor, unfocusedTextColor = textColor, )) -} +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/TimedVisibility.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/TimedVisibility.kt new file mode 100644 index 0000000..89fbb71 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/TimedVisibility.kt @@ -0,0 +1,87 @@ +package hu.bbara.purefin.common.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import kotlinx.coroutines.delay + +/** + * A composable that displays content for a specified duration after the value becomes null. + * + * @param value The value to display. When set to a non-null value, it will be shown immediately. + * When set to null, the previously shown value will remain visible for [hideAfterMillis] + * before being hidden. + * @param hideAfterMillis The duration in milliseconds to keep showing the last value after [value] becomes null. + * Defaults to 1000ms (1 second). + * @param content The composable content to display, receiving the current non-null value. + */ +@Composable +fun 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-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt index 08783fd..d66d617 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/UnwatchedEpisodeIndicator.kt @@ -43,4 +43,4 @@ fun UnwatchedEpisodeIndicator( fontSize = 15.sp ) } -} +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt index 20f696a..7e10c9f 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/common/ui/components/WatchStateIndicator.kt @@ -78,4 +78,4 @@ private fun WatchStateIndicatorPreview() { started = true ) } -} +} \ No newline at end of file diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt index 0d81090..b553de1 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvNavigationModule.kt @@ -23,4 +23,22 @@ object TvNavigationModule { fun provideTvLoginEntryBuilder(): EntryProviderScope.() -> Unit = { tvLoginSection() } + + @IntoSet + @Provides + fun provideTvMovieEntryBuilder(): EntryProviderScope.() -> Unit = { + tvMovieSection() + } + + @IntoSet + @Provides + fun provideTvSeriesEntryBuilder(): EntryProviderScope.() -> Unit = { + tvSeriesSection() + } + + @IntoSet + @Provides + fun provideTvEpisodeEntryBuilder(): EntryProviderScope.() -> Unit = { + tvEpisodeSection() + } } diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt index 00e494a..86003cc 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/navigation/TvRouteEntryBuilder.kt @@ -1,6 +1,9 @@ package hu.bbara.purefin.tv.navigation import androidx.navigation3.runtime.EntryProviderScope +import hu.bbara.purefin.app.content.episode.EpisodeScreen +import hu.bbara.purefin.app.content.movie.MovieScreen +import hu.bbara.purefin.app.content.series.SeriesScreen import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.login.ui.LoginScreen import hu.bbara.purefin.tv.home.TvHomePage @@ -16,3 +19,21 @@ fun EntryProviderScope.tvLoginSection() { LoginScreen() } } + +fun EntryProviderScope.tvMovieSection() { + entry { route -> + MovieScreen(movie = route.item) + } +} + +fun EntryProviderScope.tvSeriesSection() { + entry { route -> + SeriesScreen(series = route.item) + } +} + +fun EntryProviderScope.tvEpisodeSection() { + entry { route -> + EpisodeScreen(episode = route.item) + } +} diff --git a/app-tv/src/main/res/drawable/ic_launcher_background.xml b/app-tv/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app-tv/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-tv/src/main/res/drawable/ic_launcher_foreground.xml b/app-tv/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app-tv/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-anydpi/ic_launcher.xml b/app-tv/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app-tv/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-tv/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app-tv/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app-tv/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app-tv/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-tv/src/main/res/values/colors.xml b/app-tv/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app-tv/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app-tv/src/main/res/values/strings.xml b/app-tv/src/main/res/values/strings.xml index b6076e1..4ac9aed 100644 --- a/app-tv/src/main/res/values/strings.xml +++ b/app-tv/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ Purefin + Downloads + Media download progress \ No newline at end of file