diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 490ead69f..26836a63f 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -7,7 +7,7 @@ import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import java.util.Properties abstract class GenerateRuntimeConfigsTask : DefaultTask() { @@ -77,7 +77,7 @@ val generateRuntimeConfigs = tasks.register("generat localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) } -tasks.withType().configureEach { +tasks.withType>().configureEach { dependsOn(generateRuntimeConfigs) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 17c202baf..d34d0a020 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -10,6 +10,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage +import com.nuvio.app.features.mdblist.MdbListSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.details.SeasonViewModeStorage @@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() { SeasonViewModeStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) + MdbListSettingsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) 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 f236f90a1..ac9648ec9 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 @@ -10,6 +10,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_player_settings", "nuvio_profile_cache", "nuvio_theme_settings", + "nuvio_mdblist_settings", "nuvio_watched", "nuvio_stream_link_cache", "nuvio_continue_watching_preferences", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 00b5ebed0..28822df0f 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -2,11 +2,18 @@ package com.nuvio.app.features.addons import android.content.Context import android.content.SharedPreferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.BufferedReader -import java.net.HttpURLConnection -import java.net.URL +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess actual object AddonStorage { private const val preferencesName = "nuvio_addons" @@ -35,27 +42,45 @@ actual object AddonStorage { } } -actual suspend fun httpGetText(url: String): String = - withContext(Dispatchers.IO) { - val connection = URL(url).openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 10_000 - connection.readTimeout = 10_000 - connection.setRequestProperty("Accept", "application/json") +private val addonHttpClient = HttpClient(Android) { + install(HttpTimeout) { + requestTimeoutMillis = 10_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 10_000 + } + expectSuccess = false +} - try { - val statusCode = connection.responseCode - val stream = if (statusCode in 200..299) { - connection.inputStream - } else { - connection.errorStream ?: connection.inputStream +actual suspend fun httpGetText(url: String): String = + addonHttpClient + .get(url) { + accept(ContentType.Application.Json) + } + .let { response -> + val payload = response.bodyAsText() + if (!response.status.isSuccess()) { + error("Request failed with HTTP ${response.status.value}") } - val payload = stream.bufferedReader().use(BufferedReader::readText) - if (statusCode !in 200..299) { - error("Request failed with HTTP $statusCode") + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") + } + payload + } + +actual suspend fun httpPostJson(url: String, body: String): String = + addonHttpClient + .post(url) { + accept(ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(body) + } + .let { response -> + val payload = response.bodyAsText() + if (!response.status.isSuccess()) { + error("Request failed with HTTP ${response.status.value}") + } + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") } payload - } finally { - connection.disconnect() } - } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt new file mode 100644 index 000000000..ebc4ad16a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt @@ -0,0 +1,99 @@ +package com.nuvio.app.features.mdblist + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object MdbListSettingsStorage { + private const val preferencesName = "nuvio_mdblist_settings" + private const val enabledKey = "mdblist_enabled" + private const val apiKey = "mdblist_api_key" + private const val useImdbKey = "mdblist_use_imdb" + private const val useTmdbKey = "mdblist_use_tmdb" + private const val useTomatoesKey = "mdblist_use_tomatoes" + private const val useMetacriticKey = "mdblist_use_metacritic" + private const val useTraktKey = "mdblist_use_trakt" + private const val useLetterboxdKey = "mdblist_use_letterboxd" + private const val useAudienceKey = "mdblist_use_audience" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadApiKey(): String? = + preferences?.getString(ProfileScopedKey.of(apiKey), null) + + actual fun saveApiKey(apiKey: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(this.apiKey), apiKey) + ?.apply() + } + + actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey) + + actual fun saveUseImdb(enabled: Boolean) { + saveBoolean(useImdbKey, enabled) + } + + actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey) + + actual fun saveUseTmdb(enabled: Boolean) { + saveBoolean(useTmdbKey, enabled) + } + + actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey) + + actual fun saveUseTomatoes(enabled: Boolean) { + saveBoolean(useTomatoesKey, enabled) + } + + actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey) + + actual fun saveUseMetacritic(enabled: Boolean) { + saveBoolean(useMetacriticKey, enabled) + } + + actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey) + + actual fun saveUseTrakt(enabled: Boolean) { + saveBoolean(useTraktKey, enabled) + } + + actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey) + + actual fun saveUseLetterboxd(enabled: Boolean) { + saveBoolean(useLetterboxdKey, enabled) + } + + actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey) + + actual fun saveUseAudience(enabled: Boolean) { + saveBoolean(useAudienceKey, enabled) + } + + private fun loadBoolean(key: String): Boolean? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getBoolean(scopedKey, false) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(key), enabled) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_audience_score.png b/composeApp/src/commonMain/composeResources/drawable/rating_audience_score.png new file mode 100644 index 000000000..7f68f8d57 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_audience_score.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_imdb.png b/composeApp/src/commonMain/composeResources/drawable/rating_imdb.png new file mode 100644 index 000000000..fa5226141 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_imdb.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_letterboxd.png b/composeApp/src/commonMain/composeResources/drawable/rating_letterboxd.png new file mode 100644 index 000000000..6e6c29ce3 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_letterboxd.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_metacritic.png b/composeApp/src/commonMain/composeResources/drawable/rating_metacritic.png new file mode 100644 index 000000000..4a39e0858 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_metacritic.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_rotten_tomatoes.png b/composeApp/src/commonMain/composeResources/drawable/rating_rotten_tomatoes.png new file mode 100644 index 000000000..f9b0319e5 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_rotten_tomatoes.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_tmdb.png b/composeApp/src/commonMain/composeResources/drawable/rating_tmdb.png new file mode 100644 index 000000000..fe4ca929e Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_tmdb.png differ diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_trakt.png b/composeApp/src/commonMain/composeResources/drawable/rating_trakt.png new file mode 100644 index 000000000..a34569362 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_trakt.png differ diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index 09d2e2159..04361a5c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -6,3 +6,5 @@ internal expect object AddonStorage { } expect suspend fun httpGetText(url: String): String + +expect suspend fun httpPostJson(url: String, body: String): String diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index dda93f308..de09dd441 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -18,6 +18,7 @@ data class MetaDetails( val imdbRating: String? = null, val ageRating: String? = null, val runtime: String? = null, + val externalRatings: List = emptyList(), val genres: List = emptyList(), val director: List = emptyList(), val writer: List = emptyList(), @@ -37,6 +38,11 @@ data class MetaDetails( val videos: List = emptyList(), ) +data class MetaExternalRating( + val source: String, + val value: Double, +) + data class MetaTrailer( val id: String, val key: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 1dc279fc9..5bf1b4972 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -4,8 +4,11 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.mdblist.MdbListMetadataService +import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -120,6 +123,7 @@ object MetaDetailsRepository { private const val FETCH_TIMEOUT_MS = 5_000L private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L + private const val MDBLIST_ENRICH_TIMEOUT_MS = 5_000L private suspend fun tryFetchMeta( manifest: AddonManifest, @@ -128,6 +132,7 @@ object MetaDetailsRepository { ): MetaDetails? { return try { TmdbSettingsRepository.ensureLoaded() + MdbListSettingsRepository.ensureLoaded() val baseUrl = manifest.transportUrl .substringBefore("?") .removeSuffix("/manifest.json") @@ -136,13 +141,20 @@ object MetaDetailsRepository { val payload = httpGetText(url) log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" } val result = MetaDetailsParser.parse(payload) - val enriched = withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) { + val tmdbEnriched = withTimeoutOrNull(TMDB_ENRICH_TIMEOUT_MS) { TmdbMetadataService.enrichMeta( meta = result, fallbackItemId = id, settings = TmdbSettingsRepository.snapshot(), ) } ?: result + val enriched = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) { + MdbListMetadataService.enrichMeta( + meta = tmdbEnriched, + fallbackItemId = id, + settings = MdbListSettingsRepository.snapshot(), + ) + } ?: tmdbEnriched log.d { "Parsed meta: type=${enriched.type}, name=${enriched.name}, videos=${enriched.videos.size}" } if (enriched.videos.isNotEmpty()) { val first = enriched.videos.first() @@ -150,6 +162,7 @@ object MetaDetailsRepository { } enriched } catch (e: Throwable) { + if (e is CancellationException) throw e log.e(e) { "Failed to fetch/parse meta from ${manifest.transportUrl}" } null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index f4b524c93..5beaefd67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -1,8 +1,10 @@ package com.nuvio.app.features.details.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,8 +12,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -29,7 +33,27 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaExternalRating import com.nuvio.app.features.details.formatMetaReleaseLineForDetails +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_AUDIENCE +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_IMDB +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_LETTERBOXD +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.rating_audience_score +import nuvio.composeapp.generated.resources.rating_imdb +import nuvio.composeapp.generated.resources.rating_letterboxd +import nuvio.composeapp.generated.resources.rating_metacritic +import nuvio.composeapp.generated.resources.rating_rotten_tomatoes +import nuvio.composeapp.generated.resources.rating_tmdb +import nuvio.composeapp.generated.resources.rating_trakt +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +import kotlin.math.absoluteValue +import kotlin.math.roundToInt @Composable fun DetailMetaInfo( @@ -43,10 +67,11 @@ fun DetailMetaInfo( val releaseLine = formatMetaReleaseLineForDetails(meta) val runtimeText = meta.runtime?.trim()?.takeIf { it.isNotBlank() }?.uppercase() val ageBadge = meta.ageRating?.trim()?.takeIf { it.isNotBlank() } + val hasMdbImdbRating = meta.externalRatings.any { it.source == PROVIDER_IMDB } val hasMetaRow = releaseLine != null || runtimeText != null || ageBadge != null || - meta.imdbRating != null + (meta.imdbRating != null && !hasMdbImdbRating) if (hasMetaRow) { Row( verticalAlignment = Alignment.CenterVertically, @@ -71,7 +96,7 @@ fun DetailMetaInfo( ageBadge?.let { badge -> DetailHeroMetaBadge(text = badge) } - if (meta.imdbRating != null) { + if (meta.imdbRating != null && !hasMdbImdbRating) { Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -102,6 +127,12 @@ fun DetailMetaInfo( } } + if (meta.externalRatings.isNotEmpty()) { + DetailRatingsRow( + ratings = meta.externalRatings, + ) + } + if (meta.director.isNotEmpty()) { MetaLabelValueRow( label = "Director", @@ -139,6 +170,47 @@ fun DetailMetaInfo( } } +@Composable +private fun DetailRatingsRow( + ratings: List, +) { + val orderedRatings = remember(ratings) { + val bySource = ratings.associateBy { it.source } + ratingVisuals.mapNotNull { visuals -> + bySource[visuals.source]?.let { rating -> visuals to rating } + } + } + + if (orderedRatings.isEmpty()) return + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + orderedRatings.forEach { (visuals, rating) -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(visuals.logo), + contentDescription = visuals.displayName, + modifier = Modifier.size(width = visuals.logoWidth, height = 16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = visuals.format(rating.value), + style = MaterialTheme.typography.titleSmall, + color = visuals.valueColor, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + @Composable private fun MetaLabelValueRow( label: String, @@ -185,3 +257,82 @@ private fun DetailHeroMetaBadge( private val ImdbYellow = Color(0xFFF5C518) private val ImdbBlack = Color(0xFF000000) + +private data class RatingVisuals( + val source: String, + val displayName: String, + val logo: DrawableResource, + val logoWidth: androidx.compose.ui.unit.Dp, + val valueColor: Color, + val format: (Double) -> String, +) + +private val ratingVisuals = listOf( + RatingVisuals( + source = PROVIDER_IMDB, + displayName = "IMDb", + logo = Res.drawable.rating_imdb, + logoWidth = 30.dp, + valueColor = Color(0xFFF5C518), + format = ::formatOneDecimal, + ), + RatingVisuals( + source = PROVIDER_TMDB, + displayName = "TMDB", + logo = Res.drawable.rating_tmdb, + logoWidth = 16.dp, + valueColor = Color(0xFF01B4E4), + format = ::formatWhole, + ), + RatingVisuals( + source = PROVIDER_TOMATOES, + displayName = "Rotten Tomatoes", + logo = Res.drawable.rating_rotten_tomatoes, + logoWidth = 16.dp, + valueColor = Color(0xFFFA320A), + format = ::formatPercent, + ), + RatingVisuals( + source = PROVIDER_METACRITIC, + displayName = "Metacritic", + logo = Res.drawable.rating_metacritic, + logoWidth = 16.dp, + valueColor = Color(0xFFFFCC33), + format = ::formatWhole, + ), + RatingVisuals( + source = PROVIDER_TRAKT, + displayName = "Trakt", + logo = Res.drawable.rating_trakt, + logoWidth = 16.dp, + valueColor = Color(0xFFED1C24), + format = ::formatWhole, + ), + RatingVisuals( + source = PROVIDER_LETTERBOXD, + displayName = "Letterboxd", + logo = Res.drawable.rating_letterboxd, + logoWidth = 16.dp, + valueColor = Color(0xFF00E054), + format = ::formatOneDecimal, + ), + RatingVisuals( + source = PROVIDER_AUDIENCE, + displayName = "Audience Score", + logo = Res.drawable.rating_audience_score, + logoWidth = 16.dp, + valueColor = Color(0xFFFA320A), + format = ::formatPercent, + ), +) + +private fun formatOneDecimal(value: Double): String { + val rounded = (value * 10.0).roundToInt() + val whole = rounded / 10 + val decimal = (rounded % 10).absoluteValue + return "$whole.$decimal" +} + +private fun formatWhole(value: Double): String = value.roundToInt().toString() + +private fun formatPercent(value: Double): String = "${value.roundToInt()}%" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt new file mode 100644 index 000000000..a84c42de1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt @@ -0,0 +1,148 @@ +package com.nuvio.app.features.mdblist + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpPostJson +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaExternalRating +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object MdbListMetadataService { + const val PROVIDER_IMDB = "imdb" + const val PROVIDER_TMDB = "tmdb" + const val PROVIDER_TOMATOES = "tomatoes" + const val PROVIDER_METACRITIC = "metacritic" + const val PROVIDER_TRAKT = "trakt" + const val PROVIDER_LETTERBOXD = "letterboxd" + const val PROVIDER_AUDIENCE = "audience" + + val PROVIDER_PRIORITY_ORDER = listOf( + PROVIDER_IMDB, + PROVIDER_TMDB, + PROVIDER_TOMATOES, + PROVIDER_METACRITIC, + PROVIDER_TRAKT, + PROVIDER_LETTERBOXD, + PROVIDER_AUDIENCE, + ) + + private val log = Logger.withTag("MdbListMetadata") + private val json = Json { ignoreUnknownKeys = true } + private val ratingsCache = mutableMapOf>() + private val imdbRegex = Regex("tt\\d+") + + suspend fun enrichMeta( + meta: MetaDetails, + fallbackItemId: String, + settings: MdbListSettings, + ): MetaDetails { + if (!settings.enabled) return meta.copy(externalRatings = emptyList()) + val apiKey = settings.apiKey.trim() + if (apiKey.isBlank()) return meta.copy(externalRatings = emptyList()) + + val imdbId = extractImdbId(meta.id) + ?: extractImdbId(fallbackItemId) + ?: return meta.copy(externalRatings = emptyList()) + val mediaType = toMdbListMediaType(meta.type) + val enabledProviders = settings.enabledProvidersInPriorityOrder() + if (enabledProviders.isEmpty()) return meta.copy(externalRatings = emptyList()) + + val ratings = fetchRatings( + imdbId = imdbId, + mediaType = mediaType, + apiKey = apiKey, + providers = enabledProviders, + ) + + return meta.copy(externalRatings = ratings) + } + + fun clearCache() { + ratingsCache.clear() + } + + private suspend fun fetchRatings( + imdbId: String, + mediaType: String, + apiKey: String, + providers: List, + ): List = withContext(Dispatchers.Default) { + val cacheKey = "$mediaType:$imdbId:$apiKey:${providers.joinToString(",")}" + ratingsCache[cacheKey]?.let { return@withContext it } + + val ratings = coroutineScope { + providers.map { providerId -> + async { + fetchProviderRating( + imdbId = imdbId, + mediaType = mediaType, + providerId = providerId, + apiKey = apiKey, + ) + } + }.awaitAll().filterNotNull() + } + + ratingsCache[cacheKey] = ratings + ratings + } + + private suspend fun fetchProviderRating( + imdbId: String, + mediaType: String, + providerId: String, + apiKey: String, + ): MetaExternalRating? { + val url = "https://api.mdblist.com/rating/$mediaType/$providerId?apikey=$apiKey" + val requestBody = json.encodeToString( + RatingRequest( + ids = listOf(imdbId), + provider = PROVIDER_IMDB, + ), + ) + + return runCatching { + val payload = httpPostJson(url = url, body = requestBody) + val parsed = json.decodeFromString(payload) + val rating = parsed.ratings.firstOrNull()?.rating ?: return@runCatching null + MetaExternalRating(source = providerId, value = rating) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "MDBList request failed for $providerId/$imdbId: ${error.message}" } + }.getOrNull() + } + + private fun extractImdbId(value: String?): String? { + if (value.isNullOrBlank()) return null + return imdbRegex.find(value)?.value + } + + private fun toMdbListMediaType(metaType: String): String { + val normalized = metaType.trim().lowercase() + return if (normalized == "movie") "movie" else "show" + } +} + +@Serializable +private data class RatingRequest( + val ids: List, + val provider: String, +) + +@Serializable +private data class RatingResponse( + val ratings: List = emptyList(), +) + +@Serializable +private data class RatingItem( + val rating: Double? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt new file mode 100644 index 000000000..6d9656741 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.mdblist + +data class MdbListSettings( + val enabled: Boolean = false, + val apiKey: String = "", + val useImdb: Boolean = true, + val useTmdb: Boolean = true, + val useTomatoes: Boolean = true, + val useMetacritic: Boolean = true, + val useTrakt: Boolean = true, + val useLetterboxd: Boolean = true, + val useAudience: Boolean = true, +) { + fun isProviderEnabled(providerId: String): Boolean = + when (providerId) { + MdbListMetadataService.PROVIDER_IMDB -> useImdb + MdbListMetadataService.PROVIDER_TMDB -> useTmdb + MdbListMetadataService.PROVIDER_TOMATOES -> useTomatoes + MdbListMetadataService.PROVIDER_METACRITIC -> useMetacritic + MdbListMetadataService.PROVIDER_TRAKT -> useTrakt + MdbListMetadataService.PROVIDER_LETTERBOXD -> useLetterboxd + MdbListMetadataService.PROVIDER_AUDIENCE -> useAudience + else -> false + } + + fun enabledProvidersInPriorityOrder(): List = + MdbListMetadataService.PROVIDER_PRIORITY_ORDER.filter(::isProviderEnabled) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt new file mode 100644 index 000000000..e2647f6a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt @@ -0,0 +1,119 @@ +package com.nuvio.app.features.mdblist + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object MdbListSettingsRepository { + private val _uiState = MutableStateFlow(MdbListSettings()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + private var enabled = false + private var apiKey = "" + private var useImdb = true + private var useTmdb = true + private var useTomatoes = true + private var useMetacritic = true + private var useTrakt = true + private var useLetterboxd = true + private var useAudience = true + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun snapshot(): MdbListSettings { + ensureLoaded() + return _uiState.value + } + + fun setEnabled(value: Boolean) { + ensureLoaded() + if (enabled == value) return + enabled = value + publish() + MdbListSettingsStorage.saveEnabled(value) + } + + fun setApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (apiKey == normalized) return + apiKey = normalized + publish() + MdbListSettingsStorage.saveApiKey(normalized) + MdbListMetadataService.clearCache() + } + + fun setProviderEnabled(providerId: String, value: Boolean) { + ensureLoaded() + when (providerId) { + MdbListMetadataService.PROVIDER_IMDB -> if (useImdb != value) { + useImdb = value + MdbListSettingsStorage.saveUseImdb(value) + } else return + MdbListMetadataService.PROVIDER_TMDB -> if (useTmdb != value) { + useTmdb = value + MdbListSettingsStorage.saveUseTmdb(value) + } else return + MdbListMetadataService.PROVIDER_TOMATOES -> if (useTomatoes != value) { + useTomatoes = value + MdbListSettingsStorage.saveUseTomatoes(value) + } else return + MdbListMetadataService.PROVIDER_METACRITIC -> if (useMetacritic != value) { + useMetacritic = value + MdbListSettingsStorage.saveUseMetacritic(value) + } else return + MdbListMetadataService.PROVIDER_TRAKT -> if (useTrakt != value) { + useTrakt = value + MdbListSettingsStorage.saveUseTrakt(value) + } else return + MdbListMetadataService.PROVIDER_LETTERBOXD -> if (useLetterboxd != value) { + useLetterboxd = value + MdbListSettingsStorage.saveUseLetterboxd(value) + } else return + MdbListMetadataService.PROVIDER_AUDIENCE -> if (useAudience != value) { + useAudience = value + MdbListSettingsStorage.saveUseAudience(value) + } else return + else -> return + } + publish() + MdbListMetadataService.clearCache() + } + + private fun loadFromDisk() { + hasLoaded = true + enabled = MdbListSettingsStorage.loadEnabled() ?: false + apiKey = MdbListSettingsStorage.loadApiKey().orEmpty().trim() + useImdb = MdbListSettingsStorage.loadUseImdb() ?: true + useTmdb = MdbListSettingsStorage.loadUseTmdb() ?: true + useTomatoes = MdbListSettingsStorage.loadUseTomatoes() ?: true + useMetacritic = MdbListSettingsStorage.loadUseMetacritic() ?: true + useTrakt = MdbListSettingsStorage.loadUseTrakt() ?: true + useLetterboxd = MdbListSettingsStorage.loadUseLetterboxd() ?: true + useAudience = MdbListSettingsStorage.loadUseAudience() ?: true + publish() + } + + private fun publish() { + _uiState.value = MdbListSettings( + enabled = enabled, + apiKey = apiKey, + useImdb = useImdb, + useTmdb = useTmdb, + useTomatoes = useTomatoes, + useMetacritic = useMetacritic, + useTrakt = useTrakt, + useLetterboxd = useLetterboxd, + useAudience = useAudience, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt new file mode 100644 index 000000000..082272f9f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.mdblist + +internal expect object MdbListSettingsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) + fun loadApiKey(): String? + fun saveApiKey(apiKey: String) + fun loadUseImdb(): Boolean? + fun saveUseImdb(enabled: Boolean) + fun loadUseTmdb(): Boolean? + fun saveUseTmdb(enabled: Boolean) + fun loadUseTomatoes(): Boolean? + fun saveUseTomatoes(enabled: Boolean) + fun loadUseMetacritic(): Boolean? + fun saveUseMetacritic(enabled: Boolean) + fun loadUseTrakt(): Boolean? + fun saveUseTrakt(enabled: Boolean) + fun loadUseLetterboxd(): Boolean? + fun saveUseLetterboxd(enabled: Boolean) + fun loadUseAudience(): Boolean? + fun saveUseAudience(enabled: Boolean) +} 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 f5f75726c..81799bbee 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 @@ -7,6 +7,7 @@ import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository @@ -125,6 +126,7 @@ object ProfileRepository { HomeCatalogSettingsRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged() TmdbSettingsRepository.onProfileChanged() + MdbListSettingsRepository.onProfileChanged() SearchHistoryRepository.onProfileChanged() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 71006fa80..f9182dc41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.MovieFilter +import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Tune internal fun LazyListScope.contentDiscoveryContent( @@ -11,6 +12,7 @@ internal fun LazyListScope.contentDiscoveryContent( onAddonsClick: () -> Unit, onHomescreenClick: () -> Unit, onTmdbClick: () -> Unit, + onMdbListClick: () -> Unit, ) { item { SettingsSection( @@ -41,6 +43,14 @@ internal fun LazyListScope.contentDiscoveryContent( isTablet = isTablet, onClick = onTmdbClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "MDBList Ratings", + description = "Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages.", + icon = Icons.Rounded.Star, + isTablet = isTablet, + onClick = onMdbListClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt new file mode 100644 index 000000000..6fa3237eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt @@ -0,0 +1,162 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.mdblist.MdbListMetadataService +import com.nuvio.app.features.mdblist.MdbListSettings +import com.nuvio.app.features.mdblist.MdbListSettingsRepository + +internal fun LazyListScope.mdbListSettingsContent( + isTablet: Boolean, + settings: MdbListSettings, +) { + item { + SettingsSection( + title = "MDBLIST", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = "Enable MDBList ratings", + description = "Show external ratings from MDBList on metadata pages when an IMDb ID is available.", + checked = settings.enabled, + isTablet = isTablet, + onCheckedChange = MdbListSettingsRepository::setEnabled, + ) + } + } + } + + item { + SettingsSection( + title = "API KEY", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + MdbListApiKeyRow( + isTablet = isTablet, + value = settings.apiKey, + enabled = settings.enabled, + onApiKeyCommitted = MdbListSettingsRepository::setApiKey, + ) + } + } + } + + item { + SettingsSection( + title = "RATING PROVIDERS", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + ProviderRows( + isTablet = isTablet, + settings = settings, + ) + } + } + } +} + +@Composable +private fun ProviderRows( + isTablet: Boolean, + settings: MdbListSettings, +) { + val providers = listOf( + MdbListMetadataService.PROVIDER_IMDB to "IMDb", + MdbListMetadataService.PROVIDER_TMDB to "TMDB", + MdbListMetadataService.PROVIDER_TOMATOES to "Rotten Tomatoes", + MdbListMetadataService.PROVIDER_METACRITIC to "Metacritic", + MdbListMetadataService.PROVIDER_TRAKT to "Trakt", + MdbListMetadataService.PROVIDER_LETTERBOXD to "Letterboxd", + MdbListMetadataService.PROVIDER_AUDIENCE to "Audience Score", + ) + + providers.forEachIndexed { index, (providerId, providerLabel) -> + SettingsSwitchRow( + title = providerLabel, + checked = settings.isProviderEnabled(providerId), + enabled = settings.enabled, + isTablet = isTablet, + onCheckedChange = { checked -> + MdbListSettingsRepository.setProviderEnabled(providerId, checked) + }, + ) + if (index < providers.lastIndex) { + SettingsGroupDivider(isTablet = isTablet) + } + } +} + +@Composable +private fun MdbListApiKeyRow( + isTablet: Boolean, + value: String, + enabled: Boolean, + onApiKeyCommitted: (String) -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 16.dp else 14.dp + var draft by rememberSaveable(value) { mutableStateOf(value) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "MDBList API key", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = "Get a key from https://mdblist.com/preferences and paste it here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = draft, + onValueChange = { + draft = it + if (enabled) { + onApiKeyCommitted(it) + } + }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("API key") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index c3d2be1c7..0a67ca22e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -19,6 +19,7 @@ internal enum class SettingsPage( Appearance("Appearance"), ContentDiscovery("Content & Discovery"), TmdbEnrichment("TMDB Enrichment"), + MdbListRatings("MDBList Ratings"), } internal fun SettingsPage.previousPage(): SettingsPage? = @@ -28,4 +29,5 @@ internal fun SettingsPage.previousPage(): SettingsPage? = SettingsPage.Appearance -> SettingsPage.Root SettingsPage.ContentDiscovery -> SettingsPage.Root SettingsPage.TmdbEnrichment -> SettingsPage.ContentDiscovery + SettingsPage.MdbListRatings -> SettingsPage.ContentDiscovery } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 0d188474b..6f9d05e79 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -36,6 +36,8 @@ import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader +import com.nuvio.app.features.mdblist.MdbListSettings +import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository @@ -68,6 +70,10 @@ fun SettingsScreen( TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState }.collectAsStateWithLifecycle() + val mdbListSettings by remember { + MdbListSettingsRepository.ensureLoaded() + MdbListSettingsRepository.uiState + }.collectAsStateWithLifecycle() var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } @@ -97,6 +103,7 @@ fun SettingsScreen( amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, tmdbSettings = tmdbSettings, + mdbListSettings = mdbListSettings, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenClick, onContinueWatchingClick = onContinueWatchingClick, @@ -122,6 +129,7 @@ fun SettingsScreen( amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, tmdbSettings = tmdbSettings, + mdbListSettings = mdbListSettings, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenClick, onContinueWatchingClick = onContinueWatchingClick, @@ -151,6 +159,7 @@ private fun MobileSettingsScreen( amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, tmdbSettings: TmdbSettings, + mdbListSettings: MdbListSettings, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, @@ -201,11 +210,16 @@ private fun MobileSettingsScreen( onAddonsClick = onAddonsClick, onHomescreenClick = onHomescreenClick, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, + onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = false, settings = tmdbSettings, ) + SettingsPage.MdbListRatings -> mdbListSettingsContent( + isTablet = false, + settings = mdbListSettings, + ) } } } @@ -229,6 +243,7 @@ private fun TabletSettingsScreen( amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, tmdbSettings: TmdbSettings, + mdbListSettings: MdbListSettings, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, @@ -328,11 +343,16 @@ private fun TabletSettingsScreen( onAddonsClick = onAddonsClick, onHomescreenClick = onHomescreenClick, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, + onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = true, settings = tmdbSettings, ) + SettingsPage.MdbListRatings -> mdbListSettingsContent( + isTablet = true, + settings = mdbListSettings, + ) } } } 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 1154a8b52..550ade6d5 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 @@ -26,6 +26,15 @@ internal actual object PlatformLocalAccountDataCleaner { "subtitle_bottom_offset", "stream_reuse_last_link_enabled", "stream_reuse_last_link_cache_hours", + "mdblist_enabled", + "mdblist_api_key", + "mdblist_use_imdb", + "mdblist_use_tmdb", + "mdblist_use_tomatoes", + "mdblist_use_metacritic", + "mdblist_use_trakt", + "mdblist_use_letterboxd", + "mdblist_use_audience", ) actual fun wipe() { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index b7b046a7b..efcc0e9fd 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -1,15 +1,18 @@ package com.nuvio.app.features.addons -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.usePinned -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import platform.Foundation.NSData -import platform.Foundation.NSURL +import io.ktor.client.HttpClient +import io.ktor.client.engine.darwin.Darwin +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess import platform.Foundation.NSUserDefaults -import platform.Foundation.dataWithContentsOfURL -import platform.posix.memcpy actual object AddonStorage { private const val addonUrlsKey = "installed_manifest_urls" @@ -31,26 +34,45 @@ actual object AddonStorage { } } +private val addonHttpClient = HttpClient(Darwin) { + install(HttpTimeout) { + requestTimeoutMillis = 10_000 + connectTimeoutMillis = 10_000 + socketTimeoutMillis = 10_000 + } + expectSuccess = false +} + actual suspend fun httpGetText(url: String): String = - withContext(Dispatchers.Default) { - val nsUrl = NSURL(string = url) - - val data = NSData.dataWithContentsOfURL(nsUrl) - ?: throw IllegalStateException("Request failed") - val payload = data.toByteArray().decodeToString() - - if (payload.isBlank()) { - throw IllegalStateException("Empty response body") + addonHttpClient + .get(url) { + accept(ContentType.Application.Json) + } + .let { response -> + val payload = response.bodyAsText() + if (!response.status.isSuccess()) { + error("Request failed with HTTP ${response.status.value}") + } + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") + } + payload } - payload - } - -@OptIn(ExperimentalForeignApi::class) -private fun NSData.toByteArray(): ByteArray = - ByteArray(length.toInt()).apply { - if (isEmpty()) return@apply - usePinned { pinned -> - memcpy(pinned.addressOf(0), bytes, length) +actual suspend fun httpPostJson(url: String, body: String): String = + addonHttpClient + .post(url) { + accept(ContentType.Application.Json) + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody(body) + } + .let { response -> + val payload = response.bodyAsText() + if (!response.status.isSuccess()) { + error("Request failed with HTTP ${response.status.value}") + } + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") + } + payload } - } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt new file mode 100644 index 000000000..8b06b9ff0 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt @@ -0,0 +1,85 @@ +package com.nuvio.app.features.mdblist + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object MdbListSettingsStorage { + private const val enabledKey = "mdblist_enabled" + private const val apiKey = "mdblist_api_key" + private const val useImdbKey = "mdblist_use_imdb" + private const val useTmdbKey = "mdblist_use_tmdb" + private const val useTomatoesKey = "mdblist_use_tomatoes" + private const val useMetacriticKey = "mdblist_use_metacritic" + private const val useTraktKey = "mdblist_use_trakt" + private const val useLetterboxdKey = "mdblist_use_letterboxd" + private const val useAudienceKey = "mdblist_use_audience" + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadApiKey(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(apiKey)) + + actual fun saveApiKey(apiKey: String) { + NSUserDefaults.standardUserDefaults.setObject(apiKey, forKey = ProfileScopedKey.of(this.apiKey)) + } + + actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey) + + actual fun saveUseImdb(enabled: Boolean) { + saveBoolean(useImdbKey, enabled) + } + + actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey) + + actual fun saveUseTmdb(enabled: Boolean) { + saveBoolean(useTmdbKey, enabled) + } + + actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey) + + actual fun saveUseTomatoes(enabled: Boolean) { + saveBoolean(useTomatoesKey, enabled) + } + + actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey) + + actual fun saveUseMetacritic(enabled: Boolean) { + saveBoolean(useMetacriticKey, enabled) + } + + actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey) + + actual fun saveUseTrakt(enabled: Boolean) { + saveBoolean(useTraktKey, enabled) + } + + actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey) + + actual fun saveUseLetterboxd(enabled: Boolean) { + saveBoolean(useLetterboxdKey, enabled) + } + + actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey) + + actual fun saveUseAudience(enabled: Boolean) { + saveBoolean(useAudienceKey, enabled) + } + + private fun loadBoolean(key: String): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.boolForKey(scopedKey) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) + } +}