Refactor media models to use MediaUiModel for improved consistency and type safety

This commit is contained in:
2026-04-17 21:13:45 +02:00
parent 5a377fc428
commit 632dda52d6
27 changed files with 310 additions and 494 deletions

View File

@@ -5,27 +5,37 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.core.ui.model.MovieUiModel
import hu.bbara.purefin.core.ui.model.SeriesUiModel
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
@Composable
fun PosterCard(
item: PosterItem,
item: MediaUiModel,
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocusedItem: (FocusableItem) -> Unit = {},
onFocusedItem: (MediaUiModel) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
) {
PosterCardContent(
model = item.toPosterCardModel(),
onClick = { item.open(onMovieSelected, onSeriesSelected, onEpisodeSelected) },
model = item,
onClick = {
// TODO Fix it by addig a handler into the viewModel like onMediaUiModelSelected
when (item) {
is SeriesUiModel -> onSeriesSelected(item.id)
is MovieUiModel -> onMovieSelected(item.id)
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
}
},
modifier = modifier,
imageModifier = imageModifier,
posterWidth = posterWidth,

View File

@@ -15,20 +15,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.home.TvHomeScreen
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestination
import hu.bbara.purefin.ui.screen.home.components.TvDrawerDestinationItem
import hu.bbara.purefin.ui.screen.home.components.TvNavigationDrawer
import hu.bbara.purefin.ui.screen.libraries.TvLibrariesOverviewScreen
import hu.bbara.purefin.core.model.LibraryKind
@Composable
fun TvAppScreen(
viewModel: AppViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val serverUrl by viewModel.serverUrl.collectAsState()
val libraries by viewModel.libraries.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
@@ -77,7 +76,6 @@ fun TvAppScreen(
libraryContent = latestLibraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
serverUrl = serverUrl,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,

View File

@@ -9,10 +9,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.ui.screen.home.components.TvFocusedItemHero
import hu.bbara.purefin.ui.screen.home.components.TvHomeContent
import hu.bbara.purefin.ui.screen.home.components.rememberTvHomeHeroState
@@ -25,10 +23,9 @@ private val TvHomeMaxHeroHeight = 200.dp
@Composable
fun TvHomeScreen(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
serverUrl: String,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
@@ -66,7 +63,8 @@ fun TvHomeScreen(
libraryContent = libraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMediaFocused = heroState.onMediaFocused,
//TODO implement it by throwing out complex TvHomeHeroState shit ass code. Fuck ai btw
onMediaFocused = {},
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,

View File

@@ -1,122 +0,0 @@
package hu.bbara.purefin.ui.screen.home.components
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.image.ArtworkKind
import kotlin.math.roundToInt
internal data class TvFocusedHeroModel(
val id: UUID,
val backdropImageUrl: String,
val title: String,
val metadata: List<String>,
) {
val metadataText: String?
get() = metadata.takeIf { it.isNotEmpty() }?.joinToString("")
}
internal fun FocusableItem.toTvFocusedHeroModel(): TvFocusedHeroModel {
return when (this) {
is ContinueWatchingItem -> when (type) {
MediaKind.MOVIE -> movie!!.toTvFocusedHeroModel()
MediaKind.EPISODE -> episode!!.toTvFocusedHeroModel()
else -> unsupportedType(type)
}
is NextUpItem -> episode.toTvFocusedHeroModel()
is PosterItem -> when (type) {
MediaKind.MOVIE -> movie!!.toTvFocusedHeroModel()
MediaKind.EPISODE -> episode!!.toTvFocusedHeroModel()
MediaKind.SERIES -> series!!.toTvFocusedHeroModel()
else -> unsupportedType(type)
}
}
}
internal fun backdropImageUrl(imageUrlPrefix: String?, fallbackImageUrl: String): String {
val backdropImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.BACKDROP)
return backdropImageUrl.ifBlank { fallbackImageUrl }
}
private fun Movie.toTvFocusedHeroModel(): TvFocusedHeroModel {
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
),
title = title,
metadata = listOf(year, rating, runtime, format).compactMetadata(),
)
}
private fun Episode.toTvFocusedHeroModel(): TvFocusedHeroModel {
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
),
title = title,
metadata = listOf(
"Episode $index",
releaseDate,
runtime,
rating,
format
).compactMetadata(),
)
}
private fun Series.toTvFocusedHeroModel(): TvFocusedHeroModel {
val unwatchedText = if (unwatchedEpisodeCount > 0) {
"$unwatchedEpisodeCount unwatched"
} else {
null
}
return TvFocusedHeroModel(
id = id,
backdropImageUrl = backdropImageUrl(
imageUrlPrefix = imageUrlPrefix,
fallbackImageUrl = ImageUrlBuilder.finishImageUrl(imageUrlPrefix, ArtworkKind.PRIMARY)
),
title = name,
metadata = listOf(year, seasonLabel(seasonCount), unwatchedText).compactMetadata(),
)
}
private fun progressFraction(progress: Double?): Float? {
val fraction = progress
?.div(100.0)
?.toFloat()
?.coerceIn(0f, 1f)
return fraction?.takeIf { it > 0f }
}
private fun progressLabel(progressFraction: Float?): String? {
return progressFraction?.let { "${(it * 100).roundToInt()}%" }
}
private fun List<String?>.compactMetadata(): List<String> {
return map(String?::orEmpty)
.map(String::trim)
.filter(String::isNotBlank)
}
private fun seasonLabel(seasonCount: Int): String {
return if (seasonCount == 1) "1 season" else "$seasonCount seasons"
}
private fun unsupportedType(type: MediaKind): Nothing {
throw UnsupportedOperationException("Unsupported item type: $type")
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
internal const val TvHomeHeroTitleTag = "tv-home-hero-title"
@@ -32,7 +33,7 @@ private const val TvHomeHeroAnimationMillis = 180
@Composable
internal fun TvFocusedItemHero(
item: TvFocusedHeroModel,
item: MediaUiModel,
height: Dp,
modifier: Modifier = Modifier,
) {
@@ -44,7 +45,7 @@ internal fun TvFocusedItemHero(
.background(scheme.background)
) {
Crossfade(
targetState = item.backdropImageUrl,
targetState = item.imageUrl,
animationSpec = tween(durationMillis = TvHomeHeroAnimationMillis),
label = "tv-home-hero-background"
) { imageUrl ->
@@ -98,7 +99,7 @@ internal fun TvFocusedItemHero(
modifier = Modifier.widthIn(max = 720.dp)
) {
Text(
text = hero.title,
text = hero.primaryText,
color = scheme.onBackground,
fontSize = 34.sp,
fontWeight = FontWeight.Bold,
@@ -107,16 +108,14 @@ internal fun TvFocusedItemHero(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag(TvHomeHeroTitleTag)
)
hero.metadataText?.let { metadataText ->
Text(
text = metadataText,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = hero.description,
color = scheme.onSurfaceVariant,
fontSize = 14.sp,
lineHeight = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@@ -24,11 +24,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@@ -37,10 +34,10 @@ internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
@Composable
fun TvHomeContent(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
onMediaFocused: (FocusableItem) -> Unit,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
onMediaFocused: (MediaUiModel) -> Unit,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,

View File

@@ -5,24 +5,24 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.LibraryItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
//TODO throw this out and simplify it.
internal data class TvHomeItemRegistry(
val visibleLibraries: List<LibraryItem>,
private val libraryContent: Map<UUID, List<PosterItem>>,
private val continueWatching: List<ContinueWatchingItem>,
private val nextUp: List<NextUpItem>,
private val libraryContent: Map<UUID, List<MediaUiModel>>,
private val continueWatching: List<MediaUiModel>,
private val nextUp: List<MediaUiModel>,
val firstAvailableItemId: UUID?,
) {
val firstAvailableItem: FocusableItem?
val firstAvailableItem: MediaUiModel?
get() = firstAvailableItemId?.let(::itemById)
fun itemById(id: UUID): FocusableItem? {
fun itemById(id: UUID): MediaUiModel? {
return continueWatching.firstOrNull { it.id == id }
?: nextUp.firstOrNull { it.id == id }
?: visibleLibraries.asSequence()
@@ -33,16 +33,16 @@ internal data class TvHomeItemRegistry(
}
internal class TvHomeHeroState(
val focusedHero: TvFocusedHeroModel?,
val focusedHero: MediaUiModel?,
val onMediaFocused: (FocusableItem) -> Unit,
)
@Composable
internal fun rememberTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
): TvHomeItemRegistry {
return remember(libraries, libraryContent, continueWatching, nextUp) {
createTvHomeItemRegistry(
@@ -56,9 +56,9 @@ internal fun rememberTvHomeItemRegistry(
internal fun createTvHomeItemRegistry(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
): TvHomeItemRegistry {
val visibleLibraries = libraries.filter { libraryContent[it.id]?.isEmpty() != true }
val firstAvailableItemId = continueWatching.firstOrNull()?.id
@@ -79,9 +79,9 @@ internal fun createTvHomeItemRegistry(
@Composable
internal fun rememberTvHomeHeroState(
libraries: List<LibraryItem>,
libraryContent: Map<UUID, List<PosterItem>>,
continueWatching: List<ContinueWatchingItem>,
nextUp: List<NextUpItem>,
libraryContent: Map<UUID, List<MediaUiModel>>,
continueWatching: List<MediaUiModel>,
nextUp: List<MediaUiModel>,
): TvHomeHeroState {
val itemRegistry = rememberTvHomeItemRegistry(
libraries = libraries,
@@ -94,7 +94,7 @@ internal fun rememberTvHomeHeroState(
focusedItemId?.let(itemRegistry::itemById)
}
val focusedHero = remember(focusedItem, itemRegistry) {
(focusedItem ?: itemRegistry.firstAvailableItem)?.toTvFocusedHeroModel()
(focusedItem ?: itemRegistry.firstAvailableItem)
}
val onMediaFocused: (FocusableItem) -> Unit = remember {
{ item ->

View File

@@ -44,17 +44,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.core.ui.model.EpisodeUiModel
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCard
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.feature.browse.home.ContinueWatchingItem
import hu.bbara.purefin.feature.browse.home.FocusableItem
import hu.bbara.purefin.feature.browse.home.NextUpItem
import hu.bbara.purefin.feature.browse.home.PosterItem
import java.util.UUID
import hu.bbara.purefin.core.model.MediaKind
import hu.bbara.purefin.core.image.ArtworkKind
private val TvHomeSectionsThumbShape = RoundedCornerShape(20.dp)
private val TvHomeSectionsHorizontalPadding = 32.dp
@@ -70,8 +65,8 @@ internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
@Composable
fun TvContinueWatchingSection(
items: List<ContinueWatchingItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
onMovieSelected: (UUID) -> Unit,
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
@@ -88,24 +83,11 @@ fun TvContinueWatchingSection(
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = when (item.type) {
MediaKind.MOVIE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.movie?.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
MediaKind.EPISODE -> ImageUrlBuilder.finishImageUrl(
prefixImageUrl = item.episode?.imageUrlPrefix,
artworkKind = ArtworkKind.PRIMARY
)
else -> null
},
progress = progressFraction,
imageUrl = item.imageUrl,
progress = item.progress ?: 0f,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
@@ -123,13 +105,9 @@ fun TvContinueWatchingSection(
),
onFocusedItem = { onFocusedItem(item) },
onClick = {
when (item.type) {
MediaKind.MOVIE -> onMovieSelected(item.movie!!.id)
MediaKind.EPISODE -> {
val episode = item.episode!!
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
when (item) {
//TODO fix this shit
is EpisodeUiModel -> onEpisodeSelected(item.seriesId, item.seasonId, item.id)
else -> Unit
}
}
@@ -140,8 +118,8 @@ fun TvContinueWatchingSection(
@Composable
fun TvNextUpSection(
items: List<NextUpItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
@@ -178,8 +156,10 @@ fun TvNextUpSection(
),
onFocusedItem = { onFocusedItem(item) },
onClick = {
val episode = item.episode
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
//TODO FIX
(item as? EpisodeUiModel)?.let { episode ->
onEpisodeSelected(episode.seriesId, episode.seasonId, episode.id)
}
}
)
}
@@ -189,8 +169,8 @@ fun TvNextUpSection(
@Composable
fun TvLibraryPosterSection(
title: String,
items: List<PosterItem>,
onFocusedItem: (FocusableItem) -> Unit = {},
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,

View File

@@ -22,11 +22,11 @@ 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.ui.common.card.PosterCard
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.core.navigation.LibraryDto
import hu.bbara.purefin.feature.browse.home.PosterItem
import hu.bbara.purefin.core.ui.model.MediaUiModel
import hu.bbara.purefin.feature.browse.library.LibraryViewModel
import hu.bbara.purefin.ui.common.button.PurefinIconButton
import hu.bbara.purefin.ui.common.card.PosterCard
import java.util.UUID
@Composable
@@ -92,7 +92,7 @@ private fun TvLibraryTopBar(
@Composable
fun TvLibraryContent(
libraryItems: List<PosterItem>,
libraryItems: List<MediaUiModel>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
modifier: Modifier = Modifier