Merge branch 'NuvioMedia:cmp-rewrite' into master

This commit is contained in:
paregi12 2026-05-07 15:08:22 +05:30 committed by GitHub
commit b5b1afce8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 697 additions and 165 deletions

View file

@ -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(

View file

@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_episode_release_notifications",
"nuvio_episode_release_notifications_platform",
"nuvio_watch_progress",
"nuvio_collections",
"nuvio_plugins",
)

View file

@ -1007,9 +1007,14 @@
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
<string name="profile_avatar_options_pending">Avatar options will appear here when the catalog loads.</string>
<string name="profile_avatar_selected">Avatar: %1$s</string>
<string name="profile_avatar_url_invalid">Enter a valid http:// or https:// image URL.</string>
<string name="profile_choose_avatar">Choose an avatar</string>
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
<string name="profile_create_profile">Create Profile</string>
<string name="profile_custom_avatar_selected">Custom avatar URL selected.</string>
<string name="profile_custom_avatar_url">Custom avatar URL</string>
<string name="profile_custom_avatar_url_description">Paste an image link, or leave this empty to use the built-in avatar catalog.</string>
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
<string name="profile_delete_confirm_message">All data for &quot;%1$s&quot; will be permanently deleted.</string>
<string name="profile_delete_title">Delete Profile</string>
<string name="profile_edit_add_title">Add Profile</string>

View file

@ -61,6 +61,8 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -133,7 +135,7 @@ import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab
import com.nuvio.app.features.profiles.avatarStorageUrl
import com.nuvio.app.features.profiles.profileAvatarImageUrl
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
@ -329,6 +331,7 @@ fun App() {
profileState.activeProfile?.name,
profileState.activeProfile?.avatarColorHex,
profileState.activeProfile?.avatarId,
profileState.activeProfile?.avatarUrl,
profileAvatars,
) {
val activeProfile = profileState.activeProfile
@ -338,10 +341,7 @@ fun App() {
NativeTabBridge.publishProfileTabIcon(
name = activeProfile?.name,
avatarColorHex = activeProfile?.avatarColorHex,
avatarImageUrl = avatarItem
?.storagePath
?.takeIf { it.isNotBlank() }
?.let(::avatarStorageUrl),
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
avatarBackgroundColorHex = avatarItem?.bgColor,
)
}
@ -576,6 +576,32 @@ private fun MainAppContent(
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
}
DisposableEffect(
navController,
liquidGlassNativeTabBarSupported,
liquidGlassNativeTabBarEnabled,
initialHomeReady,
) {
fun publishNativeTabVisibilityForCurrentRoute() {
val visible = liquidGlassNativeTabBarSupported &&
liquidGlassNativeTabBarEnabled &&
initialHomeReady &&
navController.currentDestination?.hasRoute<TabsRoute>() == true
NativeTabBridge.publishTabBarVisible(visible)
}
val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ ->
publishNativeTabVisibilityForCurrentRoute()
}
publishNativeTabVisibilityForCurrentRoute()
navController.addOnDestinationChangedListener(destinationChangedListener)
onDispose {
navController.removeOnDestinationChangedListener(destinationChangedListener)
NativeTabBridge.publishTabBarVisible(false)
}
}
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
EpisodeReleaseNotificationsRepository.refreshAsync()
@ -956,13 +982,6 @@ private fun MainAppContent(
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
}
DisposableEffect(useNativeBottomTabs) {
NativeTabBridge.publishTabBarVisible(useNativeBottomTabs)
onDispose {
NativeTabBridge.publishTabBarVisible(false)
}
}
Scaffold(
modifier = Modifier
.fillMaxSize()

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@ -56,16 +57,13 @@ object CollectionSyncService {
return
}
val remoteJson = blob.collectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (remoteJson == "[]" || remoteJson == "null") {
val currentCollections = CollectionRepository.collections.value
if (currentCollections.isNotEmpty()) {
log.i { "pullFromServer — remote empty, preserving local ${currentCollections.size} collections" }
return
}
val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
JsonArray(emptyList())
} else {
blob.collectionsJson
}
val remoteJson = remoteCollectionsJson.toString()
val localJson = CollectionRepository.exportToJson()
if (remoteJson == localJson) {
log.d { "pullFromServer — remote matches local, no update needed" }
@ -78,7 +76,7 @@ object CollectionSyncService {
if (remoteCollections != null) {
isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else {

View file

@ -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
}

View file

@ -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,

View file

@ -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())
}

View file

@ -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"
}

View file

@ -98,3 +98,13 @@ enum class DownloadEnqueueResult {
}
}
}
internal fun List<DownloadItem>.sortedForSeriesDownloads(): List<DownloadItem> =
sortedWith(downloadSeriesEpisodeComparator)
internal val downloadSeriesEpisodeComparator: Comparator<DownloadItem> =
compareBy<DownloadItem> { it.seasonNumber ?: Int.MAX_VALUE }
.thenBy { it.episodeNumber ?: Int.MAX_VALUE }
.thenBy { it.episodeTitle?.trim().orEmpty().lowercase() }
.thenBy { it.title.trim().lowercase() }
.thenBy { it.id }

