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.
|
|
@ -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<GenerateRuntimeConfigsTask>("generat
|
|||
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
tasks.withType<KotlinCompilationTask<*>>().configureEach {
|
||||
dependsOn(generateRuntimeConfigs)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -6,3 +6,5 @@ internal expect object AddonStorage {
|
|||
}
|
||||
|
||||
expect suspend fun httpGetText(url: String): String
|
||||
|
||||
expect suspend fun httpPostJson(url: String, body: String): String
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ data class MetaDetails(
|
|||
val imdbRating: String? = null,
|
||||
val ageRating: String? = null,
|
||||
val runtime: String? = null,
|
||||
val externalRatings: List<MetaExternalRating> = emptyList(),
|
||||
val genres: List<String> = emptyList(),
|
||||
val director: List<String> = emptyList(),
|
||||
val writer: List<String> = emptyList(),
|
||||
|
|
@ -37,6 +38,11 @@ data class MetaDetails(
|
|||
val videos: List<MetaVideo> = emptyList(),
|
||||
)
|
||||
|
||||
data class MetaExternalRating(
|
||||
val source: String,
|
||||
val value: Double,
|
||||
)
|
||||
|
||||
data class MetaTrailer(
|
||||
val id: String,
|
||||
val key: String,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MetaExternalRating>,
|
||||
) {
|
||||
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()}%"
|
||||
|
|
|
|||
|
|
@ -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<String, List<MetaExternalRating>>()
|
||||
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<String>,
|
||||
): List<MetaExternalRating> = 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<RatingResponse>(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<String>,
|
||||
val provider: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class RatingResponse(
|
||||
val ratings: List<RatingItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class RatingItem(
|
||||
val rating: Double? = null,
|
||||
)
|
||||
|
|
@ -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<String> =
|
||||
MdbListMetadataService.PROVIDER_PRIORITY_ORDER.filter(::isProviderEnabled)
|
||||
}
|
||||
|
|
@ -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<MdbListSettings> = _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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||