From 5fcbd87522413f2a71c1fdbd71d3343f275895b0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:48:46 +0530 Subject: [PATCH] feat: Add MDBList ratings integration - Introduced MetaExternalRating data class to hold external ratings. - Updated MetaDetails to include externalRatings list. - Implemented MdbListMetadataService for fetching ratings from MDBList API. - Created MdbListSettings and MdbListSettingsRepository for managing settings. - Added UI components for displaying external ratings in DetailMetaInfo. - Integrated MDBList settings into the settings screen with options for enabling/disabling and configuring providers. - Updated ProfileRepository to handle MDBList settings changes. - Enhanced error handling and caching for MDBList API requests. --- composeApp/build.gradle.kts | 4 +- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../features/addons/AddonPlatform.android.kt | 73 +++++--- .../mdblist/MdbListSettingsStorage.android.kt | 99 +++++++++++ .../drawable/rating_audience_score.png | Bin 0 -> 5239 bytes .../composeResources/drawable/rating_imdb.png | Bin 0 -> 7474 bytes .../drawable/rating_letterboxd.png | Bin 0 -> 24174 bytes .../drawable/rating_metacritic.png | Bin 0 -> 5468 bytes .../drawable/rating_rotten_tomatoes.png | Bin 0 -> 5263 bytes .../composeResources/drawable/rating_tmdb.png | Bin 0 -> 20630 bytes .../drawable/rating_trakt.png | Bin 0 -> 11576 bytes .../app/features/addons/AddonPlatform.kt | 2 + .../app/features/details/MetaDetailsModels.kt | 6 + .../features/details/MetaDetailsRepository.kt | 15 +- .../details/components/DetailMetaInfo.kt | 155 ++++++++++++++++- .../mdblist/MdbListMetadataService.kt | 148 ++++++++++++++++ .../app/features/mdblist/MdbListSettings.kt | 28 +++ .../mdblist/MdbListSettingsRepository.kt | 119 +++++++++++++ .../mdblist/MdbListSettingsStorage.kt | 22 +++ .../features/profiles/ProfileRepository.kt | 2 + .../settings/ContentDiscoverySettingsPage.kt | 10 ++ .../features/settings/MdbListSettingsPage.kt | 162 ++++++++++++++++++ .../app/features/settings/SettingsModels.kt | 2 + .../app/features/settings/SettingsScreen.kt | 20 +++ .../PlatformLocalAccountDataCleaner.ios.kt | 9 + .../app/features/addons/AddonPlatform.ios.kt | 78 ++++++--- .../mdblist/MdbListSettingsStorage.ios.kt | 85 +++++++++ 28 files changed, 985 insertions(+), 57 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_audience_score.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_imdb.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_letterboxd.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_metacritic.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_rotten_tomatoes.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_tmdb.png create mode 100644 composeApp/src/commonMain/composeResources/drawable/rating_trakt.png create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt 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 0000000000000000000000000000000000000000..7f68f8d573d2bae53fe40657f3e8d4e0aa08169a GIT binary patch literal 5239 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fa52261413126eada69fd68cd2e9041dea7d61cd GIT binary patch literal 7474 zcmaiZc{mi_*Z5HOtwPqClt}iSVN6nK2oWKYU3QY4v1JrW*|*3?5@p}h44IGYrpUfd zlAXyiW*GeL^#1;N|MI=TKuLf z7D|d!*2_!%ur2G$MhErW#@Z7Za?aOYAkIb(`pJub^*R16QWR<8J50xQZ1lTa)n%M3Td4y65#7qR7%p-MOdUJ4%}e@*Oo8F@iWY=WF?Cndh@L ztx6Nul0`Wh_yZsh))FY)ohf7Y>WG7wC)a&n2t5r>=sSl@cAHI8``JBA2~&*c=dUMn z&lu^NUl`Gq&oGG+QA8%ct^RkCkHZIfXgX}JN!^wr5V21u3LQ_?0P!$E$}bw#g>E$xNKk7d`NsHiu8Z@m}hIQyeRDRd4kKX$@H0y$&=c}b!lLwl#cASYf`TH zL-p+A(GP&;U->}#zG2g3Q3jFUEe(0SdfN&81T`nK{4hc=lS6O!0LUD7HH-8wAtabDGNyO8Pmk3Al7$(s# zlC3OD6v=8S>}#qTcKAFWVLK{`%^|Iy&ZB>KPXU>nN{>0<5FYeB-!ifL>{tkjR7+%u zNfJfsy?Td69oiikQC;x(4}prM>Ucr>8m z7vkCJeoStx8gMa9$jn{|e%izc1)DruRcvju7FL`w0V}sUtK|5&d~(xXiXtsfboBO| zl<%Rz6V(*>+R-$0fkHpuf7MoiQ>)K2)mkzFP!TbufjwDNLZ2Y+A^^5eZv$#26LgbB zyNDN%$&O^;D+TJ!WKjVWfU_f#MUgaCqPYKn_MhPYojmKuLl#kFTJ0k^m7M4EVz<+h zs9QYhMcYkp`-dBO@;O0xT1Qx^x zmDc*#V-)z(nAP;b?)DEOOli2VXRw+Fd!i5rcgL6QXL6obH+_eBGT{u_VgBjth@tJ( zdls8PK?Gff`i(t8@T7lESs80hv(Q~{J+By7w|yR8#ojO4qkh zhK(m|{n1T>ACpYkcPYldJth@2w{qAKrSHsyQYg=KA24t1NjA=*`1vbkfVTzzZM~wP z-jp1$iZ&0lurhlfjJw|wwWY-&ZcRVaGI#=b|E;#>9YR5cKWXnh=SPcU&?Z5{BW9_H z@sOKns4F7P=FhyN%TP*OespXGuDBu&9$FR$f+sSKcb(#kOG1?r{2yX8h=@q(yShUQNMD!#Q0(Qq2bmQ~j*lowGFP3TZZ zl=@-9iG&ZcaV@ofm(045cp-KK>y;x&7}+go5$P_1OG@n{*3>H`Z4G?rs*L7|#$bH@ z%(V4Pe2fLABDERChM;mc3cq=b(?hA&1Kc##*ezYj#^{4RyGD}p$~nM6BJERaNy=;9 zQrR#jRPz9=lX$`D>sRTtv4i0}Xl!W(REtv7?X`an(85&YAS;)@xzG7yc&dw9Vxcn* zXMo}g4m)2rp*VoOzU&=zF$iGxT9`SLm!LP%__r_1ERG?p-2NgKOy=b;JkTQT^GUPei{PaLLFuUK@*j2;J2_LlstM}{r)%r4 z;HO0)-YAK&4c9g2pkF19qwi0RBz{P%%kgjkNshyx7*-!sPkCn$`Nsi$7&3C!g33nj z-nE_IgW!@7Bi)cVOlq*ZYT9tjTo{z&T^)ngwwJ}`nV=&mI*=;#K+1cc3CPRvrkP2l z#e;0@R5wqsIyyEjkN7yZz7}I(Q(=gPN*w5AZF`Wv7bvN;BElK7bJ}3*(pH`tf0b~W zPV&28`gDn#056X7@2yy`oce1j0|`P$9+Y?SJ{t};U517NfZZPto0>+*H zTC%yhQQu5$%(7i@P@umGwt5jb@nrkzS9g3;yfW+!u_ioYjRs@+5TCj0KsK&|jkl=} z!{PNA1AXqH$co^8Vj&@{%9HsLb+r!DUDcpErslu)?$_M10kN&8>+<(?cx9+jbxOdU z3yE%VUvKb_ai)Czl8BUZINzH~@AnR^Y&;E&_R#4h!3D=Qtl#d+x;Rwtp0s9`STBfwWrTdO|>P@O5*4*hkiL=oy#TP>?+LbFK*1ftwlrQFr1zp zUKUh8GC`IQ)$u06HYjf{gG)^HW(a!vD9-2m7_q*oK8m1Q8aX7C!dI!|uh+suCRFW;cuh*-rV_OoFFBzfvhr%R3lH3Z$FhmS`kEdmkj z9X7{DsD27OW=837x`abvEfT|m8}QMoG6m|xOeKce@t(Z=kriS|MQigRs|wblr9)i~ zqI$$^h^Zy7@Cc$r|5($>3a{jD6`HgeU4bLQ54xm#hCR`PGBHAYe1S_j_d-HE2iR(< zt`RdJsZO$v&5z|l)6TzDN9@zzz6+=FXWJx~L*MrkFATk^!;3f9a5`=Eq>X*PRk}~) zcG<|?2!qHRPCULPJYCHz)7VC_LPJX+2gjIZIyyvToy4e)9-}LHy0bqeQ+f>Fwr>^_ z#&ZrVRj4-^)JDH$2nk=-_dZ_zv&)QlXjHtF?L0C=FL`%=%Y*!ilVF>Yw?;9(>*(5# z=ax({U>|(nz@-)Z8UgZu=>%u}cfzO3=zP*=&w}wKXeFbo??$1|%%c@sK>G6y&c=)# zp(r+ST7mjrA$K2yZ{$DkL-2K*ws3@5?v`Q?vG5)`ALag1ED&Kms^tuXesN3K3bQa? z>qML{z=U_1rF|%<;rN*EM17MRNLz9LJey+^ftF0Tq-go#Zadq` zsy}Y!+u7<}DjBAfEBl?SKzd`jQuS4am~Z^k&zP^2KIyNcy88il)plIfq~ohI$y zR8#+~W;h}l{e?}|injKBl0Buve1pO$L@Hg*g=RGA3o_ReDoAt|@8$VdS{qlZi z+H4ag{Q00y&Q&<^q24rm*_bIN9E%?8pQ^7Q=ze+n-V4tqssZ0%L#(e#IpK;nq9kR7 z!ZHs6Kg^2nelci>)d+MBZ17~~43#)HWV4PvvO&~kuCBVtmV7$gJLZGq)xpbZx~YWy z)*+J1dRP5I5TBdgyI_IRjF5-MdRjX8@8d47fT5a>Yx()YkKma zxTH1(2NVB=wjZSn9UPoR%Np)$zhpk_GVqT9qjhdXjtygCf@XFgOn)`(U zl^^xi?(Q1zgZ+RIn+HXC`SKU+9Glmq%qD7Cil$_16He@MjSd5AJItK8J(SU05eJSi z-89nI(TJ>OoQ;XUlIj<-{6(w?n-q0>XKWf^7N6!*^DoLs^(xd&KXi83`?s>cZpKa) zXr$Q87<%y0KSoDl2xis8Z1_4V794LN^g6JCPJ?1%6Mg1A-8(~Tl&p0}my+f233dc6 ze3gIM-r#M{GWWbyzH~>Lsh3jKR8+bsb%C0=%)DZKf6a+({HC;NGCWXKD)gTa{6a3Y zWba1cJ z{cz9vx6IHDb(~F;^&>W9T}TZF!X%57f2a^_?Q8ykvt6iSX&y+V7s1>^)#tb0NCM&z zV=q6~xkjSiR1Wdu9U>iqYm0$pEd&iVGi?5=#~FsOXX5uZe36BBOi(l{GMF|O&F zc{f?ma$Lgw!xIOM`%LRYcpa(yiymA@nm}>Dw#2sPj@(l4d$w$F(}Mp_qqnusap*h% z+5zz{TU>6#^Gq1iD&wfDP!f>7)e=5KU#&y>jpqO*qt^(v1}M!wqOri7^{9ni}Fjb9|I_#Dph0sKHChR4-MJ>7)6u)_D$KF zH#Ok+$5;>eVoMw0d<-Y#?)S3F+w8`TH9@p2*P3_=2u@>f~P7K*FBsH6~d6{w~! zn>AdF9>PJA00#<6KI}K@4nbM3rO5!>gD|n6zb+G*zxe&NT=f*GJNsJ*N{}4^xb4W! zu%~>DJvopH*9eUB|4gESih9VRKlgM#NCx|Y)op(cbNNL!U^S?9NU?bmOAEM~hc911XE|;kvr`wWx zChvTRfaKk0+tp-6d=gr>I!kJo5qI+8+Z@W#n+QLkv<7Uf*V5V^A07>QrDD$Ay(}j( zGFGyMF+9AoP_n7^%LfhHA@t+>laDqVSL;?vIefP=+Gf)=7QwuHMnbX_9=*OVWrkB?2jM_q8fYIXJ#sTIvfIGp!c zI7Zwmn!nFJL{g^S#J$V=ebCr@zaUdpd}5)cfzRucbd&agd$Wr-tY11lS%U7-s6O4n zfFSBEA@G-!du2=QacE>!VRD|704mkKX60eDuSBzk#&Xd;wRL9qmv?o0YeCIZlm|zc zfH6s{)jO-WS{UJyJ1DDe>j{vuHP7C&0=ZhyuPI_FQS!Lj98WAvJpL|(`Hb@yK>mF1|HxR>n~-G5qis%D(de(NSM#<`){1$#S`ic8L+oe!q5d`f1_(}7}2 zs+MuJAsb7>t;E9F)NwWwE^x7f>A@kFHs|3_&6StV3oV0!{HRocoo`!rmYq~UOIw%Q zz&s5b0qmk1z)3R<>OzoQ=7U|R4(Wc4$Qg7{emfUIBJ@?R6U~%|< z!ftbeQTD+XU!VSx;V;C(-Ym1Z>3ow=lrU+)Cr6d3{P4X$d6Z$NPQtCIHZfB6sBX=j zY%DgkdA$tIHFslzu@tcuOrD;(7;&y<-RSg}wULV>SiKZ?t>uf-pvbtVjyYEcYUkq7 zfKr6PbE51>YfiCc0x7I|>rZY8u~1h0$S5YEAKmh~6}a$HjT-fLo(ulp^B2J zJ)x@Q=qP`Rd9LG0u$Fj#%tX1Qr+F|g$&p<0RKR9X-~VUg9HC%C47&3Pp|-w@*d?jr zD!f>|bK(Pg89mc%4dD`SKQQhxVdyRYP8>``4%^gy|B*@64zu^IPMuJ9 zY}rUHEU2hx=|NJS9>#at>adt2km-0_uz+v zNpVcr`!mX3eHqj4$=-(aopCcvTv{wghir}es+INz^GP*M$8*E=s$B`{#bUT6(0W}x zeU>S7cX;DZ8aN08?`^I(W_Q1a$~l}}~%)$#MIu6|!a z2b(AeK3=PWd*AUfs5QOJ=WoXsWPee!fzD?7 z?&KUO96Bw#9P&__xd9*bN8;wa^+rtWdwkv;1)Ko`+R7JAB`5DrdQBir;A2=x7HuJ$ z$oo^3&61SHVy1i9DL$H?6tga<1&39uW^M)jog4EyVN?Ih^!Wdr694z%^M4}$3$H$f z3XzHtMRuEz$DICW)Qvanq0Pd%QT8epv|;$k@FukmJ=%O+;b)UI&sL&87^MsRX|`5s zRrotKKNlY?I#T_bDGHvh1E%-)g?-IsU~y;d(=9jwx${X6{08Bb@D=t1>1W_^poXpy z4vWDGU-3pHL}Aje0}3;l-}}VzI?RpNUq7@aNhJWRqRb&_A<9k!8XBqA!B>_DKrSRg z&v(i;^aN`B_m-S|pygS>UBT*}E^#gVs0$;)8_t?+3dZrNjPhYBu)K0@oY6#=RMM<- z4*;5o{Hz-0U~V2=<5}lcRaIpweuO0hO}z=ubu~^yTzemmTmxV0#w7>x1Os z(27@fBtJXC4jPGdgjZG78>BYKC>?5e#0g>d*Gk$`^mcT04*t&s3ggco Q0|E&0y5Y6*tG3bq57|k{3;+NC literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6e6c29ce3b7ea90753068859963d9b336a6f5b39 GIT binary patch literal 24174 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 YgiJm+LprC!PY3)MpEfff>N$r0ABjYLSpWb4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4a39e08581ac9ad18f746cc604533ff3d531f74d GIT binary patch literal 5468 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 DeF}$z literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f9b0319e5384736fa330047aa83c46fbefc6bdc5 GIT binary patch literal 5263 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 V<|XHKW`+O&002ovPDHLkV1fuY`)B|F literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fe4ca929e981da054e4e4f1fb51a815934da74ee GIT binary patch literal 20630 zcmb@uT_Z(-oy+PwJRU;j7Y0n0 z^{bhgv!4?!=Sx+ux7{sOif10F1LqCEX1L})ERHYOqi|T?D4@fcW$M1ku&|_fk5caC zK4I8jx;aW)4t5hsfNDY4R}r1z&C9RLpnqG(gWdGg-K~&b6~H#PwsuA3>OZ^@?L*!- ze~z=FgLYdA0%oL2r^Ve|<(tz8_7p$XP z|2J&{F>m|YC#7lp>4UKh<@>_BIKiWX_G*NZ+Rn3aGWUE+x5;x8y4g&x}gZ%~bDNS;OCoqqcfZ1pog`nY})(;F=>ZQMDT ziyNo8m0JA|X8#k;<?F?oZyAR2X~j(UT8?i8uWZ-`>*C7#6iGls0E+u5|m$;lj@ z&qqV&yjM^EEpK;6P4->g+S_p!0V~D0%4qfYyQR`h$eScq1g4Z@2pBTUdf%Kn3Zd$N}`UXnByGn#Ns zIgUFuCE>8L@*$8{QN9;_lO3Vg@zP(Ga4kS6O0gr!HY_PTY4MWKQOyXd5J%u_(u--CpIo@FJ*&fGBQxAg9*QC=>@TcvU($(Y}?SU66ZTJju`~wyhJZ=zLQyA8wq(xByzt+j^W<=7K|SK`^5^+(S-y)*`%ILB+w9$zm^L zy~=sFkfTEk6%&K%ZilD1iTQ^GJb{uJImLt!So-bhjaJjwG&1cm28Zu85enHbIzhBS zBLj=~3~nnWfc3<(_!%?jqj}2Xd~3)(w9L3%K?M&3?*YmpOv1}SFmAy~F@S~j(&DRe zw#BcxJ ztH1iesQ8TpZj|To1HLZpAd$Lm<*SjdqS9aB-R*wSv_=jKNNI#;OC zknmR|J10^1A51|W&LX9Ni{?}zZ6=7Pp?3Gu_vK`O*G;BsuZuAuV}E#u@9+ zsgAua*+bF`7Fo{f0NYm6a?8*2c0`m&n4%Tt9^7?ohr3lPv5@3R(u9B6LQy57NO|B9 zqd_MT21yatFBXIbIZ6ZU-*|zs^-%n`)-y_D#rZ4+FXo6luNRR{koTlhoIKBf3{L0) zyb&vQ9LsH5i)nCDON2BuKD{Utui)op8$FvN-Fk+gA#?*cuc@HVZbX|aDw%C{7E!6T z&B1jd5B4E)3rUa3d9pe|c$Kl*NZtMW*3mGN$^dr!eSU4oAC5-C5&yqB{NwfR3!qS8 z{W)e4PXBbqavfE%%O?^K&bjtoSTP@x6vGL6RZNPGs%&HaOnE1?rptm$fXjVW)})qzk^$d2lZd zr&gmycU!0KT&=bYl~Ji2kUM-o7@MSxsAu3jJM>$F<1n9Pm#|x|`pg#uEO17JZ!;kE zekrC!5j0Wmarfa?3?<}vQ=jU^Kjy;uOW}?T1!ywJ#*zdZB>fmc9C(~uO8jIyA=?S_ z*R-x+CXTSq2>Z()4!mp6Ze-yY%EAd0_)Zc-B(m#yhwtU8!)+$v=X7zgu<@8OJNj;b^bR9r7RAiioEjXoqFgf_JHat&T%cTR&MR$ac zmdktnT64}SnBm%$m0NT{v)Up+*Q#>=_-I9LH4*6&U?ugg5k)YHUl>v83;*?teSJsg zRkH`}RkrtEe(1lOs94qqYHvZrOe8HnA#()YHU6qN2p_`iZK?u8ssgx@s$yBoB#{~k zA-%h85u0ofGZmz%hEd=fCQQ!lY+{_V*X+GXbVV&XXe`XJ@(d;-h+&i>eLB5TJFxd( z@EAd2JG*d}FT(zpz1RcJAp4`EJ(@PL>d@zxX0`>5n!!B-oKG0=Ly1*zD)|^YNeiy8 zIRf8?f2e7$N9EZd+BU>CZ~6X|N&G`hd3X+=^*cF0FZ&juQdi}nO3lb^l9I2<0OiTK z+1BItK8MLMTgkMk>l84sCaP)WIID1~?3)ovRj=sLs-i6U8S(mEZ=iAytWw84%$H-( zP%%GW)x`jD-mg&-$#J*MhLW7_w!@|L`;aY0#qjL|LFp4|5<7dHNZYZbf17X%_4tyI zPRon=0*$zEz>5DOcDnAzVo=5bEzY!Zhw~;I>`J(ts+n%j!iP9-6ysF zDv_%=STDfxhb=GxLk|ws=Kx0K}?-~>eoxSEU1dYbO#SH}Qd&Q||{!*E}2 zp=xOg1NH-fMrN}&LUYK3!U1l&!OC|U<%yUMXK|XYiT2Zp@CLN!bE*-CG zYcNC#bc0I*y`wdFLPmj|I8dZb$VGCh-Dpz4Ptn$c!8X}*sk=bRicDrHor;&dP*pml z_QNRRFo7)FJLzZEH&KRo9gYGRbNaVw41y@lg8bNgT3Be7m?M&qCdKctN_PV~54HI( zF@YO13QE(Wf->{%k_TZ&+&o0l>+A@;%cJ!T1Nu5fHP}&qBUFhn^XWeq zi~JADcS1$|T~yg(3iKXfO73ZG`V4Q}8TsZ^KYyO6IEo9p?;c@ubF816aCC;o@(b}N zsX@Gk{NKXk`Mt}Z%%&~(pS*NH2P!hmYqp8^*vraAmoe?%s?oqe0=7T0%b~9N%ksym@Xo>KgG)Rj-JLdo9cs0Go-UEdJ(L(8Y{g4Z)X8 zT0c?~o_UY1A*e87#rvOmRkuZUmTV>@xJ8i^=Lfy6O>XGc;eE!5#eFdqU!b=G%YEHD z%qZCAUavIH^ZZXnjT9(by&||tqZV#vd~7*?70f8dY4(TKsrT^b4buJ|*7ReKPe@nN zx#a;I@+c6OWpT11&VicG4nG|OLQ8TS=zNxWJ|INyw7`*y4p2tHZzj<3Yzqs>NAvyoXJVfktymSed?oQT!n}-5!%~|2^ye#ecgu-}9&5B26?BcEBRC-?Myk zx^f4Qt)C%z&j_vi=c(G5UhwK-E1*b z8uUGboy!DO5kl0zzY{Gm;FR^#S+y>DYXFa(7MF$Q14JpD*2@T{w*Q?`Wl%dbmewWp3AMMdRzNOlK7TjZPF>}ABLK=HarcLALAKXIT6`jqcY~Kb7rcT}yl+P@{?L!!d0VKMdQqy7ov;+gFHDLc5nn?%eeSv(Q>Sm_xMf=+J<3u*YK z+5;YwjqzL|U5>a%iK5s?UFbn3Mh6Azd50aN8a$0h{QGReJ8sq5%*jawixX7z#vB;& zvu}p#an-S!Dp-L&Q<}>fgPiwEsr|^Z?#x~kjoW}lR!YG^qnMz^Afj&(q!pZff_%;B zO<1z4`;(oll*5G)(YKSROG}a4kjFl6wwoPekPFid$pOMco%{oJtD|1G&rP1N+lq(G zGWka#3VP4QQEl)RLlnY0VQF-W^eR!3{T8sfvo{CCI*EOWm*HA06~qw^LG93th$-ps@-kot9z?>_V?+ z8<7^$0&DdW_OeOXEDc`sdh!!Yc_NY+6v09A5-->lNn8z$;rYB8$6xow3HrCeCuwW& zpSk^q907C@pL5WMqx4o?(~~P;Llu>3y;y5lGV0XR$g_*ciX(BTYo!5f*4IHiC)NCC zILYZF7BgMe{b_C}W-9DUp|rrAmW%OA4D6}7zz-5Vd-#d+ zQ`S+;S+g3kS0(Sgn`>eeDZSiHz6@am>n58@)gX27JD2^_*0&^<{#bN%IBZG|iQA$K z+1}LdWmuh~VI!A0KX_zFzvWRrD)n1BK5cu?G=oR#e~z4%=vsTk5p(O`fVpm*kI4Pq z^K`gJSzf+rdby#v`hgRgFUE)ZDO1{>$kfYuf4t>|0^T^{*xQLUY)cEDIq1rC7(W+- zR#F9f(*(GqbG+>g0&iz9^u0#XlJk0W3RQXD>G)+610d|lQRl60;m4){C$`hLMry;xo|`OQ87q`{z^_VWhPjA^15&8HPUkWDxc5~ zP*eEDOGayXML3I;lLlfQ%IWw*l|R3QNc6iYi+QvOKag{Xm8Qn_va#VtW9~DsJ;jc_ zN_lsC*v3&5e43ZR1KT&7Exa7*(B{}UA8W?SCd638^(s}jcG5d1YuQ)AC*6yyC%DQY;wr6gKo}>Nxq-7I> z(lETw$pcl~UlZ4T-FJ4a%@b5GpRB-yGDA<%>Zabchlh{{{F2mzBLDml*K^jw~hz>CqFXsyc{|$g?WT9{q0y%RWas z)mEM$68oz~yQXuq)MJtD4Oo88a0sWXH%b%=d2!ra$1?hGT}kN5VK}xMe?1q5TN6;q z!fb488?d1+V1w`1nIuw1Oex)~uei#lF7tGa??q+A8C#X^!Ba#Nm-H8%Uq0e*!2Quq z0{MIz-k3cycoq%wHh9bRtaVcviQr8vBfhF%yMGQt^3Vm zi9{ZRYhEO8%{Df@Fr-^0d{Ay1Adqmf74_Xus7MaS5h6mHAHX0N_~HLY1OzY{c!l?N z9-Alf5Cl3n*gn|!-fH7_oJ4#!c+e=TP0 zd{*IL;5qwJRnzek(B*PBY8SvjJd4XVkUfn-E%Un&Frdv8-{y^AjN2&1+WDgFH^U&> z?_JoKYxVW%^`W-sC4=D8f+-W!`dN~_6(htz&gG^yTWPwCu^q5+H=RRncC}g-a856` zFU0?7XG0GpK;+!@KM1ECx%mAQb<42I?QRYVop!GUczg771r!=OxY!?%@6(N>-}We8 z`D|UCu!RiC`;)+QSS9gE{accH+Wt+j*b_B^o(hq^*1Q2TcYzGOIy$l22CefT#$M;N z#hTA&SCs}GDsgXo+qahEqM3TmOR)zloT&mr5pRoZ8wqLax%XHw{1Y7QwTpjNU?-2*SVwE!^#(fsug4qXzT~Oe6Mb&cRs!C|bRU_wt1nZ-3ANg!-W^-uYJm!R*ET}`by8Y| zOf20>(Z0xDt@&|4;1@dYw+4#?VU#W0xGaRgWsLCW#X&oM{(!xKP(f~UNh%!N3e34= zMO-Vr^abu#*rhxpKp&R)AUr`OW(p%Go{;8oSJeCQk3<&E`+rqzqL&x;%O6k(AMnq< zLK~tJ3?*;;!Y8VwCkd+m`d;E68u3XZeDMFugTRA&KnfL9M z+SbYX3XR|@`ob;F-|=gjuz>lbNGOBdaAfx{*EQ$DFGt=fQ3SQ%S0ZmS#uTEF(cRj5k< z+OqxjJ~Xd^DXeNds%9qIc;n#=FxLm;>JJj*sHb7_;TfRo(w~`}l31&yu(M4v(DHJ@ zUG2XjKwb{;L-I&j+1-dGbZuH}V|yRh^0q!DgN2MBmmMJjCK zf&^Fbhvy=d`6TCgeP*+7H$G-6)pa{v5+@+Hg~vh`=`AL^aOEH)EojC7xU(|$WJCi0 zy@*kiCg3K9vgoC%I==nQ%l1b|G5Os5k>apu#^hG^kATTA>ut}e)~%51MPUM)YhzA^ zCUD`pfhdvF=wS{XYKGBTV)OxTqDaz{wcqAYNo~hfM**bL*g0^k<$*}@f1z`Hw)TRz zztnt2t(+S%i#sn*tAjk6ixd}OZ10~n1|eejLh4YL3wQT(|F z+ckmr{X)wT(G~_*ZYgX!6Q;!2I)$+G$$6RMfel|EOw;XLp>jsVtPC{Fk0iCP@Rm0PN4;oA@ zj_o(v_RD)ehcQbk#8@?EBMEVgwb!(&KAo$@ z!mVjct5|(gV}}G4d)|ki?qQ_G;T{WPsG9HX9x#4Bj&-oyZlbHe`nh&zY;^rgQqxVj zX&MXUhWC86RHM)VuKYXybX;Rzgv|Z^3?Rq=85~r^Jcn+WO#mx?RIZPta~w_1US6B} zf;o0xCB8<0m<=KBP68r$PDyCYMrR5o7UaE?2xLs@*9{ZuGae5xO_eq3&$p9Oz>-5K zY^UD4b7NYvhOEl0#=xBi%OCnwGds_;T{|&liUIIF$`PWAseQ&$-#35^W3aO z2muNbaI;$P+|VZa>k%)p{8YS>rWTyk0^_*x!ty! zxPeV@R6dP>-)=?*+Kbd&{}CmB=y6Yw;J$pv{o+Shj7Ce>CO zdH?4LRQPmnjg-E96j_bjj3Ri@LySwaa<&n{Wye8wWnPM1d!>ztDd#p?FzbENnrI%F zE%`ZQl;YENkq;w*4YykPMWXHme(l#c6DKm6{?%y7b5*9^PfU4ZN-5EdVHOJL{u~tT zyu2x_T7iV-g4Qcl=r7T+#~2<{cnABH$jL*rELLCLwzYmlrA3n>WN4KOtY!Ln^L!jT zg9q;ufL!nhKj7WR`USwj84xbTX}dvhbjAAaKfs%J1QY*=^Ro;6#NZH=26uy181ZOo8bgdc(o5$_`?QHSZ znj0CIpa((2XImgdWcYCby2Cy>xZA5=jp2GH!gp%986;4V?cU5m*LOi5T;@JyCd9ld z;S5tWqJBMgbZt;g$87!$SdQRph9$~mGTe{?W=pxZ-m$LoFm)6)Um=QXF|Yknl?h8> zwWR?R^^WiG{mJ~Ag#;IBM%anOWdB?!;sNU>2*gQVYw%g6ZyQAT;(&#{CtUH=5yA+v&R%KWtB=u!jx} z95iZ}#LiEkYYV3PyWXY;oe4?Adaz7&JZjiA~|6{#7jY?IsEB=e&BIv<|9K+k%jEo<ij=;qI>8a9qBw~NY(`XW|%oi{%8e#Lp>mv`EoH!!Tug!L$ zLTJP77UJF*HIo-C$m}gH0AH|H-A7r5Izup?0Y3kcj7DcZjX+G5ZzStr(l2-m_vQ6( z)e=Kqt^b9S=jWPJp7S=S{~2l8ZW+V^_Bmy~XU#@Ao}J+BfhtD-2ZWMW&BP}cCVhTH z8v)%QxUI&~L>u7|x0w~I{z$x1mpj#xQ65xN-!00jnO?QOB*mB@nC)(NMm9s>YX`&g zdA`g}IqLazkuCU%XaMZaqybI|dGyJgYP8Z)18L};pT+^#iBA4IY`g&w>c}*T63E z>7Wo(w@?wOc_MoAeObCZ`3maszU_%ReXNOT;eY?%2P%*G8^>w9a76Ct#4vL=ar8}v(oJ|rG4769h`Rf-#4q^ zi9g0Ry_PYP;a`u2U?6B$Kd;2|^tdLubsnszUA|;y8zD5WoX*cOU${schWm{7#Zwx~ zSzh?#3+-77($o#7$VfcFRD2V^e^#P>-|IB67O~QI+fO%*vAigQg%M<&J7SfM4My3t`Y*zR9*%Ag8k#+WyQ3|@zh z=Y@1%EzVV%V|uL~G%E@LQ2rh!g+ZGY3Aby&=Q#4PTmREtXy;vJ!r7dElbO1Vee}KH z?Og841d`gT*YJnv#=r%@?y2ZmchgBDbYz9u_6I%e*KFDY6ND7B927}%P^1#4GMW<> z%!u2>!7#og_4}>`5=MB-5Un)n5~OYdf@gad#_|9G4E9o>y0V13#x?Qs#*h?0MGbLV zVaTsekz<}lp)7U8ztM`q-KqtQ#VC5s^7zC|C1t4Ls1Bq08v8miM^JGzT|d0-G&rvY z8afx4LSTPp-K*f@az9f);bW}%JdKFlevKic@1A6Mx8GN&exU)K)ivu$$CC)wx1-Dy z5(@u@%Pre#pG1n0+tUm?AJJnVzG0*Nq`mt(0^|9N3LIsh=2$cwf z=|^7}3=cb4G;X?20~2T(jEXt%{3sz`{bpJbQn919RGCS5WrBaIL#|0J{2Z(XsT&1= z1m3N5H*SoO8Y;6J+fUF@I*KzMU+p(vbdH+zrG?tq)=;M&aP4vVqWTJ`LRSD^-C{Ei zGX{;;k4X;eSu(2|7C_nT;SzlipHn+T>quf4??~8^sr%3|^qVM$&##~Qc&vXBj35-PAtJ^M1 z20J+lKhladK4LHKQtwZviB7<;LGwMQ_{;uf+3T`-hifGG=bm>imv{N~BiWz2ss<~T?_s~@p-kKdHnLoco*7DIZ)(p?^<8$e z*u-6`uNM}TRk=R)1ty5Rr~C@Vyz#jwAZImUAACx8!CV#8G){8I;@(hm{X7q{4k%D= zMA?~~{3kW!W~cfMdqodU_L%}fuqdf~wqP$@FW-NA26pFnNk(Pw>FGe9ZPShC7Y}DI zDylyhsQ=l!2K3 ztss@OAKNYN>vL4bwZD_Xhxf^Mc+n+XE-`nH8b>5=6+=Op*RJ&WH z%4FPl$%e-yuBQU+gQIN`rtIfu!)R4HYEv_w&S`AorU66Ex`C&28=?nxx>u0^;~bBJ zKg4|dQ&2N49wb_ec)Aeni%(K`Zi9JqzgH=>G;1|Y@?7?T)_uIdNos7ee{Xw zI1mbX{?n*F4L!=+M`*L=@uHM|T3iKi@F>W0Q&rXfqTY| zN04K*X8dn{&^}Eh@H2}~bpe-Jpm;u={S;cr76odkJ=ABm@55r*y!F^xM6xPgZ4!d} zD1n=pKw(^F{MU_SpVk6zUB+yA9 zaNy@O2$CTM5Gq^`TS^SnGo+Oyir^VmRm2-$56Ha?mRKL~PyG`8hI&si@kh7 z0FJ9w`n2*<04G2*;uO|36cPcz?>UxpoUI0<GQlF9LJ=LDjo0S6e6OxWWoFq<{k&pW$MTfjHfk*fSdZw5xt z%|U+>S3Rt@4f3NveGZJjsFin#`^N{GewgH0+8W_}e6^G)@*;b)R! zLOoe{33_pp+HukUc|OWt*lV~L&`i#|mQQO#kv+|pXbjAj$wZ5mt3XN|TkKO3$KAip zi=IMx>^Uh@%RIk+R@N7C%v)1@yg8_L@%YlI{9N8tejiJIi!pL5TC~>UuxFnvqyoSd zOh*2CCmOZ%eO@ld7 zA4~3iH|bnIGRQ4M$i@0v;wS8n22mwi?$VZCqk~e$bYF1d6)K&nCw2erpke3*O=!v~ zkmh<|iTxay42;+u|GqY9T(EE8s}8}^`QUuWfh&-OWokwx;%Sl{ zD7g&E4g{UskWhLaF@IAo(>fo6M|kGxN=d7ql@PwwkJUfnP#9!l3X{tFdul(untok` zh9d1(&4#7(TKW{*b?KD9G>)K=B`;(mv4{_#qaUY77lFiCSN^7TIhIpgHp9C)zWG{OOn;0O&aC}3oC?A`Z2?j$LnwsixK@GCQC56DP z0!J)|iXkp31_=REq>c8&eN?Ta+~;QFPbf2rk-A=xUhA7ySfG-J*bB2Orl0vC__y+7 z89KDw2As@yqOQ2#wi(TPM@RJj(1(5mJnq$g^j@{^;~G_>%S(EdqNaf`=TXqYH|J!| z<}3GUkD{s2;f)BBff(Fw;L^-$z8JWARGN0wHY_|Q4XWeS{D-5<*W(WFUH9+bI<5{f zH^Nb%r6k7hv9fbF+s8j$PR;|O%b3sC1)ydrFBiF6tG&n0oqitMJgspFe7yaVaKj^w zy_|@`jh_!VC{Mn6IhZiQGErDmut|_+#~bK( zCi{_uu}wv>cyvP>pg+IgW^N!v5~$ZzjxVHUU^-70TTma_PUG`UpO8y6O%@qN1rDtj zwY)jK!`)>X4I{^$9!OasepYm-zm27LpG9|Wh;?J=eISr*z9Tm1QTb9oZAhPP17cH> zJ6&{V!%OM2{6T(;XP;hMX7rO_&!#ZK7+%1&ljqCV_kF1dbCnbncBzTHfZEsRKLU_; z-mLW9rsf)%)K!;1&Kkco!if0@*0cFXgf-p91KPejr6*XUjX{5d%ONHf_ZSND&-SLo%N|?e9 z5d0(;xI2r+IMK!#*~1&#m0x#DJfJVw#AAKY=6LD_JN$4(DKyyLzT+eILJJVRD9PPN znN2qi*ra!H{wQ!e3;Wlnx3ti0o%GZp{8}O_n)Nf9xA>o2bfHSbdTC}ADdS4n`VI4s z=%`Hy<4r_MB_X>s$lWzf+0|f-SPib{_X#clg16( z;bdPQ@p;)XBUXQ^B24i6P6UZB=rYO|dB3~pT{ecYc5arL+$JX{lx%|~)ld&Id@}!v-p&->^LiP&)q>i$ z@5%Z`JU4iZc|cw_yODqzRsUD&?^$JQ1uAidWapr^T@xfwj?e6{eP6pZvE_D{mocE@6FgNLa760n0zG*Xmi_iY z40@*X3)so)U4S(G%JG3sgk2OIJHo%qa7Fl$$8ap~PgoYiAHzvNdrg_9>_$pV6DfnD z>n8!#{w#5%VyAy5Vf4#3uOkY2{~(1wOmh;Cm=)Wq92tNt2;qrGizj(!>8aWWDw%l0 ziS?T+{CBbM#>KM<5ImoE7f$wK$7;z@=53qA^%f`gE8E8nRMWGj%t!tbQRJvAdS@@^RAIjteL+CyDi4UYiy_H=o;zG74onQCuANKGlAeqDWR z0T#SXM5a2kcz(sY%K9NZI-pCs{8(VWwdQVWxvV@7drWl zxV>xQzcvOc2<2Y@sk`Mqn(gPphjNH!6G09&T{}XX>!kna%(DS$61@0s{G9;aZs&97 zwW69ywB_zF>QHbvSZzqGiSBpd%)<~ST-m+rYDdAvDxLfQoSuI+9|7sVI(~vYKD}13 z)zQ4Vy8j5UA({WXH07L=K2*2de+c=DD`9JCAHbvYFl@C>=V;(LH*=#w<3RK?83fq( zF5{cJk9`*MgZp6QQO5T--$8RwcVe}#PeWfykw2du<)zzPqe)}7qDDhq55+99h!?O} zm5nAehOc#bW)m37;C&=}R4*Yqjf9ntHIl-v!6T7NrcLZSPwwfCG@2C5sz$^u4zkd*m zJ&~Q{MC*}DQIAZLAqOVo(kErSZVHqus!7Zx6qo1V*AuZ@vTp@oQE2DqO00?n6_cnt zaMI8V2TV?h+AbfeK5@V@<4Sn)!rKtx;0>fP&Pz!ds(i6EN5P3YHGO1vRfcmeU}@SC z)?fkMYd<3oU|y?-h%G!%uzJ!0Ld%$6(sz=-bsH+w%j<6a_edo_E&@B+_t1F3ckw(I zF!w~pv_x8+k{1VP`w_6m?jD!35iwHlzWJq^>bqWVq*q?lb{8)tgP z>=pH^g3`Hs@Vt0fW-bp0b>Jcv6Esa$QndsP<(s1Tiq(87l6h!+XbDIT+;TghR?SdL*)xS29(6qWX54#JJ|)CQ(DUsh-7(sNzPMXBscAuJ<@nl^LC#K%|$2NGT5toCZFzR9eFwS2qjdr8BZ5A)k zosdXP`4z`l8SREq0l{&2T+O@nQ|FJJS-I603J80^Y9G`r6Q7thTR_0LVf5BFQlMo> zwQI^b4dU>3-RzxTT4x+wpyi;*&{bPt@fe`;uL5`P#Dp9xK!zR(U~Z z#9+eioyeXo6J_!pxx7gvz|!>-_~=%_BzQ>^Er4Roax?p4`GbBZnKUV*8ioFA z;lWqCK)zx%(S`-AXYqWdqm2VV_vw3gY0EDGf;f*-`cTuusPG!CHXD8psiA5GfgFzq z1z_XlQtC9_KO>s7M@2__HsR5Bh8QPiy0A9J#BzOz0z#O|+ZhQgkq;+F8^J-)L8M9$ z^MJ`6W-9g$34MfM%(h=@MB%n4vS&T4G)REhw9Iu(yO#g`UB|7BECca-%urtNKm28| zTKi#J$jumRBg)LEm%lS6gxH1V_Q_h>mx8h>VB>*#SNxgK8C_@}?akzMpwUst5i}!j zlM-c$SyDX|(^+>c+S6m{x~c*SI9xdHe7)!E^xwB}>dOki{%3%=<}=1U2*shS{U+%* z1XR;4<#T#*^FunuIV8iv5igxf2d3%|S@P1I8xbgF>J78bhFU>6>N%ncKx-zID5KB9 z6?(+bpgQ48B1z!OWP8PRlql%qs-Vz*A#rvqLKB)&Ph&Lptr${9VN$uBYE&M9bE~F0 zj8vkI1H;_HvUR?mZNhQ>{NihtBouHNNorKE&7daGDDfgu&`R=q=XVca17+(oWIy-Q z4|otCyfih49VhNkma)RaA0G-5{_#<2EFvVSQln0<-Eup`8%UnDlJ$wP#(DxvFSfrx zp)R^0mx7Jk22)Ke@&(vDHq#E~dJ}(g*wWjjUbO}L_bj5~c4t_w3%p6FRca+!@LuE( z!tvRTyEgu0Y%t_)`2|Pb64NXBO856-RgpEw3OM**hOIz$Io+x}B>A2n)tGm_Kt#xP zN=cq-X_$>#7fN1CTArA4LAQ;D#x0@C?Cv1a0q6RR0&RJSf#n3L-pWp(M|GO@kNCR# z1+?xp;99H)xAIdvzajD+3m)V3JF~~fV6~y|^{jI-@^%}t{~>OwX3`sinhM>^rYQ;m zNdhsU*_abFi||X{@v5%wjY!TXlB=IVjT9E_&xVZDH(I@ppqh+7X!pg80++v%nvVrt zGljf$XbXKqS!kVCP)B*D4=gA?XBCI)xE2z)rjil0A#*pn%4ySK%m$H3zf8@rlA&F53^MOKmdTCnmBwO3FiSauQR$+er`Yc|8z4!p9$)Pf&6=z*9iZ~+u^p`wT$JIX!Q3ojGS zMqQC{?z|C`r!fjCdhD>Th|H@)O(g0F7(6tr0`V>E2+(Waa%YzcBM3c;6&#+RvmiWQ z*bb~S{k`4zC7O!d_6Vc9=EaW6hh>F}k)aiz3ozabrEPjaHvH@6KgF@4+E*7Lr>aIg zYQFM2gAk%f0)>}eW`41N+7YpGHc--7T`I|#Nrt2~JJc1=gu&jxp2T(d2&oskSFY3e zk)m5awr=~+%Hu2F(|C+RzmvlA1D53`3&c=}x5uY?Hc738I}@zi+07V|u*yZvS)y+Z z{bMiP_QNg_Qg+Q+) zJ9s=R^jPdy=fr&?NM}odwY-{oD2G5qt5!c{nIJ@_|7?DId`o}5CR>btHp^02S0io# zu&^_rAjb@ciR7K;as-erhJF~UsQ3;0o7g*D4nEO=%YQO(PBMh7rUnz^F&GiJLH`{7 z8l3Id>a;cXxqWkYtlcKN1`BnFar0Ta$7fuGYGhnNpS9##<<}Rwe5Ec=XWGG@4qx!y zj9x@HVayD;RJ_@~7AQcM=S4*;>22M}hQk}zr9ck9SRxYO*KFCMOcJ`emt6AP7$Wdi z_M1vU`sBpY;+H&%>7$)#3haacIOPY$@l%$DD!Fn#I_~`txh_6u9@6R*$6p-{QN=Of z*Kiu@PrV|I+~k@2mu?)B=euL$j!4tsM|g|xupAux4}>lra8-#>u2LxGXa=Ho#{kiv zXT-lz5z8NQTf0>@1t{Q%B7K%z@n z!Z5=~_KpjI#^|jD>9Ofs{chIX$xDtI;;C6kM6nhJWkrf-yDvN$KB7p~Wee63sY+e>yd$s^qD%#Zv49mgsHDh_I zv`JzQMX%7_L9IqB@>2{=20B8RLGKGfGe;)AC93}up$}g0N`Gqlmc(tja$N|Ds%f4+ z5i%%7%VlbK1A0U z*GP4s#)(X$T))U{Q{UXw{WooS%u_#Ht*QA;cAyMwa)9D%t;1ugAwRi^asi!D#+FKG znM^laITy7a&_=0-UZ%YDMIT`U6j~$%JN6&;F;DI8My0(V5~u?}Kf0XwWN*5Ej40MsD zx7;8ZceeB?Z_VIqyM^50`Lr^qc!>at&U?mB1bbmkAB@6l;g*b)E2`Gji|VF#>9U7@ z{>psp&;7}+)& zYu+;s8Y++}P;LO(xUxIwQiwsgHyF+6%uwb(!@p2-p7vqmo(8DcMl-{|d~%v@=v-JL zBLn&`oG1w#O{yYiE}%Dkl1-b*iQa>)?)*L7@}K~yCnCaCA3S=Ce7Z8Vd14R7XFV~9#L$1cD7MLz*}r3BUr4NA;7!PG#1j;#r|`qSy<6V^RLnT(+Ztgm&Q70o za+-AIx^8}zx(l@n-cP;|zykAiZ`;1+?;MCLqQdaJA zc1HXZ>B(f+nvdhkya*Fm>EHiRkNnw_>h!O;=pF#AZoRG7fB24$`n+Ayiu`lEx|kgI z@K&UG0T!67>xm<&Vr+C2AII!#U4F>m#oyYCb9-lL{qONc=ld$YF+5%~g9H5a zAA{Q#_kz=XV9#sw<;8iMe}HOG?L9Hw`vTrqe&~!%-dqQTGs@;BYXrpgw=V`HW}MLk z$4sjt4$_`P%`pm-UIIva6R{opD4`y>5~(lyuxtGXu`tqfYvKjyjQwh){kuz zMQ(hncj84}Us_%CFRvYVy3$Nmmr;`omn4`u`4)y*7><1x;iBTfp+b9h@$e#c@F3%y zH!F3DC)+?||3czG7zI0+DJh{M4B{4();~pN?VfNt{%vCpI=HFN-Ir+SY9z%r4*E}j ziJ6hmyyY7N=$?h~;-MG=pm1PfP%x);&|s(v_mjgk%!7Xzf=q8&oDPS=8dQ4TB|kVB zl6^`%a3Yq#WvhOTx0{Vra6m>vPzULPzRKY;}fa}zyd zV3WW5pfi7riH%wWEv@}fw`*qxNsp6ONVyl$^3>8+l|n2qrnA@_H9Jfq9#WM8lNx7` zFz9YDi9n8<#x`N!0w`7vGX^V~S*md^H=Y+XXPhg|hkrL28G+-G49LGY8!*zqbFgn+ zm_KbG#($o_FxzS#iNjZX+uQ#?rgC7=1?)=HU&%JI)NUQNb zjxx_RBaVNl@ZhrYNj3|@K6vueH$dqpS^`$&72KTPX63*p7h+R?REupEGLS7NVa$Fh zIdHsTVdC~vEM=0u0TfsU&^G2Iagaf;wFZ&}VrTI<*KQ;&EXI^NU4+NTt5G<27hlst zk&%5vD3h?$3}vZ8qwJ$C8@Uy0mVD_;aSY70141P(jW{;v>Z+?&9zNQ0TmcwEoB(W3 z?9>%Lk*DiQ92csdv-Y;-&DQ1u*grdoDyHzoM*F0$K3bhxFLm7OefQe4k7fUN+mgN z1IP&_D1x$`-6XlPv58^$P{{T2Mk0jl*;3a6%h;FyX+p^{VV`!48h$r#nr<|hVKzWc9DOf%=>mNF4I;++DI2+$q zP(aZMEb7nN$$_?ORubN}py)A93Ie3HAKTzW3|{Fa7@iGhI(^f{AXra{L4} zo?FZ+ea)Z!^Xg{}cYn=H4$3z$Bky_ApjvO+bVz3!%^pAI;fX(jbdQ81OK}XokoO0# zt^~$=4nE_2yi@<*Vtr1b0e`$!gyNaPK$I17=+i-N3NF3RV7BL>DGcCIrGZ0*`*rV~#NMG|TFQy}%Z+>k|M@SzWaE>KyHhni z*`Mf5*Sq1G%`k3L)!*qr z#DjkX$UcH*z|=q?tL$f2d|RhZNSy63XZ7r$l|C`kNeBqvNia8xZ$hRnb)XMf`p@km zK!}Z!3lig2-R4BGqQ=CaPhyhs0Jrd;t{n#eqIk^OcFF*uC@QfP$TZ6q|HZG*xat*$ z+?j%r+v3By7ZY#}UKdvryv__%-mr=-|D3cW6zb|$e75T8P%wOm zhi-6x7P-g`+BT=&C(ZODAA9`TJ9HNzPei%n`NSXB%Xh;@e%jR5`h_4>KIljXAxU=D zNtx(3U)$~`|HBu(<=(&ghrd6*n&@2(yQk!{I*|jqHdXGBfl!$^Z5Qi6mp?qh6ct(~ zPiIl3)>uktQL%k)4(rniljB4%@H{ZOGKjTe)D}m1=k-_!m<0LAvq-xkat<0|<=RV| zd^zXI017OH7E_vmJkR80%eV))4&G1B^X5A?yyCF6!ry@k9+*_ch{m~w-&`ZgGDoGQ zpDustP>7C#G-s#;e|5OwZl9Q{j{o8Di}(~=5@U`CNq_EWq^fC7W6Rue%KHaTV;CbA zvfU5VGT(vyL$UCiN1A0*|L?5lX&&9q6%FrJ9cX0S3aP{)^5oyd2B4 z^`nB6I{Sw!(CWUMAO1P-CMWs#yzuw#nA)j21cbiE1aNum938KfHU@QF#KLo!F9}W0 z+C|)yES_cUgqt+y0va96;^BGQu&;T}QJ3??kAS5$0K&K#IfT$v7k#Qo#vaWvA_V1} z)jTSha584tvqrS?kA5V^cB+jLHMLu?=1zQ8`^qa;9riiN7gObPX!vjQ6oBAH{^Vvx zj8nm291=8=M5Wki7e8n=!{$$ac>jK@escT^|8+4`Wy{xKDB7$^N|LBZIC+bMIh&V4 zp|#^uaNZw|3qjDls@~T>>1le+u;3=A+dqE6>G!<%;L}dQwHUt1`4^EAPKju&ErCru z1@M;%m2yWxwlH364uo=+)w?%opzo$@QS04xW>whtNRPwPoC(R%H&33 ziZ{55K7^ScwA1*otMl*$1^fT-`2F8E^7Zg^Jfr2N3zzT)66v2KlF*oI#BQtE_Bls+ zTKH{eWU&4y9 zcr6%7r$A_)W-b0wY&Qz2iZ?pWLvuS`2!CO!p8E8y$Gz~54%(7*bw4@I=H$gZb#i1H zBIGr4jr_xNyA~nUTAC7~`8D1+@!@T~T|Qi$@VNWCL`T}>GtEJz-XG49{3pM?Id$kZe^3)yosv1sg=G*%q36WG^Ss_aoaHk)j`P>j@ef`%GK zvAXHU8H05v0f|Wcv?r3U(h$oyxf$d;763Fo(u;)Enk{R}j7ae;A7gHIr#!m;e9(07*qoM6N<$f~^*;IRF3v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a34569362e3c820b7d3b28b1f04eeb0b0cb6a090 GIT binary patch literal 11576 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 qoTgaxo;A@}^9WLk?fS?o?f(J$z)#3qwX = 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)) + } +}