From eee6d1a2a81793bbfdfc9b98356d7f7d51b16cfc Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 20:07:01 +0530 Subject: [PATCH 1/6] ref(ios): implement native tab visibily change --- .../commonMain/kotlin/com/nuvio/app/App.kt | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 0cda0cb5..987a0643 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 @@ -576,6 +578,32 @@ private fun MainAppContent( NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) } + DisposableEffect( + navController, + liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled, + initialHomeReady, + ) { + fun publishNativeTabVisibilityForCurrentRoute() { + val visible = liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled && + initialHomeReady && + navController.currentDestination?.hasRoute() == 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 +984,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() From b293157fee9445477a422585b49630cd3091c771 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 21:00:51 +0530 Subject: [PATCH 2/6] ref: add season/episode number in downloads --- .../app/features/downloads/DownloadsModels.kt | 10 ++++ .../app/features/downloads/DownloadsScreen.kt | 48 ++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt index 94769875..48da488c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt @@ -98,3 +98,13 @@ enum class DownloadEnqueueResult { } } } + +internal fun List.sortedForSeriesDownloads(): List = + sortedWith(downloadSeriesEpisodeComparator) + +internal val downloadSeriesEpisodeComparator: Comparator = + compareBy { 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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index d8952adf..b1fe7e33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -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 { 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( From 5a0b6237738731571e0438818868af34d7f65fad Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 6 May 2026 23:46:38 +0530 Subject: [PATCH 3/6] ref: add collections support to local account data cleaner --- .../PlatformLocalAccountDataCleaner.android.kt | 1 + .../collection/CollectionSyncService.kt | 18 ++++++++---------- .../PlatformLocalAccountDataCleaner.ios.kt | 1 + 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index de84c4a5..9edf1191 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -23,6 +23,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_episode_release_notifications", "nuvio_episode_release_notifications_platform", "nuvio_watch_progress", + "nuvio_collections", "nuvio_plugins", ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index de0931ec..e1046712 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -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 { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 71d71168..553140ee 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -46,6 +46,7 @@ internal actual object PlatformLocalAccountDataCleaner { "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "collections_payload", ) actual fun wipe() { From 1e75f416e4fe45bb8ecec6863d8423b661d4bd83 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 01:33:37 +0530 Subject: [PATCH 4/6] feat: adding retry logic to library --- .../app/features/library/LibraryScreen.kt | 24 +++++------- .../features/trakt/TraktLibraryRepository.kt | 37 +++++++++++++++---- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 9b48e32d..efe6ded9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 0dc06966..03cdfc67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -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 = 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): List { val watchlistTabs = listOf( TraktListTab( From 81babba3edb9ac39bb1dbe2c04874627cd4b25d0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 02:01:11 +0530 Subject: [PATCH 5/6] feat: episode ratings api --- composeApp/build.gradle.kts | 14 + .../details/ImdbEpisodeRatingsRepository.kt | 112 +++++++ .../app/features/details/MetaDetailsScreen.kt | 53 ++++ .../app/features/details/SeriesGraphApi.kt | 65 +++++ .../details/components/DetailSeriesContent.kt | 275 ++++++++++++------ 5 files changed, 435 insertions(+), 84 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 73a97208..71d3b924 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt new file mode 100644 index 00000000..6a32a874 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt @@ -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, 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() + private val inFlight = mutableMapOf, Double>>>() + + suspend fun getEpisodeRatings( + imdbId: String?, + tmdbId: Int?, + ): Map, 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, 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): Map, 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 80c724a3..d8bfbf27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } + var episodeImdbRatings by remember(type, id) { mutableStateOf, 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, 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt new file mode 100644 index 00000000..c46996ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt @@ -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 = + requestSeasonRatings( + baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL, + showId = tmdbId.toString(), + ) +} + +internal object ImdbTapframeApi { + suspend fun getSeasonRatings(imdbId: String): List = + 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? = null, +) + +private val seriesGraphLog = Logger.withTag("SeriesGraphApi") +private val seriesGraphJson = Json { ignoreUnknownKeys = true } + +private suspend fun requestSeasonRatings( + baseUrl: String, + showId: String, +): List { + 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>(response.body) + }.onFailure { error -> + seriesGraphLog.w(error) { "Season ratings request failed for $showId" } + }.getOrDefault(emptyList()) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 10f42141..e5140b74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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 = emptyMap(), watchedKeys: Set = emptySet(), + episodeRatings: Map, 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, fallbackImage: String?, progressByVideoId: Map, + episodeRatings: Map, 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? { + 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" +} From 2fa918fe4492d7dec7f8fbe6be406aecc7bd42c4 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 7 May 2026 13:14:19 +0530 Subject: [PATCH 6/6] feat: add support for custom avatar url --- .../composeResources/values/strings.xml | 5 ++ .../commonMain/kotlin/com/nuvio/app/App.kt | 8 +- .../features/profiles/ProfileEditScreen.kt | 89 ++++++++++++++++--- .../app/features/profiles/ProfileModels.kt | 19 ++++ .../features/profiles/ProfileRepository.kt | 9 +- .../profiles/ProfileSelectionScreen.kt | 15 ++-- .../features/profiles/ProfileSwitcherTab.kt | 24 +++-- 7 files changed, 134 insertions(+), 35 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 81460967..e8b01632 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1007,9 +1007,14 @@ Locked. Try again in %1$ds Avatar options will appear here when the catalog loads. Avatar: %1$s + Enter a valid http:// or https:// image URL. Choose an avatar Choose an avatar below. Create Profile + Custom avatar URL selected. + Custom avatar URL + Paste an image link, or leave this empty to use the built-in avatar catalog. + https://example.com/avatar.png All data for "%1$s" will be permanently deleted. Delete Profile Add Profile diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 987a0643..3eebbdac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -135,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 @@ -331,6 +331,7 @@ fun App() { profileState.activeProfile?.name, profileState.activeProfile?.avatarColorHex, profileState.activeProfile?.avatarId, + profileState.activeProfile?.avatarUrl, profileAvatars, ) { val activeProfile = profileState.activeProfile @@ -340,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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 83d26dc6..5f00697d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index 3e91429f..f36aee81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 01904938..0cb6cc27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 3d487c02..195ba674 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index d6a77b3f..cecd6273 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -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,