mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into master
This commit is contained in:
commit
b5b1afce8d
19 changed files with 697 additions and 165 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(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_episode_release_notifications",
|
||||
"nuvio_episode_release_notifications_platform",
|
||||
"nuvio_watch_progress",
|
||||
"nuvio_collections",
|
||||
"nuvio_plugins",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "%1$s" will be permanently deleted.</string>
|
||||
<string name="profile_delete_title">Delete Profile</string>
|
||||
<string name="profile_edit_add_title">Add Profile</string>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"trakt_auth_payload",
|
||||
"trakt_library_payload",
|
||||
"trakt_settings_payload",
|
||||
"collections_payload",
|
||||
)
|
||||
|
||||
actual fun wipe() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue