mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +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 {
|
outDir.resolve("com/nuvio/app/core/build").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
resolve("AppVersionConfig.kt").writeText(
|
resolve("AppVersionConfig.kt").writeText(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"nuvio_episode_release_notifications",
|
"nuvio_episode_release_notifications",
|
||||||
"nuvio_episode_release_notifications_platform",
|
"nuvio_episode_release_notifications_platform",
|
||||||
"nuvio_watch_progress",
|
"nuvio_watch_progress",
|
||||||
|
"nuvio_collections",
|
||||||
"nuvio_plugins",
|
"nuvio_plugins",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1007,9 +1007,14 @@
|
||||||
<string name="pin_locked_try_again">Locked. Try again in %1$ds</string>
|
<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_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_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">Choose an avatar</string>
|
||||||
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
|
<string name="profile_choose_avatar_below">Choose an avatar below.</string>
|
||||||
<string name="profile_create_profile">Create Profile</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_confirm_message">All data for "%1$s" will be permanently deleted.</string>
|
||||||
<string name="profile_delete_title">Delete Profile</string>
|
<string name="profile_delete_title">Delete Profile</string>
|
||||||
<string name="profile_edit_add_title">Add 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.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
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.ProfileRepository
|
||||||
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
||||||
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
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.search.SearchScreen
|
||||||
import com.nuvio.app.features.settings.SettingsScreen
|
import com.nuvio.app.features.settings.SettingsScreen
|
||||||
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
import com.nuvio.app.features.settings.HomescreenSettingsScreen
|
||||||
|
|
@ -329,6 +331,7 @@ fun App() {
|
||||||
profileState.activeProfile?.name,
|
profileState.activeProfile?.name,
|
||||||
profileState.activeProfile?.avatarColorHex,
|
profileState.activeProfile?.avatarColorHex,
|
||||||
profileState.activeProfile?.avatarId,
|
profileState.activeProfile?.avatarId,
|
||||||
|
profileState.activeProfile?.avatarUrl,
|
||||||
profileAvatars,
|
profileAvatars,
|
||||||
) {
|
) {
|
||||||
val activeProfile = profileState.activeProfile
|
val activeProfile = profileState.activeProfile
|
||||||
|
|
@ -338,10 +341,7 @@ fun App() {
|
||||||
NativeTabBridge.publishProfileTabIcon(
|
NativeTabBridge.publishProfileTabIcon(
|
||||||
name = activeProfile?.name,
|
name = activeProfile?.name,
|
||||||
avatarColorHex = activeProfile?.avatarColorHex,
|
avatarColorHex = activeProfile?.avatarColorHex,
|
||||||
avatarImageUrl = avatarItem
|
avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) },
|
||||||
?.storagePath
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let(::avatarStorageUrl),
|
|
||||||
avatarBackgroundColorHex = avatarItem?.bgColor,
|
avatarBackgroundColorHex = avatarItem?.bgColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -576,6 +576,32 @@ private fun MainAppContent(
|
||||||
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
NetworkStatusRepository.ensureStarted()
|
NetworkStatusRepository.ensureStarted()
|
||||||
EpisodeReleaseNotificationsRepository.refreshAsync()
|
EpisodeReleaseNotificationsRepository.refreshAsync()
|
||||||
|
|
@ -956,13 +982,6 @@ private fun MainAppContent(
|
||||||
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
|
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(useNativeBottomTabs) {
|
|
||||||
NativeTabBridge.publishTabBarVisible(useNativeBottomTabs)
|
|
||||||
onDispose {
|
|
||||||
NativeTabBridge.publishTabBarVisible(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
|
@ -56,17 +57,14 @@ object CollectionSyncService {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val remoteJson = blob.collectionsJson.toString()
|
val remoteCollectionsJson = if (blob.collectionsJson == JsonNull) {
|
||||||
|
JsonArray(emptyList())
|
||||||
|
} else {
|
||||||
|
blob.collectionsJson
|
||||||
|
}
|
||||||
|
val remoteJson = remoteCollectionsJson.toString()
|
||||||
val localJson = CollectionRepository.exportToJson()
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteJson == localJson) {
|
if (remoteJson == localJson) {
|
||||||
log.d { "pullFromServer — remote matches local, no update needed" }
|
log.d { "pullFromServer — remote matches local, no update needed" }
|
||||||
return
|
return
|
||||||
|
|
@ -78,7 +76,7 @@ object CollectionSyncService {
|
||||||
|
|
||||||
if (remoteCollections != null) {
|
if (remoteCollections != null) {
|
||||||
isSyncingFromRemote = true
|
isSyncingFromRemote = true
|
||||||
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
|
CollectionRepository.applyFromRemote(remoteCollections, remoteCollectionsJson)
|
||||||
isSyncingFromRemote = false
|
isSyncingFromRemote = false
|
||||||
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
|
||||||
} else {
|
} 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.library.toLibraryItem
|
||||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||||
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
import com.nuvio.app.features.trakt.TraktCommentsRepository
|
||||||
|
|
@ -167,6 +168,7 @@ fun MetaDetailsScreen(
|
||||||
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
var pickerMembership by remember(type, id) { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||||
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
var pickerPending by remember(type, id) { mutableStateOf(false) }
|
||||||
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
|
||||||
|
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
|
||||||
|
|
||||||
val shouldShowComments = commentsEnabled &&
|
val shouldShowComments = commentsEnabled &&
|
||||||
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
|
||||||
|
|
@ -194,6 +196,30 @@ fun MetaDetailsScreen(
|
||||||
isCommentsLoading = false
|
isCommentsLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
|
||||||
|
val metaForRatings = displayedMeta
|
||||||
|
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
|
||||||
|
episodeImdbRatings = emptyMap()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
val imdbId = extractImdbId(metaForRatings.id) ?: extractImdbId(id)
|
||||||
|
val tmdbId = extractTmdbId(metaForRatings.id)
|
||||||
|
?: extractTmdbId(id)
|
||||||
|
?: TmdbService.ensureTmdbId(metaForRatings.id, metaForRatings.type)?.toIntOrNull()
|
||||||
|
?: TmdbService.ensureTmdbId(id, type)?.toIntOrNull()
|
||||||
|
|
||||||
|
if (imdbId == null && tmdbId == null) {
|
||||||
|
episodeImdbRatings = emptyMap()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeImdbRatings = ImdbEpisodeRatingsRepository.getEpisodeRatings(
|
||||||
|
imdbId = imdbId,
|
||||||
|
tmdbId = tmdbId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
LaunchedEffect(type, id, displayedMeta, uiState.isLoading, autoLoadAttempted) {
|
||||||
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
if (!autoLoadAttempted && displayedMeta == null && !uiState.isLoading) {
|
||||||
autoLoadAttempted = true
|
autoLoadAttempted = true
|
||||||
|
|
@ -656,6 +682,7 @@ fun MetaDetailsScreen(
|
||||||
commentsCurrentPage = commentsCurrentPage,
|
commentsCurrentPage = commentsCurrentPage,
|
||||||
commentsPageCount = commentsPageCount,
|
commentsPageCount = commentsPageCount,
|
||||||
commentsError = commentsError,
|
commentsError = commentsError,
|
||||||
|
episodeImdbRatings = episodeImdbRatings,
|
||||||
onRetryComments = {
|
onRetryComments = {
|
||||||
detailsScope.launch {
|
detailsScope.launch {
|
||||||
isCommentsLoading = true
|
isCommentsLoading = true
|
||||||
|
|
@ -937,6 +964,30 @@ fun MetaDetailsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
||||||
|
val normalizedType = type.trim().lowercase()
|
||||||
|
val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null }
|
||||||
|
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractImdbId(value: String?): String? =
|
||||||
|
value
|
||||||
|
?.trim()
|
||||||
|
?.split(':', '/', '?', '&')
|
||||||
|
?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) }
|
||||||
|
?.takeIf { it.length > 2 }
|
||||||
|
|
||||||
|
private fun extractTmdbId(value: String?): Int? {
|
||||||
|
val trimmed = value?.trim().orEmpty()
|
||||||
|
if (trimmed.isBlank()) return null
|
||||||
|
return trimmed
|
||||||
|
.takeIf { it.startsWith("tmdb:", ignoreCase = true) }
|
||||||
|
?.substringAfter(':')
|
||||||
|
?.substringBefore(':')
|
||||||
|
?.substringBefore('/')
|
||||||
|
?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
private fun ConfiguredMetaSections(
|
private fun ConfiguredMetaSections(
|
||||||
|
|
@ -965,6 +1016,7 @@ private fun ConfiguredMetaSections(
|
||||||
commentsCurrentPage: Int,
|
commentsCurrentPage: Int,
|
||||||
commentsPageCount: Int,
|
commentsPageCount: Int,
|
||||||
commentsError: String?,
|
commentsError: String?,
|
||||||
|
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
|
||||||
onRetryComments: () -> Unit,
|
onRetryComments: () -> Unit,
|
||||||
onLoadMoreComments: () -> Unit,
|
onLoadMoreComments: () -> Unit,
|
||||||
onCommentClick: (TraktCommentReview) -> Unit,
|
onCommentClick: (TraktCommentReview) -> Unit,
|
||||||
|
|
@ -1064,6 +1116,7 @@ private fun ConfiguredMetaSections(
|
||||||
episodeCardStyle = settings.episodeCardStyle,
|
episodeCardStyle = settings.episodeCardStyle,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
episodeRatings = episodeImdbRatings,
|
||||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
onEpisodeLongPress = onEpisodeLongPress,
|
onEpisodeLongPress = onEpisodeLongPress,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.nuvio.app.features.details
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.httpRequestRaw
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
internal object SeriesGraphApi {
|
||||||
|
suspend fun getSeasonRatings(tmdbId: Int): List<SeriesGraphSeasonRatingsDto> =
|
||||||
|
requestSeasonRatings(
|
||||||
|
baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL,
|
||||||
|
showId = tmdbId.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ImdbTapframeApi {
|
||||||
|
suspend fun getSeasonRatings(imdbId: String): List<SeriesGraphSeasonRatingsDto> =
|
||||||
|
requestSeasonRatings(
|
||||||
|
baseUrl = ImdbEpisodeRatingsConfig.IMDB_TAPFRAME_API_BASE_URL,
|
||||||
|
showId = imdbId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class SeriesGraphEpisodeRatingDto(
|
||||||
|
@SerialName("season_number") val seasonNumber: Int? = null,
|
||||||
|
@SerialName("episode_number") val episodeNumber: Int? = null,
|
||||||
|
@SerialName("vote_average") val voteAverage: Double? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val tconst: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class SeriesGraphSeasonRatingsDto(
|
||||||
|
val episodes: List<SeriesGraphEpisodeRatingDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val seriesGraphLog = Logger.withTag("SeriesGraphApi")
|
||||||
|
private val seriesGraphJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private suspend fun requestSeasonRatings(
|
||||||
|
baseUrl: String,
|
||||||
|
showId: String,
|
||||||
|
): List<SeriesGraphSeasonRatingsDto> {
|
||||||
|
val resolvedBaseUrl = baseUrl.trim().trimEnd('/')
|
||||||
|
if (resolvedBaseUrl.isBlank()) return emptyList()
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = "GET",
|
||||||
|
url = "$resolvedBaseUrl/api/shows/$showId/season-ratings",
|
||||||
|
headers = mapOf("Accept" to "application/json"),
|
||||||
|
body = "",
|
||||||
|
)
|
||||||
|
if (response.status !in 200..299 || response.body.isBlank()) {
|
||||||
|
seriesGraphLog.w { "Season ratings request failed for $showId (${response.status})" }
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
seriesGraphJson.decodeFromString<List<SeriesGraphSeasonRatingsDto>>(response.body)
|
||||||
|
}.onFailure { error ->
|
||||||
|
seriesGraphLog.w(error) { "Season ratings request failed for $showId" }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
@ -15,12 +15,14 @@ import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -77,7 +79,10 @@ import com.nuvio.app.features.watching.application.WatchingState
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val log = Logger.withTag("SeriesContent")
|
private val log = Logger.withTag("SeriesContent")
|
||||||
|
|
||||||
|
|
@ -91,6 +96,7 @@ fun DetailSeriesContent(
|
||||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||||
watchedKeys: Set<String> = emptySet(),
|
watchedKeys: Set<String> = emptySet(),
|
||||||
|
episodeRatings: Map<Pair<Int, Int>, Double> = emptyMap(),
|
||||||
blurUnwatchedEpisodes: Boolean = false,
|
blurUnwatchedEpisodes: Boolean = false,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
|
||||||
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
|
||||||
|
|
@ -278,6 +284,7 @@ fun DetailSeriesContent(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressByVideoId = progressByVideoId,
|
progressByVideoId = progressByVideoId,
|
||||||
|
episodeRatings = episodeRatings,
|
||||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||||
onEpisodeClick = onEpisodeClick,
|
onEpisodeClick = onEpisodeClick,
|
||||||
|
|
@ -298,6 +305,7 @@ fun DetailSeriesContent(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = meta.background ?: meta.poster,
|
fallbackImage = meta.background ?: meta.poster,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
|
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
|
@ -557,6 +565,7 @@ private fun EpisodeHorizontalRow(
|
||||||
watchedKeys: Set<String>,
|
watchedKeys: Set<String>,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||||
|
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
preferredEpisodeNumber: Int? = null,
|
preferredEpisodeNumber: Int? = null,
|
||||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||||
|
|
@ -602,6 +611,7 @@ private fun EpisodeHorizontalRow(
|
||||||
video = episode,
|
video = episode,
|
||||||
fallbackImage = fallbackImage,
|
fallbackImage = fallbackImage,
|
||||||
progressEntry = progressByVideoId[episodeVideoId],
|
progressEntry = progressByVideoId[episodeVideoId],
|
||||||
|
imdbRating = episode.seasonEpisodeKey()?.let { episodeRatings[it] },
|
||||||
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
|
||||||
WatchingState.isEpisodeWatched(
|
WatchingState.isEpisodeWatched(
|
||||||
watchedKeys = watchedKeys,
|
watchedKeys = watchedKeys,
|
||||||
|
|
@ -624,6 +634,7 @@ private fun EpisodeHorizontalCard(
|
||||||
video: MetaVideo,
|
video: MetaVideo,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
|
imdbRating: Double?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
metrics: EpisodeHorizontalCardMetrics,
|
metrics: EpisodeHorizontalCardMetrics,
|
||||||
|
|
@ -631,6 +642,9 @@ private fun EpisodeHorizontalCard(
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
val cardShape = RoundedCornerShape(metrics.cornerRadius)
|
||||||
|
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||||
|
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||||
|
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(metrics.cardWidth)
|
.width(metrics.cardWidth)
|
||||||
|
|
@ -676,30 +690,6 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.padding(start = metrics.contentPadding, top = metrics.contentPadding)
|
|
||||||
.clip(RoundedCornerShape(metrics.badgeRadius))
|
|
||||||
.background(Color.Black.copy(alpha = 0.75f))
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = Color.White.copy(alpha = 0.18f),
|
|
||||||
shape = RoundedCornerShape(metrics.badgeRadius),
|
|
||||||
)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = video.episodeBadge(),
|
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
|
||||||
fontSize = metrics.badgeTextSize,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
letterSpacing = 0.5.sp,
|
|
||||||
),
|
|
||||||
color = Color.White,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioAnimatedWatchedBadge(
|
NuvioAnimatedWatchedBadge(
|
||||||
isVisible = isWatched,
|
isVisible = isWatched,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -719,6 +709,15 @@ private fun EpisodeHorizontalCard(
|
||||||
),
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
|
EpisodeCodeBadge(
|
||||||
|
text = video.episodeBadge(),
|
||||||
|
textSize = metrics.badgeTextSize,
|
||||||
|
radius = metrics.badgeRadius,
|
||||||
|
horizontalPadding = metrics.badgeHorizontalPadding,
|
||||||
|
verticalPadding = metrics.badgeVerticalPadding,
|
||||||
|
backgroundAlpha = 0.42f,
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = video.title,
|
text = video.title,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
|
|
@ -744,30 +743,42 @@ private fun EpisodeHorizontalCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runtimeLabel != null || ratingLabel != null || formattedDate != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
video.runtime?.takeIf { it > 0 }?.let { runtimeMinutes ->
|
runtimeLabel?.let { runtime ->
|
||||||
Text(
|
Text(
|
||||||
text = formatEpisodeRuntime(runtimeMinutes),
|
text = runtime,
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||||
color = Color.White.copy(alpha = 0.78f),
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
ratingLabel?.let { rating ->
|
||||||
|
ImdbEpisodeRatingBadge(
|
||||||
|
rating = rating,
|
||||||
|
logoWidth = metrics.imdbLogoWidth,
|
||||||
|
logoHeight = metrics.imdbLogoHeight,
|
||||||
|
textSize = metrics.metaTextSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
formattedDate?.let { date ->
|
||||||
Text(
|
Text(
|
||||||
text = formattedDate,
|
text = date,
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize),
|
||||||
color = Color.White.copy(alpha = 0.78f),
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
progressEntry
|
progressEntry
|
||||||
?.takeIf { it.durationMs > 0L && !it.isCompleted }
|
?.takeIf { it.durationMs > 0L && !it.isCompleted }
|
||||||
|
|
@ -803,6 +814,10 @@ private data class EpisodeHorizontalCardMetrics(
|
||||||
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
val metaTextSize: androidx.compose.ui.unit.TextUnit,
|
||||||
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
val badgeTextSize: androidx.compose.ui.unit.TextUnit,
|
||||||
val badgeRadius: Dp,
|
val badgeRadius: Dp,
|
||||||
|
val badgeHorizontalPadding: Dp,
|
||||||
|
val badgeVerticalPadding: Dp,
|
||||||
|
val imdbLogoWidth: Dp,
|
||||||
|
val imdbLogoHeight: Dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -825,7 +840,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 3,
|
overviewMaxLines = 3,
|
||||||
metaTextSize = 12.sp,
|
metaTextSize = 12.sp,
|
||||||
badgeTextSize = 11.sp,
|
badgeTextSize = 11.sp,
|
||||||
badgeRadius = 6.dp,
|
badgeRadius = 8.dp,
|
||||||
|
badgeHorizontalPadding = 10.dp,
|
||||||
|
badgeVerticalPadding = 5.dp,
|
||||||
|
imdbLogoWidth = 28.dp,
|
||||||
|
imdbLogoHeight = 14.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
maxWidthDp >= 1000f -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -844,7 +863,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 3,
|
overviewMaxLines = 3,
|
||||||
metaTextSize = 12.sp,
|
metaTextSize = 12.sp,
|
||||||
badgeTextSize = 10.sp,
|
badgeTextSize = 10.sp,
|
||||||
badgeRadius = 6.dp,
|
badgeRadius = 7.dp,
|
||||||
|
badgeHorizontalPadding = 9.dp,
|
||||||
|
badgeVerticalPadding = 4.dp,
|
||||||
|
imdbLogoWidth = 26.dp,
|
||||||
|
imdbLogoHeight = 13.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
maxWidthDp >= 760f -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -863,7 +886,11 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
overviewMaxLines = 2,
|
overviewMaxLines = 2,
|
||||||
metaTextSize = 11.sp,
|
metaTextSize = 11.sp,
|
||||||
badgeTextSize = 10.sp,
|
badgeTextSize = 10.sp,
|
||||||
badgeRadius = 5.dp,
|
badgeRadius = 6.dp,
|
||||||
|
badgeHorizontalPadding = 8.dp,
|
||||||
|
badgeVerticalPadding = 4.dp,
|
||||||
|
imdbLogoWidth = 24.dp,
|
||||||
|
imdbLogoHeight = 12.dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> EpisodeHorizontalCardMetrics(
|
else -> EpisodeHorizontalCardMetrics(
|
||||||
|
|
@ -883,6 +910,10 @@ private fun rememberEpisodeHorizontalCardMetrics(maxWidthDp: Float): EpisodeHori
|
||||||
metaTextSize = 10.sp,
|
metaTextSize = 10.sp,
|
||||||
badgeTextSize = 9.sp,
|
badgeTextSize = 9.sp,
|
||||||
badgeRadius = 5.dp,
|
badgeRadius = 5.dp,
|
||||||
|
badgeHorizontalPadding = 7.dp,
|
||||||
|
badgeVerticalPadding = 3.dp,
|
||||||
|
imdbLogoWidth = 22.dp,
|
||||||
|
imdbLogoHeight = 11.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -892,12 +923,73 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String {
|
||||||
return formatRuntimeFromMinutes(runtimeMinutes)
|
return formatRuntimeFromMinutes(runtimeMinutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EpisodeCodeBadge(
|
||||||
|
text: String,
|
||||||
|
textSize: androidx.compose.ui.unit.TextUnit,
|
||||||
|
radius: Dp,
|
||||||
|
horizontalPadding: Dp,
|
||||||
|
verticalPadding: Dp,
|
||||||
|
backgroundAlpha: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(radius))
|
||||||
|
.background(Color.Black.copy(alpha = backgroundAlpha))
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 0.sp,
|
||||||
|
),
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImdbEpisodeRatingBadge(
|
||||||
|
rating: String,
|
||||||
|
logoWidth: Dp,
|
||||||
|
logoHeight: Dp,
|
||||||
|
textSize: androidx.compose.ui.unit.TextUnit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(Res.drawable.rating_imdb),
|
||||||
|
contentDescription = stringResource(Res.string.source_imdb),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(logoWidth)
|
||||||
|
.height(logoHeight),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = rating,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontSize = textSize,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = Color(0xFFF5C518),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EpisodeListCard(
|
private fun EpisodeListCard(
|
||||||
video: MetaVideo,
|
video: MetaVideo,
|
||||||
fallbackImage: String?,
|
fallbackImage: String?,
|
||||||
progressEntry: WatchProgressEntry?,
|
progressEntry: WatchProgressEntry?,
|
||||||
|
imdbRating: Double?,
|
||||||
isWatched: Boolean,
|
isWatched: Boolean,
|
||||||
blurUnwatchedEpisodes: Boolean,
|
blurUnwatchedEpisodes: Boolean,
|
||||||
sizing: SeriesContentSizing,
|
sizing: SeriesContentSizing,
|
||||||
|
|
@ -906,6 +998,8 @@ private fun EpisodeListCard(
|
||||||
onLongPress: (() -> Unit)? = null,
|
onLongPress: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
val cardShape = RoundedCornerShape(sizing.cardRadius)
|
||||||
|
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
|
||||||
|
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -952,32 +1046,17 @@ private fun EpisodeListCard(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
EpisodeCodeBadge(
|
||||||
|
text = video.episodeBadge(),
|
||||||
|
textSize = sizing.badgeTextSize,
|
||||||
|
radius = sizing.badgeRadius,
|
||||||
|
horizontalPadding = sizing.badgeHorizontalPadding,
|
||||||
|
verticalPadding = sizing.badgeVerticalPadding,
|
||||||
|
backgroundAlpha = 0.85f,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(start = 8.dp, top = 8.dp)
|
.padding(start = 8.dp, top = 8.dp),
|
||||||
.clip(RoundedCornerShape(sizing.badgeRadius))
|
|
||||||
.background(Color.Black.copy(alpha = 0.85f))
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = Color.White.copy(alpha = 0.2f),
|
|
||||||
shape = RoundedCornerShape(sizing.badgeRadius),
|
|
||||||
)
|
)
|
||||||
.padding(
|
|
||||||
horizontal = sizing.badgeHorizontalPadding,
|
|
||||||
vertical = sizing.badgeVerticalPadding,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = video.episodeBadge(),
|
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
|
||||||
fontSize = sizing.badgeTextSize,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
letterSpacing = 0.3.sp,
|
|
||||||
),
|
|
||||||
color = Color.White,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioAnimatedWatchedBadge(
|
NuvioAnimatedWatchedBadge(
|
||||||
isVisible = isWatched,
|
isVisible = isWatched,
|
||||||
|
|
@ -1005,16 +1084,21 @@ private fun EpisodeListCard(
|
||||||
fontSize = sizing.titleTextSize,
|
fontSize = sizing.titleTextSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
lineHeight = sizing.titleLineHeight,
|
lineHeight = sizing.titleLineHeight,
|
||||||
letterSpacing = 0.3.sp,
|
letterSpacing = 0.sp,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = sizing.titleMaxLines,
|
maxLines = sizing.titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
video.released?.let { formatReleaseDateForDisplay(it) }?.let { formattedDate ->
|
if (formattedDate != null || ratingLabel != null) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
formattedDate?.let { date ->
|
||||||
Text(
|
Text(
|
||||||
text = formattedDate,
|
text = date,
|
||||||
style = MaterialTheme.typography.labelMedium.copy(
|
style = MaterialTheme.typography.labelMedium.copy(
|
||||||
fontSize = sizing.metaTextSize,
|
fontSize = sizing.metaTextSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
|
@ -1024,6 +1108,16 @@ private fun EpisodeListCard(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
ratingLabel?.let { rating ->
|
||||||
|
ImdbEpisodeRatingBadge(
|
||||||
|
rating = rating,
|
||||||
|
logoWidth = 24.dp,
|
||||||
|
logoHeight = 12.dp,
|
||||||
|
textSize = sizing.metaTextSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!video.overview.isNullOrBlank()) {
|
if (!video.overview.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -1225,3 +1319,16 @@ private fun MetaVideo.episodeBadge(): String =
|
||||||
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
localizedSeasonEpisodeCode(seasonNumber = season, episodeNumber = episode).orEmpty()
|
||||||
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
else -> runBlocking { getString(Res.string.details_episode_badge_file) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetaVideo.seasonEpisodeKey(): Pair<Int, Int>? {
|
||||||
|
val seasonNumber = season ?: return null
|
||||||
|
val episodeNumber = episode ?: return null
|
||||||
|
return seasonNumber to episodeNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatEpisodeRating(rating: Double): String {
|
||||||
|
val roundedTenths = (rating * 10.0).roundToInt()
|
||||||
|
val whole = roundedTenths / 10
|
||||||
|
val tenth = (roundedTenths % 10).absoluteValue
|
||||||
|
return "$whole.$tenth"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
val completedEpisodes = remember(uiState.items) {
|
||||||
uiState.completedItems
|
uiState.completedItems
|
||||||
.filter { it.isEpisode }
|
.filter { it.isEpisode }
|
||||||
.sortedByDescending { it.updatedAtEpochMs }
|
.sortedForSeriesDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
val selectedShowTitle = remember(selectedShowId, completedEpisodes) {
|
||||||
|
|
@ -229,6 +229,7 @@ private fun LazyListScope.downloadsShowContent(
|
||||||
) {
|
) {
|
||||||
val showEpisodes = episodes
|
val showEpisodes = episodes
|
||||||
.filter { it.parentMetaId == showId }
|
.filter { it.parentMetaId == showId }
|
||||||
|
.sortedForSeriesDownloads()
|
||||||
|
|
||||||
val seasons = showEpisodes
|
val seasons = showEpisodes
|
||||||
.groupBy { it.seasonNumber ?: 0 }
|
.groupBy { it.seasonNumber ?: 0 }
|
||||||
|
|
@ -268,10 +269,7 @@ private fun LazyListScope.downloadsShowContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortedEpisodes = entries.sortedWith(
|
val sortedEpisodes = entries.sortedForSeriesDownloads()
|
||||||
compareBy<DownloadItem> { it.episodeNumber ?: Int.MAX_VALUE }
|
|
||||||
.thenByDescending { it.updatedAtEpochMs },
|
|
||||||
)
|
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = sortedEpisodes,
|
items = sortedEpisodes,
|
||||||
|
|
@ -298,6 +296,12 @@ private fun DownloadRow(
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val displayTitle = item.displayTitle()
|
||||||
|
val displaySubtitle = downloadDisplaySubtitle(
|
||||||
|
item = item,
|
||||||
|
displayTitle = displayTitle,
|
||||||
|
)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -322,7 +326,7 @@ private fun DownloadRow(
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = item.title,
|
text = displayTitle,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
|
@ -330,7 +334,7 @@ private fun DownloadRow(
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.displaySubtitle,
|
text = displaySubtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
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
|
@Composable
|
||||||
private fun SectionTitle(title: String) {
|
private fun SectionTitle(title: String) {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ fun LibraryScreen(
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
||||||
|
val retryLibraryLoad: () -> Unit = {
|
||||||
|
NetworkStatusRepository.requestRefresh(force = true)
|
||||||
|
coroutineScope.launch {
|
||||||
|
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
|
LaunchedEffect(networkStatusUiState.condition, isTraktSource) {
|
||||||
when (networkStatusUiState.condition) {
|
when (networkStatusUiState.condition) {
|
||||||
|
|
@ -110,14 +116,7 @@ fun LibraryScreen(
|
||||||
NuvioNetworkOfflineCard(
|
NuvioNetworkOfflineCard(
|
||||||
condition = networkStatusUiState.condition,
|
condition = networkStatusUiState.condition,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
onRetry = {
|
onRetry = retryLibraryLoad,
|
||||||
NetworkStatusRepository.requestRefresh(force = true)
|
|
||||||
if (isTraktSource) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
|
|
@ -128,6 +127,8 @@ fun LibraryScreen(
|
||||||
stringResource(Res.string.library_load_failed)
|
stringResource(Res.string.library_load_failed)
|
||||||
},
|
},
|
||||||
message = uiState.errorMessage.orEmpty(),
|
message = uiState.errorMessage.orEmpty(),
|
||||||
|
actionLabel = stringResource(Res.string.action_retry),
|
||||||
|
onActionClick = retryLibraryLoad,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,12 +140,7 @@ fun LibraryScreen(
|
||||||
NuvioNetworkOfflineCard(
|
NuvioNetworkOfflineCard(
|
||||||
condition = networkStatusUiState.condition,
|
condition = networkStatusUiState.condition,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
onRetry = {
|
onRetry = retryLibraryLoad,
|
||||||
NetworkStatusRepository.requestRefresh(force = true)
|
|
||||||
coroutineScope.launch {
|
|
||||||
LibraryRepository.pullFromServer(ProfileRepository.activeProfileId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HomeEmptyStateCard(
|
HomeEmptyStateCard(
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ fun ProfileEditScreen(
|
||||||
|
|
||||||
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
|
var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") }
|
||||||
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
|
var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) }
|
||||||
|
var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) }
|
||||||
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
|
var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) }
|
||||||
var isSaving by remember { mutableStateOf(false) }
|
var isSaving by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
@ -90,17 +91,20 @@ fun ProfileEditScreen(
|
||||||
AvatarRepository.fetchAvatars()
|
AvatarRepository.fetchAvatars()
|
||||||
AvatarRepository.refreshAvatars()
|
AvatarRepository.refreshAvatars()
|
||||||
}
|
}
|
||||||
LaunchedEffect(isNew, avatars, selectedAvatarId) {
|
LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) {
|
||||||
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) {
|
if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) {
|
||||||
selectedAvatarId = avatars.first().id
|
selectedAvatarId = avatars.first().id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) }
|
||||||
|
val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null
|
||||||
val selectedAvatarItem = remember(selectedAvatarId, avatars) {
|
val selectedAvatarItem = remember(selectedAvatarId, avatars) {
|
||||||
selectedAvatarId?.let { id -> avatars.find { it.id == id } }
|
selectedAvatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
val previewAccent = remember(selectedAvatarItem, fallbackColorHex) {
|
val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null
|
||||||
parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex)
|
val previewAccent = remember(visibleAvatarItem, fallbackColorHex) {
|
||||||
|
parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
NuvioScreen(modifier = modifier) {
|
NuvioScreen(modifier = modifier) {
|
||||||
|
|
@ -123,12 +127,47 @@ fun ProfileEditScreen(
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
onNameChange = { name = it },
|
onNameChange = { name = it },
|
||||||
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
|
onUsesPrimaryAddonsChange = { usesPrimaryAddons = it },
|
||||||
selectedAvatar = selectedAvatarItem,
|
selectedAvatar = visibleAvatarItem,
|
||||||
|
customAvatarUrl = customAvatarUrl,
|
||||||
accentColor = previewAccent,
|
accentColor = previewAccent,
|
||||||
hasAvatarChoices = avatars.isNotEmpty(),
|
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 {
|
item {
|
||||||
NuvioSurfaceCard {
|
NuvioSurfaceCard {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
|
@ -165,8 +204,11 @@ fun ProfileEditScreen(
|
||||||
AvatarChoiceItem(
|
AvatarChoiceItem(
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
size = avatarSize,
|
size = avatarSize,
|
||||||
isSelected = avatar.id == selectedAvatarId,
|
isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId,
|
||||||
onClick = { selectedAvatarId = avatar.id },
|
onClick = {
|
||||||
|
avatarUrl = ""
|
||||||
|
selectedAvatarId = avatar.id
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,16 +262,17 @@ fun ProfileEditScreen(
|
||||||
} else {
|
} else {
|
||||||
stringResource(Res.string.collections_editor_save_changes)
|
stringResource(Res.string.collections_editor_save_changes)
|
||||||
},
|
},
|
||||||
enabled = name.isNotBlank() && !isSaving,
|
enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving,
|
||||||
onClick = {
|
onClick = {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex
|
val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
ProfileRepository.createProfile(
|
ProfileRepository.createProfile(
|
||||||
name = name,
|
name = name,
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
avatarId = selectedAvatarId,
|
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
|
||||||
|
avatarUrl = customAvatarUrl,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -237,7 +280,8 @@ fun ProfileEditScreen(
|
||||||
profileIndex = currentProfile!!.profileIndex,
|
profileIndex = currentProfile!!.profileIndex,
|
||||||
name = name,
|
name = name,
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
avatarId = selectedAvatarId,
|
avatarId = if (customAvatarUrl == null) selectedAvatarId else null,
|
||||||
|
avatarUrl = customAvatarUrl,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +374,7 @@ private fun ProfileIdentityCard(
|
||||||
onNameChange: (String) -> Unit,
|
onNameChange: (String) -> Unit,
|
||||||
onUsesPrimaryAddonsChange: (Boolean) -> Unit,
|
onUsesPrimaryAddonsChange: (Boolean) -> Unit,
|
||||||
selectedAvatar: AvatarCatalogItem?,
|
selectedAvatar: AvatarCatalogItem?,
|
||||||
|
customAvatarUrl: String?,
|
||||||
accentColor: Color,
|
accentColor: Color,
|
||||||
hasAvatarChoices: Boolean,
|
hasAvatarChoices: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
@ -345,16 +390,31 @@ private fun ProfileIdentityCard(
|
||||||
.size(88.dp)
|
.size(88.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f),
|
if (selectedAvatar != null || customAvatarUrl != null) {
|
||||||
|
accentColor
|
||||||
|
} else {
|
||||||
|
accentColor.copy(alpha = 0.18f)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.border(
|
.border(
|
||||||
width = 2.dp,
|
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,
|
shape = CircleShape,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
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(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(selectedAvatar.storagePath),
|
model = avatarStorageUrl(selectedAvatar.storagePath),
|
||||||
contentDescription = selectedAvatar.displayName,
|
contentDescription = selectedAvatar.displayName,
|
||||||
|
|
@ -410,6 +470,7 @@ private fun ProfileIdentityCard(
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = when {
|
||||||
|
customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected)
|
||||||
selectedAvatar != null -> stringResource(
|
selectedAvatar != null -> stringResource(
|
||||||
Res.string.profile_avatar_selected,
|
Res.string.profile_avatar_selected,
|
||||||
selectedAvatar.displayName,
|
selectedAvatar.displayName,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ data class NuvioProfile(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
|
@SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5",
|
||||||
@SerialName("avatar_id") val avatarId: String? = null,
|
@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_addons") val usesPrimaryAddons: Boolean = false,
|
||||||
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
||||||
@SerialName("pin_enabled") val pinEnabled: 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_addons") val usesPrimaryAddons: Boolean = false,
|
||||||
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
@SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false,
|
||||||
@SerialName("avatar_id") val avatarId: String? = null,
|
@SerialName("avatar_id") val avatarId: String? = null,
|
||||||
|
@SerialName("avatar_url") val avatarUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf(
|
||||||
|
|
||||||
fun avatarStorageUrl(storagePath: String): String =
|
fun avatarStorageUrl(storagePath: String): String =
|
||||||
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
|
"${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,
|
name: String,
|
||||||
avatarColorHex: String,
|
avatarColorHex: String,
|
||||||
avatarId: String? = null,
|
avatarId: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
usesPrimaryAddons: Boolean = false,
|
usesPrimaryAddons: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val existing = _state.value.profiles
|
val existing = _state.value.profiles
|
||||||
|
|
@ -192,6 +193,7 @@ object ProfileRepository {
|
||||||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||||
avatarId = profile.avatarId,
|
avatarId = profile.avatarId,
|
||||||
|
avatarUrl = profile.avatarUrl,
|
||||||
)
|
)
|
||||||
} + ProfilePushPayload(
|
} + ProfilePushPayload(
|
||||||
profileIndex = nextIndex,
|
profileIndex = nextIndex,
|
||||||
|
|
@ -199,6 +201,7 @@ object ProfileRepository {
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
avatarId = avatarId,
|
avatarId = avatarId,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
pushProfiles(allPayloads)
|
pushProfiles(allPayloads)
|
||||||
|
|
@ -209,6 +212,7 @@ object ProfileRepository {
|
||||||
name: String,
|
name: String,
|
||||||
avatarColorHex: String,
|
avatarColorHex: String,
|
||||||
avatarId: String? = null,
|
avatarId: String? = null,
|
||||||
|
avatarUrl: String? = null,
|
||||||
usesPrimaryAddons: Boolean = false,
|
usesPrimaryAddons: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val allPayloads = _state.value.profiles.map { profile ->
|
val allPayloads = _state.value.profiles.map { profile ->
|
||||||
|
|
@ -218,7 +222,8 @@ object ProfileRepository {
|
||||||
name = name,
|
name = name,
|
||||||
avatarColorHex = avatarColorHex,
|
avatarColorHex = avatarColorHex,
|
||||||
usesPrimaryAddons = usesPrimaryAddons,
|
usesPrimaryAddons = usesPrimaryAddons,
|
||||||
avatarId = avatarId ?: profile.avatarId,
|
avatarId = avatarId,
|
||||||
|
avatarUrl = avatarUrl,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ProfilePushPayload(
|
ProfilePushPayload(
|
||||||
|
|
@ -228,6 +233,7 @@ object ProfileRepository {
|
||||||
usesPrimaryAddons = profile.usesPrimaryAddons,
|
usesPrimaryAddons = profile.usesPrimaryAddons,
|
||||||
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
usesPrimaryPlugins = profile.usesPrimaryPlugins,
|
||||||
avatarId = profile.avatarId,
|
avatarId = profile.avatarId,
|
||||||
|
avatarUrl = profile.avatarUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -357,6 +363,7 @@ object ProfileRepository {
|
||||||
name = p.name,
|
name = p.name,
|
||||||
avatarColorHex = p.avatarColorHex,
|
avatarColorHex = p.avatarColorHex,
|
||||||
avatarId = p.avatarId,
|
avatarId = p.avatarId,
|
||||||
|
avatarUrl = p.avatarUrl,
|
||||||
usesPrimaryAddons = p.usesPrimaryAddons,
|
usesPrimaryAddons = p.usesPrimaryAddons,
|
||||||
usesPrimaryPlugins = p.usesPrimaryPlugins,
|
usesPrimaryPlugins = p.usesPrimaryPlugins,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,9 @@ private fun ProfileAvatarCard(
|
||||||
val avatarItem = remember(profile.avatarId, avatars) {
|
val avatarItem = remember(profile.avatarId, avatars) {
|
||||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||||
|
profileAvatarImageUrl(profile, avatarItem)
|
||||||
|
}
|
||||||
|
|
||||||
val animAlpha = remember { Animatable(0f) }
|
val animAlpha = remember { Animatable(0f) }
|
||||||
val animScale = remember { Animatable(0.85f) }
|
val animScale = remember { Animatable(0.85f) }
|
||||||
|
|
@ -342,8 +345,8 @@ private fun ProfileAvatarCard(
|
||||||
modifier = Modifier.size(110.dp),
|
modifier = Modifier.size(110.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(110.dp)
|
.size(110.dp)
|
||||||
|
|
@ -364,15 +367,15 @@ private fun ProfileAvatarCard(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then(
|
.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,
|
else Modifier,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(avatarItem.storagePath),
|
model = avatarImageUrl,
|
||||||
contentDescription = avatarItem.displayName,
|
contentDescription = avatarItem?.displayName ?: profile.name,
|
||||||
modifier = Modifier.size(100.dp).clip(CircleShape),
|
modifier = Modifier.size(100.dp).clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,9 @@ private fun PopupProfileBubble(
|
||||||
val avatarItem = remember(profile.avatarId, avatars) {
|
val avatarItem = remember(profile.avatarId, avatars) {
|
||||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||||
|
profileAvatarImageUrl(profile, avatarItem)
|
||||||
|
}
|
||||||
|
|
||||||
// Per-item entrance animation
|
// Per-item entrance animation
|
||||||
val itemAlpha = remember { Animatable(0f) }
|
val itemAlpha = remember { Animatable(0f) }
|
||||||
|
|
@ -393,8 +396,8 @@ private fun PopupProfileBubble(
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||||
} else {
|
} else {
|
||||||
avatarColor.copy(alpha = 0.15f)
|
avatarColor.copy(alpha = 0.15f)
|
||||||
},
|
},
|
||||||
|
|
@ -411,7 +414,7 @@ private fun PopupProfileBubble(
|
||||||
avatarColor.copy(alpha = 0.6f),
|
avatarColor.copy(alpha = 0.6f),
|
||||||
CircleShape,
|
CircleShape,
|
||||||
)
|
)
|
||||||
avatarItem == null -> Modifier.border(
|
avatarImageUrl == null -> Modifier.border(
|
||||||
1.5.dp,
|
1.5.dp,
|
||||||
avatarColor.copy(alpha = 0.3f),
|
avatarColor.copy(alpha = 0.3f),
|
||||||
CircleShape,
|
CircleShape,
|
||||||
|
|
@ -421,9 +424,9 @@ private fun PopupProfileBubble(
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(avatarItem.storagePath),
|
model = avatarImageUrl,
|
||||||
contentDescription = profile.name,
|
contentDescription = profile.name,
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
|
|
@ -700,6 +703,9 @@ fun ActiveProfileMiniAvatar(
|
||||||
val avatarItem = remember(profile.avatarId, avatars) {
|
val avatarItem = remember(profile.avatarId, avatars) {
|
||||||
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
profile.avatarId?.let { id -> avatars.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
val avatarImageUrl = remember(profile.avatarUrl, avatarItem) {
|
||||||
|
profileAvatarImageUrl(profile, avatarItem)
|
||||||
|
}
|
||||||
|
|
||||||
val borderColor = if (selected) {
|
val borderColor = if (selected) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
|
|
@ -712,8 +718,8 @@ fun ActiveProfileMiniAvatar(
|
||||||
.size(size.dp)
|
.size(size.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor
|
||||||
} else {
|
} else {
|
||||||
avatarColor.copy(alpha = 0.15f)
|
avatarColor.copy(alpha = 0.15f)
|
||||||
},
|
},
|
||||||
|
|
@ -721,9 +727,9 @@ fun ActiveProfileMiniAvatar(
|
||||||
.border(1.5.dp, borderColor, CircleShape),
|
.border(1.5.dp, borderColor, CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (avatarItem != null) {
|
if (avatarImageUrl != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = avatarStorageUrl(avatarItem.storagePath),
|
model = avatarImageUrl,
|
||||||
contentDescription = profile.name,
|
contentDescription = profile.name,
|
||||||
modifier = Modifier.size(size.dp).clip(CircleShape),
|
modifier = Modifier.size(size.dp).clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
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 SNAPSHOT_CACHE_TTL_MS = 60_000L
|
||||||
private const val LIST_TABS_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 FORCE_REFRESH_DEDUP_MS = 10_000L
|
||||||
|
private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240
|
||||||
|
|
||||||
data class TraktLibraryUiState(
|
data class TraktLibraryUiState(
|
||||||
val listTabs: List<TraktListTab> = emptyList(),
|
val listTabs: List<TraktListTab> = emptyList(),
|
||||||
|
|
@ -159,21 +160,20 @@ object TraktLibraryRepository {
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}
|
||||||
|
result.exceptionOrNull()?.let { error ->
|
||||||
if (error is CancellationException) throw error
|
if (error is CancellationException) throw error
|
||||||
log.w { "Failed to refresh Trakt library: ${error.message}" }
|
log.w(error) { "Failed to refresh Trakt library" }
|
||||||
}.getOrNull()
|
_uiState.value = _uiState.value.copy(
|
||||||
|
|
||||||
if (result == null) {
|
|
||||||
_uiState.value = current.copy(
|
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
hasLoaded = true,
|
hasLoaded = true,
|
||||||
errorMessage = getString(Res.string.trakt_library_load_failed),
|
errorMessage = traktLibraryLoadErrorMessage(error),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.value = result.copy(
|
val snapshot = result.getOrThrow()
|
||||||
|
_uiState.value = snapshot.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
hasLoaded = true,
|
hasLoaded = true,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
|
@ -414,6 +414,27 @@ object TraktLibraryRepository {
|
||||||
TraktLibraryStorage.savePayload(json.encodeToString(payload))
|
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> {
|
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
|
||||||
val watchlistTabs = listOf(
|
val watchlistTabs = listOf(
|
||||||
TraktListTab(
|
TraktListTab(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
||||||
"trakt_auth_payload",
|
"trakt_auth_payload",
|
||||||
"trakt_library_payload",
|
"trakt_library_payload",
|
||||||
"trakt_settings_payload",
|
"trakt_settings_payload",
|
||||||
|
"collections_payload",
|
||||||
)
|
)
|
||||||
|
|
||||||
actual fun wipe() {
|
actual fun wipe() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue