mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
implement episode details screen and ViewModel
- Create `EpisodeScreen` and `EpisodeCard` components with adaptive layouts for wide and narrow viewports. - Implement `EpisodeScreenViewModel` to fetch "Next Up" episode data using the Jellyfin API. - Add `EpisodeUiModel` and `CastMember` data classes for content representation. - Develop custom UI components including `EpisodeHero`, `PlaybackSettings`, `CastRow`, and `EpisodeTopBar`. - Define a dedicated color palette for the episode view in `EpisodeColors.kt`. - Integrate navigation to the player activity from the play buttons.
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
package hu.bbara.purefin.app.content.episode
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EpisodeCard(
|
||||||
|
seriesId: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EpisodeScreenViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
|
||||||
|
LaunchedEffect(seriesId) {
|
||||||
|
viewModel.selectNextUpEpisodeForSeries(UUID.fromString(seriesId))
|
||||||
|
}
|
||||||
|
|
||||||
|
val episodeItem = viewModel.episode.collectAsState()
|
||||||
|
|
||||||
|
if (episodeItem.value != null) {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(EpisodeBackgroundDark)
|
||||||
|
) {
|
||||||
|
val isWide = maxWidth >= 900.dp
|
||||||
|
val contentPadding = if (isWide) 32.dp else 20.dp
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (isWide) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
EpisodeHero(
|
||||||
|
episode = episodeItem.value!!,
|
||||||
|
height = 300.dp,
|
||||||
|
isWide = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.weight(0.5f)
|
||||||
|
)
|
||||||
|
EpisodeDetails(
|
||||||
|
episode = episodeItem.value!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.5f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
EpisodeHero(
|
||||||
|
episode = episodeItem.value!!,
|
||||||
|
height = 400.dp,
|
||||||
|
isWide = false,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
EpisodeDetails(
|
||||||
|
episode = episodeItem.value!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = contentPadding)
|
||||||
|
.offset(y = (-48).dp)
|
||||||
|
.padding(bottom = 96.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EpisodeTopBar(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isWide) {
|
||||||
|
FloatingPlayButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(EpisodeBackgroundDark),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Loading...",
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package hu.bbara.purefin.app.content.episode
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
internal val EpisodePrimary = Color(0xFFDDA73C)
|
||||||
|
internal val EpisodeBackgroundDark = Color(0xFF141414)
|
||||||
|
internal val EpisodeSurfaceDark = Color(0xFF1F1F1F)
|
||||||
|
internal val EpisodeSurfaceBorder = Color(0x1AFFFFFF)
|
||||||
|
internal val EpisodeMuted = Color(0x99FFFFFF)
|
||||||
|
internal val EpisodeMutedStrong = Color(0x66FFFFFF)
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
package hu.bbara.purefin.app.content.episode
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
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.PaddingValues
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
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.ClosedCaption
|
||||||
|
import androidx.compose.material.icons.outlined.Download
|
||||||
|
import androidx.compose.material.icons.outlined.ExpandMore
|
||||||
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
|
import androidx.compose.material.icons.outlined.Person
|
||||||
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
|
import androidx.compose.material.icons.outlined.VolumeUp
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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 androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import hu.bbara.purefin.player.PlayerActivity
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EpisodeTopBar(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back")
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast")
|
||||||
|
GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GhostIconButton(
|
||||||
|
icon: ImageVector,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(EpisodeBackgroundDark.copy(alpha = 0.4f))
|
||||||
|
.clickable { },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun EpisodeHero(
|
||||||
|
episode: EpisodeUiModel,
|
||||||
|
height: Dp,
|
||||||
|
isWide: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(height)
|
||||||
|
.background(EpisodeBackgroundDark)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = episode.heroImageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
EpisodeBackgroundDark.copy(alpha = 0.4f),
|
||||||
|
EpisodeBackgroundDark
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (isWide) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
EpisodeBackgroundDark.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
PlayButton(size = if (isWide) 96.dp else 80.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun EpisodeDetails(
|
||||||
|
episode: EpisodeUiModel,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = episode.title,
|
||||||
|
color = Color.White,
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
MetaChip(text = episode.releaseDate)
|
||||||
|
MetaChip(text = episode.rating)
|
||||||
|
MetaChip(text = episode.runtime)
|
||||||
|
MetaChip(
|
||||||
|
text = episode.format,
|
||||||
|
background = EpisodePrimary.copy(alpha = 0.2f),
|
||||||
|
border = EpisodePrimary.copy(alpha = 0.3f),
|
||||||
|
textColor = EpisodePrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
PlaybackSettings(episode = episode)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = "Synopsis",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = episode.synopsis,
|
||||||
|
color = EpisodeMuted,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
lineHeight = 22.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
ActionButtons()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
Text(
|
||||||
|
text = "Cast",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
CastRow(cast = episode.cast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetaChip(
|
||||||
|
text: String,
|
||||||
|
background: Color = Color.White.copy(alpha = 0.1f),
|
||||||
|
border: Color = Color.Transparent,
|
||||||
|
textColor: Color = Color.White
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
private fun PlaybackSettings(episode: EpisodeUiModel) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(EpisodeSurfaceDark)
|
||||||
|
.border(1.dp, EpisodeSurfaceBorder, RoundedCornerShape(16.dp))
|
||||||
|
.padding(20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Tune,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = EpisodePrimary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Playback Settings",
|
||||||
|
color = EpisodeMuted,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 2.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Audio Track",
|
||||||
|
icon = Icons.Outlined.VolumeUp,
|
||||||
|
value = episode.audioTrack
|
||||||
|
)
|
||||||
|
SettingDropdown(
|
||||||
|
label = "Subtitles",
|
||||||
|
icon = Icons.Outlined.ClosedCaption,
|
||||||
|
value = episode.subtitles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingDropdown(
|
||||||
|
label: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
value: String
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = EpisodeMutedStrong,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(EpisodeBackgroundDark)
|
||||||
|
.border(1.dp, EpisodeSurfaceBorder, RoundedCornerShape(12.dp))
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(imageVector = icon, contentDescription = null, tint = EpisodeMutedStrong)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(text = value, color = Color.White, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
Icon(imageVector = Icons.Outlined.ExpandMore, contentDescription = null, tint = EpisodeMutedStrong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionButtons() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
ActionButton(
|
||||||
|
text = "Watchlist",
|
||||||
|
icon = Icons.Outlined.Add,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
ActionButton(
|
||||||
|
text = "Download",
|
||||||
|
icon = Icons.Outlined.Download,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionButton(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.height(48.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.05f))
|
||||||
|
.border(1.dp, EpisodeSurfaceBorder, RoundedCornerShape(12.dp))
|
||||||
|
.clickable { },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(imageVector = icon, contentDescription = null, tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text = text, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CastRow(cast: List<CastMember>) {
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
items(cast) { member ->
|
||||||
|
Column(modifier = Modifier.width(96.dp)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(4f / 5f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(EpisodeSurfaceDark)
|
||||||
|
) {
|
||||||
|
if (member.imageUrl == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White.copy(alpha = 0.05f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = EpisodeMutedStrong
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = member.imageUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = member.name,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = member.role,
|
||||||
|
color = EpisodeMutedStrong,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayButton(
|
||||||
|
size: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EpisodeScreenViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val episodeItem = viewModel.episode.collectAsState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.shadow(24.dp, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(EpisodePrimary)
|
||||||
|
.clickable {
|
||||||
|
val intent = Intent(context, PlayerActivity::class.java)
|
||||||
|
intent.putExtra("MEDIA_ID", episodeItem.value!!.id.toString())
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Play",
|
||||||
|
tint = EpisodeBackgroundDark,
|
||||||
|
modifier = Modifier.size(42.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun FloatingPlayButton(modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.shadow(20.dp, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(EpisodePrimary),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Play",
|
||||||
|
tint = EpisodeBackgroundDark
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package hu.bbara.purefin.app.content.episode
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
|
||||||
|
data class CastMember(
|
||||||
|
val name: String,
|
||||||
|
val role: String,
|
||||||
|
val imageUrl: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class EpisodeUiModel(
|
||||||
|
val id: UUID,
|
||||||
|
val title: String,
|
||||||
|
val releaseDate: String,
|
||||||
|
val rating: String,
|
||||||
|
val runtime: String,
|
||||||
|
val format: String,
|
||||||
|
val synopsis: String,
|
||||||
|
val heroImageUrl: String,
|
||||||
|
val audioTrack: String,
|
||||||
|
val subtitles: String,
|
||||||
|
val cast: List<CastMember>
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package hu.bbara.purefin.app.content.episode
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EpisodeScreen(
|
||||||
|
seriesId: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
EpisodeCard(
|
||||||
|
seriesId = seriesId,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeScreenPreview() {
|
||||||
|
EpisodeScreen(seriesId = "test")
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package hu.bbara.purefin.app.content.episode
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import hu.bbara.purefin.client.JellyfinApiClient
|
||||||
|
import hu.bbara.purefin.image.JellyfinImageHelper
|
||||||
|
import hu.bbara.purefin.session.UserSessionRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class EpisodeScreenViewModel @Inject constructor(
|
||||||
|
private val jellyfinApiClient: JellyfinApiClient,
|
||||||
|
private val userSessionRepository: UserSessionRepository
|
||||||
|
): ViewModel() {
|
||||||
|
|
||||||
|
private val _episode = MutableStateFlow<EpisodeUiModel?>(null)
|
||||||
|
val episode = _episode.asStateFlow()
|
||||||
|
|
||||||
|
fun selectNextUpEpisodeForSeries(seriesId: UUID) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val episode = jellyfinApiClient.getNextUpEpisode(seriesId)
|
||||||
|
if (episode == null) {
|
||||||
|
_episode.value = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
selectEpisodeInternal(episode.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectEpisode(episodeId: UUID) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
selectEpisodeInternal(episodeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selectEpisodeInternal(episodeId: UUID) {
|
||||||
|
val episodeInfo = jellyfinApiClient.getItemInfo(episodeId)
|
||||||
|
val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank {
|
||||||
|
"https://jellyfin.bbara.hu"
|
||||||
|
}
|
||||||
|
_episode.value = episodeInfo!!.toUiModel(serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BaseItemDto.toUiModel(serverUrl: String): EpisodeUiModel {
|
||||||
|
val releaseDate = formatReleaseDate(premiereDate, productionYear)
|
||||||
|
val rating = officialRating ?: "NR"
|
||||||
|
val runtime = formatRuntime(runTimeTicks)
|
||||||
|
val format = container?.uppercase() ?: "VIDEO"
|
||||||
|
val synopsis = overview ?: "No synopsis available."
|
||||||
|
val heroImageUrl = id?.let { itemId ->
|
||||||
|
JellyfinImageHelper.toImageUrl(
|
||||||
|
url = serverUrl,
|
||||||
|
itemId = itemId,
|
||||||
|
type = ImageType.PRIMARY
|
||||||
|
)
|
||||||
|
} ?: ""
|
||||||
|
val cast = people.orEmpty().map { it.toCastMember() }
|
||||||
|
return EpisodeUiModel(
|
||||||
|
id = id,
|
||||||
|
title = name ?: "Unknown title",
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
rating = rating,
|
||||||
|
runtime = runtime,
|
||||||
|
format = format,
|
||||||
|
synopsis = synopsis,
|
||||||
|
heroImageUrl = heroImageUrl,
|
||||||
|
audioTrack = "Default",
|
||||||
|
subtitles = "Unknown",
|
||||||
|
cast = cast
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BaseItemPerson.toCastMember(): CastMember {
|
||||||
|
return CastMember(
|
||||||
|
name = name ?: "Unknown",
|
||||||
|
role = role ?: "",
|
||||||
|
imageUrl = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatReleaseDate(date: LocalDateTime?, fallbackYear: Int?): String {
|
||||||
|
if (date == null) {
|
||||||
|
return fallbackYear?.toString() ?: "—"
|
||||||
|
}
|
||||||
|
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.getDefault())
|
||||||
|
return date.toLocalDate().format(formatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatRuntime(ticks: Long?): String {
|
||||||
|
if (ticks == null || ticks <= 0) return "—"
|
||||||
|
val totalSeconds = ticks / 10_000_000
|
||||||
|
val hours = TimeUnit.SECONDS.toHours(totalSeconds)
|
||||||
|
val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
|
||||||
|
return if (hours > 0) {
|
||||||
|
"${hours}h ${minutes}m"
|
||||||
|
} else {
|
||||||
|
"${minutes}m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ class HomePageViewModel @Inject constructor(
|
|||||||
navigationManager.navigate(Route.Movie(movieId))
|
navigationManager.navigate(Route.Movie(movieId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSeriesSelected(seriesId: String) {
|
||||||
|
navigationManager.navigate(Route.Episode(seriesId))
|
||||||
|
}
|
||||||
|
|
||||||
fun onBack() {
|
fun onBack() {
|
||||||
navigationManager.pop()
|
navigationManager.pop()
|
||||||
}
|
}
|
||||||
@@ -140,7 +144,6 @@ class HomePageViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun loadLatestLibraryItems(libraryId: UUID) {
|
fun loadLatestLibraryItems(libraryId: UUID) {
|
||||||
if (_libraryItems.value.containsKey(libraryId)) return
|
if (_libraryItems.value.containsKey(libraryId)) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -150,18 +153,18 @@ class HomePageViewModel @Inject constructor(
|
|||||||
BaseItemKind.MOVIE -> PosterItem(
|
BaseItemKind.MOVIE -> PosterItem(
|
||||||
id = it.id,
|
id = it.id,
|
||||||
title = it.name ?: "Unknown",
|
title = it.name ?: "Unknown",
|
||||||
type = it.type
|
type = BaseItemKind.MOVIE
|
||||||
)
|
)
|
||||||
BaseItemKind.EPISODE -> PosterItem(
|
BaseItemKind.EPISODE -> PosterItem(
|
||||||
id = it.seriesId!!,
|
id = it.seriesId!!,
|
||||||
title = it.seriesName ?: "Unknown",
|
title = it.seriesName ?: "Unknown",
|
||||||
type = it.type
|
type = BaseItemKind.SERIES
|
||||||
)
|
)
|
||||||
|
|
||||||
BaseItemKind.SEASON -> PosterItem(
|
BaseItemKind.SEASON -> PosterItem(
|
||||||
id = it.seriesId!!,
|
id = it.seriesId!!,
|
||||||
title = it.seriesName ?: "Unknown",
|
title = it.seriesName ?: "Unknown",
|
||||||
type = it.type
|
type = BaseItemKind.SERIES
|
||||||
)
|
)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ fun PosterCard(
|
|||||||
{
|
{
|
||||||
when (posterItem.type) {
|
when (posterItem.type) {
|
||||||
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
|
BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString())
|
||||||
|
BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString())
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ sealed interface Route : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Movie(val movieId: String) : Route
|
data class Movie(val movieId: String) : Route
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Episode(val seriesId: String) : Route
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package hu.bbara.purefin.navigation
|
package hu.bbara.purefin.navigation
|
||||||
|
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
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.movie.MovieScreen
|
||||||
import hu.bbara.purefin.app.home.HomePage
|
import hu.bbara.purefin.app.home.HomePage
|
||||||
|
|
||||||
@@ -11,4 +12,7 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
|
|||||||
entry<Route.Movie> {
|
entry<Route.Movie> {
|
||||||
MovieScreen(movieId = it.movieId)
|
MovieScreen(movieId = it.movieId)
|
||||||
}
|
}
|
||||||
}
|
entry<Route.Episode> {
|
||||||
|
EpisodeScreen(seriesId = it.seriesId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user