mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +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 {
|
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
resolve("AppVersionConfig.kt").writeText(
|
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.library.toLibraryItem
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
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.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
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 pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
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 &&
|
val shouldShowComments = commentsEnabled &&
|
||||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||||
|
|
@ -194,6 +196,30 @@ fun MetaDetailsScreen(
|
||||||
isCommentsLoading = false
|
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) {
|
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
||||||
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
||||||
autoLoadAttempted = true
|
autoLoadAttempted = true
|
||||||
|
|
@ -656,6 +682,7 @@ fun MetaDetailsScreen(
|
||||||
commentsCurrentPage = commentsCurrentPage,
|
commentsCurrentPage = commentsCurrentPage,
|
||||||
commentsPageCount = commentsPageCount,
|
commentsPageCount = commentsPageCount,
|
||||||
commentsError = commentsError,
|
commentsError = commentsError,
|
||||||
|
episodeImdbRatings = episodeImdbRatings,
|
||||||
onRetryComments = {
|
onRetryComments = {
|
||||||
detailsScope.launch {
|
detailsScope.launch {
|
||||||
isCommentsLoading = true
|
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
|
@Composable
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
private fun ConfiguredMetaSections(
|
private fun ConfiguredMetaSections(
|
||||||
|
|
@ -965,6 +1016,7 @@ private fun ConfiguredMetaSections(
|
||||||
commentsCurrentPage: Int,
|
commentsCurrentPage: Int,
|
||||||
commentsPageCount: Int,
|
commentsPageCount: Int,
|
||||||
commentsError: String?,
|
commentsError: String?,
|
||||||
|
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||||
onRetryComments: () -> Unit,
|
onRetryComments: () -> Unit,
|
||||||
onLoadMoreComments: () -> Unit,
|
onLoadMoreComments: () -> Unit,
|
||||||
onCommentClick: (TraktCommentReview) -> Unit,
|
onCommentClick: (TraktCommentReview) -> Unit,
|
||||||
|
|
@ -1064,6 +1116,7 @@ private fun ConfiguredMetaSections(
|
||||||
episodeCardStyle = settings.episodeCardStyle,
|
episodeCardStyle = settings.episodeCardStyle,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
episodeRatings = episodeImdbRatings,
|
||||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
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.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -77,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val log = Logger.withTag("SeriesContent")
|
private val log = Logger.withTag("SeriesContent")
|
||||||
|
|
||||||
|
|
@ -91,6 +96,7 @@ fun DetailSeriesContent(
|
||||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||||
watchedKeys: Set<String> = emptySet(),
|
watchedKeys: Set<String> = emptySet(),
|
||||||
|
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
|
||||||
blurUnwatchedEpisodes: Boolean = false,
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||||
|
|
@ -278,6 +284,7 @@ fun DetailSeriesContent(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
|
episodeRatings = episodeRatings,
|
||||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
|
|
@ -298,6 +305,7 @@ fun DetailSeriesContent(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
|
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
|
@ -557,6 +565,7 @@ private fun EpisodeHorizontalRow(
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
preferredEpisodeNumber: Int? = null,
|
preferredEpisodeNumber: Int? = null,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||||
|
|
@ -602,6 +611,7 @@ private fun EpisodeHorizontalRow(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = fallbackImage,
|
fallbackImage = fallbackImage,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
|
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
|
@ -624,6 +634,7 @@ private fun EpisodeHorizontalCard(
|
||||||
video: MetaVideo,
|
video: MetaVideo,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
|
imdbRating: Double?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
metrics: EpisodeHorizontalCardMetrics,
|
metrics: EpisodeHorizontalCardMetrics,
|
||||||
|
|
@ -631,6 +642,9 @@ private fun EpisodeHorizontalCard(
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(metrics.cardWidth)
|
.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(
|
NuvioAnimatedWatchedBadge(
|
||||||
isVisible = isWatched,
|
isVisible = isWatched,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -719,6 +709,15 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
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(
|
||||||
text = video.title,
|
text = video.title,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
|
@ -744,30 +743,42 @@ private fun EpisodeHorizontalCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
runtimeLabel?.let { runtime ->
|
||||||
Text(
|
Text(
|
||||||
text = formatEpisodeRuntime(runtimeMinutes),
|
text = runtime,
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||||
color = Color.White.copy(alpha = 0.78f),
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
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(
|
||||||
text = formattedDate,
|
text = date,
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||||
color = Color.White.copy(alpha = 0.78f),
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
progressEntry
|
progressEntry
|
||||||
?.takeIf { it.durationMs > 0L && !it.isCompleted }
|
?.takeIf { it.durationMs > 0L && !it.isCompleted }
|
||||||
|
|
@ -803,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
|
||||||
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||||
val badgeRadius: Dp,
|
val badgeRadius: Dp,
|
||||||
|
val badgeHorizontalPadding: Dp,
|
||||||
|
val badgeVerticalPadding: Dp,
|
||||||
|
val imdbLogoWidth: Dp,
|
||||||
|
val imdbLogoHeight: Dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -825,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 3,
|
overviewMaxLines = 3,
|
||||||
metaTextSize = 12.sp,
|
metaTextSize = 12.sp,
|
||||||
badgeTextSize = 11.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(
|
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -844,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 3,
|
overviewMaxLines = 3,
|
||||||
metaTextSize = 12.sp,
|
metaTextSize = 12.sp,
|
||||||
badgeTextSize = 10.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(
|
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -863,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 2,
|
overviewMaxLines = 2,
|
||||||
metaTextSize = 11.sp,
|
metaTextSize = 11.sp,
|
||||||
badgeTextSize = 10.sp,
|
badgeTextSize = 10.sp,
|
||||||
badgeRadius = 5.dp,
|
badgeRadius = 6.dp,
|
||||||
|
badgeHorizontalPadding = 8.dp,
|
||||||
|
badgeVerticalPadding = 4.dp,
|
||||||
|
imdbLogoWidth = 24.dp,
|
||||||
|
imdbLogoHeight = 12.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> EpisodeHorizontalCardMetrics(
|
else -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -883,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
metaTextSize = 10.sp,
|
metaTextSize = 10.sp,
|
||||||
badgeTextSize = 9.sp,
|
badgeTextSize = 9.sp,
|
||||||
badgeRadius = 5.dp,
|
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)
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeListCard(
|
private fun EpisodeListCard(
|
||||||
video: MetaVideo,
|
video: MetaVideo,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
|
imdbRating: Double?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
|
|
@ -906,6 +998,8 @@ private fun EpisodeListCard(
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.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
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(start = 8.dp, top = 8.dp)
|
.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioAnimatedWatchedBadge(
|
NuvioAnimatedWatchedBadge(
|
||||||
isVisible = isWatched,
|
isVisible = isWatched,
|
||||||
|
|
@ -1005,16 +1084,21 @@ private fun EpisodeListCard(
|
||||||
fontSize = sizing.titleTextSize,
|
fontSize = sizing.titleTextSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = sizing.titleLineHeight,
|
lineHeight = sizing.titleLineHeight,
|
||||||
letterSpacing = 0.3.sp,
|
letterSpacing = 0.sp,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = sizing.titleMaxLines,
|
maxLines = sizing.titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
if (formattedDate != null || ratingLabel != null) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
formattedDate?.let { date ->
|
||||||
Text(
|
Text(
|
||||||
text = formattedDate,
|
text = date,
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
fontSize = sizing.metaTextSize,
|
fontSize = sizing.metaTextSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -1024,6 +1108,16 @@ private fun EpisodeListCard(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ratingLabel?.let { rating ->
|
||||||
|
ImdbEpisodeRatingBadge(
|
||||||
|
rating = rating,
|
||||||
|
logoWidth = 24.dp,
|
||||||
|
logoHeight = 12.dp,
|
||||||
|
textSize = sizing.metaTextSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!video.overview.isNullOrBlank()) {
|
if (!video.overview.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -1225,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String =
|
||||||
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||||
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
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