Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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 java.util.Locale
import org.siloserver.silo.android.ui.theme.SiloBackground
import org.siloserver.silo.android.ui.theme.SiloOnSurface
import org.siloserver.silo.android.ui.theme.SiloSecondaryText
Expand Down Expand Up @@ -112,6 +113,8 @@ fun DetailHero(
translation: (@Composable () -> Unit)? = null,
actions: @Composable () -> Unit,
) {
val normalizedContentRating = HeroMetadata.contentRating(detail)

Column(modifier = modifier.fillMaxWidth()) {
Backdrop(
backdropUrl = detail.backdropUrl,
Expand All @@ -132,10 +135,10 @@ fun DetailHero(
EyebrowChip(text = eyebrow)
}
HeroTitle(detail = detail)
if (sourceTokens.isNotEmpty() || detail.contentRating != null) {
if (sourceTokens.isNotEmpty() || normalizedContentRating != null) {
SourceRow(
tokens = sourceTokens,
ratingChip = detail.contentRating,
ratingChip = normalizedContentRating,
)
}
actions()
Expand Down Expand Up @@ -855,14 +858,17 @@ fun SeasonChips(

object HeroMetadata {

fun movieEyebrow(detail: ItemDetail): String? {
val rating = detail.ratingImdb?.let { "IMDb %.1f".format(it) }
?: detail.ratingTmdb?.let { "TMDB %.1f".format(it) }
return rating
}
fun movieEyebrow(detail: ItemDetail): String? =
validImdb(detail.ratingImdb)?.let { "IMDb ${formatOneDecimal(it)}" }

fun seriesEyebrow(detail: ItemDetail): String? = movieEyebrow(detail)

fun contentRating(detail: ItemDetail): String? =
detail.contentRating
?.trim()
?.takeIf { it.isNotEmpty() }
?.uppercase(Locale.US)

fun episodeEyebrow(detail: ItemDetail): String? {
val s = detail.seasonNumber
val e = detail.episodeNumber
Expand All @@ -876,31 +882,45 @@ object HeroMetadata {
fun movieSourceTokens(detail: ItemDetail): List<String> = buildList {
if (detail.year > 0) add(detail.year.toString())
if (detail.runtime > 0) add(formatRuntime(detail.runtime))
detail.studios.firstOrNull()?.takeIf { it.isNotBlank() }?.let { add(it) }
detail.studios
.firstNotNullOfOrNull { it.trim().takeIf(String::isNotEmpty) }
?.let(::add)
}

fun seriesSourceTokens(detail: ItemDetail): List<String> = buildList {
if (detail.year > 0) add(detail.year.toString())
detail.seasonCount?.takeIf { it > 0 }?.let {
add("$it Season${if (it > 1) "s" else ""}")
}
detail.networks.firstOrNull()?.takeIf { it.isNotBlank() }?.let { add(it) }
detail.networks
.firstNotNullOfOrNull { it.trim().takeIf(String::isNotEmpty) }
?.let(::add)
}

fun movieFactsLine(detail: ItemDetail): List<String> = buildList {
if (detail.genres.isNotEmpty()) {
add(detail.genres.take(3).joinToString(" · "))
}
detail.ratingImdb?.let { add("IMDb %.1f".format(it)) }
}
fun movieFactsLine(detail: ItemDetail): List<String> = detailFactsLine(detail)

fun seriesFactsLine(detail: ItemDetail): List<String> = buildList {
if (detail.genres.isNotEmpty()) {
add(detail.genres.take(3).joinToString(" · "))
fun seriesFactsLine(detail: ItemDetail): List<String> = detailFactsLine(detail)

private fun detailFactsLine(detail: ItemDetail): List<String> = buildList {
normalizedGenres(detail.genres).takeIf { it.isNotEmpty() }?.let {
add(it.joinToString(" · "))
}
detail.ratingImdb?.let { add("IMDb %.1f".format(it)) }
validImdb(detail.ratingImdb)?.let { add("IMDb ${formatOneDecimal(it)}") }
}

private fun normalizedGenres(genres: List<String>): List<String> =
genres
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
.take(2)

private fun validImdb(rating: Double?): Double? =
rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 }

private fun formatOneDecimal(value: Double): String =
String.format(Locale.US, "%.1f", value)

private fun formatRuntime(minutes: Int): String {
if (minutes <= 0) return ""
val h = minutes / 60
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ fun FeaturedCarousel(

val pagerState = rememberPagerState(pageCount = { items.size })
val configuration = LocalConfiguration.current
val maxHeroGenres = featuredHeroMaxGenres(configuration.screenWidthDp)
val screenWidthDp = configuration.screenWidthDp.toFloat()

// Match iOS metrics: card width is screen minus 32pt margin (capped at 780),
Expand Down Expand Up @@ -142,6 +143,7 @@ fun FeaturedCarousel(

FeaturedCard(
item = item,
maxGenres = maxHeroGenres,
emphasis = emphasis,
cornerRadius = cardCornerRadius,
onPlayClick = { onPlayClick(item.contentId, playbackResumePosition(item)) },
Expand Down Expand Up @@ -185,6 +187,7 @@ fun FeaturedCarousel(
@Composable
private fun FeaturedCard(
item: SectionItem,
maxGenres: Int,
emphasis: Float,
cornerRadius: androidx.compose.ui.unit.Dp,
onPlayClick: () -> Unit,
Expand Down Expand Up @@ -248,6 +251,7 @@ private fun FeaturedCard(

FeaturedCardContent(
item = item,
maxGenres = maxGenres,
visibility = emphasis,
onPlayClick = onPlayClick,
onInfoClick = onInfoClick,
Expand Down Expand Up @@ -275,6 +279,7 @@ private fun FeaturedCard(
@OptIn(ExperimentalLayoutApi::class)
private fun FeaturedCardContent(
item: SectionItem,
maxGenres: Int,
visibility: Float,
onPlayClick: () -> Unit,
onInfoClick: () -> Unit,
Expand Down Expand Up @@ -330,7 +335,9 @@ private fun FeaturedCardContent(
)
}

val chips = remember(item) { featuredHeroMetadata(item) }
val chips = remember(item, maxGenres) {
featuredHeroMetadata(item = item, maxGenres = maxGenres)
}
if (chips.isNotEmpty()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package org.siloserver.silo.android.ui.screens.home

import java.util.Locale
import kotlin.math.roundToInt
import org.siloserver.silo.model.section.SectionItem
import org.siloserver.silo.model.section.toBrowseHeroMetadata

internal enum class FeaturedHeroMetadataKind {
Plain,
Expand All @@ -15,69 +14,23 @@ internal data class FeaturedHeroMetadataChip(
val kind: FeaturedHeroMetadataKind = FeaturedHeroMetadataKind.Plain,
)

internal fun featuredHeroMetadata(item: SectionItem): List<FeaturedHeroMetadataChip> {
val result = mutableListOf<FeaturedHeroMetadataChip>()
val isEpisode = item.type.equals("episode", ignoreCase = true)

if (isEpisode) {
episodeToken(item.seasonNumber, item.episodeNumber)?.let {
result += FeaturedHeroMetadataChip(it)
internal fun featuredHeroMaxGenres(screenWidthDp: Int): Int =
if (screenWidthDp >= 600) 2 else 1

internal fun featuredHeroMetadata(
item: SectionItem,
maxGenres: Int,
): List<FeaturedHeroMetadataChip> {
val metadata = item.toBrowseHeroMetadata(maxGenres)
return buildList {
metadata.leadingToken?.let { add(FeaturedHeroMetadataChip(it)) }
metadata.runtimeToken?.let { add(FeaturedHeroMetadataChip(it)) }
metadata.imdbRatingToken?.let {
add(FeaturedHeroMetadataChip(it, FeaturedHeroMetadataKind.Rating))
}
} else if (item.year > 0) {
result += FeaturedHeroMetadataChip(item.year.toString())
}

formatFeaturedRuntime(item.runtime, item.durationSeconds)?.let {
result += FeaturedHeroMetadataChip(it)
}
validImdbRating(item.ratingImdb)
?.let {
result += FeaturedHeroMetadataChip(
label = String.format(Locale.US, "%.1f", it),
kind = FeaturedHeroMetadataKind.Rating,
)
}
if (!isEpisode) {
item.genres.firstOrNull { it.isNotBlank() }?.let {
result += FeaturedHeroMetadataChip(it)
metadata.genres.forEach { add(FeaturedHeroMetadataChip(it)) }
metadata.contentRating?.let {
add(FeaturedHeroMetadataChip(it, FeaturedHeroMetadataKind.Classification))
}
}
item.contentRating
?.takeIf { it.isNotBlank() }
?.uppercase(Locale.US)
?.let {
result += FeaturedHeroMetadataChip(
label = it,
kind = FeaturedHeroMetadataKind.Classification,
)
}
return result
}

private fun validImdbRating(rating: Double?): Double? =
rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 }

private fun episodeToken(season: Int?, episode: Int?): String? = when {
season != null && episode != null -> "S$season E$episode"
season != null -> "Season $season"
episode != null -> "Episode $episode"
else -> null
}

/** Episode/movie length: the metadata runtime when present, else derived
* from the file duration the payload already carries. */
private fun formatFeaturedRuntime(runtimeMinutes: Int?, durationSeconds: Double?): String? {
runtimeMinutes?.takeIf { it > 0 }?.let { return formatRuntimeMinutes(it) }
val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 }
?: return null
val minutes = (duration / 60.0).roundToInt().takeIf { it > 0 }
?: return null
return formatRuntimeMinutes(minutes)
}

private fun formatRuntimeMinutes(minutes: Int): String {
if (minutes < 60) return "$minutes min"
val hours = minutes / 60
val remainder = minutes % 60
return if (remainder == 0) "${hours}h" else "${hours}h ${remainder}m"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.siloserver.silo.android.ui.screens.detail

import org.siloserver.silo.model.catalog.ItemDetail
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class PhoneDetailHeroMetadataTest {
private fun detail(
type: String = "movie",
ratingImdb: Double? = 7.9,
ratingTmdb: Double? = 8.8,
genres: List<String> = listOf(" Drama ", "", "Drama", "Science Fiction", "Thriller"),
contentRating: String? = " pg-13 ",
) = ItemDetail(
contentId = "detail-1",
type = type,
title = "Arrival",
year = 2016,
runtime = 116,
ratingImdb = ratingImdb,
ratingTmdb = ratingTmdb,
genres = genres,
contentRating = contentRating,
studios = listOf(" Paramount "),
networks = listOf(" HBO "),
)

@Test
fun validImdbUsesLocaleStableLabelAndFacts() {
val detail = detail(ratingImdb = 8.05)

assertEquals("IMDb 8.1", HeroMetadata.movieEyebrow(detail))
assertEquals(listOf("Drama · Science Fiction", "IMDb 8.1"), HeroMetadata.movieFactsLine(detail))
}

@Test
fun absentImdbDoesNotRelabelTmdbAsImdb() {
val detail = detail(ratingImdb = null, ratingTmdb = 8.8)

assertNull(HeroMetadata.movieEyebrow(detail))
assertEquals(listOf("Drama · Science Fiction"), HeroMetadata.movieFactsLine(detail))
}

@Test
fun invalidImdbValuesAreOmittedFromEyebrowAndFacts() {
listOf(
Double.NaN,
Double.POSITIVE_INFINITY,
Double.NEGATIVE_INFINITY,
0.0,
-1.0,
10.1,
).forEach { invalid ->
val detail = detail(ratingImdb = invalid)
assertNull(HeroMetadata.movieEyebrow(detail))
assertEquals(listOf("Drama · Science Fiction"), HeroMetadata.movieFactsLine(detail))
}
}

@Test
fun detailGenresAreTrimmedDeduplicatedAndCappedAtTwo() {
assertEquals(
listOf("Drama · Science Fiction", "IMDb 7.9"),
HeroMetadata.seriesFactsLine(detail(type = "series")),
)
}

@Test
fun detailContentRatingIsTrimmedUppercasedAndBlankSafe() {
assertEquals("PG-13", HeroMetadata.contentRating(detail()))
assertNull(HeroMetadata.contentRating(detail(contentRating = " ")))
assertNull(HeroMetadata.contentRating(detail(contentRating = null)))
}

@Test
fun sourceTokensTrimEditorialStudioAndNetworkAndOmitEmptyValues() {
assertEquals(
listOf("2016", "1h 56m", "Paramount"),
HeroMetadata.movieSourceTokens(detail()),
)
assertEquals(
listOf("2016", "HBO"),
HeroMetadata.seriesSourceTokens(detail(type = "series")),
)
}
}
Loading