mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: episode ratings api
This commit is contained in:
parent
1e75f416e4
commit
81babba3ed
5 changed files with 435 additions and 84 deletions
|
|
@ -76,6 +76,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/details").apply {
|
||||
mkdirs()
|
||||
resolve("ImdbEpisodeRatingsConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.features.details
|
||||
|
|
||||
|object ImdbEpisodeRatingsConfig {
|
||||
| const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}"
|
||||
| const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||
mkdirs()
|
||||
resolve("AppVersionConfig.kt").writeText(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.library.LibraryClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
object ImdbEpisodeRatingsRepository {
|
||||
private data class CacheEntry(
|
||||
val ratings: Map<Pair<Int, Int>, Double>,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private val log = Logger.withTag("ImdbEpisodeRatingsRepo")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val mutex = Mutex()
|
||||
private val cache = mutableMapOf<String, CacheEntry>()
|
||||
private val inFlight = mutableMapOf<String, Deferred<Map<Pair<Int, Int>, Double>>>()
|
||||
|
||||
suspend fun getEpisodeRatings(
|
||||
imdbId: String?,
|
||||
tmdbId: Int?,
|
||||
): Map<Pair<Int, Int>, Double> {
|
||||
val normalizedImdbId = normalizeImdbId(imdbId)
|
||||
val normalizedTmdbId = tmdbId?.takeIf { it > 0 }
|
||||
if (normalizedImdbId == null && normalizedTmdbId == null) return emptyMap()
|
||||
|
||||
val cacheKey = normalizedImdbId?.let { "imdb:$it" } ?: "tmdb:$normalizedTmdbId"
|
||||
val now = currentTimeMs()
|
||||
mutex.withLock {
|
||||
cache[cacheKey]?.let { cached ->
|
||||
if (cached.expiresAtMs > now) return cached.ratings
|
||||
cache.remove(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
val deferred = mutex.withLock {
|
||||
inFlight[cacheKey] ?: scope.async {
|
||||
try {
|
||||
fetchEpisodeRatings(
|
||||
imdbId = normalizedImdbId,
|
||||
tmdbId = normalizedTmdbId,
|
||||
).also { ratings ->
|
||||
mutex.withLock {
|
||||
cache[cacheKey] = CacheEntry(
|
||||
ratings = ratings,
|
||||
expiresAtMs = currentTimeMs() + CACHE_TTL_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mutex.withLock {
|
||||
inFlight.remove(cacheKey)
|
||||
}
|
||||
}
|
||||
}.also { created ->
|
||||
inFlight[cacheKey] = created
|
||||
}
|
||||
}
|
||||
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cache.clear()
|
||||
inFlight.clear()
|
||||
}
|
||||
|
||||
private suspend fun fetchEpisodeRatings(
|
||||
imdbId: String?,
|
||||
tmdbId: Int?,
|
||||
): Map<Pair<Int, Int>, Double> {
|
||||
if (!imdbId.isNullOrBlank()) {
|
||||
val primary = toRatingsMap(ImdbTapframeApi.getSeasonRatings(imdbId))
|
||||
if (primary.isNotEmpty()) return primary
|
||||
log.w { "Primary episode ratings empty for imdbId=$imdbId, trying fallback" }
|
||||
}
|
||||
|
||||
if (tmdbId != null) {
|
||||
return toRatingsMap(SeriesGraphApi.getSeasonRatings(tmdbId))
|
||||
}
|
||||
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
private fun toRatingsMap(payload: List<SeriesGraphSeasonRatingsDto>): Map<Pair<Int, Int>, Double> =
|
||||
buildMap {
|
||||
payload.forEach { season ->
|
||||
season.episodes.orEmpty().forEach { episode ->
|
||||
val seasonNumber = episode.seasonNumber ?: return@forEach
|
||||
val episodeNumber = episode.episodeNumber ?: return@forEach
|
||||
val voteAverage = episode.voteAverage?.takeIf { it > 0.0 } ?: return@forEach
|
||||
put(seasonNumber to episodeNumber, voteAverage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeImdbId(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.substringBefore(':')
|
||||
?.takeIf { it.startsWith("tt", ignoreCase = true) }
|
||||
|
||||
private fun currentTimeMs(): Long = LibraryClock.nowEpochMs()
|
||||
|
||||
private const val CACHE_TTL_MS = 30L * 60L * 1000L
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ import com.nuvio.app.features.library.LibraryRepository
|
|||
import com.nuvio.app.features.library.toLibraryItem
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||
|
|
@ -167,6 +168,7 @@ fun MetaDetailsScreen(
|
|||
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
|
||||
|
||||
val shouldShowComments = commentsEnabled &&
|
||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||
|
|
@ -194,6 +196,30 @@ fun MetaDetailsScreen(
|
|||
isCommentsLoading = false
|
||||
}
|
||||
|
||||
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
|
||||
val metaForRatings = displayedMeta
|
||||
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id)
|
||||
val tmdbId = extractTmdbId(metaForRatings.id)
|
||||
?: extractTmdbId(id)
|
||||
?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull()
|
||||
?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull()
|
||||
|
||||
if (imdbId == null && tmdbId == null) {
|
||||
episodeImdbRatings = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings(
|
||||
imdbId = imdbId,
|
||||
tmdbId = tmdbId,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
||||
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
||||
autoLoadAttempted = true
|
||||
|
|
@ -656,6 +682,7 @@ fun MetaDetailsScreen(
|
|||
commentsCurrentPage = commentsCurrentPage,
|
||||
commentsPageCount = commentsPageCount,
|
||||
commentsError = commentsError,
|
||||
episodeImdbRatings = episodeImdbRatings,
|
||||
onRetryComments = {
|
||||
detailsScope.launch {
|
||||
isCommentsLoading = true
|
||||
|
|
@ -937,6 +964,30 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
||||
val normalizedType = type.trim().lowercase()
|
||||
val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null }
|
||||
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||
}
|
||||
|
||||
private fun extractImdbId(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.split(':', '/', '?', '&')
|
||||
?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
|
||||
?.takeIf { it.length > 2 }
|
||||
|
||||
private fun extractTmdbId(value: String?): Int? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isBlank()) return null
|
||||
return trimmed
|
||||
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
|
||||
?.substringAfter(':')
|
||||
?.substringBefore(':')
|
||||
?.substringBefore('/')
|
||||
?.toIntOrNull()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun ConfiguredMetaSections(
|
||||
|
|
@ -965,6 +1016,7 @@ private fun ConfiguredMetaSections(
|
|||
commentsCurrentPage: Int,
|
||||
commentsPageCount: Int,
|
||||
commentsError: String?,
|
||||
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||
onRetryComments: () -> Unit,
|
||||
onLoadMoreComments: () -> Unit,
|
||||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
|
|
@ -1064,6 +1116,7 @@ private fun ConfiguredMetaSections(
|
|||
episodeCardStyle = settings.episodeCardStyle,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
episodeRatings = episodeImdbRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
internal object SeriesGraphApi {
|
||||
suspend fun getSeasonRatings(tmdbId: Int): List<SeriesGraphSeasonRatingsDto> =
|
||||
requestSeasonRatings(
|
||||
baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL,
|
||||
showId = tmdbId.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
internal object ImdbTapframeApi {
|
||||
suspend fun getSeasonRatings(imdbId: String): List<SeriesGraphSeasonRatingsDto> =
|
||||
requestSeasonRatings(
|
||||
baseUrl = ImdbEpisodeRatingsConfig.IMDB_TAPFRAME_API_BASE_URL,
|
||||
showId = imdbId,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class SeriesGraphEpisodeRatingDto(
|
||||
@SerialName("season_number") val seasonNumber: Int? = null,
|
||||
@SerialName("episode_number") val episodeNumber: Int? = null,
|
||||
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||
val name: String? = null,
|
||||
val tconst: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SeriesGraphSeasonRatingsDto(
|
||||
val episodes: List<SeriesGraphEpisodeRatingDto>? = null,
|
||||
)
|
||||
|
||||
private val seriesGraphLog = Logger.withTag("SeriesGraphApi")
|
||||
private val seriesGraphJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private suspend fun requestSeasonRatings(
|
||||
baseUrl: String,
|
||||
showId: String,
|
||||
): List<SeriesGraphSeasonRatingsDto> {
|
||||
val resolvedBaseUrl = baseUrl.trim().trimEnd('/')
|
||||
if (resolvedBaseUrl.isBlank()) return emptyList()
|
||||
|
||||
return runCatching {
|
||||
val response = httpRequestRaw(
|
||||
method = "GET",
|
||||
url = "$resolvedBaseUrl/api/shows/$showId/season-ratings",
|
||||
headers = mapOf("Accept" to "application/json"),
|
||||
body = "",
|
||||
)
|
||||
if (response.status !in 200..299 || response.body.isBlank()) {
|
||||
seriesGraphLog.w { "Season ratings request failed for $showId (${response.status})" }
|
||||
return emptyList()
|
||||
}
|
||||
seriesGraphJson.decodeFromString<List<SeriesGraphSeasonRatingsDto>>(response.body)
|
||||
}.onFailure { error ->
|
||||
seriesGraphLog.w(error) { "Season ratings request failed for $showId" }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
|
@ -15,12 +15,14 @@ import androidx.compose.foundation.border
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -77,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val log = Logger.withTag("SeriesContent")
|
||||
|
||||
|
|
@ -91,6 +96,7 @@ fun DetailSeriesContent(
|
|||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
|
||||
blurUnwatchedEpisodes: Boolean = false,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||
|
|
@ -278,6 +284,7 @@ fun DetailSeriesContent(
|
|||
watchedKeys = watchedKeys,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressByVideoId = progressByVideoId,
|
||||
episodeRatings = episodeRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
|
|
@ -298,6 +305,7 @@ fun DetailSeriesContent(
|
|||
video = episode,
|
||||
fallbackImage = meta.background ?: meta.poster,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
|
|
@ -557,6 +565,7 @@ private fun EpisodeHorizontalRow(
|
|||
watchedKeys: Set<String>,
|
||||
fallbackImage: String?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||
|
|
@ -602,6 +611,7 @@ private fun EpisodeHorizontalRow(
|
|||
video = episode,
|
||||
fallbackImage = fallbackImage,
|
||||
progressEntry = progressByVideoId[episodeVideoId],
|
||||
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||
WatchingState.isEpisodeWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
|
|
@ -624,6 +634,7 @@ private fun EpisodeHorizontalCard(
|
|||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
imdbRating: Double?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
metrics: EpisodeHorizontalCardMetrics,
|
||||
|
|
@ -631,6 +642,9 @@ private fun EpisodeHorizontalCard(
|
|||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
||||
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(metrics.cardWidth)
|
||||
|
|
@ -676,30 +690,6 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
|
||||
.clip(RoundedCornerShape(metrics.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.18f),
|
||||
shape = RoundedCornerShape(metrics.badgeRadius),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = metrics.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = Modifier
|
||||
|
|
@ -719,6 +709,15 @@ private fun EpisodeHorizontalCard(
|
|||
),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
EpisodeCodeBadge(
|
||||
text = video.episodeBadge(),
|
||||
textSize = metrics.badgeTextSize,
|
||||
radius = metrics.badgeRadius,
|
||||
horizontalPadding = metrics.badgeHorizontalPadding,
|
||||
verticalPadding = metrics.badgeVerticalPadding,
|
||||
backgroundAlpha = 0.42f,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = video.title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
|
|
@ -744,27 +743,39 @@ private fun EpisodeHorizontalCard(
|
|||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
||||
Text(
|
||||
text = formatEpisodeRuntime(runtimeMinutes),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
runtimeLabel?.let { runtime ->
|
||||
Text(
|
||||
text = runtime,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ratingLabel?.let { rating ->
|
||||
ImdbEpisodeRatingBadge(
|
||||
rating = rating,
|
||||
logoWidth = metrics.imdbLogoWidth,
|
||||
logoHeight = metrics.imdbLogoHeight,
|
||||
textSize = metrics.metaTextSize,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
formattedDate?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||
color = Color.White.copy(alpha = 0.78f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -803,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
|
|||
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||
val badgeRadius: Dp,
|
||||
val badgeHorizontalPadding: Dp,
|
||||
val badgeVerticalPadding: Dp,
|
||||
val imdbLogoWidth: Dp,
|
||||
val imdbLogoHeight: Dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -825,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 11.sp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeRadius = 8.dp,
|
||||
badgeHorizontalPadding = 10.dp,
|
||||
badgeVerticalPadding = 5.dp,
|
||||
imdbLogoWidth = 28.dp,
|
||||
imdbLogoHeight = 14.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -844,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 3,
|
||||
metaTextSize = 12.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeRadius = 7.dp,
|
||||
badgeHorizontalPadding = 9.dp,
|
||||
badgeVerticalPadding = 4.dp,
|
||||
imdbLogoWidth = 26.dp,
|
||||
imdbLogoHeight = 13.dp,
|
||||
)
|
||||
|
||||
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -863,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
overviewMaxLines = 2,
|
||||
metaTextSize = 11.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
badgeRadius = 5.dp,
|
||||
badgeRadius = 6.dp,
|
||||
badgeHorizontalPadding = 8.dp,
|
||||
badgeVerticalPadding = 4.dp,
|
||||
imdbLogoWidth = 24.dp,
|
||||
imdbLogoHeight = 12.dp,
|
||||
)
|
||||
|
||||
else -> EpisodeHorizontalCardMetrics(
|
||||
|
|
@ -883,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
|||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 9.sp,
|
||||
badgeRadius = 5.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
imdbLogoWidth = 22.dp,
|
||||
imdbLogoHeight = 11.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -892,12 +923,73 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
|
|||
return formatRuntimeFromMinutes(runtimeMinutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeCodeBadge(
|
||||
text: String,
|
||||
textSize: androidx.compose.ui.unit.TextUnit,
|
||||
radius: Dp,
|
||||
horizontalPadding: Dp,
|
||||
verticalPadding: Dp,
|
||||
backgroundAlpha: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImdbEpisodeRatingBadge(
|
||||
rating: String,
|
||||
logoWidth: Dp,
|
||||
logoHeight: Dp,
|
||||
textSize: androidx.compose.ui.unit.TextUnit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.rating_imdb),
|
||||
contentDescription = stringResource(Res.string.source_imdb),
|
||||
modifier = Modifier
|
||||
.width(logoWidth)
|
||||
.height(logoHeight),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text(
|
||||
text = rating,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = textSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = Color(0xFFF5C518),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EpisodeListCard(
|
||||
video: MetaVideo,
|
||||
fallbackImage: String?,
|
||||
progressEntry: WatchProgressEntry?,
|
||||
imdbRating: Double?,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
sizing: SeriesContentSizing,
|
||||
|
|
@ -906,6 +998,8 @@ private fun EpisodeListCard(
|
|||
onLongPress: (() -> Unit)? = null,
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
||||
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -952,32 +1046,17 @@ private fun EpisodeListCard(
|
|||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
EpisodeCodeBadge(
|
||||
text = video.episodeBadge(),
|
||||
textSize = sizing.badgeTextSize,
|
||||
radius = sizing.badgeRadius,
|
||||
horizontalPadding = sizing.badgeHorizontalPadding,
|
||||
verticalPadding = sizing.badgeVerticalPadding,
|
||||
backgroundAlpha = 0.85f,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 8.dp, top = 8.dp)
|
||||
.clip(RoundedCornerShape(sizing.badgeRadius))
|
||||
.background(Color.Black.copy(alpha = 0.85f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(sizing.badgeRadius),
|
||||
)
|
||||
.padding(
|
||||
horizontal = sizing.badgeHorizontalPadding,
|
||||
vertical = sizing.badgeVerticalPadding,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = video.episodeBadge(),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.3.sp,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
.padding(start = 8.dp, top = 8.dp),
|
||||
)
|
||||
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
|
|
@ -1005,24 +1084,39 @@ private fun EpisodeListCard(
|
|||
fontSize = sizing.titleTextSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
lineHeight = sizing.titleLineHeight,
|
||||
letterSpacing = 0.3.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = sizing.titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
||||
Text(
|
||||
text = formattedDate,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (formattedDate != null || ratingLabel != null) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
formattedDate?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = sizing.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ratingLabel?.let { rating ->
|
||||
ImdbEpisodeRatingBadge(
|
||||
rating = rating,
|
||||
logoWidth = 24.dp,
|
||||
logoHeight = 12.dp,
|
||||
textSize = sizing.metaTextSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!video.overview.isNullOrBlank()) {
|
||||
|
|
@ -1225,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String =
|
|||
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
||||
}
|
||||
|
||||
private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {
|
||||
val seasonNumber = season ?: return null
|
||||
val episodeNumber = episode ?: return null
|
||||
return seasonNumber to episodeNumber
|
||||
}
|
||||
|
||||
private fun formatEpisodeRating(rating: Double): String {
|
||||
val roundedTenths = (rating * 10.0).roundToInt()
|
||||
val whole = roundedTenths / 10
|
||||
val tenth = (roundedTenths % 10).absoluteValue
|
||||
return "$whole.$tenth"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue