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