From ae02536ac6f14de4ca1f31cd3d9a9557a332bb25 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Sun, 18 Jan 2026 14:24:47 +0100 Subject: [PATCH] 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. --- .../app/content/episode/EpisodeCard.kt | 119 +++++ .../app/content/episode/EpisodeColors.kt | 10 + .../app/content/episode/EpisodeComponents.kt | 466 ++++++++++++++++++ .../app/content/episode/EpisodeModels.kt | 23 + .../app/content/episode/EpisodeScreen.kt | 22 + .../content/episode/EpisodeScreenViewModel.kt | 114 +++++ .../purefin/app/home/HomePageViewModel.kt | 11 +- .../bbara/purefin/app/home/ui/HomeSections.kt | 1 + .../java/hu/bbara/purefin/navigation/Route.kt | 3 + .../purefin/navigation/RouteEntryBuilder.kt | 6 +- 10 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt create mode 100644 app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeColors.kt create mode 100644 app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt create mode 100644 app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeModels.kt create mode 100644 app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt create mode 100644 app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt new file mode 100644 index 0000000..7dd0b16 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeCard.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeColors.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeColors.kt new file mode 100644 index 0000000..2b279fc --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeColors.kt @@ -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) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt new file mode 100644 index 0000000..a96fbda --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -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) { + 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 + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeModels.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeModels.kt new file mode 100644 index 0000000..6dad161 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeModels.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt new file mode 100644 index 0000000..ab94732 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -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") +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt new file mode 100644 index 0000000..9f6aac6 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreenViewModel.kt @@ -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(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" + } + } + +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index a7673be..e0b601e 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -49,6 +49,10 @@ class HomePageViewModel @Inject constructor( navigationManager.navigate(Route.Movie(movieId)) } + fun onSeriesSelected(seriesId: String) { + navigationManager.navigate(Route.Episode(seriesId)) + } + fun onBack() { navigationManager.pop() } @@ -140,7 +144,6 @@ class HomePageViewModel @Inject constructor( } } - fun loadLatestLibraryItems(libraryId: UUID) { if (_libraryItems.value.containsKey(libraryId)) return viewModelScope.launch { @@ -150,18 +153,18 @@ class HomePageViewModel @Inject constructor( BaseItemKind.MOVIE -> PosterItem( id = it.id, title = it.name ?: "Unknown", - type = it.type + type = BaseItemKind.MOVIE ) BaseItemKind.EPISODE -> PosterItem( id = it.seriesId!!, title = it.seriesName ?: "Unknown", - type = it.type + type = BaseItemKind.SERIES ) BaseItemKind.SEASON -> PosterItem( id = it.seriesId!!, title = it.seriesName ?: "Unknown", - type = it.type + type = BaseItemKind.SERIES ) else -> null } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index ef30ecc..486128b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -193,6 +193,7 @@ fun PosterCard( { when (posterItem.type) { BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString()) + BaseItemKind.SERIES -> viewModel.onSeriesSelected(posterItem.id.toString()) else -> {} } diff --git a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt index 8792c15..fa30cf4 100644 --- a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt +++ b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt @@ -9,4 +9,7 @@ sealed interface Route : NavKey { @Serializable data class Movie(val movieId: String) : Route + + @Serializable + data class Episode(val seriesId: String) : Route } diff --git a/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt b/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt index 8979012..235cc0c 100644 --- a/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt +++ b/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt @@ -1,6 +1,7 @@ package hu.bbara.purefin.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.home.HomePage @@ -11,4 +12,7 @@ fun EntryProviderScope.appRouteEntryBuilder() { entry { MovieScreen(movieId = it.movieId) } -} \ No newline at end of file + entry { + EpisodeScreen(seriesId = it.seriesId) + } +}