View file

@ -56,7 +56,7 @@ fun DownloadsScreen(
val completedEpisodes = remember(uiState.items) {
uiState.completedItems
.filter { it.isEpisode }
.sortedByDescending { it.updatedAtEpochMs }
.sortedForSeriesDownloads()
}
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
@ -229,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
) {
val showEpisodes = episodes
.filter { it.parentMetaId == showId }
.sortedForSeriesDownloads()
val seasons = showEpisodes
.groupBy { it.seasonNumber ?: 0 }
@ -268,10 +269,7 @@ private fun LazyListScope.downloadsShowContent(
)
}
val sortedEpisodes = entries.sortedWith(
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
.thenByDescending { it.updatedAtEpochMs },
)
val sortedEpisodes = entries.sortedForSeriesDownloads()
items(
items = sortedEpisodes,
@ -298,6 +296,12 @@ private fun DownloadRow(
onRetry: () -> Unit,
onDelete: () -> Unit,
) {
val displayTitle = item.displayTitle()
val displaySubtitle = downloadDisplaySubtitle(
item = item,
displayTitle = displayTitle,
)
Surface(
modifier = Modifier
.fillMaxWidth()
@ -322,7 +326,7 @@ private fun DownloadRow(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = item.title,
text = displayTitle,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
@ -330,7 +334,7 @@ private fun DownloadRow(
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.displaySubtitle,
text = displaySubtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@ -403,6 +407,36 @@ private fun DownloadRow(
}
}
private fun DownloadItem.displayTitle(): String =
if (isEpisode) {
episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: title
} else {
title
}
@Composable
private fun downloadDisplaySubtitle(
item: DownloadItem,
displayTitle: String,
): String {
val seasonNumber = item.seasonNumber
val episodeNumber = item.episodeNumber
if (seasonNumber == null || episodeNumber == null) {
return item.displaySubtitle
}
val episodeCode = stringResource(
Res.string.compose_player_episode_code_full,
seasonNumber,
episodeNumber,
)
return listOf(
episodeCode,
item.episodeTitle?.trim().orEmpty().takeIf { it.isNotBlank() && it != displayTitle },
item.title.trim().takeIf { it.isNotBlank() && it != displayTitle },
).filterNotNull().joinToString("")
}
@Composable
private fun SectionTitle(title: String) {
Text(

View file

@ -50,6 +50,12 @@ fun LibraryScreen(
var observedOfflineState by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
val retryLibraryLoad: () -> Unit = {
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
}
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
when (networkStatusUiState.condition) {
@ -110,14 +116,7 @@ fun LibraryScreen(
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
if (isTraktSource) {
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
}
},
onRetry = retryLibraryLoad,
)
} else {
HomeEmptyStateCard(
@ -128,6 +127,8 @@ fun LibraryScreen(
stringResource(Res.string.library_load_failed)
},
message = uiState.errorMessage.orEmpty(),
actionLabel = stringResource(Res.string.action_retry),
onActionClick = retryLibraryLoad,
)
}
}
@ -139,12 +140,7 @@ fun LibraryScreen(
NuvioNetworkOfflineCard(
condition = networkStatusUiState.condition,
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
coroutineScope.launch {
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
}
},
onRetry = retryLibraryLoad,
)
} else {
HomeEmptyStateCard(

View file

@ -78,6 +78,7 @@ fun ProfileEditScreen(
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) }
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
var isSaving by remember { mutableStateOf(false) }
var showDeleteConfirm by remember { mutableStateOf(false) }
@ -90,17 +91,20 @@ fun ProfileEditScreen(
AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
LaunchedEffect(isNew, avatars, selectedAvatarId) {
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) {
LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) {
if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) {
selectedAvatarId = avatars.first().id
}
}
val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) }
val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null
val selectedAvatarItem = remember(selectedAvatarId, avatars) {
selectedAvatarId?.let { id -> avatars.find { it.id == id } }
}
val previewAccent = remember(selectedAvatarItem, fallbackColorHex) {
parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex)
val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null
val previewAccent = remember(visibleAvatarItem, fallbackColorHex) {
parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex)
}
NuvioScreen(modifier = modifier) {
@ -123,12 +127,47 @@ fun ProfileEditScreen(
usesPrimaryAddons = usesPrimaryAddons,
onNameChange = { name = it },
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
selectedAvatar = selectedAvatarItem,
selectedAvatar = visibleAvatarItem,
customAvatarUrl = customAvatarUrl,
accentColor = previewAccent,
hasAvatarChoices = avatars.isNotEmpty(),
)
}
item {
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(Res.string.profile_custom_avatar_url),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.profile_custom_avatar_url_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
NuvioInputField(
value = avatarUrl,
onValueChange = { value ->
avatarUrl = value
if (value.isNotBlank()) {
selectedAvatarId = null
}
},
placeholder = stringResource(Res.string.profile_custom_avatar_url_placeholder),
)
if (avatarUrlIsInvalid) {
Text(
text = stringResource(Res.string.profile_avatar_url_invalid),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
item {
NuvioSurfaceCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
@ -165,8 +204,11 @@ fun ProfileEditScreen(
AvatarChoiceItem(
avatar = avatar,
size = avatarSize,
isSelected = avatar.id == selectedAvatarId,
onClick = { selectedAvatarId = avatar.id },
isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId,
onClick = {
avatarUrl = ""
selectedAvatarId = avatar.id
},
)
}
}
@ -220,16 +262,17 @@ fun ProfileEditScreen(
} else {
stringResource(Res.string.collections_editor_save_changes)
},
enabled = name.isNotBlank() && !isSaving,
enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving,
onClick = {
isSaving = true
scope.launch {
val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex
val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex
if (isNew) {
ProfileRepository.createProfile(
name = name,
avatarColorHex = avatarColorHex,
avatarId = selectedAvatarId,
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
avatarUrl = customAvatarUrl,
usesPrimaryAddons = usesPrimaryAddons,
)
} else {
@ -237,7 +280,8 @@ fun ProfileEditScreen(
profileIndex = currentProfile!!.profileIndex,
name = name,
avatarColorHex = avatarColorHex,
avatarId = selectedAvatarId,
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
avatarUrl = customAvatarUrl,
usesPrimaryAddons = usesPrimaryAddons,
)
}
@ -330,6 +374,7 @@ private fun ProfileIdentityCard(
onNameChange: (String) -> Unit,
onUsesPrimaryAddonsChange: (Boolean) -> Unit,
selectedAvatar: AvatarCatalogItem?,
customAvatarUrl: String?,
accentColor: Color,
hasAvatarChoices: Boolean,
) {
@ -345,16 +390,31 @@ private fun ProfileIdentityCard(
.size(88.dp)
.clip(CircleShape)
.background(
if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f),
if (selectedAvatar != null || customAvatarUrl != null) {
accentColor
} else {
accentColor.copy(alpha = 0.18f)
},
)
.border(
width = 2.dp,
color = if (selectedAvatar == null) accentColor.copy(alpha = 0.35f) else Color.Transparent,
color = if (selectedAvatar == null && customAvatarUrl == null) {
accentColor.copy(alpha = 0.35f)
} else {
Color.Transparent
},
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
if (selectedAvatar != null) {
if (customAvatarUrl != null) {
AsyncImage(
model = customAvatarUrl,
contentDescription = name,
modifier = Modifier.size(88.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
} else if (selectedAvatar != null) {
AsyncImage(
model = avatarStorageUrl(selectedAvatar.storagePath),
contentDescription = selectedAvatar.displayName,
@ -410,6 +470,7 @@ private fun ProfileIdentityCard(
)
Text(
text = when {
customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected)
selectedAvatar != null -> stringResource(
Res.string.profile_avatar_selected,
selectedAvatar.displayName,

View file

@ -12,6 +12,7 @@ data class NuvioProfile(
val name: String = "",
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
@SerialName("avatar_id") val avatarId: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
@SerialName("pin_enabled") val pinEnabled: Boolean = false,
@ -28,6 +29,7 @@ data class ProfilePushPayload(
@SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false,
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
@SerialName("avatar_id") val avatarId: String? = null,
@SerialName("avatar_url") val avatarUrl: String? = null,
)
@Serializable
@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf(
fun avatarStorageUrl(storagePath: String): String =
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
fun normalizedAvatarUrl(url: String?): String? =
url?.trim()?.takeIf { it.isValidAvatarUrl() }
fun String.isValidAvatarUrl(): Boolean {
val value = trim()
return value.length <= 2048 &&
!value.any { it.isWhitespace() } &&
(value.startsWith("https://") || value.startsWith("http://"))
}
fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): String? =
normalizedAvatarUrl(profile.avatarUrl)
?: avatar
?.storagePath
?.takeIf { it.isNotBlank() }
?.let(::avatarStorageUrl)

View file

@ -179,6 +179,7 @@ object ProfileRepository {
name: String,
avatarColorHex: String,
avatarId: String? = null,
avatarUrl: String? = null,
usesPrimaryAddons: Boolean = false,
) {
val existing = _state.value.profiles
@ -192,6 +193,7 @@ object ProfileRepository {
usesPrimaryAddons = profile.usesPrimaryAddons,
usesPrimaryPlugins = profile.usesPrimaryPlugins,
avatarId = profile.avatarId,
avatarUrl = profile.avatarUrl,
)
} + ProfilePushPayload(
profileIndex = nextIndex,
@ -199,6 +201,7 @@ object ProfileRepository {
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
avatarId = avatarId,
avatarUrl = avatarUrl,
)
pushProfiles(allPayloads)
@ -209,6 +212,7 @@ object ProfileRepository {
name: String,
avatarColorHex: String,
avatarId: String? = null,
avatarUrl: String? = null,
usesPrimaryAddons: Boolean = false,
) {
val allPayloads = _state.value.profiles.map { profile ->
@ -218,7 +222,8 @@ object ProfileRepository {
name = name,
avatarColorHex = avatarColorHex,
usesPrimaryAddons = usesPrimaryAddons,
avatarId = avatarId ?: profile.avatarId,
avatarId = avatarId,
avatarUrl = avatarUrl,
)
} else {
ProfilePushPayload(
@ -228,6 +233,7 @@ object ProfileRepository {
usesPrimaryAddons = profile.usesPrimaryAddons,
usesPrimaryPlugins = profile.usesPrimaryPlugins,
avatarId = profile.avatarId,
avatarUrl = profile.avatarUrl,
)
}
}
@ -357,6 +363,7 @@ object ProfileRepository {
name = p.name,
avatarColorHex = p.avatarColorHex,
avatarId = p.avatarId,
avatarUrl = p.avatarUrl,
usesPrimaryAddons = p.usesPrimaryAddons,
usesPrimaryPlugins = p.usesPrimaryPlugins,
)

View file

@ -304,6 +304,9 @@ private fun ProfileAvatarCard(
val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } }
}
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
profileAvatarImageUrl(profile, avatarItem)
}
val animAlpha = remember { Animatable(0f) }
val animScale = remember { Animatable(0.85f) }
@ -342,8 +345,8 @@ private fun ProfileAvatarCard(
modifier = Modifier.size(110.dp),
contentAlignment = Alignment.Center,
) {
if (avatarItem != null) {
val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
if (avatarImageUrl != null) {
val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
Box(
modifier = Modifier
.size(110.dp)
@ -364,15 +367,15 @@ private fun ProfileAvatarCard(
},
)
.then(
if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
if (avatarImageUrl == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape)
else Modifier,
),
contentAlignment = Alignment.Center,
) {
if (avatarItem != null) {
if (avatarImageUrl != null) {
AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath),
contentDescription = avatarItem.displayName,
model = avatarImageUrl,
contentDescription = avatarItem?.displayName ?: profile.name,
modifier = Modifier.size(100.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)

View file

@ -341,6 +341,9 @@ private fun PopupProfileBubble(
val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } }
}
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
profileAvatarImageUrl(profile, avatarItem)
}
// Per-item entrance animation
val itemAlpha = remember { Animatable(0f) }
@ -393,8 +396,8 @@ private fun PopupProfileBubble(
.size(48.dp)
.clip(CircleShape)
.background(
if (avatarItem != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
if (avatarImageUrl != null) {
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else {
avatarColor.copy(alpha = 0.15f)
},
@ -411,7 +414,7 @@ private fun PopupProfileBubble(
avatarColor.copy(alpha = 0.6f),
CircleShape,
)
avatarItem == null -> Modifier.border(
avatarImageUrl == null -> Modifier.border(
1.5.dp,
avatarColor.copy(alpha = 0.3f),
CircleShape,
@ -421,9 +424,9 @@ private fun PopupProfileBubble(
),
contentAlignment = Alignment.Center,
) {
if (avatarItem != null) {
if (avatarImageUrl != null) {
AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath),
model = avatarImageUrl,
contentDescription = profile.name,
modifier = Modifier.size(48.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar(
val avatarItem = remember(profile.avatarId, avatars) {
profile.avatarId?.let { id -> avatars.find { it.id == id } }
}
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
profileAvatarImageUrl(profile, avatarItem)
}
val borderColor = if (selected) {
MaterialTheme.colorScheme.primary
@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar(
.size(size.dp)
.clip(CircleShape)
.background(
if (avatarItem != null) {
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
if (avatarImageUrl != null) {
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
} else {
avatarColor.copy(alpha = 0.15f)
},
@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar(
.border(1.5.dp, borderColor, CircleShape),
contentAlignment = Alignment.Center,
) {
if (avatarItem != null) {
if (avatarImageUrl != null) {
AsyncImage(
model = avatarStorageUrl(avatarItem.storagePath),
model = avatarImageUrl,
contentDescription = profile.name,
modifier = Modifier.size(size.dp).clip(CircleShape),
contentScale = ContentScale.Crop,

View file

@ -36,6 +36,7 @@ private const val LIST_FETCH_CONCURRENCY = 4
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
private const val FORCE_REFRESH_DEDUP_MS = 10_000L
private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240
data class TraktLibraryUiState(
val listTabs: List<TraktListTab> = emptyList(),
@ -159,21 +160,20 @@ object TraktLibraryRepository {
errorMessage = null,
)
}
}.onFailure { error ->
}
result.exceptionOrNull()?.let { error ->
if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt library: ${error.message}" }
}.getOrNull()
if (result == null) {
_uiState.value = current.copy(
log.w(error) { "Failed to refresh Trakt library" }
_uiState.value = _uiState.value.copy(
isLoading = false,
hasLoaded = true,
errorMessage = getString(Res.string.trakt_library_load_failed),
errorMessage = traktLibraryLoadErrorMessage(error),
)
return
}
_uiState.value = result.copy(
val snapshot = result.getOrThrow()
_uiState.value = snapshot.copy(
isLoading = false,
hasLoaded = true,
errorMessage = null,
@ -414,6 +414,27 @@ object TraktLibraryRepository {
TraktLibraryStorage.savePayload(json.encodeToString(payload))
}
private suspend fun traktLibraryLoadErrorMessage(error: Throwable): String {
val fallback = getString(Res.string.trakt_library_load_failed)
val detail = error.userVisibleMessage()
return when {
detail.isBlank() -> fallback
detail.equals(fallback, ignoreCase = true) -> fallback
else -> detail
}
}
private fun Throwable.userVisibleMessage(): String {
val raw = message?.trim()?.takeIf { it.isNotBlank() }
?: toString().trim()
val firstLine = raw.lines().firstOrNull()?.trim().orEmpty()
return if (firstLine.length <= MAX_VISIBLE_ERROR_MESSAGE_LENGTH) {
firstLine
} else {
firstLine.take(MAX_VISIBLE_ERROR_MESSAGE_LENGTH).trimEnd() + "..."
}
}
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
val watchlistTabs = listOf(
TraktListTab(

View file

@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"trakt_auth_payload",
"trakt_library_payload",
"trakt_settings_payload",
"collections_payload",
)
actual fun wipe() {