diff --git a/composeApp/libs/lib-decoder-ffmpeg-release.aar b/composeApp/libs/lib-decoder-ffmpeg-release.aar index 2ee1cee7..75ec0a86 100644 Binary files a/composeApp/libs/lib-decoder-ffmpeg-release.aar and b/composeApp/libs/lib-decoder-ffmpeg-release.aar differ diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index ee512489..650f7b96 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -21,6 +21,14 @@ kotlinx.serialization.KSerializer serializer(...); } +# Avoid R8 merging/optimizing the imported badge chip used in lazy stream rows. +-keep class com.nuvio.app.features.debrid.ImportedBadgeChipKt { *; } +-keep class com.nuvio.app.features.debrid.ImportedBadgeChipSize { *; } +-keep class com.nuvio.app.features.debrid.BadgeChipDefaults { *; } + +-keep class com.nuvio.app.features.streams.StreamsScreenKt { *; } +-keep class com.nuvio.app.features.streams.StreamsScreenKt$* { *; } + # QuickJS plugin runtime is dynamic; keep runtime and app plugin classes. -keep class com.dokar.quickjs.** { *; } -keep class com.nuvio.app.features.plugins.** { *; } diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt index 5d93ab92..79335c17 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/trailer/YoutubeChunkedDataSourceFactory.kt @@ -8,6 +8,10 @@ import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.TransferListener +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_error_unable_to_play_stream +import org.jetbrains.compose.resources.getString /** * A DataSource.Factory that wraps DefaultHttpDataSource and appends YouTube's @@ -75,7 +79,9 @@ class YoutubeChunkedDataSourceFactory( } private fun openNextChunk(): Long { - val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec") + val spec = originalDataSpec ?: throw IllegalStateException( + runBlocking { getString(Res.string.player_error_unable_to_play_stream) }, + ) val end = if (totalContentLength != C.LENGTH_UNSET.toLong()) { minOf(currentChunkStart + chunkSize - 1, currentChunkStart + totalContentLength - 1) } else { diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt index 72c2e50e..390ce23f 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -7,9 +7,15 @@ import android.os.Build import android.provider.Settings import androidx.core.content.FileProvider import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.updates_download_failed_http +import nuvio.composeapp.generated.resources.updates_downloaded_file_missing +import nuvio.composeapp.generated.resources.updates_empty_download_body import okhttp3.OkHttpClient import okhttp3.Request +import org.jetbrains.compose.resources.getString import java.io.File import java.io.FileOutputStream import java.util.concurrent.TimeUnit @@ -63,10 +69,10 @@ object AndroidAppUpdaterPlatform { httpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) { - error("Download failed with HTTP ${response.code}") + error(runBlocking { getString(Res.string.updates_download_failed_http, response.code) }) } - val body = response.body ?: error("Empty download body") + val body = response.body ?: error(runBlocking { getString(Res.string.updates_empty_download_body) }) val totalBytes = body.contentLength().takeIf { it > 0L } body.byteStream().use { input -> FileOutputStream(destination).use { output -> @@ -115,7 +121,7 @@ object AndroidAppUpdaterPlatform { fun installDownloadedApk(path: String): Result = runCatching { val context = requireContext() val apkFile = File(path) - check(apkFile.exists()) { "Downloaded update file is missing." } + check(apkFile.exists()) { runBlocking { getString(Res.string.updates_downloaded_file_missing) } } val apkUri = FileProvider.getUriForFile( context, 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 4549add8..01afe4ef 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 @@ -4,7 +4,12 @@ import android.content.Context import android.content.SharedPreferences import com.nuvio.app.core.network.IPv4FirstDns import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.network_empty_response_body +import nuvio.composeapp.generated.resources.network_request_failed_http +import org.jetbrains.compose.resources.getString import okhttp3.ResponseBody import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -182,10 +187,10 @@ private suspend fun executeTextRequest( addonHttpClient.newCall(request).execute().use { response -> val payload = readResponseBody(response.body) if (!response.isSuccessful) { - error("Request failed with HTTP ${response.code}") + error(runBlocking { getString(Res.string.network_request_failed_http, response.code) }) } if (payload.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) }) } payload } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index de8e76b1..969bc8eb 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -30,6 +30,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val streamBadgeRulesKey = "debrid_stream_badge_rules" private fun syncKeys(): List = listOf( enabledKey, @@ -150,6 +151,12 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + + actual fun saveStreamBadgeRules(rules: String) { + saveString(streamBadgeRulesKey, rules) + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt index f8f5d9d3..ba21cd68 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt @@ -111,7 +111,7 @@ internal actual object DownloadsLiveStatusPlatform { .setPriority(NotificationCompat.PRIORITY_LOW) .addAction( 0, - "Pause", + runBlocking { getString(Res.string.compose_action_pause) }, buildActionPendingIntent( context = context, action = DownloadsNotificationActionReceiver.actionPause, @@ -178,7 +178,15 @@ internal actual object DownloadsLiveStatusPlatform { private fun formatBytes(bytes: Long): String { val safe = bytes.coerceAtLeast(0L).toDouble() - val units = arrayOf("B", "KB", "MB", "GB", "TB") + val units = runBlocking { + arrayOf( + getString(Res.string.unit_bytes_b), + getString(Res.string.unit_bytes_kb), + getString(Res.string.unit_bytes_mb), + getString(Res.string.unit_bytes_gb), + getString(Res.string.unit_bytes_tb), + ) + } var value = safe var unitIndex = 0 while (value >= 1024.0 && unitIndex < units.lastIndex) { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt index 9466bb41..19bb3021 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt @@ -5,6 +5,10 @@ import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.content.FileProvider +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.external_player_android_system +import org.jetbrains.compose.resources.getString import java.io.File import java.net.URI @@ -20,7 +24,12 @@ internal actual object ExternalPlayerPlatform { actual fun defaultPlayerId(): String? = AndroidSystemPlayerId actual fun availablePlayers(): List = - listOf(ExternalPlayerApp(AndroidSystemPlayerId, "Android system player")) + listOf( + ExternalPlayerApp( + AndroidSystemPlayerId, + runBlocking { getString(Res.string.external_player_android_system) }, + ), + ) actual fun open( request: ExternalPlayerPlaybackRequest, diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt index 6139ed02..4fcefb8b 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt @@ -3,6 +3,9 @@ package com.nuvio.app.features.plugins import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.plugins_error_unavailable_build +import org.jetbrains.compose.resources.getString actual object PluginRepository { private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false)) @@ -18,7 +21,7 @@ actual object PluginRepository { actual suspend fun pullFromServer(profileId: Int) = Unit actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult = - AddPluginRepositoryResult.Error("Plugins are not available in this build.") + AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build)) actual fun removeRepository(manifestUrl: String) = Unit @@ -35,7 +38,7 @@ actual object PluginRepository { actual fun getEnabledScrapersForType(type: String): List = emptyList() actual suspend fun testScraper(scraperId: String): Result> = - Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) + Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) actual suspend fun executeScraper( scraper: PluginScraper, @@ -44,5 +47,5 @@ actual object PluginRepository { season: Int?, episode: Int?, ): Result> = - Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) + Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) } \ No newline at end of file diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 01acbee9..6b06204e 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -1,5 +1,10 @@ package com.nuvio.app.features.updater +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.updates_not_available +import org.jetbrains.compose.resources.getString + actual object AppUpdaterPlatform { actual val isSupported: Boolean = false @@ -13,12 +18,12 @@ actual object AppUpdaterPlatform { assetUrl: String, assetName: String, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, - ): Result = Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + ): Result = Result.failure(IllegalStateException(getString(Res.string.updates_not_available))) actual fun canRequestPackageInstalls(): Boolean = false actual fun openUnknownSourcesSettings() = Unit actual fun installDownloadedApk(path: String): Result = - Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) } \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 8905f087..a09f3175 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -1,4 +1,6 @@ + + Sources de données, remerciements et licences de plateforme Reconnaissance et crédits du projet Retour Annuler @@ -17,68 +19,71 @@ Reprendre Réessayer Enregistrer + Enregistrement… + Valider Installation en cours Addons Actif %1$d catalogues Configurable + Désactivé Actualisation %1$d ressources Indisponible - Configurer l'addon - Supprimer l'addon - Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. + Configurer l’addon + Supprimer l’addon + Ajoute une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. Aucun addon installé. - Veuillez saisir une URL d'addon. - URL de l'addon - Installer l'addon + Saisis une URL d’addon. + URL de l’addon + Installer l’addon Chargement des détails du manifeste… - Validation de l'URL du manifeste et chargement des détails de l'addon avant installation. - Vérification de l'addon - Échec de l'installation + Validation de l’URL du manifeste et chargement des détails de l’addon avant installation. + Vérification de l’addon + Échec de l’installation %1$s a été validé et ajouté avec succès. Addon installé - Déplacer l'addon vers le bas - Déplacer l'addon vers le haut + Déplacer l’addon vers le bas + Déplacer l’addon vers le haut Actif Addons Catalogues - Actualiser l'addon + Actualiser l’addon Ajouter un addon Addons installés Aperçu - %1$d règles d'ID + %1$d règles d’ID Version %1$s Sélectionné Copier le JSON %1$d collection(s), %2$d dossier(s) - Supprimer "%1$s" ? Cette action est irréversible. + Supprimer « %1$s » ? Cette action est irréversible. Supprimer la collection Ajouter un catalogue Ajouter un dossier Tous les genres - Ajoutez des catalogues depuis vos addons installés pour définir ce qu'affiche ce dossier. + Ajoute des catalogues depuis tes addons installés pour définir ce qu’affiche ce dossier. Aucune source de catalogue Choisir Emoji - URL de l'image + URL de l’image Aucune Couverture Créer une collection Terminé Modifier la collection Modifier le dossier - Configurez l'identité, la présentation et les sources de catalogue du dossier avec la même structure que l'éditeur principal de collections. - Ajoutez-en un pour commencer. + Configure l’identité, la présentation et les sources de catalogue du dossier avec la même structure que l’éditeur principal de collections. + Ajoutes-en un pour commencer. Aucun dossier Dossiers Filtre de genre - Afficher uniquement l'image de couverture + Afficher uniquement l’image de couverture Masquer le titre Nouveau dossier - Affiche cette collection au-dessus de tous les catalogues normaux de l'accueil. Plusieurs collections épinglées suivent l'ordre de création. + Affiche cette collection au-dessus de tous les catalogues normaux de l’accueil. Plusieurs collections épinglées suivent l’ordre de création. Épingler au-dessus des catalogues - URL de l'image de fond (facultatif) + URL de l’image de fond (facultatif) Nom du dossier URL du GIF animé (se lit uniquement au focus) Nom de la collection @@ -87,7 +92,7 @@ Apparence Informations de base Sources de catalogue - Choisissez les catalogues d'addon que ce dossier doit regrouper. + Choisis les catalogues d’addon que ce dossier doit regrouper. Sélectionner des catalogues Sélectionner un genre %1$d sélectionné(s) @@ -97,14 +102,14 @@ Carré Large Combiner tous les catalogues en un seul onglet - Afficher l'onglet « Tout » - Lire le GIF configuré à la place de la couverture statique lorsqu'il est disponible. + Afficher l’onglet « Tout » + Lire le GIF configuré à la place de la couverture statique lorsqu’il est disponible. Afficher le GIF si configuré %1$d source(s) · %2$s Forme de la tuile Lignes Onglets - Mode d'affichage + Mode d’affichage Sources TMDB Liste publique Production @@ -113,14 +118,14 @@ Personne Réalisateur Personnalisé - Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée. - Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL. - Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement. - Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. - Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB. - Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de casting. - Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de réalisation. - Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre. + Choisis une source prédéfinie. Tu peux la modifier ou la supprimer après l’avoir ajoutée. + Colle une URL de liste publique TMDB ou uniquement le numéro de l’URL. + Cherche par nom de studio, ou colle un ID/URL de société TMDB et ajoute-le directement. + Saisis un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. + Cherche le nom d’une collection de films ou colle l’ID de collection TMDB. + Saisis un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de casting. + Saisis un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de réalisation. + Crée une ligne TMDB dynamique avec des filtres optionnels. Laisse les champs vides si tu n’as pas besoin de ce filtre. Liste publique TMDB ID de chaîne ID de collection @@ -130,19 +135,19 @@ https://www.themoviedb.org/list/8504994 ou 8504994 213 pour Netflix, 49 pour HBO, 2739 pour Disney+ 10 pour Star Wars Collection + 31 pour Tom Hanks, ou URL de la personne Marvel Studios, 420 ou URL de société - 31 pour Tom Hanks, ou URL de personne - Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420. - Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection. - Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739. - Exemple : https://www.themoviedb.org/list/8504994 ou 8504994. - Exemple : https://www.themoviedb.org/person/31-tom-hanks ou 31. + Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420. + Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection. + Exemples d’ID : Netflix 213, HBO 49, Disney+ 2739. + Exemple : https://www.themoviedb.org/list/8504994 ou 8504994. + Exemple : https://www.themoviedb.org/person/31-tom-hanks ou 31. Titre affiché Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source. Films Marvel, Originaux Netflix, Pixar - Films avec Tom Hanks, Acteurs favoris + Films de Tom Hanks, Acteurs favoris Films de Christopher Nolan, Réalisateurs favoris - Meilleurs films d'action, drames coréens, animation 2024 + Meilleurs films d’action, drames coréens, animation 2024 Résultats de recherche Collection TMDB Société TMDB %1$d @@ -153,7 +158,7 @@ Les deux Tri Filtres - Laissez les champs vides si vous n'avez pas besoin de ce filtre. + Laisse les champs vides si tu n’as pas besoin de ce filtre. Genres rapides Langues rapides Pays rapides @@ -161,30 +166,32 @@ Studios rapides Chaînes rapides ID de genre - Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. + Utilise des numéros de genre TMDB. Sépare plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. Date de sortie ou de diffusion depuis - Date de sortie ou de diffusion jusqu'au - Utilisez le format AAAA-MM-JJ, ex. 2024-01-01. + Date de sortie ou de diffusion jusqu’au + Utilise le format AAAA-MM-JJ, ex. 2024-01-01. Note minimale Note maximale - Note TMDB de 0 à 10. Exemple : 7.0. + Note TMDB de 0 à 10. Exemple : 7.0. + 7.0 + 10 Votes minimum - Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. + Utilise ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. Langue originale - Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi. - Pays d'origine - Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN. + Utilise des codes de langue à deux lettres, ex. en, ko, ja, hi. + Pays d’origine + Utilise des codes de pays à deux lettres, ex. US, KR, JP, IN. ID de mots-clés - Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. + Utilise des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. 9715 pour super-héros ID de société - Utilisez des ID de studio/société. Les puces rapides remplissent des exemples courants. + Utilise des ID de studio/société. Les puces rapides remplissent des exemples courants. 420 pour Marvel Studios ID de chaîne - Pour les séries uniquement. Utilisez des ID de chaîne comme Netflix 213 ou HBO 49. + Pour les séries uniquement. Utilise des ID de chaîne comme Netflix 213 ou HBO 49. 213 pour Netflix Année - Utilisez une année à quatre chiffres, ex. 2024. + Utilise une année à quatre chiffres, ex. 2024. Préréglages Rechercher Ajouter une source @@ -193,12 +200,12 @@ Listes Trakt Liste Trakt Rechercher un titre, URL Trakt ou ID de liste - Utilisez une URL publique de liste Trakt ou un ID numérique de liste, ou recherchez par nom. + Utilise une URL publique de liste Trakt ou un ID numérique de liste, ou cherche par nom. Programme du week-end, Lauréats Résultats de recherche - Listes tendances + Listes en vogue Listes populaires - Ordre + Sens Croissant Décroissant Ordre de la liste @@ -209,6 +216,12 @@ Populaire Pourcentage Votes + Saisis un nom, une URL ou un ID de liste Trakt + Saisis un ID ou une URL de liste Trakt + Impossible de charger la liste Trakt + Aucune liste Trakt trouvée + Liste Trakt résolue + Liste Trakt %1$d Action Aventure Animation @@ -229,7 +242,7 @@ Inde Royaume-Uni Super-héros - Adapté d'un roman + Adapté d’un roman Voyage dans le temps Espace Marvel @@ -242,18 +255,30 @@ Disney+ Prime Video Hulu - Original Populaire Mieux notés Récent + Les plus votés + Région de visionnage + Code pays ISO 3166-1 où le titre est disponible. Exemple : US, GB. + Régions rapides + ID des fournisseurs de visionnage + Utilise les ID de fournisseurs de visionnage TMDB. Sépare-les par des virgules pour ET, ou par des barres verticales pour OU. + 8|337|350 + Fournisseurs rapides + Netflix + Prime Video + Disney+ + Apple TV+ + Hulu Liste TMDB Collection de films TMDB Production Chaîne Personne Réalisateur - Découverte TMDB - Créez-en une pour organiser vos catalogues. + TMDB Discover + Crées-en une pour organiser tes catalogues. Aucune collection %1$d dossier(s) Aucun élément trouvé @@ -261,31 +286,31 @@ Collections Importer des collections JSON - Collez le JSON de vos collections ci-dessous. + Colle le JSON de tes collections ci-dessous. Importer Nouvelle collection Épinglé Tout - Vos collections + Tes collections Fait avec ❤️ par Tapframe et ses amis Version %1$s (%2$s) Désactivé Activé Pause Recharger - Vous avez déjà un compte ? + Tu as déjà un compte ? Continuer sans compte Créer un compte - Pas encore de compte ? + Pas encore de compte ? Adresse e-mail ou Mot de passe - Connectez-vous pour accéder à votre bibliothèque et votre progression + Connecte-toi pour accéder à ta bibliothèque et ta progression Se connecter - Inscrivez-vous pour synchroniser vos données entre appareils - S'inscrire - Vos données seront uniquement stockées localement - Regardez tout, partout + Inscris-toi pour synchroniser tes données entre appareils + S’inscrire + Tes données seront uniquement stockées localement + Regarde tout, partout Bon retour Bibliothèque Bibliothèque Trakt @@ -317,7 +342,7 @@ Streams Erreur de lecture Lecture en cours - Appuyez pour chercher des sous-titres + Appuie pour chercher des sous-titres Retour Rétablir les valeurs par défaut Remplir @@ -339,20 +364,20 @@ Téléchargé Diffusé À confirmer - Appuyez pour déverrouiller + Appuie pour déverrouiller Piste %1$d Déverrouiller les contrôles du lecteur - Vous regardez + Tu regardes Ajouter un profil Effacer la recherche Découvrir - Les addons installés n'ont renvoyé aucun résultat de recherche valide. + Les addons installés n’ont retourné aucun résultat de recherche valide. La recherche a échoué - Installez et validez au moins un addon avant de rechercher. + Installe et valide au moins un addon avant de rechercher. Aucun addon actif - Les catalogues installés n'ont renvoyé aucun résultat pour cette requête. + Les catalogues installés n’ont retourné aucun résultat pour cette requête. Aucun résultat trouvé - Vos addons installés n'exposent pas de catalogue de recherche. + Tes addons installés n’exposent pas de catalogue de recherche. Aucun catalogue de recherche Rechercher des films, séries… Recherches récentes @@ -364,8 +389,10 @@ Apparence Contenu et découverte Continuer à regarder - Écran d'accueil + Services connectés + Écran d’accueil Intégrations + Licences et attributions Notes MDBList Écran méta Notifications @@ -377,22 +404,51 @@ Enrichissement TMDB Trakt À PROPOS - Gérez votre compte, déconnectez-vous ou supprimez-le. + Gère ton compte, déconnecte-toi ou supprime-le. COMPTE - Ajustez la présentation de l'accueil et les préférences visuelles. - Rechercher de nouvelles versions de l'application. + Ajuste la présentation de l’accueil et les préférences visuelles. + Rechercher de nouvelles versions de l’application. Vérifier les mises à jour - Gérez les addons et sources de découverte. - Gérez vos films et épisodes téléchargés. + Gère les addons et sources de découverte. + Gère tes films et épisodes téléchargés. Téléchargements GÉNÉRAL - Connectez les services TMDB et MDBList. - Gérez les alertes de sortie d'épisodes et envoyez une notification de test. + Connecte les services TMDB et MDBList. + Gère les alertes de sortie d’épisodes et envoie une notification de test. Basculer vers un profil différent. Changer de profil - Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt. - Chargement de vos listes Trakt… - Choisissez où enregistrer ce titre dans Trakt + Connecte Trakt, synchronise des listes et enregistre des titres directement dans Trakt. + Aucun paramètre trouvé. + Rechercher dans les paramètres… + RÉSULTATS + LICENCE DE L’APP + DONNÉES ET SERVICES + LICENCE DE LECTURE + Nuvio Mobile + Le code source et les termes de la licence sont disponibles dans le dépôt du projet. + Distribué sous la licence GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio utilise l’API TMDB pour les métadonnées de films et séries, les visuels, les bandes-annonces, le casting, les détails de production, les collections et les recommandations. Ce produit utilise l’API TMDB mais n’est ni approuvé ni certifié par TMDB. + Jeux de données non commerciaux IMDb + Nuvio utilise les jeux de données non commerciaux IMDb, y compris title.ratings.tsv.gz, pour les notes et le nombre de votes IMDb. Informations fournies par IMDb (https://www.imdb.com). Utilisé avec autorisation. Les données IMDb sont destinées à un usage personnel et non commercial selon les conditions d'IMDb. + Trakt + Nuvio se connecte à Trakt pour l’authentification du compte, l’historique de visionnage, la synchronisation de progression, les données de bibliothèque, les notes, les listes et les commentaires. Nuvio n’est ni affilié ni approuvé par Trakt. + Premiumize + Nuvio se connecte à Premiumize pour l’authentification du compte, l’accès à la cloud library, les vérifications de cache et les fonctionnalités de lecture cloud. Nuvio n’est ni affilié ni approuvé par Premiumize. + TorBox + Nuvio se connecte à TorBox pour l’authentification du compte, l’accès à la cloud library, les vérifications de cache et les fonctionnalités de lecture cloud. Nuvio n’est ni affilié ni approuvé par TorBox. + MDBList + Nuvio utilise MDBList pour les notes et les données de scores externes. Nuvio n’est ni affilié ni approuvé par MDBList. + IntroDB + Nuvio utilise l’API IntroDB pour les timestamps d’intro, de récap, de générique et d’aperçu fournis par la communauté et utilisés par les contrôles de saut. Nuvio n’est ni affilié ni approuvé par IntroDB. + MPVKit + Utilisé pour la lecture sur les builds iOS. + Le code source MPVKit seul est distribué sous LGPL v3.0. Les bundles MPVKit, y compris les bibliothèques libmpv et FFmpeg, sont également distribués sous LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Utilisé pour la lecture sur les builds Android. + Distribué sous la licence Apache, version 2.0. + Chargement de tes listes Trakt… + Choisis où enregistrer ce titre dans Trakt Faire un don Voir les détails Supprimer @@ -401,8 +457,8 @@ %1$d/10 Avis Spoiler - Aucun avis Trakt pour l'instant. - %1$d j'aime + Aucun avis Trakt pour l’instant. + %1$d j’aime Ce commentaire contient des spoilers. Ce commentaire contient des spoilers et a été masqué. Commentaires @@ -423,7 +479,7 @@ Vu Saison %1$d Spéciaux - Reprendre où vous en étiez + Reprendre où tu en étais Ajouter à la bibliothèque Marquer comme non vu Marquer comme vu @@ -433,37 +489,39 @@ Logo de %1$s Compte Supprimer le compte - Cela supprimera définitivement votre compte et toutes les données associées. - Cette action est irréversible. Toutes vos données, profils et historique de synchronisation seront définitivement supprimés. + Cela supprimera définitivement ton compte et toutes les données associées. + Cette action est irréversible. Toutes tes données, profils et historique de synchronisation seront définitivement supprimés. Supprimer le compte ? Adresse e-mail Non connecté Se déconnecter - Vous serez redirigé vers l'écran de connexion. + Tu seras redirigé vers l’écran de connexion. Se déconnecter ? Statut Anonyme Connecté Noir AMOLED Utilise des fonds noirs purs pour les écrans OLED. - Langue de l'application + Langue de l’application Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. - Ajustez la largeur partagée des cartes d'affiches et les rayons des coins. + Liquid Glass + Utilise la barre d’onglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre d’onglets n’est pas disponible quand cette option est activée. + Ajuste la largeur des cartes et le rayon des coins. AFFICHAGE ACCUEIL THÈME Collection • %1$s Nom affiché - Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil. - Aucun catalogue d'accueil + Installe un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l’écran d’accueil. + Aucun catalogue d’accueil Source Hero Masqué - Garder l'accueil en focus + Garder le focus sur l’accueil %1$s • Limite atteinte (max. %2$d) Aucune source Hero sélectionnée Absent du Hero - Retirez l'épingle de la collection pour la déplacer + Retire l’épingle de la collection pour la déplacer Épinglé Épinglé en haut Réorganiser @@ -474,23 +532,28 @@ SOURCES HERO %1$d sur %2$d sélectionnés Afficher le Hero - Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous. + Afficher un carrousel Hero en vedette en haut de l’accueil. Choisis jusqu’à 2 catalogues sources ci-dessous. + Masquer le contenu non sorti + Masque les films et séries qui ne sont pas encore sortis. + Masquer le soulignement des catalogues + Supprime la ligne d’accent sous les titres de catalogues et de collections dans toute l’app. %1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées - Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser. + Ouvre un catalogue uniquement si tu as besoin de le renommer ou de le réorganiser. Visible + Masquer la valeur Lecteur, sous-titres et lecture automatique Rayon de carte - STYLE DE CARTE D'AFFICHE + STYLE DE CARTE D’AFFICHE Largeur de carte Personnalisé - Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. + Ajuste la largeur des cartes et le rayon des coins. Masquer les étiquettes - Mode paysage pour les affiches dans les rayons + Affiches en mode paysage Aperçu en direct %1$s (%2$s) - Rayon de coin : %1$ddp - Hauteur : %1$ddp - Largeur : %1$ddp + Rayon de coin : %1$ddp + Hauteur : %1$ddp + Largeur : %1$ddp Classique Pilule Arrondi @@ -502,89 +565,150 @@ Dense Grand Standard - Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur. + Afficher la valeur + Afficher une invite pour reprendre là où tu en étais à l’ouverture de l’application après avoir quitté le lecteur. Invite de reprise au démarrage - STYLE DE CARTE + Floute les vignettes du prochain épisode dans Continuer à regarder pour éviter les spoilers. + Flouter le contenu non vu dans Continuer à regarder + Inclus les épisodes à venir dans Continuer à regarder avant leur diffusion. + Afficher les épisodes à venir non diffusés + ORDRE DE TRI + Ordre de tri + Par défaut + Trier tous les éléments par récence + Style streaming + Éléments sortis d’abord, à venir à la fin + STYLE DE CARTE D’AFFICHE AU DÉMARRAGE COMPORTEMENT DE LA SUITE VISIBILITÉ - Afficher le bandeau Continuer à regarder sur l'écran d'accueil. + Afficher le bandeau Continuer à regarder sur l’écran d’accueil. Afficher Continuer à regarder Affiche - Carte d'affiche centrée sur la couverture + Carte d’affiche centrée sur la couverture Large Carte horizontale riche en informations - Quand activé, La suite reprend toujours depuis l'épisode le plus avancé vu. Quand désactivé, suit l'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents. - La suite depuis l'épisode le plus avancé + Quand activé, La suite reprend toujours depuis l’épisode le plus avancé vu. Quand désactivé, suit l’épisode le plus récemment visionné. Utile si tu revois des épisodes précédents. + La suite depuis l’épisode le plus avancé + Préfère les vignettes d’épisode quand elles sont disponibles. + Préférer les vignettes d’épisode dans Continuer à regarder ACCUEIL SOURCES - Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. - Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne. - Contrôlez quels catalogues apparaissent à l'accueil et dans quel ordre. - Désactivez des sections de détails et réorganisez tout sous le Hero. - Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l'accueil. + Installe, supprime, mets à jour et ordonne tes sources de contenu. + Installe des dépôts de scrapers JavaScript et teste des fournisseurs en interne. + Contrôle quels catalogues apparaissent à l’accueil et dans quel ordre. + Désactive des sections de détails et réorganise tout sous le Hero. + Crée des regroupements de catalogues personnalisés avec des dossiers affichés à l’accueil. INTÉGRATIONS - Enrichissez les pages de détails avec de l'art, des crédits, des métadonnées d'épisodes et plus depuis TMDB. - Ajoutez des notes externes d'IMDb, Rotten Tomatoes, Metacritic et d'autres aux pages de détails. - Ajoutez votre clé API MDBList ci-dessous avant d'activer les notes. - Obtenez une clé sur https://mdblist.com/preferences et collez-la ici. + Enrichis les pages de détails avec de l’art, des crédits, des métadonnées d’épisodes et plus depuis TMDB. + Ajoute des notes externes d’IMDb, Rotten Tomatoes, Metacritic et d’autres aux pages de détails. + Connecte des comptes pour les liens et l’accès à la bibliothèque + Services connectés + Ces intégrations sont expérimentales et peuvent être conservées, modifiées ou supprimées plus tard. + Cloud library + Parcours et lis les fichiers déjà présents dans tes comptes connectés. + Résoudre les liens lisibles + Demande à un service connecté des liens lisibles quand un résultat le nécessite. Cela peut ajouter l’élément à ce service. + Résoudre avec + Choisis quel compte connecté gère les liens lisibles. + Connecte d’abord un compte. + Comptes + Connecte ton compte %1$s. + Lie ton compte %1$s dans le navigateur. + Clé API %1$s + Saisis ta clé API %1$s. + Saisir la clé API %1$s + Non défini + Connecté + Connecter %1$s + Déconnecter %1$s + Déconnecter + %1$s est connecté sur cet appareil. + Démarrage de la connexion sécurisée… + Ouvre le lien et saisis ce code pour approuver Nuvio. + Code copié. + Ouvrir le lien + En attente de l’approbation… + Impossible de démarrer la connexion. + Cette méthode de connexion n’est pas configurée dans ce build. + Ce code a expiré. Réessaie. + Préparation des liens + Préparer les liens + Résoudre les liens lisibles avant le démarrage de la lecture. + Liens à préparer + Utilise un nombre plus faible si possible. Les services connectés peuvent limiter le nombre de liens résolus sur une période donnée. Ouvrir un film ou un épisode peut compter dans ces limites même si tu n’appuies pas sur Regarder, car les liens sont préparés à l’avance. + 1 lien + %1$d liens + Mise en forme + Modèle de nom + Contrôle la manière dont les noms de résultats apparaissent. + Modèle de description + Contrôle les métadonnées affichées sous chaque résultat. + Réinitialiser la mise en forme + Restaurer la mise en forme par défaut des résultats. + Clé API validée. + Impossible de valider cette clé API. + Ajoute ta clé API MDBList ci-dessous avant d’activer les notes. + Obtiens une clé sur https://mdblist.com/preferences et colle-la ici. Clé API Clé API MDBList Activer les notes MDBList - Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu'un ID IMDb est disponible. + Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu’un ID IMDb est disponible. CLÉ API FOURNISSEURS DE NOTES MDBLIST Actions - Contrôles de lecture et de sauvegarde. + Contrôles de lecture et d’enregistrement. Casting Liste principale du casting. Fond cinématographique - Fond flou derrière le contenu, similaire à l'écran de streams. + Fond flou derrière le contenu, similaire à l’écran de streams. Collection Rayon de collection ou de franchise associée. Commentaires Section de commentaires Trakt. Détails - Durée, statut, sortie, langue et informations associées. - Cartes d'épisodes - Choisissez comment les épisodes sont affichés sur l'écran de métadonnées. + Durée, statut, date de sortie, langue et informations associées. + Cartes d’épisodes + Choisis comment les épisodes sont affichés sur l’écran de métadonnées. Horizontal Cartes en ligne style fond Liste Cartes empilées centrées sur les détails Épisodes - Saisons et liste d'épisodes pour les séries. + Saisons et liste d’épisodes pour les séries. + Flouter les épisodes non vus + Floute les vignettes d’épisodes jusqu’à leur visionnage pour éviter les spoilers. Groupe %1$d - Plus comme ceci - Rayon de recommandations. + À voir aussi + Visuels de recommandations TMDB sur la page de détail. Aucun - Résumé + Aperçu Synopsis, notes, genres et crédits principaux. Production Studios et chaînes. APPARENCE SECTIONS - Groupe d'onglets %1$d + Groupe d’onglets %1$d Disposition des onglets - Regroupez les sections en onglets comme dans l'application TV. Assignez jusqu'à 3 sections par groupe d'onglets. + Regroupe les sections en onglets comme dans l’application TV. Assigne jusqu’à 3 sections par groupe d’onglets. Bandes-annonces Rayon de bandes-annonces et raccourcis de lecture. Les notifications sont actuellement désactivées dans Nuvio. - Alertes de sortie d'épisodes - Programmez des notifications locales lorsqu'un nouvel épisode d'une série sauvegardée est disponible. - Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test. + Alertes de sortie d’épisodes + Programme des notifications locales lorsqu’un nouvel épisode d’une série sauvegardée est disponible. + Les notifications système sont désactivées pour Nuvio. Active-les pour recevoir des alertes et des notifications de test. Il y a actuellement %1$d alertes de sortie programmées sur cet appareil. ALERTES TEST Envoyer une notification de test Envoi de la notification de test… Envoyer une notification locale de test pour %1$s. - Sauvegardez d'abord une série dans votre bibliothèque pour tester les notifications. + Enregistre d’abord une série dans ta bibliothèque pour tester les notifications. Notification de test Communauté - Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. - L'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. + Découvre les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. + L’API des supporters n’est pas configurée. Ajoute DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters Ouvrir GitHub @@ -606,8 +730,8 @@ Mar Avr Mai - Jun - Jul + Juin + Juil. Aoû Sep Oct @@ -620,47 +744,58 @@ Plugins autorisés Anime Skip ID client AnimeSkip - Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com. + Saisis ton ID client API AnimeSkip. Obtiens-en un sur anime-skip.com. + Activer la soumission d’intro + Affiche un bouton pour soumettre des timestamps d’intro/outro à la base de données communautaire. + Clé API IntroDB + Saisis ta clé API IntroDB pour soumettre des timestamps. Requise pour la soumission. Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client). - Lecture automatique de l'épisode suivant - Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint. + Lecture automatique de l’épisode suivant + Rechercher et lire automatiquement l’épisode suivant lorsque le seuil est atteint. Appareil uniquement - Préférer l'application (FFmpeg) - Préférer l'appareil + Préférer l’application (FFmpeg) + Préférer l’appareil Priorité du décodeur - Appuyez en dehors pour fermer - Appuyez en dehors pour enregistrer et fermer + Appuie en dehors pour fermer + Appuie en dehors pour enregistrer et fermer %1$d jour %1$d jours %1$d heure %1$d heures Activer libass Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut. + Lecteur externe + App de lecteur externe + Ouvre les nouvelles lectures avec l’app vidéo par défaut d’Android ou le sélecteur système. + Ouvre les nouvelles lectures avec le lecteur installé sélectionné. + Aucun lecteur externe compatible n’est installé Vitesse au maintien Maintenir pour accélérer - Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse. + Garde le doigt appuyé n’importe où sur le lecteur pour accélérer temporairement. Modèle regex invalide Durée du cache du dernier lien Mapper DV7 vers HEVC - Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles. + Convertir Dolby Vision Profil 7 en HEVC standard pour les appareils non compatibles. Minutes avant la fin - Afficher la carte de l'épisode suivant ce nombre de minutes avant la fin. + Afficher la carte de l’épisode suivant ce nombre de minutes avant la fin. %1$s min Aucun élément disponible Non défini Par défaut - Langue de l'appareil + Langue de l’appareil Forcé Aucun Préférer le groupe binge Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel. + Réutiliser le groupe de binge + Mémorise et réutilise le dernier groupe de binge entre les sessions (Continuer à regarder, Détails, etc.). Langue audio préférée Langue des sous-titres préférée Préréglages - Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL. + Correspond au nom du stream, à l’étiquette, à la description, à l’addon et à l’URL. Modèle regex 4K|2160p|Remux - N'importe quel 1080p+ + N’importe quel 1080p+ AVC / x264 Qualité BluRay Dolby Atmos / DTS @@ -680,7 +815,7 @@ Canvas superposé OpenGL superposé Réutiliser le dernier lien - Lire automatiquement votre dernier stream fonctionnel pour ce même film/épisode lorsque le cache est encore valide. + Lire automatiquement ton dernier stream fonctionnel pour ce même film/épisode lorsque le cache est encore valide. Langue audio secondaire Langue des sous-titres secondaire DÉCODEUR @@ -693,9 +828,9 @@ RENDU DES SOUS-TITRES %1$d sélectionné(s) Afficher la superposition de chargement - Afficher la superposition de chargement initiale pendant le démarrage d'un stream. - Passer l'intro/outro/récap - Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés. + Afficher la superposition de chargement initiale pendant le démarrage d’un stream. + Passer l’intro/outro/récap + Afficher un bouton de saut lors des segments d’intro, d’outro et de récapitulatif détectés. Périmètre des sources Tous les addons Considérer les streams de tous les addons installés. @@ -712,28 +847,28 @@ Sélectionner les streams manuellement à chaque fois. Correspondance regex Sélectionner automatiquement un stream correspondant à un modèle regex. - Délai d'expiration du stream + Délai d’expiration du stream Combien de temps attendre les streams avant la sélection automatique. Minutes avant la fin Mode de seuil Minutes avant la fin Pourcentage Pourcentage de seuil - Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage. - %1$s % + Afficher la carte de l’épisode suivant lorsque la lecture atteint ce pourcentage. + %1$s % Instantané - %1$ss + %1$ds Illimité Lecture tunnelisée Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo. - Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement. + Ajoute ta propre clé API TMDB ci-dessous avant d’activer l’enrichissement. Clé API TMDB - Activer l'enrichissement TMDB - Utiliser votre clé API TMDB pour enrichir les métadonnées de l'addon sur l'écran de détails lorsqu'un ID TMDB ou IMDb est disponible. - Saisissez votre clé API v3 TMDB. + Activer l’enrichissement TMDB + Utiliser ta clé API TMDB pour enrichir les métadonnées de l’addon sur l’écran de détails lorsqu’un ID TMDB ou IMDb est disponible. + Saisis ta clé API v3 TMDB. Code de langue Visuels - Remplacer le fond, l'affiche et le logo par les visuels TMDB. + Remplacer le fond, l’affiche et le logo par les visuels TMDB. Informations de base Utiliser le titre, le synopsis, les genres et la note de TMDB. Collections @@ -744,24 +879,24 @@ Utiliser les informations de sortie, durée, classification, statut, pays et langue de TMDB. Épisodes Utiliser les titres, miniatures, descriptions et durées des épisodes de TMDB pour les séries. - Plus comme ceci - Afficher les recommandations TMDB en bas des pages de détails. + À voir aussi + Visuels de recommandations TMDB sur la page de détail. Chaînes Utiliser les métadonnées des chaînes TMDB pour les titres TV. Sociétés de production - Utiliser les métadonnées des sociétés de production TMDB sur l'écran de détails. + Utiliser les métadonnées des sociétés de production TMDB sur l’écran de détails. Affiches de saison - Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l'écran de métadonnées pour les séries. + Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l’écran de métadonnées pour les séries. Bandes-annonces Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails. Clé API personnelle Langue préférée - Configurez le code de langue TMDB utilisé pour les métadonnées localisées, ex. `en`, `en-US` ou `pt-BR`. + Configure le code de langue TMDB utilisé pour les métadonnées localisées, ex. `en`, `en-US` ou `pt-BR`. IDENTIFIANTS LOCALISATION MODULES TMDB - Après approbation, vous serez redirigé automatiquement. + Après approbation, tu seras redirigé automatiquement. AUTHENTIFICATION Commentaires Afficher les commentaires Trakt dans les détails des films et séries @@ -769,14 +904,36 @@ Connecté en tant que %1$s Utilisateur Trakt Déconnecter - Impossible d'ouvrir le navigateur + Impossible d’ouvrir le navigateur FONCTIONNALITÉS - Terminez la connexion Trakt dans votre navigateur - Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt. + Termine la connexion Trakt dans ton navigateur + Suis ce que tu regardes, enregistre dans ta liste ou tes listes personnalisées et garde ta bibliothèque synchronisée avec Trakt. Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Ouvrir la connexion Trakt - Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. - Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt. + Tes actions d’enregistrement peuvent maintenant cibler la watchlist Trakt et tes listes personnelles. + Connecte-toi à Trakt pour activer l’enregistrement par liste et le mode bibliothèque Trakt. + Source de la bibliothèque + Choisis quelle bibliothèque utiliser pour enregistrer et consulter ta collection + Source de la bibliothèque + Choisis où enregistrer et gérer les éléments de ta bibliothèque + Trakt + Bibliothèque Nuvio + Bibliothèque Trakt sélectionnée + Bibliothèque Nuvio sélectionnée + Progression de visionnage + Choisis quelle source de progression alimente la reprise et Continuer à regarder + Progression de visionnage + Choisis si la reprise et Continuer à regarder doivent utiliser Trakt ou Nuvio Sync pendant que le scrobbling Trakt reste actif. + Trakt + Nuvio Sync + Source de progression réglée sur Trakt + Source de progression réglée sur Nuvio Sync + Fenêtre de Continuer à regarder + Historique Trakt pris en compte pour Continuer à regarder + Fenêtre de Continuer à regarder + Choisis quelle quantité d’activité Trakt apparaît dans Continuer à regarder. + Tout l’historique + %1$d jours Score du public IMDb Letterboxd @@ -795,11 +952,11 @@ Épisode suivant Recherche de la source… Lecture via %1$s dans %2$d… - Miniature de l'épisode suivant + Miniature de l’épisode suivant Non diffusé Passer - Passer l'intro - Passer l'outro + Passer l’intro + Passer l’outro Passer le récap Aucun sous-titre trouvé Afrikaans @@ -888,32 +1045,32 @@ Non Mettre à jour Oui - Voulez-vous quitter l'application ? - Quitter l'application - Ce catalogue n'a renvoyé aucun élément. + Veux-tu quitter l’application ? + Quitter l’application + Ce catalogue n’a renvoyé aucun élément. Aucun titre trouvé - Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez. + Vérifie ta connexion Wi‑Fi ou données mobiles et réessaie. Réalisateur Échec du chargement - Plus comme ceci + À voir aussi Saisons - Cet addon a renvoyé des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. - Cet addon n'a fourni aucune métadonnée d'épisode pour cette série. - Cet addon n'a pas encore publié d'épisodes. - Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires. + Cet addon a renvoyé des vidéos pour la série, mais aucune n’incluait de numéros de saison ou d’épisode. + Cet addon n’a fourni aucune métadonnée d’épisode pour cette série. + Cet addon n’a pas encore publié d’épisodes. + Ton appareil est en ligne, mais Nuvio n’a pas pu se connecter aux serveurs nécessaires. Afficher moins Afficher plus ▾ Scénariste Tous les genres Catalogue %1$s • %2$s - Le catalogue sélectionné n'a renvoyé aucun élément de découverte. + Le catalogue sélectionné n’a retourné aucun élément de découverte. Impossible de charger Découvrir - Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. + Les addons installés n’exposent pas de catalogues compatibles avec le tableau pour Découvrir. Aucun catalogue de découverte - Le catalogue et les filtres sélectionnés n'ont renvoyé aucun élément. + Le catalogue et les filtres sélectionnés n’ont retourné aucun élément. Aucun titre trouvé - Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir. + Installe et valide au moins un addon avant d’explorer les catalogues dans Découvrir. Sélectionner un catalogue Sélectionner un genre Sélectionner un type @@ -922,15 +1079,16 @@ Marquer les précédents comme vus Marquer %1$s comme non vue Marquer %1$s comme vue + Marquer les saisons précédentes comme vues Marquer comme non vu Marquer comme vu - Suivant + À suivre %1$s vu - Installez et validez au moins un addon avant de charger des lignes de catalogue à l'accueil. - Les addons installés n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. - Aucune ligne d'accueil disponible + Installe et valide au moins un addon avant de charger des lignes de catalogue à l’accueil. + Les addons installés n’exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. + Aucune ligne d’accueil disponible Voir les détails - Contrôles pour lire et enregistrer. + Contrôles de lecture et d’enregistrement. Actions Liste principale du casting. Rayon de collection ou de franchise associée. @@ -938,21 +1096,23 @@ Section de commentaires Trakt. Durée, statut, date de sortie, langue et informations associées. Détails - Saisons et liste d'épisodes pour les séries. + Saisons et liste d’épisodes pour les séries. Rayon de recommandations. - Plus comme ceci + À voir aussi Synopsis, notes, genres et crédits principaux. - Résumé + Aperçu Studios et chaînes. Production Rayon de bandes-annonces et raccourcis de lecture. De nouveau en ligne - Impossible d'atteindre les serveurs + Impossible d’atteindre les serveurs + Corps de réponse vide + La requête a échoué avec HTTP %1$d Pas de connexion Internet (âge %1$d) Né(e) le %1$s%2$s Décédé(e) le %1$s - Connu(e) pour : %1$s + Connu(e) pour : %1$s Récent Impossible de charger les détails de %1$s Populaire @@ -962,14 +1122,19 @@ Annuler Saisir le code PIN Saisir le code PIN pour %1$s - Code PIN oublié ? + Code PIN oublié ? Code PIN incorrect - Bloqué. Réessayez dans %1$ds - Les options d'avatar apparaîtront ici une fois le catalogue chargé. + Bloqué. Réessaie dans %1$ds + Les options d’avatar apparaîtront ici une fois le catalogue chargé. Avatar : %1$s + Saisis une URL d’image http:// ou https:// valide. Choisir un avatar - Choisissez un avatar ci-dessous. + Choisis un avatar ci-dessous. Créer un profil + URL d’avatar personnalisée sélectionnée. + URL d’avatar personnalisée + Colle un lien d’image, ou laisse vide pour utiliser le catalogue d’avatars intégré. + https://example.com/avatar.png Toutes les données de "%1$s" seront définitivement supprimées. Supprimer le profil Ajouter un profil @@ -987,26 +1152,28 @@ Supprimer le verrouillage PIN Enregistrement… Sécurité - Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d'y accéder. + Ajoute un code PIN si tu souhaites que ce profil soit verrouillé avant d’y accéder. Ce profil est protégé par un code PIN. - Sélectionnez un avatar pour ce profil. + Sélectionne un avatar pour ce profil. Configurer le verrouillage PIN Profil sans nom Utiliser les addons principaux Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée. - Qui regarde ? + Qui regarde ? Téléchargé Reprendre Scrapers actifs - Vérification d'autres addons… + Vérification d’autres addons… Copier le lien du stream Télécharger le fichier - Les addons de streams installés n'ont pas renvoyé de réponse valide. + Ouvrir dans le lecteur externe + Ouvrir dans le lecteur interne + Les addons de streams installés n’ont pas renvoyé de réponse valide. Impossible de charger les streams - Installez d'abord un addon pour charger les streams de ce titre. - Vos addons installés ne fournissent pas de streams pour ce type de titre. + Installe d’abord un addon pour charger les streams de ce titre. + Tes addons installés ne fournissent pas de streams pour ce type de titre. Aucun addon de streams disponible - Aucun de vos addons installés n'a renvoyé de stream pour ce titre. + Aucun de tes addons installés n’a renvoyé de stream pour ce titre. S%1$d E%2$d Épisode S%1$dE%2$d - %3$s @@ -1017,9 +1184,17 @@ Aucun lien direct du stream disponible Aucune métadonnée disponible Actualiser les streams - Reprendre depuis %1$d % + Reprendre depuis %1$d% Reprendre depuis %1$s TAILLE %1$s + Ce type de stream n’est pas pris en charge + Connecte un compte dans les paramètres. + Non mis en cache sur Torbox. + Ce lien a expiré. Actualisation des résultats. + Impossible d’ouvrir ce lien. + Impossible d’ouvrir le lecteur externe + Choisis d’abord un lecteur externe dans les paramètres + Aucun lecteur externe disponible Fermer la bande-annonce Impossible de lire la bande-annonce Impossible de charger les listes Trakt @@ -1028,9 +1203,9 @@ Échec de la vérification des mises à jour Échec du téléchargement Téléchargement %1$d % - Impossible de démarrer l'installation - Vous utilisez la version la plus récente. - Activez l'installation d'applications pour Nuvio puis revenez pour continuer. + Impossible de démarrer l’installation + Tu utilises la version la plus récente. + Active l’installation d’applications pour Nuvio puis reviens pour continuer. Téléchargement de la mise à jour… Aucune mise à jour trouvée. Une nouvelle version est prête à être installée. @@ -1041,61 +1216,93 @@ Mise à jour disponible Statut de la mise à jour Cet addon est déjà installé. - Veuillez saisir une URL d'addon valide + Saisis une URL d’addon valide Impossible de charger le manifeste Nuvio Impossible de supprimer le compte Échec de la connexion Échec de la déconnexion - Échec de l'inscription + Échec de l’inscription Impossible de charger les éléments du catalogue. À suivre À suivre • S%1$dE%2$d - logo de %1$s + Logo de %1$s Impossible de charger les commentaires - Impossible de charger les détails depuis aucun addon. - Réseaux + Impossible de charger les détails depuis les addons disponibles. + Chaînes Aucun addon ne fournit de métadonnées pour ce contenu. - Téléchargement échoué + Échec du téléchargement Affiche la progression en direct et les contrôles de téléchargement. Téléchargements Téléchargement terminé Téléchargement de %1$s • %2$s Téléchargement de %1$s • %2$s / %3$s - Téléchargement échoué + Échec du téléchargement En pause %1$s Supprimer - Supprimer %1$s de votre bibliothèque ? + Retirer %1$s de %2$s ? + Supprimer %1$s de ta bibliothèque ? Retirer de la bibliothèque ? Film - Alertes lorsqu'un nouvel épisode d'une série sauvegardée est disponible. - Aperçu de l'alerte de sortie d'épisode. - Impossible d'envoyer une notification de test. + Alertes lorsqu’un nouvel épisode d’une série enregistrée est disponible. + Aperçu de l’alerte de sortie d’épisode. + Impossible d’envoyer une notification de test. Notification de test envoyée pour %1$s. + Moteur de lecture MPV indisponible. Reconstruis l’app. Impossible de lire ce stream. - Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil. - Impossible de supprimer le verrouillage PIN. Veuillez réessayer. - Connectez-vous à Internet pour supprimer le verrouillage PIN. - Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d'abord. - Impossible de définir le code PIN. Veuillez réessayer. - Connectez-vous à Internet pour définir un code PIN. + Le code PIN de ce profil a changé. Connecte-toi une fois pour mettre à jour le verrouillage sur cet appareil. + Impossible de supprimer le verrouillage PIN. Réessaie. + Connecte-toi à Internet pour supprimer le verrouillage PIN. + Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connecte-toi une fois et déverrouille-le en ligne d’abord. + Impossible de définir le code PIN. Réessaie. + Connecte-toi à Internet pour définir un code PIN. Ce profil utilise les addons principaux. Impossible de charger %1$s Source Intégré Autorisation refusée - Terminez la connexion Trakt dans votre navigateur + Termine la connexion Trakt dans ton navigateur Callback Trakt invalide État du callback Trakt invalide Réponse de jeton Trakt invalide Impossible de charger la bibliothèque Trakt Liste %1$d - Trakt n'a pas renvoyé de code d'autorisation + Trakt n’a pas retourné de code d’autorisation Identifiants Trakt manquants Impossible de charger la progression Trakt + Liste publique Trakt + Saisis un ID ou une URL de liste Trakt valide + %1$d éléments + %1$d j’aime + Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID). + ID de liste Trakt manquant + La liste Trakt n’inclut pas d’ID numérique + Liste Trakt introuvable ou non publique + Limite de requêtes Trakt atteinte + Échec de la requête Trakt Impossible de terminer la connexion Trakt Utilisateur Trakt Liste de suivi + Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB. + Collection TMDB %1$d + Collection TMDB introuvable + Production TMDB %1$d + Société TMDB introuvable + Réalisateur TMDB %1$d + La découverte TMDB n’a retourné aucune donnée + Découverte TMDB + Saisis un ID ou une URL TMDB valide. + Liste TMDB %1$d + Liste TMDB introuvable + Impossible de charger la source TMDB + ID de collection TMDB manquant + ID de liste TMDB manquant + ID de personne TMDB manquant + Chaîne TMDB %1$d + Chaîne TMDB introuvable + Crédits TMDB de la personne introuvables + Personne TMDB %1$d + Personne TMDB introuvable Bande-annonce Inconnu Addon @@ -1103,14 +1310,16 @@ Lire %1$s Reprendre %1$s Le JSON est vide. - La collection '%1$d' a un ID vide. - La collection '%1$s' a un titre vide. - Le dossier '%1$d' dans '%2$s' a un ID vide. - Le dossier '%1$s' dans '%2$s' a un titre vide. - La source '%1$d' dans le dossier '%2$s' a des champs vides. - La source '%1$d' dans le dossier '%2$s' n'a pas d'ID de liste Trakt. - JSON invalide : %1$s - Addon introuvable : %1$s + La collection %1$d a un ID vide. + La collection « %1$s » a un titre vide. + Le dossier %1$d dans « %2$s » a un ID vide. + Le dossier « %1$s » dans « %2$s » a un titre vide. + La source %1$d dans le dossier « %2$s » a des champs vides. + La source %1$d dans le dossier « %2$s » n’a pas d’ID de liste Trakt. + JSON invalide : %1$s + Addon introuvable : %1$s + Liste de films Trakt + Liste de séries Trakt Janvier Février Mars @@ -1128,8 +1337,8 @@ Mar Avr Mai - Jun - Jul + Juin + Juil. Aoû Sep Oct @@ -1141,11 +1350,11 @@ Populaire Récent %1$s • %2$s - Mieux noté + Mieux notés Classification Détails du film Langue originale - Pays d'origine + Pays d’origine Informations de sortie Durée Affiches @@ -1159,19 +1368,54 @@ Téléchargement démarré Format de stream non pris en charge pour les téléchargements Corps de réponse vide + Impossible de finaliser le fichier téléchargé La requête a échoué avec HTTP %1$d - Le système de téléchargement n'est pas initialisé + Le système de téléchargement n’est pas initialisé + Impossible d’ouvrir le fichier de téléchargement partiel + Le fichier de téléchargement partiel n’est pas ouvert La requête de téléchargement a échoué + Impossible d’écrire dans le fichier de téléchargement partiel %1$s - %2$s Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails. - Votre bibliothèque est vide + Ta bibliothèque est vide Impossible de charger la bibliothèque Autre + Cloud + Enregistrés Bibliothèque - Connectez Trakt et enregistrez des titres dans votre liste de suivi ou vos listes personnelles. - Votre bibliothèque Trakt est vide + Connecte Trakt et enregistre des titres dans ta liste de suivi ou tes listes personnelles. + Ta bibliothèque Trakt est vide Impossible de charger la bibliothèque Trakt Bibliothèque Trakt + Connecter un compte + Connecte un compte dans les paramètres Services connectés pour parcourir les fichiers lisibles de ta cloud library. + Aucun compte cloud connecté + Ouvrir Services connectés + Active la cloud library dans les paramètres Services connectés pour parcourir les fichiers des comptes connectés. + Cloud library désactivée + Aucun fichier cloud lisible ne correspond aux filtres actuels. + Rien ici pour l’instant + Choisis un fichier à lire + Impossible de charger la cloud library %1$s + Cet élément n’expose aucun fichier vidéo lisible. + Aucun fichier lisible + Aucun fichier lisible + Cloud library désactivée. + Impossible de lire ce fichier cloud. + Lire le fichier + Aucun service cloud connecté. + %1$s n’est pas connecté. + %1$d fichiers lisibles + Tous + Actualiser la cloud library + Sélectionner le fournisseur + Sélectionner un type + Prêt à lire + Tous + Torrents + Usenet + Web + Fichiers Anime Chaînes Films @@ -1181,7 +1425,15 @@ %1$s • %2$s est maintenant disponible Un nouvel épisode est maintenant disponible %1$s est maintenant disponible - Sorties d'épisodes + Sorties d’épisodes + Alcool/Drogues + Effrayant + Nudité + Grossièretés + Léger + Modéré + Sévère + Violence Créateur Réalisateur Scénariste @@ -1192,8 +1444,303 @@ Ko Mo Go + + Soumettre l’intro + Paramètres vidéo + + La cloud library n’est pas disponible pour %1$s. + + Vidéo + Réinitialiser le réglage + Préréglage de sortie + Détection du pic HDR + Estime le pic de luminosité HDR quand les métadonnées sont absentes ou erronées. + Tone mapping + Deband + Réduit les artefacts de banding au prix d’une légère perte de performances. + Interpolation d’images + Lisse le mouvement quand mpv peut utiliser la synchro d’affichage proprement. + Luminosité + Contraste + Saturation + Gamma + + Native EDR + Optimal pour les iPhones et iPads compatibles HDR. + SDR tone mapped + Blancs et noirs plus prévisibles sur une sortie de type SDR. + Compatibilité + Au plus près de l’ancien comportement MPV iOS. + Personnalisé + Utilise tes valeurs avancées ci-dessous. + Désactivé + + Sortie vidéo iOS + Décodeur matériel + Plage dynamique étendue + Mode de sortie Metal par défaut pour les nouvelles sessions de lecture. + Indication de couleur d’affichage + Permettre à mpv de cibler par défaut l’espace colorimétrique de l’écran actif. + Primaires cibles + Transfert cible + + Gestion des résultats + Nombre max de résultats + Limite le nombre de résultats affichés. + Tri des résultats + Choisis l’ordre dans lequel les résultats apparaissent. + Limite par résolution + Plafonne les résultats répétés en 2160p, 1080p, 720p après le tri. + Limite par qualité + Plafonne les résultats répétés en BluRay, WEB-DL, REMUX après le tri. + Plage de taille + Filtre les résultats par taille de fichier. + + En savoir plus + Format par défaut + Saisis un groupe par ligne. + + Ordre original + Meilleure qualité d’abord + Plus gros d’abord + Plus petits d’abord + Meilleur audio d’abord + Langue d’abord + + Tous + %1$d sélectionné(s) + + Tous les résultats + %1$d résultats + + Jusqu’à %1$d Go + %1$d Go et plus + %1$d-%2$d Go + + Résolutions préférées + Trie d’abord les résolutions sélectionnées, dans l’ordre par défaut. + Résolutions requises + Affiche uniquement les résolutions sélectionnées. + Résolutions exclues + Masque les résolutions sélectionnées. + + Qualités préférées + Trie d’abord les qualités sélectionnées, dans l’ordre par défaut. + Qualités requises + Affiche uniquement les qualités sélectionnées. + Qualités exclues + Masque les qualités sélectionnées. + + Tags visuels préférés + Trie d’abord les tags DV, HDR, 10bit, IMAX et similaires. + Tags visuels requis + Exige les tags DV, HDR, 10bit, IMAX, SDR et similaires. + Tags visuels exclus + Masque les tags DV, HDR, 10bit, 3D et similaires. + + Tags audio préférés + Trie d’abord les tags Atmos, TrueHD, DTS, AAC et similaires. + Tags audio requis + Exige les tags Atmos, TrueHD, DTS, AAC et similaires. + Tags audio exclus + Masque les tags audio sélectionnés. + + Canaux préférés + Trie d’abord les configurations de canaux préférées. + Canaux requis + Affiche uniquement les configurations de canaux sélectionnées. + Canaux exclus + Masque les configurations de canaux sélectionnées. + + Encodages préférés + Trie d’abord les encodages AV1, HEVC, AVC et similaires. + Encodages requis + Exige les encodages AV1, HEVC, AVC et similaires. + Encodages exclus + Masque les encodages sélectionnés. + + Langues préférées + Trie d’abord les langues audio préférées. + Langues requises + Affiche uniquement les résultats avec les langues sélectionnées. + Langues exclues + Masque les résultats où toutes les langues sont exclues. + + Release groups requis + Affiche uniquement les release groups sélectionnés. + Release groups exclus + Masque les release groups sélectionnés. + + Soumettre les timestamps + TYPE DE SEGMENT + Intro + Récap + Outro + DÉBUT (MM:SS) + FIN (MM:SS) + Soumettre + Capturer + + Décodeur matériel + Primaires cibles + Transfert cible + + Liste Trakt résolue + TMDB Discover + US, KR, JP, IN + + Saisis un nom de liste Trakt, une URL ou un ID + Saisis un ID ou une URL TMDB valide. + Impossible de charger la source TMDB + Saisis un ID ou une URL de liste Trakt + Impossible de charger la liste Trakt + + Canada + Australie + Allemagne + + Films + Séries + + La cloud library est désactivée. + + Clé API invalide ou échec de connexion + + US, KR, JP, IN + 2020-01-01 + 2024-12-31 + 28,12 + 18,35 + en, ko, ja, hi + Ordre original + 100 + 2024 + Connecté à Trakt + Déconnecté de Trakt + Corps de téléchargement vide + Le fichier de mise à jour téléchargé est introuvable. + Champ « %1$s » manquant dans le manifeste + Collection TMDB %1$s + Réalisateur TMDB %1$s + Découverte TMDB + Saisis un ID ou une URL TMDB valide. + Liste TMDB %1$s + Chaîne TMDB %1$s + Personne TMDB %1$s + Production TMDB %1$s + Impossible de charger la source TMDB + ID %1$s + Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB. + Collection TMDB introuvable + Société TMDB introuvable + Découverte TMDB n’a retourné aucune donnée + Liste TMDB introuvable + ID de collection TMDB manquant + ID de liste TMDB manquant + ID de personne TMDB manquant + Chaîne TMDB introuvable + Crédits TMDB de la personne introuvables + Personne TMDB introuvable + Identifiants Trakt manquants. + %1$s (%2$d) + Saisis un ID ou une URL de liste Trakt valide + %1$d éléments + %1$d j’aime + Liste Trakt introuvable ou non publique + ID de liste Trakt manquant + La liste Trakt ne contient pas d’ID numérique + Liste Trakt publique + Limite de requêtes Trakt atteinte + Échec de la requête Trakt + Échec du chargement des commentaires Trakt (%1$d) + %1$d h %2$d min + %1$d h + %1$d min + Impossible de finaliser le fichier de téléchargement + Impossible d’ouvrir le fichier de téléchargement partiel + Le fichier de téléchargement partiel n’est pas ouvert + Impossible d’écrire dans le fichier de téléchargement partiel + Bibliothèque Nuvio + Problème de connexion + Corps de réponse vide + Vérifie ta connexion et réessaie. + Échec de la requête (HTTP %1$d) + %1$s (%2$s) + Moteur de lecture MPV indisponible. Reconstruis l’app. + Impossible de lire ce stream. + Plugins désactivés + Plugins activés + %1$d fournisseurs + Actualisation + %1$d dépôts + Clé API TMDB manquante + Clé API TMDB renseignée + Installer le dépôt de plugin + Installation… + Tester le fournisseur + Test en cours… + Supprimer le dépôt de plugin + Actualiser le dépôt de plugin + Aucun fournisseur disponible pour l’instant. + Ajoute une URL de dépôt pour installer des plugins fournisseurs. + Aucun dépôt de plugin installé pour l’instant. + Utilise les fournisseurs de plugins pendant la recherche de streams. + Activer les fournisseurs de plugins + Ce dépôt de plugin est déjà installé. + Saisis une URL de dépôt de plugin. + Saisis une URL de plugin valide. + Impossible d’installer le dépôt de plugin + Fournisseur introuvable + Impossible d’actualiser le dépôt + Les plugins ne sont pas disponibles dans cette version. + Dans Streams, affiche un fournisseur par dépôt au lieu d’un par source. + Regrouper les fournisseurs par dépôt + URL du manifeste du plugin + Nom du manifeste manquant. + Le manifeste ne contient aucun fournisseur. + Version du manifeste manquante. + Le nom du manifeste est manquant. + Le manifeste ne contient aucun fournisseur. + La version du manifeste est manquante. + %1$s installé. + Désactivé par le dépôt + Aucune description + v%1$s + Dépôt de plugin + Version %1$s + Ce dépôt de plugin est déjà installé. + Impossible d’installer le dépôt de plugin + Impossible d’actualiser le dépôt + AJOUTER UN DÉPÔT + DÉPÔTS INSTALLÉS + APERÇU + FOURNISSEURS + Erreur + Échec du test du fournisseur + Résultats du test (%1$d) + Les fournisseurs de plugins nécessitent une clé API TMDB. Configure-la dans l’écran TMDB, sinon les fournisseurs ne fonctionneront pas correctement. + Aucun résultat de recherche pour %1$s. + Clé API invalide ou échec de connexion + Dépôt de plugin + Soumettre l’intro + Soumettre + Capturer + FIN (MM:SS) + TYPE DE SEGMENT + DÉBUT (MM:SS) + Connecté à Trakt + Déconnecté de Trakt + To + Aucun fichier APK trouvé dans la release + Échec du téléchargement (HTTP %1$d) + Le fichier de mise à jour téléchargé est introuvable. + Corps de téléchargement vide + Erreur API releases GitHub : %1$d + Aucune mise à jour n’a encore été publiée. + La release n’a pas de tag ni de nom + Diffusion le %1$s - Diffusion aujourd\'hui + Diffusion aujourd’hui Diffusion demain Diffusion dans %1$d jour @@ -1201,7 +1748,7 @@ Diffusion dans %1$d jours %1$s - Aujourd\'hui + Aujourd’hui Demain Dans %1$d jour @@ -1210,4 +1757,7 @@ Nouvel épisode Nouvelle saison + + Liste Trakt %1$s + Lecteur système Android diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f2784b68..baaf9ab7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -166,18 +166,27 @@ Quick networks Genre IDs Use TMDB genre numbers. Separate multiple with commas for AND, or pipes for OR. + 28,12 + 18,35 Release or air date from Release or air date to Use YYYY-MM-DD, for example 2024-01-01. + 2020-01-01 + 2024-12-31 Minimum rating Maximum rating TMDB rating from 0 to 10. Example: 7.0. + 7.0 + 10 Minimum votes Use this to avoid obscure low-vote titles. Example: 100. + 100 Original language Use two-letter language codes, for example en, ko, ja, hi. + en, ko, ja, hi Origin country Use two-letter country codes, for example US, KR, JP, IN. + US, KR, JP, IN Keyword IDs Use TMDB keyword numbers. Quick chips fill common examples. 9715 for superhero @@ -189,6 +198,7 @@ 213 for Netflix Year Use a four-digit year, for example 2024. + 2024 Presets Search Add Source @@ -213,6 +223,12 @@ Popular Percentage Votes + Enter a Trakt list name, URL, or ID + Enter a Trakt list ID or URL + Could not load Trakt list + No Trakt lists found + Resolved Trakt list + Trakt List %1$d Action Adventure Animation @@ -586,6 +602,8 @@ VISIBILITY Display the Continue Watching shelf on the Home screen. Show Continue Watching + Card + TV-style landscape card Poster Artwork-first poster card Wide @@ -643,9 +661,9 @@ %1$d links Formatting Name template - Controls how result names appear. + Controls how result names appear. Leave blank to use the original result name. Description template - Controls the metadata shown under each result. + Controls the metadata shown under each result. Leave blank to use the original result details. Reset formatting Restore default result formatting. API key validated. @@ -1113,6 +1131,8 @@ Mark as watched Up next %1$s watched + %1$dh %2$dm left + %1$dm left Install and validate at least one addon before loading catalog rows on Home. Installed addons do not currently expose board-compatible catalogs without required extras. No home rows available @@ -1135,6 +1155,8 @@ Trailer rail and playback shortcuts. Back online Cannot reach servers + Empty response body + Request failed with HTTP %1$d No internet connection (age %1$d) Born %1$s%2$s @@ -1230,6 +1252,8 @@ %1$s • %2$s Update check failed Download failed + Empty download body + Downloaded update file is missing. Downloading %1$d% Unable to start installation You're using the latest version. @@ -1277,6 +1301,7 @@ Failed to send a test notification. Test notification sent for %1$s. Unable to play this stream. + MPV player engine not available. Please rebuild the app. This profile PIN changed. Connect once to refresh the lock on this device. Couldn't remove PIN lock. Try again. Connect to the internet to remove the PIN lock. @@ -1289,6 +1314,8 @@ Embedded Authorization denied Complete Trakt sign in in your browser + Connected to Trakt + Disconnected from Trakt Invalid Trakt callback Invalid Trakt callback state Invalid Trakt token response @@ -1297,9 +1324,39 @@ Trakt did not return an authorization code Missing Trakt credentials Failed to load Trakt progress + Trakt public list + Enter a valid Trakt list ID or URL + %1$d items + %1$d likes + Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID). + Missing Trakt list ID + Trakt list did not include a numeric ID + Trakt list not found or not public + Trakt rate limit reached + Trakt request failed Failed to complete Trakt sign in Trakt user Watchlist + Add a TMDB API key in Settings to use TMDB sources. + TMDB Collection %1$d + TMDB collection not found + TMDB Production %1$d + TMDB company not found + TMDB Director %1$d + TMDB discover returned no data + TMDB Discover + Enter a valid TMDB ID or URL. + TMDB List %1$d + TMDB list not found + Could not load TMDB source + Missing TMDB collection ID + Missing TMDB list ID + Missing TMDB person ID + TMDB Network %1$d + TMDB network not found + TMDB person credits not found + TMDB Person %1$d + TMDB person not found Trailer Unknown Addon @@ -1315,6 +1372,8 @@ Source %1$d in folder '%2$s' is missing a Trakt list ID. Invalid JSON: %1$s Addon not found: %1$s + Trakt Movie List + Trakt Series List January February March @@ -1363,9 +1422,13 @@ Download started Unsupported stream format for downloads Empty response body + Failed to finalize download file Request failed with HTTP %1$d Download system is not initialized + Failed to open partial download file + Partial download file is not open Download request failed + Failed to write partial download file %1$s - %2$s Saved titles will appear here after you tap Save on a details screen. Your library is empty @@ -1435,6 +1498,288 @@ KB MB GB + + Submit Intro + Video settings + + Cloud library is not available for %1$s. + + Video + Reset tuning + Output preset + HDR peak detection + Estimate HDR peak brightness when metadata is bad or missing. + Tone mapping + Deband + Reduce color banding at a small performance cost. + Frame interpolation + Smooth motion when mpv can use display sync cleanly. + Brightness + Contrast + Saturation + Gamma + + Native EDR + Best for HDR-capable iPhones and iPads. + SDR tone mapped + More predictable whites and blacks on SDR-style output. + Compatibility + Closest to the older iOS MPV behavior. + Custom + Use your advanced values below. + Off + + iOS video output + Hardware decoder + Extended dynamic range + Default Metal output mode for new playback sessions. + Display color hint + Let mpv target the active display color space by default. + Target primaries + Target transfer + + Result Management + Max results + Limit how many results appear. + Sort results + Choose how results are ordered. + Per resolution limit + Cap repeated 2160p, 1080p, 720p results after sorting. + Per quality limit + Cap repeated BluRay, WEB-DL, REMUX results after sorting. + Size range + Filter results by file size. + + Learn more + Default format + Original format + Enter one group per line. + + Original order + Best quality first + Largest first + Smallest first + Best audio first + Language first + + Any + %1$d selected + + All results + %1$d results + + Up to %1$dGB + %1$dGB+ + %1$d-%2$dGB + + Preferred resolutions + Sort selected resolutions first, in default order. + Required resolutions + Only show selected resolutions. + Excluded resolutions + Hide selected resolutions. + + Preferred qualities + Sort selected qualities first, in default order. + Required qualities + Only show selected qualities. + Excluded qualities + Hide selected qualities. + + Preferred visual tags + Sort DV, HDR, 10bit, IMAX and similar tags. + Required visual tags + Require DV, HDR, 10bit, IMAX, SDR and similar tags. + Excluded visual tags + Hide DV, HDR, 10bit, 3D and similar tags. + + Preferred audio tags + Sort Atmos, TrueHD, DTS, AAC and similar tags. + Required audio tags + Require Atmos, TrueHD, DTS, AAC and similar tags. + Excluded audio tags + Hide selected audio tags. + + Preferred channels + Sort preferred channel layouts first. + Required channels + Only show selected channel layouts. + Excluded channels + Hide selected channel layouts. + + Preferred encodes + Sort AV1, HEVC, AVC and similar encodes. + Required encodes + Require AV1, HEVC, AVC and similar encodes. + Excluded encodes + Hide selected encodes. + + Preferred languages + Sort preferred audio languages first. + Required languages + Only show results with selected languages. + Excluded languages + Hide results where every language is excluded. + + Required release groups + Only show selected release groups. + Excluded release groups + Hide selected release groups. + + Submit Timestamps + SEGMENT TYPE + Intro + Recap + Outro + START TIME (MM:SS) + END TIME (MM:SS) + Submit + Capture + + Hardware decoder + Target primaries + Target transfer + + Resolved Trakt list + TMDB Discover + US, KR, JP, IN + + Enter a Trakt list name, URL, or ID + Enter a valid TMDB ID or URL. + Could not load TMDB source + Enter a Trakt list ID or URL + Could not load Trakt list + + Canada + Australia + Germany + + Movies + Series + + Cloud library is disabled. + + Invalid API Key or connection failed + Manifest missing \"%1$s\" + TMDB Collection %1$s + TMDB Director %1$s + TMDB Discover + Enter a valid TMDB ID or URL. + TMDB List %1$s + TMDB Network %1$s + TMDB Person %1$s + TMDB Production %1$s + Could not load TMDB source + ID %1$s + Add a TMDB API key in Settings to use TMDB sources. + TMDB collection not found + TMDB company not found + TMDB discover returned no data + TMDB list not found + Missing TMDB collection ID + Missing TMDB list ID + Missing TMDB person ID + TMDB network not found + TMDB person credits not found + TMDB person not found + Missing Trakt credentials. + %1$s (%2$d) + Enter a valid Trakt list ID or URL + %1$d items + %1$d likes + Trakt list not found or not public + Missing Trakt list ID + Trakt list did not include a numeric ID + Trakt public list + Trakt rate limit reached + Trakt request failed + Failed to load Trakt comments (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Failed to finalize download file + Failed to open partial download file + Partial download file is not open + Failed to write partial download file + Nuvio Library + Connection issue + Empty response body + Please check your connection and try again. + Request failed with HTTP %1$d + %1$s (%2$s) + MPV player engine not available. Please rebuild the app. + Unable to play this stream. + Plugins disabled + Plugins enabled + %1$d providers + Refreshing + %1$d repos + TMDB API key missing + TMDB API key set + Install Plugin Repository + Installing… + Test Provider + Testing… + Delete plugin repository + Refresh plugin repository + No providers available yet. + Add a repository URL to install provider plugins for stream discovery. + No plugin repositories installed yet. + Use plugin providers during stream discovery. + Enable plugin providers globally + That plugin repository is already installed. + Enter a plugin repository URL. + Enter a valid plugin URL. + Unable to install plugin repository + Provider not found + Unable to refresh repository + Plugins are not available in this build. + In Streams, show one provider per repository instead of one per source. + Group plugin providers by repository + Plugin manifest URL + Manifest name is missing. + Manifest has no providers. + Manifest version is missing. + Manifest name is missing. + Manifest has no providers. + Manifest version is missing. + Installed %1$s. + Disabled by repo + No description + v%1$s + Plugin repository + Version %1$s + That plugin repository is already installed. + Unable to install plugin repository + Unable to refresh repository + ADD REPOSITORY + INSTALLED REPOSITORIES + OVERVIEW + PROVIDERS + Error + Provider test failed + Test results (%1$d) + Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly. + No search results returned for %1$s. + Invalid API Key or connection failed + Plugin repository + Submit Intro + Submit + Capture + END TIME (MM:SS) + SEGMENT TYPE + START TIME (MM:SS) + Connected to Trakt + Disconnected from Trakt + TB + No APK asset found in the release + Download failed with HTTP %1$d + Downloaded update file is missing. + Empty download body + GitHub releases API error: %1$d + No update has been published yet. + Release has no tag or name + Airs %1$s Airs today Airs tomorrow @@ -1451,4 +1796,7 @@ New Episode New Season + + Trakt List %1$s + Android system player diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2028147c..3ae3ccf8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -103,7 +103,6 @@ import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository -import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL @@ -630,14 +629,6 @@ private fun MainAppContent( var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) } - val addonProbeTargets = remember(addonsUiState.addons) { - addonsUiState.addons - .enabledAddons() - .mapNotNull { it.manifest?.transportUrl } - .distinct() - .sorted() - } - fun handleRootTabClick(tab: AppScreenTab) { if (selectedTab != tab) { selectedTab = tab @@ -703,13 +694,9 @@ private fun MainAppContent( initialHomeReady = true } - LaunchedEffect(addonProbeTargets) { - NetworkStatusRepository.updateAddonProbeTargets(addonProbeTargets) - } - LaunchedEffect(Unit) { AppForegroundMonitor.events().collect { - NetworkStatusRepository.requestRefresh(force = true) + NetworkStatusRepository.requestForegroundRefresh() } } @@ -790,7 +777,7 @@ private fun MainAppContent( val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect AppForegroundMonitor.events().collect { - SyncManager.requestForegroundPull(activeProfileId) + SyncManager.requestForegroundPull(activeProfileId, force = true) } } var profileSwitchLoading by remember { mutableStateOf(false) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt index e9976f75..4288603f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/NetworkStatusRepository.kt @@ -1,14 +1,25 @@ package com.nuvio.app.core.network +import androidx.compose.runtime.Composable import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.details_check_connection +import nuvio.composeapp.generated.resources.details_servers_unreachable +import nuvio.composeapp.generated.resources.network_cannot_reach_servers +import nuvio.composeapp.generated.resources.network_connection_issue +import nuvio.composeapp.generated.resources.network_no_internet_connection +import nuvio.composeapp.generated.resources.network_please_check_connection +import org.jetbrains.compose.resources.stringResource enum class NetworkCondition { Unknown, @@ -28,22 +39,26 @@ data class NetworkStatusUiState( get() = condition == NetworkCondition.NoInternet || condition == NetworkCondition.ServersUnreachable } +@Composable fun NetworkCondition.titleForEmptyState(): String = when (this) { - NetworkCondition.ServersUnreachable -> "Cannot reach servers" - NetworkCondition.NoInternet -> "No internet connection" - else -> "Connection issue" + NetworkCondition.ServersUnreachable -> stringResource(Res.string.network_cannot_reach_servers) + NetworkCondition.NoInternet -> stringResource(Res.string.network_no_internet_connection) + else -> stringResource(Res.string.network_connection_issue) } +@Composable fun NetworkCondition.messageForEmptyState(): String = when (this) { - NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers." - NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again." - else -> "Please check your connection and try again." + NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable) + NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection) + else -> stringResource(Res.string.network_please_check_connection) } object NetworkStatusRepository { private const val REQUEST_TIMEOUT_MS = 4_500L + private const val FOREGROUND_REFRESH_DELAY_MS = 6_000L + private const val FOREGROUND_FAILURE_CONFIRM_DELAY_MS = 2_000L private const val PUBLIC_PROBE_PRIMARY = "https://www.gstatic.com/generate_204" private const val PUBLIC_PROBE_FALLBACK = "https://cloudflare.com/cdn-cgi/trace" @@ -54,7 +69,8 @@ object NetworkStatusRepository { private var started = false private var probeInFlight = false private var pendingProbeAfterCurrent = false - private var addonProbeTargets: List = emptyList() + private var pendingProbeConfirmFailures = false + private var foregroundRefreshJob: Job? = null fun ensureStarted() { if (started) return @@ -62,42 +78,62 @@ object NetworkStatusRepository { requestRefresh(force = true) } - fun updateAddonProbeTargets(urls: List) { - val normalized = urls - .map { it.trim() } - .filter { it.isNotBlank() } - .distinct() - if (normalized == addonProbeTargets) return - addonProbeTargets = normalized - requestRefresh(force = true) + fun requestForegroundRefresh() { + ensureStarted() + foregroundRefreshJob?.cancel() + foregroundRefreshJob = scope.launch { + delay(FOREGROUND_REFRESH_DELAY_MS) + requestRefresh(force = true, confirmFailures = true) + } } - fun requestRefresh(force: Boolean = false) { - ensureStarted() + fun requestRefresh(force: Boolean = false, confirmFailures: Boolean = false) { + if (!started) started = true if (probeInFlight) { - if (force) pendingProbeAfterCurrent = true + if (force) { + pendingProbeAfterCurrent = true + pendingProbeConfirmFailures = pendingProbeConfirmFailures || confirmFailures + } return } scope.launch { + var nextConfirmFailures = confirmFailures do { + val runConfirmFailures = nextConfirmFailures || pendingProbeConfirmFailures + nextConfirmFailures = false pendingProbeAfterCurrent = false + pendingProbeConfirmFailures = false probeInFlight = true - runProbe() + runProbe(confirmFailures = runConfirmFailures) probeInFlight = false } while (pendingProbeAfterCurrent) } } - private suspend fun runProbe() { + private suspend fun runProbe(confirmFailures: Boolean) { if (_uiState.value.condition == NetworkCondition.Unknown) { _uiState.value = NetworkStatusUiState(condition = NetworkCondition.Checking) } + val previousCondition = _uiState.value.condition + var nextCondition = probeCondition() + if ( + confirmFailures && + previousCondition == NetworkCondition.Online && + nextCondition.isOfflineLike() + ) { + delay(FOREGROUND_FAILURE_CONFIRM_DELAY_MS) + nextCondition = probeCondition() + } + + _uiState.value = NetworkStatusUiState(condition = nextCondition) + } + + private suspend fun probeCondition(): NetworkCondition { val internetReachable = probePublicInternet() if (!internetReachable) { - _uiState.value = NetworkStatusUiState(condition = NetworkCondition.NoInternet) - return + return NetworkCondition.NoInternet } val supabaseReachable = probeReachable( @@ -105,20 +141,10 @@ object NetworkStatusRepository { headers = mapOf("apikey" to SupabaseConfig.ANON_KEY), ) if (!supabaseReachable) { - _uiState.value = NetworkStatusUiState(condition = NetworkCondition.ServersUnreachable) - return + return NetworkCondition.ServersUnreachable } - val addonTarget = addonProbeTargets.firstOrNull() - if (addonTarget != null) { - val addonReachable = probeReachable(url = addonTarget) - if (!addonReachable) { - _uiState.value = NetworkStatusUiState(condition = NetworkCondition.ServersUnreachable) - return - } - } - - _uiState.value = NetworkStatusUiState(condition = NetworkCondition.Online) + return NetworkCondition.Online } private suspend fun probePublicInternet(): Boolean = @@ -141,4 +167,7 @@ object NetworkStatusRepository { return response.status in 100..599 } -} \ No newline at end of file + + private fun NetworkCondition.isOfflineLike(): Boolean = + this == NetworkCondition.NoInternet || this == NetworkCondition.ServersUnreachable +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index ed9617ef..afaecb15 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val FOREGROUND_PULL_DELAY_MS = 2500L -private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 60_000L +private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L object SyncManager { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt index 414c4cf1..48634dcc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonManifestParser.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.addons +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -8,6 +9,9 @@ import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.addons_manifest_missing_field +import org.jetbrains.compose.resources.getString internal object AddonManifestParser { private val json = Json { @@ -92,7 +96,9 @@ internal object AddonManifestParser { private fun JsonObject.requiredString(name: String): String = optionalString(name)?.takeIf { it.isNotBlank() } - ?: throw IllegalArgumentException("Manifest missing \"$name\"") + ?: throw IllegalArgumentException( + runBlocking { getString(Res.string.addons_manifest_missing_field, name) }, + ) private fun JsonObject.optionalString(name: String): String? = this[name]?.jsonPrimitive?.contentOrNull diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index 3e69411d..f430f3ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -497,7 +498,7 @@ private fun ensureManifestSuffix(url: String): String { private fun normalizeManifestUrl(rawUrl: String): String { val trimmed = rawUrl.trim() - require(trimmed.isNotEmpty()) { "Enter an addon URL." } + require(trimmed.isNotEmpty()) { runBlocking { getString(Res.string.addons_error_enter_url) } } val normalizedScheme = when { trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt index 492b70a3..72c7e97a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt @@ -12,6 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.cloud_library_playback_disabled +import nuvio.composeapp.generated.resources.cloud_library_provider_unavailable +import org.jetbrains.compose.resources.getString internal class CloudLibraryStore( private val credentialsProvider: suspend () -> List, @@ -26,7 +30,10 @@ internal class CloudLibraryStore( if (api == null) { return@map CloudLibraryProviderState( provider = credential.provider, - errorMessage = "Cloud library is not available for ${credential.provider.displayName}.", + errorMessage = getString( + Res.string.cloud_library_provider_unavailable, + credential.provider.displayName, + ), ) } @@ -191,7 +198,7 @@ object CloudLibraryRepository { ): CloudLibraryPlaybackResult { DebridSettingsRepository.ensureLoaded() if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { - return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.") + return CloudLibraryPlaybackResult.Failed(getString(Res.string.cloud_library_playback_disabled)) } val result = store.resolvePlayback(item, file) if (result is CloudLibraryPlaybackResult.Success) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index 70b5204f..c3843bdd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -11,6 +11,25 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.collections_editor_media_movies_suffix +import nuvio.composeapp.generated.resources.collections_editor_media_series_suffix +import nuvio.composeapp.generated.resources.collections_editor_resolved_trakt_list +import nuvio.composeapp.generated.resources.collections_editor_tmdb_collection_title_format +import nuvio.composeapp.generated.resources.collections_editor_tmdb_director_title_format +import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover +import nuvio.composeapp.generated.resources.collections_editor_tmdb_invalid_id_error +import nuvio.composeapp.generated.resources.collections_editor_tmdb_list_title_format +import nuvio.composeapp.generated.resources.collections_editor_tmdb_load_error +import nuvio.composeapp.generated.resources.collections_editor_tmdb_network_title_format +import nuvio.composeapp.generated.resources.collections_editor_tmdb_person_title_format +import nuvio.composeapp.generated.resources.collections_editor_tmdb_production_title_format +import nuvio.composeapp.generated.resources.collections_editor_trakt_id_url_required +import nuvio.composeapp.generated.resources.collections_editor_trakt_input_required +import nuvio.composeapp.generated.resources.collections_editor_trakt_list_title_format +import nuvio.composeapp.generated.resources.collections_editor_trakt_load_error +import nuvio.composeapp.generated.resources.collections_editor_trakt_no_lists_found +import org.jetbrains.compose.resources.getString import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -394,20 +413,25 @@ object CollectionEditorRepository { val state = _uiState.value val query = state.traktInput.trim() if (query.isBlank()) { - _uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID") + scope.launch { + _uiState.value = _uiState.value.copy( + traktSearchError = getString(Res.string.collections_editor_trakt_input_required), + ) + } return } scope.launch { + val loadErrorMessage = getString(Res.string.collections_editor_trakt_load_error) val results = if (query.isTraktListIdentifierInput()) { runCatching { val metadata = TraktPublicListSourceResolver.listImportMetadata(query) - val id = metadata.traktListId ?: error("Could not load Trakt list") + val id = metadata.traktListId ?: error(loadErrorMessage) listOf( TraktPublicListSearchResult( traktListId = id, - title = metadata.title ?: "Trakt List $id", - subtitle = "Resolved Trakt list", + title = metadata.title ?: getString(Res.string.collections_editor_trakt_list_title_format, id), + subtitle = getString(Res.string.collections_editor_resolved_trakt_list), coverImageUrl = metadata.coverImageUrl, ), ) @@ -419,7 +443,7 @@ object CollectionEditorRepository { _uiState.value = _uiState.value.copy( traktSearchResults = mapped, traktSearchError = results.exceptionOrNull()?.message - ?: if (mapped.isEmpty()) "No Trakt lists found" else null, + ?: if (mapped.isEmpty()) getString(Res.string.collections_editor_trakt_no_lists_found) else null, ) } } @@ -624,39 +648,46 @@ object CollectionEditorRepository { } val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput) if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) { - _uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.") + scope.launch { + _uiState.value = _uiState.value.copy( + tmdbSearchError = getString(Res.string.collections_editor_tmdb_invalid_id_error), + ) + } return } val mediaTypes = selectedMediaTypes(state, sourceType) - val baseTitle = state.tmdbTitleInput.ifBlank { - when (sourceType) { - TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim() - TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim() - TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim() - TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim() - TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim() - TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim() - TmdbCollectionSourceType.DISCOVER -> "TMDB Discover" + scope.launch { + val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix) + val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix) + val baseTitle = state.tmdbTitleInput.ifBlank { + when (sourceType) { + TmdbCollectionSourceType.LIST -> getString(Res.string.collections_editor_tmdb_list_title_format, id ?: "").trim() + TmdbCollectionSourceType.COLLECTION -> getString(Res.string.collections_editor_tmdb_collection_title_format, id ?: "").trim() + TmdbCollectionSourceType.COMPANY -> getString(Res.string.collections_editor_tmdb_production_title_format, id ?: "").trim() + TmdbCollectionSourceType.NETWORK -> getString(Res.string.collections_editor_tmdb_network_title_format, id ?: "").trim() + TmdbCollectionSourceType.PERSON -> getString(Res.string.collections_editor_tmdb_person_title_format, id ?: "").trim() + TmdbCollectionSourceType.DIRECTOR -> getString(Res.string.collections_editor_tmdb_director_title_format, id ?: "").trim() + TmdbCollectionSourceType.DISCOVER -> getString(Res.string.collections_editor_tmdb_discover) + } } - } - val sources = mediaTypes.map { mediaType -> - CollectionSource( - provider = "tmdb", - tmdbSourceType = sourceType.name, - title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1), - tmdbId = id, - mediaType = mediaType.name, - sortBy = state.tmdbSortBy, - filters = state.tmdbFilters, - ) - } - if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) { - scope.launch { + val sources = mediaTypes.map { mediaType -> + CollectionSource( + provider = "tmdb", + tmdbSourceType = sourceType.name, + title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1, moviesSuffix, seriesSuffix), + tmdbId = id, + mediaType = mediaType.name, + sortBy = state.tmdbSortBy, + filters = state.tmdbFilters, + ) + } + if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) { val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) } val resolved = metadata.getOrNull() if (metadata.isFailure) { _uiState.value = _uiState.value.copy( - tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source", + tmdbSearchError = metadata.exceptionOrNull()?.message + ?: getString(Res.string.collections_editor_tmdb_load_error), ) return@launch } @@ -666,10 +697,10 @@ object CollectionEditorRepository { }, coverImageUrl = resolved?.coverImageUrl, ) + } else { + addTmdbSourcesFromPicker(sources) } - return } - addTmdbSourcesFromPicker(sources) } private fun addTmdbSources(sources: List, coverImageUrl: String? = null) { @@ -701,27 +732,34 @@ object CollectionEditorRepository { val state = _uiState.value val input = state.traktInput.trim() if (input.isBlank()) { - _uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL") + scope.launch { + _uiState.value = _uiState.value.copy( + traktSearchError = getString(Res.string.collections_editor_trakt_id_url_required), + ) + } return } scope.launch { + val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix) + val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix) val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) } val resolved = metadata.getOrNull() val listId = resolved?.traktListId if (metadata.isFailure || listId == null) { _uiState.value = _uiState.value.copy( - traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list", + traktSearchError = metadata.exceptionOrNull()?.message + ?: getString(Res.string.collections_editor_trakt_load_error), ) return@launch } - val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" } + val title = state.traktTitleInput.ifBlank { resolved.title ?: getString(Res.string.collections_editor_trakt_list_title_format, listId) } addTraktSourcesToFolder( sources = selectedTraktMediaTypes(state).map { mediaType -> CollectionSource( provider = "trakt", - title = titleForMedia(title, mediaType, state.traktMediaBoth), + title = titleForMedia(title, mediaType, state.traktMediaBoth, moviesSuffix, seriesSuffix), traktListId = listId, mediaType = mediaType.name, sortBy = TraktListSort.normalize(state.traktSortBy), @@ -736,19 +774,23 @@ object CollectionEditorRepository { fun addTraktSourceFromResult(result: TraktPublicListSearchResult) { val state = _uiState.value val title = state.traktTitleInput.ifBlank { result.title } - addTraktSourcesToFolder( - sources = selectedTraktMediaTypes(state).map { mediaType -> - CollectionSource( - provider = "trakt", - title = titleForMedia(title, mediaType, state.traktMediaBoth), - traktListId = result.traktListId, - mediaType = mediaType.name, - sortBy = TraktListSort.normalize(state.traktSortBy), - sortHow = TraktSortHow.normalize(state.traktSortHow), - ) - }, - coverImageUrl = result.coverImageUrl, - ) + scope.launch { + val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix) + val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix) + addTraktSourcesToFolder( + sources = selectedTraktMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "trakt", + title = titleForMedia(title, mediaType, state.traktMediaBoth, moviesSuffix, seriesSuffix), + traktListId = result.traktListId, + mediaType = mediaType.name, + sortBy = TraktListSort.normalize(state.traktSortBy), + sortHow = TraktSortHow.normalize(state.traktSortHow), + ) + }, + coverImageUrl = result.coverImageUrl, + ) + } } private fun addTraktSourcesToFolder(sources: List, coverImageUrl: String? = null) { @@ -879,11 +921,13 @@ private fun titleForMedia( title: String, mediaType: TmdbCollectionMediaType, addSuffix: Boolean, + moviesSuffix: String, + seriesSuffix: String, ): String { if (!addSuffix) return title val suffix = when (mediaType) { - TmdbCollectionMediaType.MOVIE -> "Movies" - TmdbCollectionMediaType.TV -> "Series" + TmdbCollectionMediaType.MOVIE -> moviesSuffix + TmdbCollectionMediaType.TV -> seriesSuffix } return "$title $suffix" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index f5e0b6b8..01c9ec61 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -1162,7 +1162,11 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_genres), helper = stringResource(Res.string.collections_editor_tmdb_genres_helper), value = state.tmdbFilters.withGenres.orEmpty(), - placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) "28,12" else "18,35", + placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) { + stringResource(Res.string.collections_editor_tmdb_genres_movie_placeholder) + } else { + stringResource(Res.string.collections_editor_tmdb_genres_series_placeholder) + }, onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(withGenres = value.ifBlank { null }) @@ -1173,7 +1177,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_date_from), helper = stringResource(Res.string.collections_editor_tmdb_date_helper), value = state.tmdbFilters.releaseDateGte.orEmpty(), - placeholder = "2020-01-01", + placeholder = stringResource(Res.string.collections_editor_tmdb_date_from_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(releaseDateGte = value.ifBlank { null }) @@ -1184,7 +1188,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_date_to), helper = stringResource(Res.string.collections_editor_tmdb_date_helper), value = state.tmdbFilters.releaseDateLte.orEmpty(), - placeholder = "2024-12-31", + placeholder = stringResource(Res.string.collections_editor_tmdb_date_to_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(releaseDateLte = value.ifBlank { null }) @@ -1195,7 +1199,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_rating_min), helper = stringResource(Res.string.collections_editor_tmdb_rating_helper), value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(), - placeholder = "7.0", + placeholder = stringResource(Res.string.collections_editor_tmdb_rating_min_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(voteAverageGte = value.toDoubleOrNull()) @@ -1206,7 +1210,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_rating_max), helper = stringResource(Res.string.collections_editor_tmdb_rating_helper), value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(), - placeholder = "10", + placeholder = stringResource(Res.string.collections_editor_tmdb_rating_max_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(voteAverageLte = value.toDoubleOrNull()) @@ -1217,7 +1221,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_votes_min), helper = stringResource(Res.string.collections_editor_tmdb_votes_helper), value = state.tmdbFilters.voteCountGte?.toString().orEmpty(), - placeholder = "100", + placeholder = stringResource(Res.string.collections_editor_tmdb_votes_min_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(voteCountGte = value.toIntOrNull()) @@ -1241,7 +1245,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_language), helper = stringResource(Res.string.collections_editor_tmdb_language_helper), value = state.tmdbFilters.withOriginalLanguage.orEmpty(), - placeholder = "en, ko, ja, hi", + placeholder = stringResource(Res.string.collections_editor_tmdb_language_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginalLanguage = value.ifBlank { null }) @@ -1265,7 +1269,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_country), helper = stringResource(Res.string.collections_editor_tmdb_country_helper), value = state.tmdbFilters.withOriginCountry.orEmpty(), - placeholder = "US, KR, JP, IN", + placeholder = stringResource(Res.string.collections_editor_tmdb_country_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginCountry = value.ifBlank { null }) @@ -1347,7 +1351,7 @@ private fun TmdbSourcePickerScreen( label = stringResource(Res.string.collections_editor_tmdb_year), helper = stringResource(Res.string.collections_editor_tmdb_year_helper), value = state.tmdbFilters.year?.toString().orEmpty(), - placeholder = "2024", + placeholder = stringResource(Res.string.collections_editor_tmdb_year_placeholder), onValueChange = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(year = value.toIntOrNull()) @@ -1383,9 +1387,9 @@ private fun TmdbSourcePickerScreen( chips = listOf( stringResource(Res.string.collections_editor_tmdb_country_us) to "US", stringResource(Res.string.collections_editor_tmdb_country_uk) to "GB", - "Canada" to "CA", - "Australia" to "AU", - "Germany" to "DE", + stringResource(Res.string.collections_editor_tmdb_country_ca) to "CA", + stringResource(Res.string.collections_editor_tmdb_country_au) to "AU", + stringResource(Res.string.collections_editor_tmdb_country_de) to "DE", ), onSelect = { value -> CollectionEditorRepository.updateTmdbFilters { it.copy(watchRegion = value) } @@ -2352,7 +2356,7 @@ private fun traktSourceSubtitle(source: CollectionSource): String { media, traktSortLabel(source.sortBy), traktDirectionLabel(source.sortHow), - "ID ${source.traktListId ?: ""}".trim(), + stringResource(Res.string.collections_editor_trakt_list_id_format, source.traktListId ?: ""), ).joinToString(" • ") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index d5c7a172..d4e6d9ef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -26,6 +26,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.collections_folder_addon_not_found +import nuvio.composeapp.generated.resources.collections_folder_trakt_movie_list +import nuvio.composeapp.generated.resources.collections_folder_trakt_series_list import nuvio.composeapp.generated.resources.collections_tab_all import org.jetbrains.compose.resources.getString @@ -156,10 +158,14 @@ object FolderDetailRepository { } else if (source.isTrakt) { val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie" - val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) { - "Trakt Series List" - } else { - "Trakt Movie List" + val typeLabel = runBlocking { + getString( + if (mediaType == TmdbCollectionMediaType.TV) { + Res.string.collections_folder_trakt_series_list + } else { + Res.string.collections_folder_trakt_movie_list + }, + ) } add( FolderTab( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt index 8f683a73..43dc443e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt @@ -13,6 +13,20 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover +import nuvio.composeapp.generated.resources.collections_tmdb_api_key_required +import nuvio.composeapp.generated.resources.collections_tmdb_collection_not_found +import nuvio.composeapp.generated.resources.collections_tmdb_company_not_found +import nuvio.composeapp.generated.resources.collections_tmdb_discover_no_data +import nuvio.composeapp.generated.resources.collections_tmdb_list_not_found +import nuvio.composeapp.generated.resources.collections_tmdb_missing_collection_id +import nuvio.composeapp.generated.resources.collections_tmdb_missing_list_id +import nuvio.composeapp.generated.resources.collections_tmdb_missing_person_id +import nuvio.composeapp.generated.resources.collections_tmdb_network_not_found +import nuvio.composeapp.generated.resources.collections_tmdb_person_credits_not_found +import nuvio.composeapp.generated.resources.collections_tmdb_person_not_found +import org.jetbrains.compose.resources.getString import kotlin.math.roundToInt object TmdbCollectionSourceResolver { @@ -22,7 +36,7 @@ object TmdbCollectionSourceResolver { suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { val settings = TmdbSettingsRepository.snapshot() val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } - ?: error("Add a TMDB API key in Settings to use TMDB sources.") + ?: error(getString(Res.string.collections_tmdb_api_key_required)) val language = normalizeTmdbLanguage(settings.language) val sourceType = source.tmdbType() @@ -41,7 +55,7 @@ object TmdbCollectionSourceResolver { withContext(Dispatchers.Default) { val settings = TmdbSettingsRepository.snapshot() val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } - ?: error("Add a TMDB API key in Settings to use TMDB sources.") + ?: error(getString(Res.string.collections_tmdb_api_key_required)) val language = normalizeTmdbLanguage(settings.language) when (sourceType) { TmdbCollectionSourceType.LIST -> { @@ -49,7 +63,7 @@ object TmdbCollectionSourceResolver { endpoint = "list/$id", apiKey = apiKey, query = mapOf("language" to language, "page" to "1"), - ) ?: error("TMDB list not found") + ) ?: error(getString(Res.string.collections_tmdb_list_not_found)) TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() }) } @@ -58,7 +72,7 @@ object TmdbCollectionSourceResolver { endpoint = "collection/$id", apiKey = apiKey, query = mapOf("language" to language), - ) ?: error("TMDB collection not found") + ) ?: error(getString(Res.string.collections_tmdb_collection_not_found)) TmdbSourceImportMetadata( title = body.name?.takeIf { it.isNotBlank() }, coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"), @@ -69,7 +83,7 @@ object TmdbCollectionSourceResolver { val body = fetch( endpoint = "company/$id", apiKey = apiKey, - ) ?: error("TMDB company not found") + ) ?: error(getString(Res.string.collections_tmdb_company_not_found)) TmdbSourceImportMetadata( title = body.name?.takeIf { it.isNotBlank() }, coverImageUrl = imageUrl(body.logoPath, "w500"), @@ -80,7 +94,7 @@ object TmdbCollectionSourceResolver { val body = fetch( endpoint = "network/$id", apiKey = apiKey, - ) ?: error("TMDB network not found") + ) ?: error(getString(Res.string.collections_tmdb_network_not_found)) TmdbSourceImportMetadata( title = body.name?.takeIf { it.isNotBlank() }, coverImageUrl = imageUrl(body.logoPath, "w500"), @@ -93,14 +107,14 @@ object TmdbCollectionSourceResolver { endpoint = "person/$id", apiKey = apiKey, query = mapOf("language" to language), - ) ?: error("TMDB person not found") + ) ?: error(getString(Res.string.collections_tmdb_person_not_found)) TmdbSourceImportMetadata( title = body.name?.takeIf { it.isNotBlank() }, coverImageUrl = imageUrl(body.profilePath, "w500"), ) } - TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover") + TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = getString(Res.string.collections_editor_tmdb_discover)) } } @@ -109,7 +123,7 @@ object TmdbCollectionSourceResolver { if (trimmed.isBlank()) return@withContext emptyList() val settings = TmdbSettingsRepository.snapshot() val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } - ?: error("Add a TMDB API key in Settings to use TMDB sources.") + ?: error(getString(Res.string.collections_tmdb_api_key_required)) fetch( endpoint = "search/company", apiKey = apiKey, @@ -122,7 +136,7 @@ object TmdbCollectionSourceResolver { if (trimmed.isBlank()) return@withContext emptyList() val settings = TmdbSettingsRepository.snapshot() val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } - ?: error("Add a TMDB API key in Settings to use TMDB sources.") + ?: error(getString(Res.string.collections_tmdb_api_key_required)) val language = normalizeTmdbLanguage(settings.language) fetch( endpoint = "search/collection", @@ -136,7 +150,7 @@ object TmdbCollectionSourceResolver { if (trimmed.isBlank()) return@withContext emptyMap() val settings = TmdbSettingsRepository.snapshot() val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } - ?: error("Add a TMDB API key in Settings to use TMDB sources.") + ?: error(getString(Res.string.collections_tmdb_api_key_required)) fetch( endpoint = "search/keyword", apiKey = apiKey, @@ -152,7 +166,7 @@ object TmdbCollectionSourceResolver { suspend fun genres(mediaType: TmdbCollectionMediaType): Map = withContext(Dispatchers.Default) { val settings = TmdbSettingsRepository.snapshot() val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() } - ?: error("Add a TMDB API key in Settings to use TMDB sources.") + ?: error(getString(Res.string.collections_tmdb_api_key_required)) val language = normalizeTmdbLanguage(settings.language) val endpoint = when (mediaType) { TmdbCollectionMediaType.MOVIE -> "genre/movie/list" @@ -200,12 +214,12 @@ object TmdbCollectionSourceResolver { language: String, page: Int, ): CatalogPage { - val id = source.tmdbId ?: error("Missing TMDB list ID") + val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_list_id)) val body = fetch( endpoint = "list/$id", apiKey = apiKey, query = mapOf("language" to language, "page" to page.toString()), - ) ?: error("TMDB list not found") + ) ?: error(getString(Res.string.collections_tmdb_list_not_found)) val items = body.items.orEmpty() .mapNotNull { it.toPreview() } .sortedFor(source.sortBy) @@ -222,12 +236,12 @@ object TmdbCollectionSourceResolver { apiKey: String, language: String, ): CatalogPage { - val id = source.tmdbId ?: error("Missing TMDB collection ID") + val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_collection_id)) val body = fetch( endpoint = "collection/$id", apiKey = apiKey, query = mapOf("language" to language), - ) ?: error("TMDB collection not found") + ) ?: error(getString(Res.string.collections_tmdb_collection_not_found)) val items = body.parts.orEmpty() .mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) } .sortedFor(source.sortBy) @@ -240,13 +254,13 @@ object TmdbCollectionSourceResolver { apiKey: String, language: String, ): CatalogPage { - val id = source.tmdbId ?: error("Missing TMDB person ID") + val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_person_id)) val mediaType = source.tmdbMediaType() val body = fetch( endpoint = "person/$id/combined_credits", apiKey = apiKey, query = mapOf("language" to language), - ) ?: error("TMDB person credits not found") + ) ?: error(getString(Res.string.collections_tmdb_person_credits_not_found)) val items = when (source.tmdbType()) { TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty() .filter { it.job.equals("Director", ignoreCase = true) } @@ -287,7 +301,7 @@ object TmdbCollectionSourceResolver { endpoint = endpoint, apiKey = apiKey, query = query, - ) ?: error("TMDB discover returned no data") + ) ?: error(getString(Res.string.collections_tmdb_discover_no_data)) val items = body.results.orEmpty() .mapNotNull { it.toPreview(mediaType) } .distinctBy { it.id } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index 8774316f..2baf0c1e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -17,6 +17,7 @@ data class DebridSettings( val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + val streamBadgeRules: StreamBadgeRules = StreamBadgeRules(), ) { val torboxApiKey: String get() = apiKeyFor(DebridProviders.TORBOX_ID) @@ -129,7 +130,7 @@ data class DebridStreamPreferences( val excludedLanguages: List = emptyList(), val requiredReleaseGroups: List = emptyList(), val excludedReleaseGroups: List = emptyList(), - val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, + val sortCriteria: List = DebridStreamSortCriterion.originalOrder, ) @Serializable @@ -266,6 +267,7 @@ data class DebridStreamSortCriterion( val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, ) { companion object { + val originalOrder = emptyList() val defaultOrder = listOf( DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index a23c52f6..f42d6995 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -1,9 +1,12 @@ package com.nuvio.app.features.debrid +import com.nuvio.app.features.addons.httpGetText +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -34,6 +37,7 @@ object DebridSettingsRepository { private var streamPreferences = DebridStreamPreferences() private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + private var streamBadgeRules = StreamBadgeRules() fun ensureLoaded() { if (hasLoaded) return @@ -184,11 +188,17 @@ object DebridSettingsRepository { fun setStreamPreferences(value: DebridStreamPreferences) { ensureLoaded() val normalized = value.normalized() - if (streamPreferences == normalized) return + val nextSortMode = legacyModeForSortCriteria(normalized.sortCriteria) + val sortModeChanged = streamSortMode != nextSortMode + if (streamPreferences == normalized && !sortModeChanged) return streamPreferences = normalized streamMaxResults = normalized.maxResults + streamSortMode = nextSortMode publish() DebridSettingsStorage.saveStreamMaxResults(streamMaxResults) + if (sortModeChanged) { + DebridSettingsStorage.saveStreamSortMode(nextSortMode.name) + } saveStreamPreferences() } @@ -226,6 +236,61 @@ object DebridSettingsRepository { ) } + suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult { + ensureLoaded() + val normalizedUrl = url.trim() + if (normalizedUrl.isBlank()) { + return StreamBadgeImportResult.Error("Enter a badge JSON URL.") + } + if (!normalizedUrl.startsWith("https://", ignoreCase = true) && + !normalizedUrl.startsWith("http://", ignoreCase = true) + ) { + return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.") + } + + return try { + val currentRules = streamBadgeRules.normalized() + val isExistingImport = currentRules.imports.any { import -> + import.sourceUrl.equals(normalizedUrl, ignoreCase = true) + } + if (!isExistingImport && currentRules.imports.size >= STREAM_BADGE_IMPORT_LIMIT) { + return StreamBadgeImportResult.Error("You can import up to $STREAM_BADGE_IMPORT_LIMIT badge URLs.") + } + val payload = httpGetText(normalizedUrl) + val parsedImport = StreamBadgeRulesParser.parse( + sourceUrl = normalizedUrl, + payload = payload, + ) + streamBadgeRules = currentRules.upsert(parsedImport, activate = true) + publish() + saveStreamBadgeRules() + StreamBadgeImportResult.Success(streamBadgeRules) + } catch (error: Exception) { + if (error is CancellationException) throw error + StreamBadgeImportResult.Error(error.message ?: "Badge import failed.") + } + } + + fun setActiveStreamBadgeRulesSource(sourceUrl: String) { + ensureLoaded() + val currentRules = streamBadgeRules.normalized() + val nextRules = currentRules.setActiveSource(sourceUrl) + if (nextRules == currentRules) return + streamBadgeRules = nextRules + publish() + saveStreamBadgeRules() + } + + fun deleteStreamBadgeRulesSource(sourceUrl: String) { + ensureLoaded() + val currentRules = streamBadgeRules.normalized() + val nextRules = currentRules.removeSource(sourceUrl) + if (nextRules == currentRules) return + streamBadgeRules = nextRules + publish() + saveStreamBadgeRules() + } + private fun disableIfNoResolver() { if (!hasResolverProvider()) { enabled = false @@ -307,7 +372,8 @@ object DebridSettingsRepository { DebridSettingsStorage.loadStreamCodecFilter(), DebridStreamCodecFilter.ANY, ) - streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences()) + val parsedStreamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences()) + streamPreferences = parsedStreamPreferences ?: legacyStreamPreferences( maxResults = streamMaxResults, sortMode = streamSortMode, @@ -316,14 +382,24 @@ object DebridSettingsRepository { hdrFilter = streamHdrFilter, codecFilter = streamCodecFilter, ) + if (parsedStreamPreferences != null) { + val normalizedSortMode = legacyModeForSortCriteria(streamPreferences.sortCriteria) + if (streamSortMode != normalizedSortMode) { + streamSortMode = normalizedSortMode + DebridSettingsStorage.saveStreamSortMode(normalizedSortMode.name) + } + } streamNameTemplate = normalizeStreamTemplate( - DebridSettingsStorage.loadStreamNameTemplate().orEmpty(), + DebridSettingsStorage.loadStreamNameTemplate() + ?: DebridStreamFormatterDefaults.NAME_TEMPLATE, DebridTemplateKind.NAME, ) streamDescriptionTemplate = normalizeStreamTemplate( - DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(), + DebridSettingsStorage.loadStreamDescriptionTemplate() + ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, DebridTemplateKind.DESCRIPTION, ) + streamBadgeRules = parseStreamBadgeRules(DebridSettingsStorage.loadStreamBadgeRules()) ?: StreamBadgeRules() publish() } @@ -343,6 +419,7 @@ object DebridSettingsRepository { streamPreferences = streamPreferences, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, + streamBadgeRules = streamBadgeRules, ) } @@ -350,6 +427,16 @@ object DebridSettingsRepository { DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) } + private fun saveStreamBadgeRules() { + val normalizedRules = streamBadgeRules.normalized() + val payload = if (normalizedRules.hasImport) { + json.encodeToString(normalizedRules) + } else { + "" + } + DebridSettingsStorage.saveStreamBadgeRules(payload) + } + private inline fun > enumValueOrDefault(value: String?, default: T): T = runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) @@ -364,6 +451,29 @@ object DebridSettingsRepository { } } + private fun parseStreamBadgeRules(value: String?): StreamBadgeRules? { + if (value.isNullOrBlank()) return null + val decodedRules = try { + json.decodeFromString(value).normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + if (decodedRules?.hasImport == true) return decodedRules + + val legacyRules = try { + json.decodeFromString(value) + .toBadgeRules() + .normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + return legacyRules?.takeIf { it.hasImport } ?: decodedRules + } + private enum class DebridTemplateKind { NAME, DESCRIPTION, @@ -380,6 +490,24 @@ object DebridSettingsRepository { } } +@Serializable +private data class LegacyStreamBadgeRules( + val sourceUrl: String = "", + val filters: List = emptyList(), + val groups: List = emptyList(), +) { + fun toBadgeRules(): StreamBadgeRules = + StreamBadgeRules( + imports = listOf( + StreamBadgeImport( + sourceUrl = sourceUrl, + filters = filters, + groups = groups, + ), + ), + ) +} + internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = copy( maxResults = normalizeDebridStreamMaxResults(maxResults), @@ -410,7 +538,7 @@ internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = excludedLanguages = excludedLanguages, requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), - sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + sortCriteria = sortCriteria, ) private fun legacyStreamPreferences( @@ -486,7 +614,7 @@ private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): L private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = when (mode) { - DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.originalOrder DebridStreamSortMode.QUALITY_DESC -> listOf( DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), @@ -496,6 +624,20 @@ private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) } +private fun legacyModeForSortCriteria(criteria: List): DebridStreamSortMode { + val normalized = criteria.map { it.key to it.direction } + val bestQuality = DebridStreamSortCriterion.defaultOrder.map { it.key to it.direction } + fun legacySignature(mode: DebridStreamSortMode) = sortCriteriaForLegacyMode(mode).map { it.key to it.direction } + return when { + normalized.isEmpty() -> DebridStreamSortMode.DEFAULT + normalized == bestQuality -> DebridStreamSortMode.QUALITY_DESC + normalized == legacySignature(DebridStreamSortMode.QUALITY_DESC) -> DebridStreamSortMode.QUALITY_DESC + normalized == legacySignature(DebridStreamSortMode.SIZE_DESC) -> DebridStreamSortMode.SIZE_DESC + normalized == legacySignature(DebridStreamSortMode.SIZE_ASC) -> DebridStreamSortMode.SIZE_ASC + else -> DebridStreamSortMode.DEFAULT + } +} + private val dolbyVisionTags = listOf( DebridStreamVisualTag.DV, DebridStreamVisualTag.DV_ONLY, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 7d68db48..a64cca0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -35,6 +35,8 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) + fun loadStreamBadgeRules(): String? + fun saveStreamBadgeRules(rules: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index a148c9de..da1af160 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -4,22 +4,27 @@ import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStr import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamClientResolveParsed import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamBadge import com.nuvio.app.features.streams.StreamItem class DebridStreamFormatter( private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), ) { - fun format(stream: StreamItem, settings: DebridSettings): StreamItem { + fun format( + stream: StreamItem, + settings: DebridSettings, + compiledBadgeFilters: List = + StreamBadgeMatcher.compile(settings.streamBadgeRules), + ): StreamItem { if (!stream.isManagedDebridStream) return stream - val values = buildValues(stream, settings) - val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } - val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } - val formattedName = engine.render(nameTemplate, values) + val matchedBadges = StreamBadgeMatcher.matchedBadges(stream, compiledBadgeFilters) + val values = buildValues(stream, settings, matchedBadges) + val formattedName = engine.render(settings.streamNameTemplate, values) .lineSequence() .joinToString(" ") { it.trim() } .replace(Regex("\\s+"), " ") .trim() - val formattedDescription = engine.render(descriptionTemplate, values) + val formattedDescription = engine.render(settings.streamDescriptionTemplate, values) .lineSequence() .map { it.trim() } .filter { it.isNotBlank() } @@ -29,10 +34,15 @@ class DebridStreamFormatter( return stream.copy( name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) }, description = formattedDescription.ifBlank { stream.description ?: stream.title }, + badges = matchedBadges, ) } - private fun buildValues(stream: StreamItem, settings: DebridSettings): Map { + private fun buildValues( + stream: StreamItem, + settings: DebridSettings, + matchedBadges: List, + ): Map { val resolve = stream.clientResolve val raw = resolve?.stream?.raw val parsed = raw?.parsed @@ -48,6 +58,7 @@ class DebridStreamFormatter( val audioTags = facts.audioTags.mapNotUnknown { it.label } val audioChannels = facts.audioChannels.mapNotUnknown { it.label } val edition = parsed?.edition ?: buildEdition(parsed) + val matchedBadgeNames = matchedBadges.map { it.name } return linkedMapOf( "stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title), @@ -76,7 +87,8 @@ class DebridStreamFormatter( "stream.duration" to parsed?.duration, "stream.edition" to edition, "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName), - "stream.regexMatched" to null, + "stream.regexMatched" to matchedBadgeNames, + "stream.rseMatched" to matchedBadgeNames, "stream.type" to streamType(stream, resolve), "service.cached" to serviceCached(stream, resolve), "service.shortName" to DebridProviders.shortName(serviceId(stream)), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index f9eedc46..e84a75aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -16,10 +16,12 @@ object DebridStreamPresentation { val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) + val compiledBadgeFilters = StreamBadgeMatcher.compile(settings.streamBadgeRules) + val shouldFormatStreams = settings.hasCustomStreamFormatting || compiledBadgeFilters.isNotEmpty() val presentedDebridStreams = applyPreferences(debridStreams, settings) .map { stream -> - if (settings.hasCustomStreamFormatting) { - formatter.format(stream, settings) + if (shouldFormatStreams) { + formatter.format(stream, settings, compiledBadgeFilters) } else { stream } @@ -32,10 +34,18 @@ object DebridStreamPresentation { internal fun applyPreferences(streams: List, settings: DebridSettings): List { val preferences = DebridStreamMetadata.effectivePreferences(settings) - return streams.map { it to DebridStreamMetadata.facts(it, preferences) } + val matchedStreams = streams.map { it to DebridStreamMetadata.facts(it, preferences) } .filter { (_, facts) -> facts.matchesFilters(preferences) } - .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } - .let { sorted -> applyLimits(sorted, preferences) } + + val orderedStreams = if (preferences.sortCriteria.isEmpty()) { + matchedStreams + } else { + matchedStreams.sortedWith { left, right -> + compareFacts(left.second, right.second, preferences.sortCriteria) + } + } + + return applyLimits(orderedStreams, preferences) .map { it.first } } @@ -112,7 +122,7 @@ object DebridStreamPresentation { right: DebridStreamFacts, criteria: List, ): Int { - for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + for (criterion in criteria) { val comparison = compareKey(left, right, criterion) if (comparison != 0) return comparison } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt new file mode 100644 index 00000000..452e43ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/ImportedBadgeChip.kt @@ -0,0 +1,106 @@ +package com.nuvio.app.features.debrid + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +internal object BadgeChipDefaults { + val shape = RoundedCornerShape(6.dp) + val fileSizeHorizontalPadding = 6.dp + val fileSizeFontSize: TextUnit = 10.sp + val fileSizeLineHeight: TextUnit = 12.sp + val fileSizeLetterSpacing: TextUnit = 0.sp +} + +internal enum class ImportedBadgeChipSize( + val containerHeight: Dp, + val imageHeight: Dp, + val minImageWidth: Dp, + val maxImageWidth: Dp, + val horizontalPadding: Dp, + val verticalPadding: Dp, +) { + STREAM( + containerHeight = 20.dp, + imageHeight = 16.dp, + minImageWidth = 34.dp, + maxImageWidth = 92.dp, + horizontalPadding = 3.dp, + verticalPadding = 2.dp, + ), + PREVIEW( + containerHeight = 24.dp, + imageHeight = 18.dp, + minImageWidth = 38.dp, + maxImageWidth = 112.dp, + horizontalPadding = 4.dp, + verticalPadding = 3.dp, + ), +} + +@Composable +internal fun ImportedBadgeChip( + imageURL: String, + name: String, + tagColor: String, + tagStyle: String, + borderColor: String, + size: ImportedBadgeChipSize, + modifier: Modifier = Modifier, +) { + val backgroundColorArgb = if (tagStyle.equals("filled", ignoreCase = true)) { + tagColor.toBadgeColorArgbOrNull() + } else { + null + } + val outlineColorArgb = borderColor.toBadgeColorArgbOrNull() + val shape = BadgeChipDefaults.shape + var chipModifier = modifier.height(size.containerHeight) + if (backgroundColorArgb != null) { + chipModifier = chipModifier.background(Color(backgroundColorArgb), shape) + } + if (outlineColorArgb != null) { + chipModifier = chipModifier.border(1.dp, Color(outlineColorArgb), shape) + } + + Box( + modifier = chipModifier + .padding(horizontal = size.horizontalPadding, vertical = size.verticalPadding), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = imageURL, + contentDescription = name, + modifier = Modifier + .height(size.imageHeight) + .widthIn(min = size.minImageWidth, max = size.maxImageWidth) + .clip(shape), + contentScale = ContentScale.Fit, + ) + } +} + +private fun String.toBadgeColorArgbOrNull(): Long? { + val hex = trim().removePrefix("#") + val argb = when (hex.length) { + 6 -> "FF$hex" + 8 -> hex + else -> return null + } + return argb.toLongOrNull(16) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt index 227a6d37..b638ce7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt @@ -6,6 +6,21 @@ import com.nuvio.app.features.streams.StreamDebridCacheStatus import com.nuvio.app.features.streams.StreamItem object LocalDebridAvailabilityService { + fun hasPendingCacheCheck( + groups: List, + eligibleGroupIds: Set? = null, + ): Boolean { + cacheCheckAccount() ?: return false + return groups + .filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds } + .any { group -> + group.streams.any { stream -> + stream.localAvailabilityHash() != null && + stream.debridCacheStatus?.state !in FINAL_CACHE_STATES + } + } + } + fun markChecking( groups: List, eligibleGroupIds: Set? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt new file mode 100644 index 00000000..eb47d17c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/StreamBadgeRules.kt @@ -0,0 +1,310 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamBadge +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +const val STREAM_BADGE_IMPORT_LIMIT = 3 + +@Serializable +data class StreamBadgeRules( + val imports: List = emptyList(), +) { + val hasImport: Boolean + get() = imports.isNotEmpty() + + val activeImport: StreamBadgeImport? + get() = imports.firstOrNull { it.isActive } ?: imports.firstOrNull() + + val enabledFilterCount: Int + get() = activeImport?.enabledFilterCount ?: 0 + + fun normalized(): StreamBadgeRules { + val normalizedImports = mutableListOf() + imports.forEach { import -> + val normalizedUrl = import.sourceUrl.trim() + if (normalizedUrl.isBlank() || import.filters.isEmpty()) return@forEach + val normalizedImport = import.copy(sourceUrl = normalizedUrl) + val existingIndex = normalizedImports.indexOfFirst { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) } + if (existingIndex >= 0) { + normalizedImports[existingIndex] = normalizedImport + } else if (normalizedImports.size < STREAM_BADGE_IMPORT_LIMIT) { + normalizedImports += normalizedImport + } + } + if (normalizedImports.isEmpty()) return copy(imports = emptyList()) + val activeIndex = normalizedImports.indexOfFirst { it.isActive }.takeIf { it >= 0 } ?: 0 + return copy( + imports = normalizedImports.mapIndexed { index, import -> + import.copy(isActive = index == activeIndex) + }, + ) + } + + fun upsert(import: StreamBadgeImport, activate: Boolean = true): StreamBadgeRules { + val normalizedUrl = import.sourceUrl.trim() + if (normalizedUrl.isBlank()) return normalized() + val normalizedImport = import.copy(sourceUrl = normalizedUrl, isActive = activate) + val replaced = imports.map { existing -> + if (existing.sourceUrl.equals(normalizedUrl, ignoreCase = true)) normalizedImport else existing + } + val nextImports = if (imports.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) { + replaced + } else { + imports + normalizedImport + } + val activeImports = if (activate) { + nextImports.map { existing -> + existing.copy(isActive = existing.sourceUrl.equals(normalizedUrl, ignoreCase = true)) + } + } else { + nextImports + } + return copy(imports = activeImports).normalized() + } + + fun setActiveSource(sourceUrl: String): StreamBadgeRules { + val normalizedUrl = sourceUrl.trim() + if (normalizedUrl.isBlank() || imports.none { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) { + return normalized() + } + return copy( + imports = imports.map { import -> + import.copy(isActive = import.sourceUrl.equals(normalizedUrl, ignoreCase = true)) + }, + ).normalized() + } + + fun removeSource(sourceUrl: String): StreamBadgeRules = + copy(imports = imports.filterNot { it.sourceUrl.equals(sourceUrl.trim(), ignoreCase = true) }).normalized() + + override fun toString(): String = + "StreamBadgeRules(imports=${imports.size}, activeFilters=$enabledFilterCount, groups=${imports.sumOf { it.groups.size }})" +} + +@Serializable +data class StreamBadgeImport( + val sourceUrl: String = "", + val filters: List = emptyList(), + val groups: List = emptyList(), + val isActive: Boolean = true, +) { + val enabledFilterCount: Int + get() = filters.count { it.isEnabled } + + override fun toString(): String = + "StreamBadgeImport(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size}, isActive=$isActive)" +} + +@Serializable +data class StreamBadgeFilter( + val id: String = "", + val groupId: String = "", + val name: String = "", + val pattern: String = "", + val imageURL: String = "", + val isEnabled: Boolean = true, + val tagColor: String = "", + val tagStyle: String = "", + val textColor: String = "", + val borderColor: String = "", +) + +@Serializable +data class StreamBadgeGroup( + val id: String = "", + val name: String = "", + val color: String = "", + val isExpanded: Boolean = true, +) + +sealed interface StreamBadgeImportResult { + data class Success(val rules: StreamBadgeRules) : StreamBadgeImportResult + data class Error(val message: String) : StreamBadgeImportResult +} + +internal object StreamBadgeRulesParser { + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + fun parse(sourceUrl: String, payload: String): StreamBadgeImport { + val decoded = try { + json.decodeFromString(payload) + } catch (error: SerializationException) { + throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}") + } catch (error: IllegalArgumentException) { + throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}") + } + + val filters = decoded.filters.mapNotNull { filter -> + val name = filter.name.orEmpty().trim() + val pattern = filter.pattern.orEmpty().trim() + if (name.isBlank() || pattern.isBlank()) { + return@mapNotNull null + } + + StreamBadgeFilter( + id = filter.id.orEmpty(), + groupId = filter.groupId.orEmpty(), + name = name, + pattern = pattern, + imageURL = filter.imageURL.orEmpty(), + isEnabled = filter.isEnabled ?: true, + tagColor = filter.tagColor.orEmpty(), + tagStyle = filter.tagStyle.orEmpty(), + textColor = filter.textColor.orEmpty(), + borderColor = filter.borderColor.orEmpty(), + ) + } + + if (filters.isEmpty()) { + throw IllegalArgumentException("Badge import did not contain any usable filters.") + } + + val groups = decoded.groups.map { group -> + StreamBadgeGroup( + id = group.id.orEmpty(), + name = group.name.orEmpty(), + color = group.color.orEmpty(), + isExpanded = group.isExpanded ?: true, + ) + } + + return StreamBadgeImport( + sourceUrl = sourceUrl.trim(), + filters = filters, + groups = groups, + ) + } +} + +internal object StreamBadgeMatcher { + fun compile(rules: StreamBadgeRules): List { + if (!rules.hasImport) return emptyList() + return rules.normalized().imports.filter { it.isActive }.flatMap { import -> + import.filters.mapNotNull { filter -> + if (!filter.isEnabled || filter.name.isBlank() || filter.pattern.isBlank()) { + return@mapNotNull null + } + val regex = runCatching { Regex(filter.pattern) }.getOrNull() ?: return@mapNotNull null + CompiledStreamBadgeFilter( + name = filter.name, + badge = StreamBadge( + name = filter.name, + imageURL = filter.imageURL, + tagColor = filter.tagColor, + tagStyle = filter.tagStyle, + textColor = filter.textColor, + borderColor = filter.borderColor, + ), + regex = regex, + ) + } + } + } + + fun matchedNames(stream: StreamItem, rules: StreamBadgeRules): List = + matchedNames(stream, compile(rules)) + + fun matchedNames(stream: StreamItem, filters: List): List { + return matchedBadges(stream, filters).map { it.name } + } + + fun matchedBadges(stream: StreamItem, filters: List): List { + if (filters.isEmpty()) return emptyList() + val candidates = badgeMatchCandidates(stream) + if (candidates.isEmpty()) return emptyList() + + val matched = linkedMapOf() + filters.forEach { filter -> + if (candidates.any { candidate -> filter.regex.containsMatchIn(candidate) }) { + val key = filter.badge.dedupeKey() + if (key !in matched) matched[key] = filter.badge + } + } + return matched.values.toList() + } + + private fun badgeMatchCandidates(stream: StreamItem): List { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + val candidates = listOfNotNull( + raw?.filename, + resolve?.filename, + stream.behaviorHints.filename, + stream.debridCacheStatus?.cachedName, + raw?.torrentName, + resolve?.torrentName, + stream.name, + stream.title, + stream.description, + parsed?.rawTitle, + parsed?.parsedTitle, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.edition, + parsed?.audio?.joinToString(" "), + parsed?.channels?.joinToString(" "), + parsed?.hdr?.joinToString(" "), + parsed?.group, + stream.sourceName, + stream.addonName, + ) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + + return if (candidates.size <= 1) { + candidates + } else { + candidates + candidates.joinToString(" ") + } + } +} + +data class CompiledStreamBadgeFilter( + val name: String, + val badge: StreamBadge, + val regex: Regex, +) + +private fun StreamBadge.dedupeKey(): String = + imageURL.takeIf { it.isNotBlank() } ?: name + +@Serializable +private data class StreamBadgePayload( + val filters: List = emptyList(), + val groups: List = emptyList(), +) + +@Serializable +private data class StreamBadgeFilterPayload( + val id: String? = null, + val groupId: String? = null, + val name: String? = null, + val pattern: String? = null, + val imageURL: String? = null, + val isEnabled: Boolean? = null, + val tagColor: String? = null, + val tagStyle: String? = null, + val textColor: String? = null, + val borderColor: String? = null, + val type: String? = null, +) + +@Serializable +private data class StreamBadgeGroupPayload( + val id: String? = null, + val name: String? = null, + val color: String? = null, + val isExpanded: Boolean? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt index 375cba10..05704df9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt @@ -1,5 +1,12 @@ package com.nuvio.app.features.details +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.details_runtime_hours_minutes +import nuvio.composeapp.generated.resources.details_runtime_hours_only +import nuvio.composeapp.generated.resources.details_runtime_minutes_only +import org.jetbrains.compose.resources.getString + private val hourTokenRegex = Regex("""(?i)(\d+)\s*h(?:ours?)?""") private val minuteTokenRegex = Regex("""(?i)(\d+)\s*m(?:in(?:ute)?s?)?""") private val hourMinuteColonRegex = Regex("""^\s*(\d+)\s*:\s*(\d{1,2})\s*$""") @@ -16,10 +23,12 @@ internal fun formatRuntimeFromMinutes(totalMinutes: Int): String { val hours = totalMinutes / 60 val minutes = totalMinutes % 60 - return when { - hours > 0 && minutes > 0 -> "${hours}h ${minutes}m" - hours > 0 -> "${hours}h" - else -> "${minutes}m" + return runBlocking { + when { + hours > 0 && minutes > 0 -> getString(Res.string.details_runtime_hours_minutes, hours, minutes) + hours > 0 -> getString(Res.string.details_runtime_hours_only, hours) + else -> getString(Res.string.details_runtime_minutes_only, minutes) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index e7d59bf6..d06980b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -20,6 +20,7 @@ import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.cloud.CloudLibraryContentType @@ -79,6 +80,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import com.nuvio.app.features.home.components.ContinueWatchingLayout +import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.rememberContinueWatchingLayout import kotlinx.coroutines.CancellationException @@ -585,6 +587,10 @@ fun HomeScreen( BoxWithConstraints(modifier = modifier.fillMaxSize()) { val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value) val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value) + val posterCardStyle = rememberPosterCardStyleUiState() + val continueWatchingCardHeight = remember(posterCardStyle.widthDp) { + continueWatchingLandscapeCardHeight(posterCardStyle.widthDp) + } val nativeBottomNavigationOverlayHeight = if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) { nuvioSafeBottomPadding() @@ -597,6 +603,7 @@ fun HomeScreen( continueWatchingPreferences.style, continueWatchingItems.isNotEmpty(), continueWatchingLayout, + continueWatchingCardHeight, nativeBottomNavigationOverlayHeight, ) { heroMobileBelowSectionHeightHint( @@ -605,6 +612,7 @@ fun HomeScreen( hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), continueWatchingStyle = continueWatchingPreferences.style, continueWatchingLayout = continueWatchingLayout, + continueWatchingCardHeight = continueWatchingCardHeight, bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight, ) } @@ -967,11 +975,13 @@ private fun heroMobileBelowSectionHeightHint( hasContinueWatchingItems: Boolean, continueWatchingStyle: ContinueWatchingSectionStyle, continueWatchingLayout: ContinueWatchingLayout, + continueWatchingCardHeight: Dp, bottomNavigationOverlayHeight: Dp, ): Dp? { if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null val sectionHeight = when (continueWatchingStyle) { + ContinueWatchingSectionStyle.Card -> continueWatchingCardHeight + 56.dp ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp ContinueWatchingSectionStyle.Poster -> continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 8f85e8ba..e608debf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -32,19 +32,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.core.ui.PosterLandscapeAspectRatio +import com.nuvio.app.core.ui.landscapePosterHeightForWidth +import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository @@ -56,6 +64,17 @@ import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource +private val ContinueWatchingStatusBadgeShape = RoundedCornerShape(4.dp) +private val ContinueWatchingNewEpisodeBadgeColor = Color(0xFF1D4ED8) +private val ContinueWatchingNewSeasonBadgeColor = Color(0xFFB45309) +private const val ContinueWatchingLandscapeCardScale = 1.2f + +internal fun continueWatchingLandscapeCardWidth(basePosterWidthDp: Int): Dp = + (landscapePosterWidth(basePosterWidthDp).value * ContinueWatchingLandscapeCardScale).dp + +internal fun continueWatchingLandscapeCardHeight(basePosterWidthDp: Int): Dp = + landscapePosterHeightForWidth(continueWatchingLandscapeCardWidth(basePosterWidthDp)) + private fun continueWatchingProgressPercent(progressFraction: Float): Int = (progressFraction * 100f).roundToInt().coerceIn(1, 99) @@ -123,6 +142,42 @@ private fun ContinueWatchingItem.continueWatchingPosterArtworkUrl( ) } +private fun ContinueWatchingItem.continueWatchingCardArtworkUrl( + useEpisodeThumbnails: Boolean, + preferBackdropForNextUp: Boolean, +): String? = when { + isNextUp && preferBackdropForNextUp -> firstNonBlank( + background, + poster, + episodeThumbnail, + imageUrl, + ) + isNextUp && useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + background, + poster, + imageUrl, + ) + isNextUp -> firstNonBlank( + background, + poster, + episodeThumbnail, + imageUrl, + ) + useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + background, + poster, + imageUrl, + ) + else -> firstNonBlank( + background, + poster, + episodeThumbnail, + imageUrl, + ) +} + private fun firstNonBlank(vararg values: String?): String? = values.firstOrNull { value -> !value.isNullOrBlank() }?.trim() @@ -202,6 +257,13 @@ private fun HomeContinueWatchingSectionContent( key = { item -> item.videoId }, ) { item -> when (style) { + ContinueWatchingSectionStyle.Card -> ContinueWatchingCard( + item = item, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, + onClick = onItemClick?.let { { it(item) } }, + onLongClick = onItemLongPress?.let { { it(item) } }, + ) ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( item = item, layout = layout, @@ -246,11 +308,6 @@ fun ContinueWatchingStylePreview( modifier: Modifier = Modifier, isSelected: Boolean = false, ) { - val borderColor = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f) - } val backgroundColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) } else { @@ -261,18 +318,82 @@ fun ContinueWatchingStylePreview( modifier = modifier .clip(RoundedCornerShape(12.dp)) .background(backgroundColor) - .border(1.dp, borderColor, RoundedCornerShape(12.dp)) .padding(horizontal = 12.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { when (style) { + ContinueWatchingSectionStyle.Card -> CardStylePreview() ContinueWatchingSectionStyle.Wide -> WideCardPreview() ContinueWatchingSectionStyle.Poster -> PosterCardPreview() } } } +@Composable +private fun CardStylePreview() { + Box( + modifier = Modifier + .width(100.dp) + .height(56.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Transparent, + 0.60f to MaterialTheme.colorScheme.background.copy(alpha = 0.45f), + 1.0f to MaterialTheme.colorScheme.background.copy(alpha = 0.90f), + ), + ), + ), + ) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(5.dp) + .width(26.dp) + .height(9.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.80f)), + ) + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(7.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Box( + modifier = Modifier + .width(28.dp) + .height(5.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.55f)), + ) + Box( + modifier = Modifier + .width(58.dp) + .height(7.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.75f)), + ) + } + NuvioProgressBar( + progress = 0.55f, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 5.dp, vertical = 3.dp) + .fillMaxWidth(), + height = 3.dp, + trackColor = Color.Black.copy(alpha = 0.30f), + ) + } +} + @Composable private fun WideCardPreview() { Row( @@ -385,6 +506,280 @@ private fun PosterCardPreview() { } } +private data class ContinueWatchingLandscapeCardMetrics( + val width: Dp, + val cornerRadius: Dp, + val contentPadding: Dp, + val textGap: Dp, + val badgeInset: Dp, + val badgeHorizontalPadding: Dp, + val badgeVerticalPadding: Dp, + val progressHorizontalPadding: Dp, + val progressBottomPadding: Dp, + val progressHeight: Dp, + val titleTextSize: TextUnit, + val metaTextSize: TextUnit, + val badgeTextSize: TextUnit, +) + +private fun continueWatchingLandscapeCardMetrics( + basePosterWidthDp: Int, + cornerRadiusDp: Int, +): ContinueWatchingLandscapeCardMetrics { + val width = continueWatchingLandscapeCardWidth(basePosterWidthDp) + return when { + basePosterWidthDp <= 108 -> ContinueWatchingLandscapeCardMetrics( + width = width, + cornerRadius = cornerRadiusDp.dp, + contentPadding = 8.dp, + textGap = 1.dp, + badgeInset = 6.dp, + badgeHorizontalPadding = 6.dp, + badgeVerticalPadding = 2.dp, + progressHorizontalPadding = 8.dp, + progressBottomPadding = 3.dp, + progressHeight = 3.dp, + titleTextSize = 12.sp, + metaTextSize = 9.sp, + badgeTextSize = 8.sp, + ) + basePosterWidthDp <= 120 -> ContinueWatchingLandscapeCardMetrics( + width = width, + cornerRadius = cornerRadiusDp.dp, + contentPadding = 9.dp, + textGap = 1.dp, + badgeInset = 6.dp, + badgeHorizontalPadding = 7.dp, + badgeVerticalPadding = 3.dp, + progressHorizontalPadding = 8.dp, + progressBottomPadding = 3.dp, + progressHeight = 3.dp, + titleTextSize = 13.sp, + metaTextSize = 10.sp, + badgeTextSize = 9.sp, + ) + else -> ContinueWatchingLandscapeCardMetrics( + width = width, + cornerRadius = cornerRadiusDp.dp, + contentPadding = 10.dp, + textGap = 2.dp, + badgeInset = 7.dp, + badgeHorizontalPadding = 7.dp, + badgeVerticalPadding = 3.dp, + progressHorizontalPadding = 9.dp, + progressBottomPadding = 4.dp, + progressHeight = 3.dp, + titleTextSize = 14.sp, + metaTextSize = 10.sp, + badgeTextSize = 10.sp, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ContinueWatchingCard( + item: ContinueWatchingItem, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, + onClick: (() -> Unit)?, + onLongClick: (() -> Unit)?, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + val cardMetrics = remember(posterCardStyle.widthDp, posterCardStyle.cornerRadiusDp) { + continueWatchingLandscapeCardMetrics( + basePosterWidthDp = posterCardStyle.widthDp, + cornerRadiusDp = posterCardStyle.cornerRadiusDp, + ) + } + val todayIsoDate = CurrentDateProvider.todayIsoDate() + val compactAirDateText = if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { + computeAirDateBadgeText(item.released, todayIsoDate, compact = true) + } else { + null + } + val preferBackdropForNextUp = item.isNextUp && compactAirDateText != null && !item.isReleaseAlert + val imageUrl = item.continueWatchingCardArtworkUrl( + useEpisodeThumbnails = useEpisodeThumbnails, + preferBackdropForNextUp = preferBackdropForNextUp, + ) + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val episodeCode = if (item.seasonNumber != null && item.episodeNumber != null) { + stringResource(Res.string.streams_episode_badge, item.seasonNumber, item.episodeNumber) + } else { + null + } + val episodeTitle = item.episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: compactAirDateText + val badgeText = continueWatchingCardBadgeText(item = item, compactAirDateText = compactAirDateText) + val backgroundColor = MaterialTheme.colorScheme.background + val badgeBackground = when { + item.isNewSeasonRelease -> ContinueWatchingNewSeasonBadgeColor + item.isReleaseAlert -> ContinueWatchingNewEpisodeBadgeColor + else -> backgroundColor.copy(alpha = 0.80f) + } + + Box( + modifier = Modifier + .width(cardMetrics.width) + .aspectRatio(PosterLandscapeAspectRatio) + .clip(RoundedCornerShape(cardMetrics.cornerRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .posterCardClickable(onClick = onClick, onLongClick = onLongClick), + ) { + if (imageUrl != null) { + AsyncImage( + model = cloudLibraryDisplayArtworkUrl(imageUrl), + contentDescription = item.title, + modifier = Modifier + .fillMaxSize() + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier) + .drawWithContent { + drawContent() + + val startY = size.height * 0.45f + val gradient = Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Transparent, + 0.60f to backgroundColor.copy(alpha = 0.70f), + 1.0f to backgroundColor.copy(alpha = 0.95f), + ), + startY = startY, + endY = size.height, + ) + + drawRect( + brush = gradient, + topLeft = Offset(-2f, startY), + size = Size(size.width + 4f, (size.height - startY) + 4f), + ) + }, + contentScale = ContentScale.Crop, + ) + } + if (!posterCardStyle.hideLabelsEnabled) { + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(cardMetrics.contentPadding), + verticalArrangement = Arrangement.spacedBy(cardMetrics.textGap), + ) { + if (episodeCode != null) { + Text( + text = episodeCode, + style = MaterialTheme.typography.labelMedium.copy( + fontSize = cardMetrics.metaTextSize, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall.copy( + fontSize = cardMetrics.titleTextSize, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (episodeTitle != null) { + Text( + text = episodeTitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = cardMetrics.metaTextSize, + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(cardMetrics.badgeInset) + .clip(ContinueWatchingStatusBadgeShape) + .background(badgeBackground) + .padding( + horizontal = cardMetrics.badgeHorizontalPadding, + vertical = cardMetrics.badgeVerticalPadding, + ), + ) { + Text( + text = badgeText, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = cardMetrics.badgeTextSize, + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + ) + } + if (item.progressFraction > 0f) { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding( + horizontal = cardMetrics.progressHorizontalPadding, + vertical = cardMetrics.progressBottomPadding, + ) + .fillMaxWidth() + .clip(RoundedCornerShape(999.dp)) + .height(cardMetrics.progressHeight) + .background(Color.Black.copy(alpha = 0.30f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(item.progressFraction.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.primary), + ) + } + } + } +} + +@Composable +private fun continueWatchingCardBadgeText( + item: ContinueWatchingItem, + compactAirDateText: String?, +): String { + if (item.progressFraction > 0f) { + if (item.durationMs <= 0L) { + return stringResource( + Res.string.home_continue_watching_watched, + "${continueWatchingProgressPercent(item.progressFraction)}%", + ) + } + val effectivePositionMs = when { + item.resumePositionMs > 0L -> item.resumePositionMs + else -> (item.durationMs * item.progressFraction.coerceIn(0f, 1f)).toLong() + } + val remainingMinutes = ((item.durationMs - effectivePositionMs).coerceAtLeast(0L) / 60_000L) + .coerceAtLeast(1L) + val hours = remainingMinutes / 60L + val minutes = remainingMinutes % 60L + return if (hours > 0L) { + stringResource(Res.string.home_continue_watching_hours_minutes_left, hours, minutes) + } else { + stringResource(Res.string.home_continue_watching_minutes_left, remainingMinutes) + } + } + + return when { + item.isReleaseAlert && item.isNewSeasonRelease -> stringResource(Res.string.cw_new_season) + item.isReleaseAlert -> stringResource(Res.string.cw_new_episode) + compactAirDateText != null -> compactAirDateText + else -> stringResource(Res.string.home_continue_watching_up_next) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun ContinueWatchingWideCard( @@ -532,11 +927,6 @@ private fun ContinueWatchingPosterCard( .height(layout.posterCardHeight) .clip(RoundedCornerShape(layout.cardRadius)) .background(MaterialTheme.colorScheme.surfaceVariant) - .border( - width = 1.5.dp, - color = Color.White.copy(alpha = 0.15f), - shape = RoundedCornerShape(layout.cardRadius), - ) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt index 871e32a5..c1871101 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryModels.kt @@ -18,6 +18,12 @@ data class LibraryItem( val imdbRating: String? = null, val genres: List = emptyList(), val posterShape: PosterShape = PosterShape.Poster, + val addonBaseUrl: String? = null, + val listKeys: Set = emptySet(), + val traktRank: Int? = null, + val imdbId: String? = null, + val tmdbId: Int? = null, + val traktId: Int? = null, val savedAtEpochMs: Long, ) @@ -54,6 +60,7 @@ fun MetaDetails.toLibraryItem(savedAtEpochMs: Long): LibraryItem = imdbRating = imdbRating, genres = genres, posterShape = PosterShape.Poster, + imdbId = id.takeIf { it.startsWith("tt") }, savedAtEpochMs = savedAtEpochMs, ) @@ -70,6 +77,7 @@ fun MetaPreview.toLibraryItem(savedAtEpochMs: Long): LibraryItem = imdbRating = imdbRating, genres = genres, posterShape = posterShape, + imdbId = id.takeIf { it.startsWith("tt") }, savedAtEpochMs = savedAtEpochMs, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 46c2acdc..906c59e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -1,7 +1,10 @@ package com.nuvio.app.features.library import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktLibraryRepository @@ -13,9 +16,16 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_local_tab_title +import nuvio.composeapp.generated.resources.library_other +import org.jetbrains.compose.resources.getString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -49,10 +59,13 @@ private data class LibrarySyncItem( @SerialName("release_info") val releaseInfo: String? = null, @SerialName("imdb_rating") val imdbRating: Float? = null, val genres: List = emptyList(), + @SerialName("addon_base_url") val addonBaseUrl: String? = null, @SerialName("added_at") val addedAt: Long = 0, ) object LibraryRepository { + private const val PULL_PAGE_SIZE = 500 + private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("LibraryRepository") private val json = Json { @@ -66,6 +79,9 @@ object LibraryRepository { private var hasLoaded = false private var currentProfileId: Int = 1 private var itemsById: MutableMap = mutableMapOf() + private var isPullingNuvioSyncFromServer = false + private var hasCompletedInitialNuvioSyncPull = false + private var pushJob: Job? = null init { syncScope.launch { @@ -119,6 +135,9 @@ object LibraryRepository { fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return + pushJob?.cancel() + isPullingNuvioSyncFromServer = false + hasCompletedInitialNuvioSyncPull = false TraktSettingsRepository.onProfileChanged() loadFromDisk(profileId) TraktAuthRepository.onProfileChanged() @@ -135,6 +154,9 @@ object LibraryRepository { hasLoaded = false currentProfileId = 1 itemsById.clear() + pushJob?.cancel() + isPullingNuvioSyncFromServer = false + hasCompletedInitialNuvioSyncPull = false TraktAuthRepository.clearLocalState() TraktLibraryRepository.clearLocalState() _uiState.value = LibraryUiState() @@ -150,7 +172,7 @@ object LibraryRepository { val items = runCatching { json.decodeFromString(payload).items }.getOrDefault(emptyList()) - itemsById = items.associateBy { it.id }.toMutableMap() + itemsById = items.associateBy { libraryItemKey(it.id, it.type) }.toMutableMap() } publish() @@ -162,24 +184,30 @@ object LibraryRepository { if (isTraktLibrarySourceActive()) { runCatching { TraktLibraryRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } + hasCompletedInitialNuvioSyncPull = true publish() return } + isPullingNuvioSyncFromServer = true runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_limit", 500) - put("p_offset", 0) + val serverItems = pullAllLibrarySyncItems(profileId) + if (serverItems.isEmpty() && itemsById.isNotEmpty()) { + log.w { "Remote library is empty while local has ${itemsById.size} entries; preserving local library" } + } else { + itemsById = serverItems + .map { it.toLibraryItem() } + .associateBy { libraryItemKey(it.id, it.type) } + .toMutableMap() + persist() } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params) - val serverItems = result.decodeList() - itemsById = serverItems.map { it.toLibraryItem() }.associateBy { it.id }.toMutableMap() hasLoaded = true publish() - persist() }.onFailure { e -> log.e(e) { "Failed to pull library from server" } + }.also { + hasCompletedInitialNuvioSyncPull = true + isPullingNuvioSyncFromServer = false } } @@ -195,8 +223,8 @@ object LibraryRepository { return } - if (itemsById.containsKey(item.id)) { - remove(item.id) + if (itemsById.containsKey(libraryItemKey(item.id, item.type))) { + remove(item.id, item.type) } else { save(item) } @@ -204,7 +232,7 @@ object LibraryRepository { fun save(item: LibraryItem) { ensureLoaded() - itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) + itemsById[libraryItemKey(item.id, item.type)] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) publish() persist() pushToServer() @@ -212,7 +240,18 @@ object LibraryRepository { fun remove(id: String) { ensureLoaded() - if (itemsById.remove(id) != null) { + val before = itemsById.size + itemsById.entries.removeAll { (_, item) -> item.id == id } + if (itemsById.size != before) { + publish() + persist() + pushToServer() + } + } + + private fun remove(id: String, type: String) { + ensureLoaded() + if (itemsById.remove(libraryItemKey(id, type)) != null) { publish() persist() pushToServer() @@ -233,7 +272,11 @@ object LibraryRepository { return false } - return itemsById.containsKey(id) + return if (type != null) { + itemsById.containsKey(libraryItemKey(id, type)) + } else { + itemsById.values.any { it.id == id } + } } fun savedItem(id: String): LibraryItem? { @@ -243,7 +286,7 @@ object LibraryRepository { return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } } - return itemsById[id] + return itemsById.values.firstOrNull { it.id == id } } fun libraryListTabs(): List { @@ -259,7 +302,7 @@ object LibraryRepository { suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() - val inLocal = itemsById.containsKey(item.id) + val inLocal = itemsById.containsKey(libraryItemKey(item.id, item.type)) if (TraktAuthRepository.isAuthenticated.value) { val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership return libraryMembershipWithLocal( @@ -273,12 +316,12 @@ object LibraryRepository { suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { ensureLoaded() val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true - val currentlyInLocal = itemsById.containsKey(item.id) + val currentlyInLocal = itemsById.containsKey(libraryItemKey(item.id, item.type)) if (localDesired != currentlyInLocal) { if (localDesired) { save(item) } else { - remove(item.id) + remove(item.id, item.type) } } @@ -305,10 +348,17 @@ object LibraryRepository { } private fun pushToServer() { - syncScope.launch { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return + if (isPullingNuvioSyncFromServer || !hasCompletedInitialNuvioSyncPull) return + + pushJob?.cancel() + pushJob = syncScope.launch { + delay(500) runCatching { val profileId = ProfileRepository.activeProfileId val syncItems = itemsById.values.map { it.toSyncItem() } + if (syncItems.isEmpty()) return@runCatching val params = buildJsonObject { put("p_profile_id", profileId) put("p_items", json.encodeToJsonElement(syncItems)) @@ -320,6 +370,27 @@ object LibraryRepository { } } + private suspend fun pullAllLibrarySyncItems(profileId: Int): List { + val allItems = mutableListOf() + var offset = 0 + + while (true) { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_limit", PULL_PAGE_SIZE) + put("p_offset", offset) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params) + val page = result.decodeList() + allItems.addAll(page) + + if (page.size < PULL_PAGE_SIZE) break + offset += PULL_PAGE_SIZE + } + + return allItems + } + private fun publish() { if (isTraktLibrarySourceActive()) { val traktState = TraktLibraryRepository.uiState.value @@ -405,12 +476,11 @@ object LibraryRepository { } internal const val LOCAL_LIBRARY_LIST_KEY = "local" -internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library" internal fun localLibraryListTab(): TraktListTab = TraktListTab( key = LOCAL_LIBRARY_LIST_KEY, - title = LOCAL_LIBRARY_LIST_TITLE, + title = runBlocking { getString(Res.string.library_local_tab_title) }, type = TraktListType.WATCHLIST, ) @@ -443,6 +513,8 @@ private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( releaseInfo = releaseInfo, imdbRating = imdbRating?.toString(), genres = genres, + posterShape = posterShape.toPosterShape(), + addonBaseUrl = addonBaseUrl, savedAtEpochMs = addedAt, ) @@ -451,17 +523,36 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem( contentType = type, name = name, poster = poster, + posterShape = posterShape.toSyncName(), background = banner, description = description, releaseInfo = releaseInfo, imdbRating = imdbRating?.toFloatOrNull(), genres = genres, + addonBaseUrl = addonBaseUrl, addedAt = savedAtEpochMs, ) +private fun libraryItemKey(id: String, type: String): String = + "${type.trim().lowercase()}:${id.trim()}" + +private fun String.toPosterShape(): PosterShape = + when (trim().uppercase()) { + "LANDSCAPE" -> PosterShape.Landscape + "SQUARE" -> PosterShape.Square + else -> PosterShape.Poster + } + +private fun PosterShape.toSyncName(): String = + when (this) { + PosterShape.Poster -> "POSTER" + PosterShape.Square -> "SQUARE" + PosterShape.Landscape -> "LANDSCAPE" + } + internal fun String.toLibraryDisplayTitle(): String { val normalized = trim() - if (normalized.isBlank()) return "Other" + if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) } return normalized .split('-', '_', ' ') @@ -469,5 +560,5 @@ internal fun String.toLibraryDisplayTitle(): String { .joinToString(" ") { token -> token.lowercase().replaceFirstChar { char -> char.uppercase() } } - .ifBlank { "Other" } + .ifBlank { runBlocking { getString(Res.string.library_other) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt index c2ab3eac..b7bb198f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit @@ -294,7 +295,7 @@ object EpisodeReleaseNotificationsRepository { permissionGranted = granted, testTargetTitle = currentTestTarget()?.name, errorMessage = when { - _uiState.value.isEnabled && !granted -> "System notifications are currently disabled for Nuvio." + _uiState.value.isEnabled && !granted -> runBlocking { getString(Res.string.settings_notifications_permission_disabled) } else -> _uiState.value.errorMessage }, ) @@ -362,7 +363,7 @@ object EpisodeReleaseNotificationsRepository { scheduledCount = 0, testTargetTitle = currentTestTarget()?.name, errorMessage = if (_uiState.value.isEnabled && !permissionGranted) { - "System notifications are currently disabled for Nuvio." + runBlocking { getString(Res.string.settings_notifications_permission_disabled) } } else { null }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt index adb17d13..7b3f6f25 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -41,6 +41,22 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_video_settings_brightness +import nuvio.composeapp.generated.resources.player_video_settings_contrast +import nuvio.composeapp.generated.resources.player_video_settings_deband +import nuvio.composeapp.generated.resources.player_video_settings_deband_desc +import nuvio.composeapp.generated.resources.player_video_settings_gamma +import nuvio.composeapp.generated.resources.player_video_settings_hdr_peak_detection +import nuvio.composeapp.generated.resources.player_video_settings_hdr_peak_detection_desc +import nuvio.composeapp.generated.resources.player_video_settings_interpolation +import nuvio.composeapp.generated.resources.player_video_settings_interpolation_desc +import nuvio.composeapp.generated.resources.player_video_settings_output_preset +import nuvio.composeapp.generated.resources.player_video_settings_reset_tuning +import nuvio.composeapp.generated.resources.player_video_settings_saturation +import nuvio.composeapp.generated.resources.player_video_settings_title +import nuvio.composeapp.generated.resources.player_video_settings_tone_mapping +import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt @Composable @@ -96,7 +112,7 @@ internal fun IosVideoSettingsModal( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Video", + text = stringResource(Res.string.player_video_settings_title), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -106,7 +122,7 @@ internal fun IosVideoSettingsModal( PlayerSettingsRepository.resetIosVideoOutputTuning() onSettingsChanged() }) { - Text("Reset tuning") + Text(stringResource(Res.string.player_video_settings_reset_tuning)) } } @@ -118,11 +134,11 @@ internal fun IosVideoSettingsModal( verticalArrangement = Arrangement.spacedBy(14.dp), ) { OptionGroup( - title = "Output preset", + title = stringResource(Res.string.player_video_settings_output_preset), options = IosVideoOutputPreset.entries, selected = settings.iosVideoOutputPreset, - label = { it.label }, - description = { it.description }, + label = { it.localizedLabel() }, + description = { it.localizedDescription() }, onSelect = { PlayerSettingsRepository.setIosVideoOutputPreset(it) onSettingsChanged() @@ -130,8 +146,8 @@ internal fun IosVideoSettingsModal( ) ToggleRow( - title = "HDR peak detection", - description = "Estimate HDR peak brightness when metadata is bad or missing.", + title = stringResource(Res.string.player_video_settings_hdr_peak_detection), + description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), checked = settings.iosHdrComputePeakEnabled, onCheckedChange = { PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) @@ -140,7 +156,7 @@ internal fun IosVideoSettingsModal( ) OptionGroup( - title = "Tone mapping", + title = stringResource(Res.string.player_video_settings_tone_mapping), options = IosToneMappingMode.entries, selected = settings.iosToneMappingMode, label = { it.label }, @@ -151,8 +167,8 @@ internal fun IosVideoSettingsModal( ) ToggleRow( - title = "Deband", - description = "Reduce color banding at a small performance cost.", + title = stringResource(Res.string.player_video_settings_deband), + description = stringResource(Res.string.player_video_settings_deband_desc), checked = settings.iosDebandEnabled, onCheckedChange = { PlayerSettingsRepository.setIosDebandEnabled(it) @@ -160,8 +176,8 @@ internal fun IosVideoSettingsModal( }, ) ToggleRow( - title = "Frame interpolation", - description = "Smooth motion when mpv can use display sync cleanly.", + title = stringResource(Res.string.player_video_settings_interpolation), + description = stringResource(Res.string.player_video_settings_interpolation_desc), checked = settings.iosInterpolationEnabled, onCheckedChange = { PlayerSettingsRepository.setIosInterpolationEnabled(it) @@ -170,7 +186,7 @@ internal fun IosVideoSettingsModal( ) PictureSlider( - title = "Brightness", + title = stringResource(Res.string.player_video_settings_brightness), value = settings.iosBrightness, onValueChanged = { PlayerSettingsRepository.setIosBrightness(it) @@ -178,7 +194,7 @@ internal fun IosVideoSettingsModal( }, ) PictureSlider( - title = "Contrast", + title = stringResource(Res.string.player_video_settings_contrast), value = settings.iosContrast, onValueChanged = { PlayerSettingsRepository.setIosContrast(it) @@ -186,7 +202,7 @@ internal fun IosVideoSettingsModal( }, ) PictureSlider( - title = "Saturation", + title = stringResource(Res.string.player_video_settings_saturation), value = settings.iosSaturation, onValueChanged = { PlayerSettingsRepository.setIosSaturation(it) @@ -194,7 +210,7 @@ internal fun IosVideoSettingsModal( }, ) PictureSlider( - title = "Gamma", + title = stringResource(Res.string.player_video_settings_gamma), value = settings.iosGamma, onValueChanged = { PlayerSettingsRepository.setIosGamma(it) @@ -260,8 +276,8 @@ private fun OptionGroup( title: String, options: List, selected: T, - label: (T) -> String, - description: ((T) -> String)? = null, + label: @Composable (T) -> String, + description: @Composable ((T) -> String)? = null, onSelect: (T) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index b28939da..3c11d430 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -311,7 +311,7 @@ private fun PlayerHeader( if (onSubmitIntroClick != null) { PlayerHeaderIconButton( icon = Icons.Rounded.Flag, - contentDescription = "Submit Intro", + contentDescription = stringResource(Res.string.submit_intro_action), buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, onClick = onSubmitIntroClick, @@ -331,7 +331,7 @@ private fun PlayerHeader( if (onVideoSettingsClick != null) { PlayerHeaderIconButton( icon = Icons.Rounded.Build, - contentDescription = "Video settings", + contentDescription = stringResource(Res.string.player_action_video_settings), buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, onClick = onVideoSettingsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 773a276d..a7b97866 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -1,6 +1,18 @@ package com.nuvio.app.features.player +import androidx.compose.runtime.Composable import kotlinx.serialization.Serializable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_ios_hardware_decoder_off +import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_desc +import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_label +import nuvio.composeapp.generated.resources.player_ios_preset_custom_desc +import nuvio.composeapp.generated.resources.player_ios_preset_custom_label +import nuvio.composeapp.generated.resources.player_ios_preset_native_edr_desc +import nuvio.composeapp.generated.resources.player_ios_preset_native_edr_label +import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_desc +import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_label +import org.jetbrains.compose.resources.stringResource @Serializable data class PlayerRoute( @@ -129,6 +141,28 @@ enum class IosHardwareDecoderMode( Off("no", "Off"), } +@Composable +fun IosVideoOutputPreset.localizedLabel(): String = when (this) { + IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_label) + IosVideoOutputPreset.SdrToneMapped -> stringResource(Res.string.player_ios_preset_sdr_tone_mapped_label) + IosVideoOutputPreset.Compatibility -> stringResource(Res.string.player_ios_preset_compatibility_label) + IosVideoOutputPreset.Custom -> stringResource(Res.string.player_ios_preset_custom_label) +} + +@Composable +fun IosVideoOutputPreset.localizedDescription(): String = when (this) { + IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_desc) + IosVideoOutputPreset.SdrToneMapped -> stringResource(Res.string.player_ios_preset_sdr_tone_mapped_desc) + IosVideoOutputPreset.Compatibility -> stringResource(Res.string.player_ios_preset_compatibility_desc) + IosVideoOutputPreset.Custom -> stringResource(Res.string.player_ios_preset_custom_desc) +} + +@Composable +fun IosHardwareDecoderMode.localizedLabel(): String = when (this) { + IosHardwareDecoderMode.Off -> stringResource(Res.string.player_ios_hardware_decoder_off) + else -> label +} + data class PlayerPlaybackSnapshot( val isLoading: Boolean = true, val isPlaying: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index cc902113..4f4e8831 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1858,6 +1858,7 @@ fun PlayerScreen( WatchProgressRepository.upsertPlaybackProgress( session = playbackSession, snapshot = playbackSnapshot, + syncRemote = false, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 32eeca28..ed69b6dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -271,15 +271,26 @@ object PlayerStreamsRepository { } } - fun launchDebridAvailability(group: AddonStreamGroup) { - if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return + fun publishStreamGroupAfterCacheCheck(group: AddonStreamGroup) { + if (group.addonId !in installedAddonIds || group.streams.isEmpty()) { + publishStreamGroup(presentDebridGroup(group)) + return + } val eligibleGroupIds = setOf(group.addonId) + val shouldWaitForCacheCheck = LocalDebridAvailabilityService.hasPendingCacheCheck( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ) + if (!shouldWaitForCacheCheck) { + publishStreamGroup(presentDebridGroup(group)) + return + } + val checkingGroup = LocalDebridAvailabilityService.markChecking( groups = listOf(group), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: group - publishStreamGroup(checkingGroup) val availabilityJob = launch { val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( @@ -360,8 +371,7 @@ object PlayerStreamsRepository { } repeat(jobs.size) { val result = completions.receive() - publishStreamGroup(result) - launchDebridAvailability(result) + publishStreamGroupAfterCacheCheck(result) } for (availabilityJob in debridAvailabilityJobs) { availabilityJob.join() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index 1a2d41ec..cc2d18e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found +import nuvio.composeapp.generated.resources.player_addon_subtitle_display_format import org.jetbrains.compose.resources.getString object SubtitleRepository { @@ -85,7 +86,11 @@ object SubtitleRepository { id = id, url = url, language = normalizedLang, - display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})", + display = getString( + Res.string.player_addon_subtitle_display_format, + getLanguageLabelForCode(rawLang), + addon.displayTitle, + ), addonName = addon.displayTitle, ) ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt index 7d3df719..6f3e8069 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt @@ -49,6 +49,19 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.submit_intro_button_submit +import nuvio.composeapp.generated.resources.submit_intro_capture_button +import nuvio.composeapp.generated.resources.submit_intro_end_time_label +import nuvio.composeapp.generated.resources.submit_intro_segment_intro +import nuvio.composeapp.generated.resources.submit_intro_segment_outro +import nuvio.composeapp.generated.resources.submit_intro_segment_recap +import nuvio.composeapp.generated.resources.submit_intro_segment_type_label +import nuvio.composeapp.generated.resources.submit_intro_start_time_label +import nuvio.composeapp.generated.resources.submit_intro_title +import org.jetbrains.compose.resources.stringResource import kotlin.math.floor @OptIn(ExperimentalMaterial3Api::class) @@ -91,20 +104,24 @@ fun SubmitIntroDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Submit Timestamps", + text = stringResource(Res.string.submit_intro_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, ) IconButton(onClick = onDismiss) { - Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } // Segment Type Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "SEGMENT TYPE", + text = stringResource(Res.string.submit_intro_segment_type_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, @@ -114,21 +131,21 @@ fun SubmitIntroDialog( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { SegmentTypeButton( - label = "Intro", + label = stringResource(Res.string.submit_intro_segment_intro), icon = Icons.Rounded.PlayCircleOutline, selected = segmentType == "intro", onClick = { onSegmentTypeChange("intro") }, modifier = Modifier.weight(1f) ) SegmentTypeButton( - label = "Recap", + label = stringResource(Res.string.submit_intro_segment_recap), icon = Icons.Rounded.Replay, selected = segmentType == "recap", onClick = { onSegmentTypeChange("recap") }, modifier = Modifier.weight(1f) ) SegmentTypeButton( - label = "Outro", + label = stringResource(Res.string.submit_intro_segment_outro), icon = Icons.Rounded.StopCircle, selected = segmentType == "outro", onClick = { onSegmentTypeChange("outro") }, @@ -139,7 +156,7 @@ fun SubmitIntroDialog( // Start Time TimeInputRow( - label = "START TIME (MM:SS)", + label = stringResource(Res.string.submit_intro_start_time_label), value = startTimeStr, onValueChange = onStartTimeChange, onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) } @@ -147,7 +164,7 @@ fun SubmitIntroDialog( // End Time TimeInputRow( - label = "END TIME (MM:SS)", + label = stringResource(Res.string.submit_intro_end_time_label), value = endTimeStr, onValueChange = onEndTimeChange, onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) } @@ -170,7 +187,7 @@ fun SubmitIntroDialog( contentAlignment = Alignment.Center ) { Text( - text = "Cancel", + text = stringResource(Res.string.action_cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold ) @@ -217,7 +234,7 @@ fun SubmitIntroDialog( ) { Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp)) Text( - text = "Submit", + text = stringResource(Res.string.submit_intro_button_submit), color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold ) @@ -328,7 +345,7 @@ private fun TimeInputRow( modifier = Modifier.size(18.dp) ) Text( - text = "Capture", + text = stringResource(Res.string.submit_intro_capture_button), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index aa0b8e97..6ea474da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -369,7 +369,9 @@ object SearchRepository { search = query, ).withUnreleasedFilter() val items = page.items - require(items.isNotEmpty()) { "No search results returned for $catalogName." } + require(items.isNotEmpty()) { + getString(Res.string.search_error_no_results_for_catalog, catalogName) + } return HomeCatalogSection( key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index f0483992..75275178 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.features.home.components.ContinueWatchingStylePreview import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -54,6 +55,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title +import nuvio.composeapp.generated.resources.settings_continue_watching_style_card +import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide @@ -205,7 +208,7 @@ private fun ContinueWatchingStyleSelector( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(if (isTablet) 12.dp else 8.dp), ) { ContinueWatchingSectionStyle.entries.forEach { style -> Box(modifier = Modifier.weight(1f)) { @@ -237,36 +240,33 @@ private fun ContinueWatchingStyleOption( MaterialTheme.colorScheme.surface }, shape = RoundedCornerShape(12.dp), - border = androidx.compose.foundation.BorderStroke( - 1.dp, - if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant, - ), ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 16.dp), + .padding(horizontal = if (isTablet) 12.dp else 8.dp, vertical = if (isTablet) 14.dp else 10.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(if (isTablet) 8.dp else 6.dp), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(bottom = 4.dp), + .padding(bottom = if (isTablet) 4.dp else 0.dp), horizontalArrangement = Arrangement.End, ) { Icon( imageVector = Icons.Rounded.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.alpha(if (selected) 1f else 0f), + modifier = Modifier + .size(if (isTablet) 24.dp else 18.dp) + .alpha(if (selected) 1f else 0f), ) } Box( modifier = Modifier .fillMaxWidth() - .height(148.dp), + .height(if (isTablet) 96.dp else 66.dp), contentAlignment = Alignment.Center, ) { ContinueWatchingStylePreview( @@ -276,14 +276,18 @@ private fun ContinueWatchingStyleOption( } Text( text = stringResource(style.labelRes), - style = MaterialTheme.typography.bodyMedium, + style = if (isTablet) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Text( text = stringResource(style.descriptionRes), - style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, + style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } @@ -291,12 +295,14 @@ private fun ContinueWatchingStyleOption( private val ContinueWatchingSectionStyle.labelRes: StringResource get() = when (this) { + ContinueWatchingSectionStyle.Card -> Res.string.settings_continue_watching_style_card ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster } private val ContinueWatchingSectionStyle.descriptionRes: StringResource get() = when (this) { + ContinueWatchingSectionStyle.Card -> Res.string.settings_continue_watching_style_card_description ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 80eb22b1..d5858fd2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -12,21 +14,26 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -44,8 +51,12 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT +import com.nuvio.app.features.debrid.STREAM_BADGE_IMPORT_LIMIT +import com.nuvio.app.features.debrid.ImportedBadgeChip +import com.nuvio.app.features.debrid.ImportedBadgeChipSize import com.nuvio.app.features.debrid.DebridCredentialValidator import com.nuvio.app.features.debrid.DebridDeviceAuthorization import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult @@ -55,6 +66,10 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.StreamBadgeImportResult +import com.nuvio.app.features.debrid.StreamBadgeFilter +import com.nuvio.app.features.debrid.StreamBadgeImport +import com.nuvio.app.features.debrid.StreamBadgeRules import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag @@ -73,6 +88,7 @@ import kotlinx.coroutines.delay import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear +import nuvio.composeapp.generated.resources.action_delete import nuvio.composeapp.generated.resources.action_retry import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_save @@ -120,8 +136,84 @@ import nuvio.composeapp.generated.resources.settings_debrid_resolve_with_descrip import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting import nuvio.composeapp.generated.resources.settings_debrid_section_providers +import nuvio.composeapp.generated.resources.settings_debrid_section_result_management import nuvio.composeapp.generated.resources.settings_debrid_section_title +import nuvio.composeapp.generated.resources.settings_debrid_max_results +import nuvio.composeapp.generated.resources.settings_debrid_max_results_desc +import nuvio.composeapp.generated.resources.settings_debrid_sort_results +import nuvio.composeapp.generated.resources.settings_debrid_sort_results_desc +import nuvio.composeapp.generated.resources.settings_debrid_per_resolution_limit +import nuvio.composeapp.generated.resources.settings_debrid_per_resolution_limit_desc +import nuvio.composeapp.generated.resources.settings_debrid_per_quality_limit +import nuvio.composeapp.generated.resources.settings_debrid_per_quality_limit_desc +import nuvio.composeapp.generated.resources.settings_debrid_size_range +import nuvio.composeapp.generated.resources.settings_debrid_size_range_desc +import nuvio.composeapp.generated.resources.settings_debrid_learn_more +import nuvio.composeapp.generated.resources.settings_debrid_template_default_format +import nuvio.composeapp.generated.resources.settings_debrid_template_original_format +import nuvio.composeapp.generated.resources.settings_debrid_release_groups_hint +import nuvio.composeapp.generated.resources.settings_debrid_sort_best_quality +import nuvio.composeapp.generated.resources.settings_debrid_sort_largest +import nuvio.composeapp.generated.resources.settings_debrid_sort_original +import nuvio.composeapp.generated.resources.settings_debrid_sort_smallest +import nuvio.composeapp.generated.resources.settings_debrid_sort_best_audio +import nuvio.composeapp.generated.resources.settings_debrid_sort_language +import nuvio.composeapp.generated.resources.settings_debrid_selection_any +import nuvio.composeapp.generated.resources.settings_debrid_selection_count +import nuvio.composeapp.generated.resources.settings_debrid_results_all +import nuvio.composeapp.generated.resources.settings_debrid_results_count +import nuvio.composeapp.generated.resources.settings_debrid_size_up_to +import nuvio.composeapp.generated.resources.settings_debrid_size_min +import nuvio.composeapp.generated.resources.settings_debrid_size_range_value +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_resolutions +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_resolutions_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_resolutions +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_resolutions_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_resolutions +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_resolutions_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_qualities +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_qualities_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_qualities +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_qualities_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_qualities +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_qualities_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_visual_tags +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_visual_tags_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_visual_tags +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_visual_tags_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_visual_tags +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_visual_tags_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_audio_tags +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_audio_tags_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_audio_tags +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_audio_tags_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_audio_tags +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_audio_tags_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_channels +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_channels_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_channels +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_channels_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_channels +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_channels_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_encodes +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_encodes_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_encodes +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_encodes_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_encodes +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_encodes_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_languages +import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_languages_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_languages +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_languages_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_languages +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_languages_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_release_groups +import nuvio.composeapp.generated.resources.settings_debrid_rule_required_release_groups_desc +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_release_groups +import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_release_groups_desc +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import kotlinx.coroutines.runBlocking private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services" @@ -319,14 +411,14 @@ internal fun LazyListScope.debridSettingsContent( val rows = debridRuleRows(preferences) SettingsSection( - title = "Result Management", + title = stringResource(Res.string.settings_debrid_section_result_management), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { DebridPreferenceRow( isTablet = isTablet, - title = "Max results", - description = "Limit how many results appear.", + title = stringResource(Res.string.settings_debrid_max_results), + description = stringResource(Res.string.settings_debrid_max_results_desc), value = streamMaxResultsLabel(preferences.maxResults), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, @@ -334,8 +426,8 @@ internal fun LazyListScope.debridSettingsContent( SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, - title = "Sort results", - description = "Choose how results are ordered.", + title = stringResource(Res.string.settings_debrid_sort_results), + description = stringResource(Res.string.settings_debrid_sort_results_desc), value = sortProfileLabel(preferences.sortCriteria), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, @@ -343,8 +435,8 @@ internal fun LazyListScope.debridSettingsContent( SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, - title = "Per resolution limit", - description = "Cap repeated 2160p, 1080p, 720p results after sorting.", + title = stringResource(Res.string.settings_debrid_per_resolution_limit), + description = stringResource(Res.string.settings_debrid_per_resolution_limit_desc), value = streamMaxResultsLabel(preferences.maxPerResolution), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, @@ -352,8 +444,8 @@ internal fun LazyListScope.debridSettingsContent( SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, - title = "Per quality limit", - description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", + title = stringResource(Res.string.settings_debrid_per_quality_limit), + description = stringResource(Res.string.settings_debrid_per_quality_limit_desc), value = streamMaxResultsLabel(preferences.maxPerQuality), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, @@ -361,8 +453,8 @@ internal fun LazyListScope.debridSettingsContent( SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, - title = "Size range", - description = "Filter results by file size.", + title = stringResource(Res.string.settings_debrid_size_range), + description = stringResource(Res.string.settings_debrid_size_range_desc), value = sizeRangeLabel(preferences), enabled = settings.canResolvePlayableLinks, onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, @@ -393,6 +485,7 @@ internal fun LazyListScope.debridSettingsContent( item { var activeTemplateField by rememberSaveable { mutableStateOf(null) } + var showBadgeImportDialog by rememberSaveable { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), @@ -423,6 +516,15 @@ internal fun LazyListScope.debridSettingsContent( onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, ) SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Badge URLs", + description = "Manage imported label badge JSON URLs.", + value = badgeRulesPreview(settings.streamBadgeRules), + enabled = settings.canResolvePlayableLinks, + onClick = { showBadgeImportDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_formatter_reset_title), @@ -453,6 +555,13 @@ internal fun LazyListScope.debridSettingsContent( ) null -> Unit } + + if (showBadgeImportDialog) { + BadgeUrlManagerDialog( + currentRules = settings.streamBadgeRules, + onDismiss = { showBadgeImportDialog = false }, + ) + } } debridLearnMoreFooterItem(isTablet) @@ -480,7 +589,7 @@ private fun DebridLearnMoreFooter( contentAlignment = Alignment.Center, ) { TextButton(onClick = onClick) { - Text("Learn more") + Text(stringResource(Res.string.settings_debrid_learn_more)) } } } @@ -491,15 +600,28 @@ private enum class DebridTemplateField { } private fun templatePreview(value: String, defaultValue: String): String { - if (value.trim().isBlank() || value.trim() == defaultValue.trim()) return "Default format" + val defaultFormat = runBlocking { getString(Res.string.settings_debrid_template_default_format) } + val originalFormat = runBlocking { getString(Res.string.settings_debrid_template_original_format) } + val trimmed = value.trim() + if (trimmed.isBlank()) return originalFormat + if (trimmed == defaultValue.trim()) return defaultFormat val firstLine = value .lineSequence() .map { it.trim() } .firstOrNull { it.isNotBlank() } - ?: return "Default format" + ?: return defaultFormat return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." } +private fun badgeRulesPreview(rules: StreamBadgeRules): String { + val normalizedRules = rules.normalized() + return if (normalizedRules.hasImport) { + "${normalizedRules.imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges" + } else { + "Not imported" + } +} + @Composable private fun prepareCountLabel(limit: Int): String = if (limit == 1) { @@ -663,6 +785,355 @@ private fun DebridTemplateDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun BadgeUrlManagerDialog( + currentRules: StreamBadgeRules, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val imports = currentRules.normalized().imports + var draftUrl by rememberSaveable { mutableStateOf("") } + var errorMessage by rememberSaveable { mutableStateOf(null) } + var isImporting by rememberSaveable { mutableStateOf(false) } + var previewImport by remember { mutableStateOf(null) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = "Badge URLs") { + Text( + text = "Import up to $STREAM_BADGE_IMPORT_LIMIT label badge JSON URLs. Each URL can be updated or deleted separately.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draftUrl, + onValueChange = { + draftUrl = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Badge JSON URL") }, + singleLine = false, + minLines = 2, + maxLines = 4, + enabled = !isImporting, + 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, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${imports.size}/$STREAM_BADGE_IMPORT_LIMIT URLs imported", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Button( + enabled = !isImporting && draftUrl.isNotBlank(), + onClick = { + scope.launch { + isImporting = true + errorMessage = null + when (val result = DebridSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) { + is StreamBadgeImportResult.Success -> { + draftUrl = "" + isImporting = false + } + is StreamBadgeImportResult.Error -> { + errorMessage = result.message + isImporting = false + } + } + } + }, + ) { + if (isImporting) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(text = "Import", maxLines = 1) + } + } + } + errorMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + if (imports.isEmpty()) { + Text( + text = "No badge URLs imported.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = imports, + key = { import -> import.sourceUrl }, + ) { import -> + BadgeUrlRow( + import = import, + showActiveChoice = imports.size > 1, + enabled = !isImporting, + onActivate = { + DebridSettingsRepository.setActiveStreamBadgeRulesSource(import.sourceUrl) + }, + onPreview = { previewImport = import }, + onDelete = { + DebridSettingsRepository.deleteStreamBadgeRulesSource(import.sourceUrl) + if (previewImport?.sourceUrl.equals(import.sourceUrl, ignoreCase = true)) { + previewImport = null + } + }, + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + enabled = !isImporting, + onClick = onDismiss, + ) { + Text(text = stringResource(Res.string.action_cancel), maxLines = 1) + } + } + } + } + + previewImport?.let { import -> + BadgePreviewDialog( + import = import, + onDismiss = { previewImport = null }, + ) + } +} + +@Composable +private fun BadgeUrlRow( + import: StreamBadgeImport, + showActiveChoice: Boolean, + enabled: Boolean, + onActivate: () -> Unit, + onPreview: () -> Unit, + onDelete: () -> Unit, +) { + val containerColor = if (import.isActive) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (showActiveChoice) { + RadioButton( + selected = import.isActive, + onClick = onActivate, + enabled = enabled, + ) + } + Text( + text = import.sourceUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + val status = if (import.isActive) "Active" else "Inactive" + Text( + text = "$status, ${import.enabledFilterCount} enabled badges, ${import.groups.size} groups", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton( + enabled = enabled, + onClick = onPreview, + ) { + Icon( + imageVector = Icons.Rounded.Visibility, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "Preview", maxLines = 1) + } + IconButton( + enabled = enabled, + onClick = onDelete, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(Res.string.action_delete), + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +private fun BadgePreviewDialog( + import: StreamBadgeImport, + onDismiss: () -> Unit, +) { + val sections = remember(import) { badgePreviewSections(import) } + val badgeCount = sections.sumOf { it.filters.size } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = "Badge preview") { + Text( + text = import.sourceUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "$badgeCount badges from this URL", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (sections.isEmpty()) { + Text( + text = "No badge images in this URL.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 460.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items( + items = sections, + key = { section -> section.id }, + ) { section -> + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = section.title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + section.filters.forEach { filter -> + ImportedBadgeChip( + imageURL = filter.imageURL, + name = filter.name, + tagColor = filter.tagColor, + tagStyle = filter.tagStyle, + borderColor = filter.borderColor, + size = ImportedBadgeChipSize.PREVIEW, + ) + } + } + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = "Close", maxLines = 1) + } + } + } + } +} + +private data class BadgePreviewSection( + val id: String, + val title: String, + val filters: List, +) + +private fun badgePreviewSections(import: StreamBadgeImport): List { + val filters = import.filters.filter { it.imageURL.isNotBlank() } + if (filters.isEmpty()) return emptyList() + + val filtersByGroupId = filters.groupBy { it.groupId } + val usedGroupIds = mutableSetOf() + val sections = mutableListOf() + import.groups.forEachIndexed { index, group -> + val groupFilters = filtersByGroupId[group.id].orEmpty() + if (groupFilters.isNotEmpty()) { + usedGroupIds += group.id + sections += BadgePreviewSection( + id = group.id.ifBlank { "group-$index" }, + title = group.name.ifBlank { "Group ${index + 1}" }, + filters = groupFilters, + ) + } + } + + val ungroupedFilters = filters.filter { it.groupId !in usedGroupIds } + if (ungroupedFilters.isNotEmpty()) { + sections += BadgePreviewSection( + id = "other", + title = "Other badges", + filters = ungroupedFilters, + ) + } + return sections +} + @Composable private fun DebridPreferenceRow( isTablet: Boolean, @@ -716,7 +1187,7 @@ private fun DebridStreamPreferenceDialog( ) { when (picker) { DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog( - title = "Max results", + title = stringResource(Res.string.settings_debrid_max_results), selectedValue = preferences.maxResults, options = listOf(0, 5, 10, 20, 50), label = { streamMaxResultsLabel(it) }, @@ -724,7 +1195,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog( - title = "Max results", + title = stringResource(Res.string.settings_debrid_max_results), selectedValue = preferences.maxPerResolution, options = listOf(0, 1, 2, 3, 5), label = { streamMaxResultsLabel(it) }, @@ -732,7 +1203,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog( - title = "Max results", + title = stringResource(Res.string.settings_debrid_max_results), selectedValue = preferences.maxPerQuality, options = listOf(0, 1, 2, 3, 5), label = { streamMaxResultsLabel(it) }, @@ -740,10 +1211,11 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( - title = "Sort results", + title = stringResource(Res.string.settings_debrid_sort_results), selectedValue = sortProfileFor(preferences.sortCriteria), options = listOf( - DebridSortProfile.DEFAULT, + DebridSortProfile.ORIGINAL, + DebridSortProfile.BEST_QUALITY, DebridSortProfile.LARGEST, DebridSortProfile.SMALLEST, DebridSortProfile.AUDIO, @@ -754,7 +1226,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog( - title = "Size range", + title = stringResource(Res.string.settings_debrid_size_range), selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb, options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100), label = { sizeRangeLabel(it.first, it.second) }, @@ -762,7 +1234,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog( - title = "Preferred resolutions", + title = stringResource(Res.string.settings_debrid_rule_preferred_resolutions), selectedValues = preferences.preferredResolutions, values = DebridStreamResolution.defaultOrder, label = { it.label }, @@ -770,7 +1242,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog( - title = "Required resolutions", + title = stringResource(Res.string.settings_debrid_rule_required_resolutions), selectedValues = preferences.requiredResolutions, values = DebridStreamResolution.defaultOrder, label = { it.label }, @@ -778,7 +1250,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog( - title = "Excluded resolutions", + title = stringResource(Res.string.settings_debrid_rule_excluded_resolutions), selectedValues = preferences.excludedResolutions, values = DebridStreamResolution.defaultOrder, label = { it.label }, @@ -786,7 +1258,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog( - title = "Preferred qualities", + title = stringResource(Res.string.settings_debrid_rule_preferred_qualities), selectedValues = preferences.preferredQualities, values = DebridStreamQuality.defaultOrder, label = { it.label }, @@ -794,7 +1266,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog( - title = "Required qualities", + title = stringResource(Res.string.settings_debrid_rule_required_qualities), selectedValues = preferences.requiredQualities, values = DebridStreamQuality.defaultOrder, label = { it.label }, @@ -802,7 +1274,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog( - title = "Excluded qualities", + title = stringResource(Res.string.settings_debrid_rule_excluded_qualities), selectedValues = preferences.excludedQualities, values = DebridStreamQuality.defaultOrder, label = { it.label }, @@ -810,7 +1282,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog( - title = "Preferred visual tags", + title = stringResource(Res.string.settings_debrid_rule_preferred_visual_tags), selectedValues = preferences.preferredVisualTags, values = DebridStreamVisualTag.defaultOrder, label = { it.label }, @@ -818,7 +1290,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog( - title = "Required visual tags", + title = stringResource(Res.string.settings_debrid_rule_required_visual_tags), selectedValues = preferences.requiredVisualTags, values = DebridStreamVisualTag.defaultOrder, label = { it.label }, @@ -826,7 +1298,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog( - title = "Excluded visual tags", + title = stringResource(Res.string.settings_debrid_rule_excluded_visual_tags), selectedValues = preferences.excludedVisualTags, values = DebridStreamVisualTag.defaultOrder, label = { it.label }, @@ -834,7 +1306,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog( - title = "Preferred audio tags", + title = stringResource(Res.string.settings_debrid_rule_preferred_audio_tags), selectedValues = preferences.preferredAudioTags, values = DebridStreamAudioTag.defaultOrder, label = { it.label }, @@ -842,7 +1314,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog( - title = "Required audio tags", + title = stringResource(Res.string.settings_debrid_rule_required_audio_tags), selectedValues = preferences.requiredAudioTags, values = DebridStreamAudioTag.defaultOrder, label = { it.label }, @@ -850,7 +1322,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog( - title = "Excluded audio tags", + title = stringResource(Res.string.settings_debrid_rule_excluded_audio_tags), selectedValues = preferences.excludedAudioTags, values = DebridStreamAudioTag.defaultOrder, label = { it.label }, @@ -858,7 +1330,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( - title = "Preferred channels", + title = stringResource(Res.string.settings_debrid_rule_preferred_channels), selectedValues = preferences.preferredAudioChannels, values = DebridStreamAudioChannel.defaultOrder, label = { it.label }, @@ -866,7 +1338,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( - title = "Required channels", + title = stringResource(Res.string.settings_debrid_rule_required_channels), selectedValues = preferences.requiredAudioChannels, values = DebridStreamAudioChannel.defaultOrder, label = { it.label }, @@ -874,7 +1346,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( - title = "Excluded channels", + title = stringResource(Res.string.settings_debrid_rule_excluded_channels), selectedValues = preferences.excludedAudioChannels, values = DebridStreamAudioChannel.defaultOrder, label = { it.label }, @@ -882,7 +1354,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog( - title = "Preferred encodes", + title = stringResource(Res.string.settings_debrid_rule_preferred_encodes), selectedValues = preferences.preferredEncodes, values = DebridStreamEncode.defaultOrder, label = { it.label }, @@ -890,7 +1362,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog( - title = "Required encodes", + title = stringResource(Res.string.settings_debrid_rule_required_encodes), selectedValues = preferences.requiredEncodes, values = DebridStreamEncode.defaultOrder, label = { it.label }, @@ -898,7 +1370,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog( - title = "Excluded encodes", + title = stringResource(Res.string.settings_debrid_rule_excluded_encodes), selectedValues = preferences.excludedEncodes, values = DebridStreamEncode.defaultOrder, label = { it.label }, @@ -906,7 +1378,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog( - title = "Preferred languages", + title = stringResource(Res.string.settings_debrid_rule_preferred_languages), selectedValues = preferences.preferredLanguages, values = DebridStreamLanguage.entries, label = { it.label }, @@ -914,7 +1386,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog( - title = "Required languages", + title = stringResource(Res.string.settings_debrid_rule_required_languages), selectedValues = preferences.requiredLanguages, values = DebridStreamLanguage.entries, label = { it.label }, @@ -922,7 +1394,7 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog( - title = "Excluded languages", + title = stringResource(Res.string.settings_debrid_rule_excluded_languages), selectedValues = preferences.excludedLanguages, values = DebridStreamLanguage.entries, label = { it.label }, @@ -930,13 +1402,13 @@ private fun DebridStreamPreferenceDialog( onDismiss = onDismiss, ) DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog( - title = "Required release groups", + title = stringResource(Res.string.settings_debrid_rule_required_release_groups), selectedValues = preferences.requiredReleaseGroups, onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) }, onDismiss = onDismiss, ) DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog( - title = "Excluded release groups", + title = stringResource(Res.string.settings_debrid_rule_excluded_release_groups), selectedValues = preferences.excludedReleaseGroups, onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) }, onDismiss = onDismiss, @@ -1063,7 +1535,7 @@ private fun DebridTextListDialog( BasicAlertDialog(onDismissRequest = onDismiss) { DebridDialogSurface(title = title) { Text( - text = "Enter one group per line.", + text = stringResource(Res.string.settings_debrid_release_groups_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1184,61 +1656,87 @@ private fun DebridDialogOptionRow( @Composable private fun streamMaxResultsLabel(value: Int): String = - if (value <= 0) "All results" else "$value results" - -private fun sortProfileLabel(value: DebridSortProfile): String = - when (value) { - DebridSortProfile.DEFAULT -> "Default" - DebridSortProfile.LARGEST -> "Largest first" - DebridSortProfile.SMALLEST -> "Smallest first" - DebridSortProfile.AUDIO -> "Best audio first" - DebridSortProfile.LANGUAGE -> "Language first" + if (value <= 0) { + stringResource(Res.string.settings_debrid_results_all) + } else { + stringResource(Res.string.settings_debrid_results_count, value) } +@Composable +private fun sortProfileLabel(value: DebridSortProfile): String = + when (value) { + DebridSortProfile.ORIGINAL -> stringResource(Res.string.settings_debrid_sort_original) + DebridSortProfile.BEST_QUALITY -> stringResource(Res.string.settings_debrid_sort_best_quality) + DebridSortProfile.LARGEST -> stringResource(Res.string.settings_debrid_sort_largest) + DebridSortProfile.SMALLEST -> stringResource(Res.string.settings_debrid_sort_smallest) + DebridSortProfile.AUDIO -> stringResource(Res.string.settings_debrid_sort_best_audio) + DebridSortProfile.LANGUAGE -> stringResource(Res.string.settings_debrid_sort_language) + } + +@Composable +private fun sortProfileLabel(criteria: List): String = + sortProfileLabel(sortProfileFor(criteria)) + +@Composable private fun debridRuleRows(preferences: DebridStreamPreferences): List = listOf( - DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)), - DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)), - DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)), - DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected qualities.", selectionCountLabel(preferences.requiredQualities)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected qualities.", selectionCountLabel(preferences.excludedQualities)), - DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)), - DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)), - DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)), - DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)), - DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)), - DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)), - DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)), - DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)), - DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)), - DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show results with selected languages.", selectionCountLabel(preferences.requiredLanguages)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide results where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)), - DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)), - DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)), + DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, stringResource(Res.string.settings_debrid_rule_preferred_resolutions), stringResource(Res.string.settings_debrid_rule_preferred_resolutions_desc), selectionCountLabel(preferences.preferredResolutions)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, stringResource(Res.string.settings_debrid_rule_required_resolutions), stringResource(Res.string.settings_debrid_rule_required_resolutions_desc), selectionCountLabel(preferences.requiredResolutions)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, stringResource(Res.string.settings_debrid_rule_excluded_resolutions), stringResource(Res.string.settings_debrid_rule_excluded_resolutions_desc), selectionCountLabel(preferences.excludedResolutions)), + DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, stringResource(Res.string.settings_debrid_rule_preferred_qualities), stringResource(Res.string.settings_debrid_rule_preferred_qualities_desc), selectionCountLabel(preferences.preferredQualities)), + DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, stringResource(Res.string.settings_debrid_rule_required_qualities), stringResource(Res.string.settings_debrid_rule_required_qualities_desc), selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, stringResource(Res.string.settings_debrid_rule_excluded_qualities), stringResource(Res.string.settings_debrid_rule_excluded_qualities_desc), selectionCountLabel(preferences.excludedQualities)), + DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, stringResource(Res.string.settings_debrid_rule_preferred_visual_tags), stringResource(Res.string.settings_debrid_rule_preferred_visual_tags_desc), selectionCountLabel(preferences.preferredVisualTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, stringResource(Res.string.settings_debrid_rule_required_visual_tags), stringResource(Res.string.settings_debrid_rule_required_visual_tags_desc), selectionCountLabel(preferences.requiredVisualTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, stringResource(Res.string.settings_debrid_rule_excluded_visual_tags), stringResource(Res.string.settings_debrid_rule_excluded_visual_tags_desc), selectionCountLabel(preferences.excludedVisualTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, stringResource(Res.string.settings_debrid_rule_preferred_audio_tags), stringResource(Res.string.settings_debrid_rule_preferred_audio_tags_desc), selectionCountLabel(preferences.preferredAudioTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, stringResource(Res.string.settings_debrid_rule_required_audio_tags), stringResource(Res.string.settings_debrid_rule_required_audio_tags_desc), selectionCountLabel(preferences.requiredAudioTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, stringResource(Res.string.settings_debrid_rule_excluded_audio_tags), stringResource(Res.string.settings_debrid_rule_excluded_audio_tags_desc), selectionCountLabel(preferences.excludedAudioTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, stringResource(Res.string.settings_debrid_rule_preferred_channels), stringResource(Res.string.settings_debrid_rule_preferred_channels_desc), selectionCountLabel(preferences.preferredAudioChannels)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, stringResource(Res.string.settings_debrid_rule_required_channels), stringResource(Res.string.settings_debrid_rule_required_channels_desc), selectionCountLabel(preferences.requiredAudioChannels)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, stringResource(Res.string.settings_debrid_rule_excluded_channels), stringResource(Res.string.settings_debrid_rule_excluded_channels_desc), selectionCountLabel(preferences.excludedAudioChannels)), + DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, stringResource(Res.string.settings_debrid_rule_preferred_encodes), stringResource(Res.string.settings_debrid_rule_preferred_encodes_desc), selectionCountLabel(preferences.preferredEncodes)), + DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, stringResource(Res.string.settings_debrid_rule_required_encodes), stringResource(Res.string.settings_debrid_rule_required_encodes_desc), selectionCountLabel(preferences.requiredEncodes)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, stringResource(Res.string.settings_debrid_rule_excluded_encodes), stringResource(Res.string.settings_debrid_rule_excluded_encodes_desc), selectionCountLabel(preferences.excludedEncodes)), + DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, stringResource(Res.string.settings_debrid_rule_preferred_languages), stringResource(Res.string.settings_debrid_rule_preferred_languages_desc), selectionCountLabel(preferences.preferredLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, stringResource(Res.string.settings_debrid_rule_required_languages), stringResource(Res.string.settings_debrid_rule_required_languages_desc), selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, stringResource(Res.string.settings_debrid_rule_excluded_languages), stringResource(Res.string.settings_debrid_rule_excluded_languages_desc), selectionCountLabel(preferences.excludedLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, stringResource(Res.string.settings_debrid_rule_required_release_groups), stringResource(Res.string.settings_debrid_rule_required_release_groups_desc), selectionCountLabel(preferences.requiredReleaseGroups)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, stringResource(Res.string.settings_debrid_rule_excluded_release_groups), stringResource(Res.string.settings_debrid_rule_excluded_release_groups_desc), selectionCountLabel(preferences.excludedReleaseGroups)), ) +@Composable private fun selectionCountLabel(values: List<*>): String = - if (values.isEmpty()) "Any" else "${values.size} selected" + if (values.isEmpty()) { + stringResource(Res.string.settings_debrid_selection_any) + } else { + stringResource(Res.string.settings_debrid_selection_count, values.size) + } +@Composable private fun sizeRangeLabel(preferences: DebridStreamPreferences): String = sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb) +@Composable private fun sizeRangeLabel(minGb: Int, maxGb: Int): String = when { - minGb <= 0 && maxGb <= 0 -> "Any" - minGb <= 0 -> "Up to ${maxGb}GB" - maxGb <= 0 -> "${minGb}GB+" - else -> "${minGb}-${maxGb}GB" + minGb <= 0 && maxGb <= 0 -> stringResource(Res.string.settings_debrid_selection_any) + minGb <= 0 -> stringResource(Res.string.settings_debrid_size_up_to, maxGb) + maxGb <= 0 -> stringResource(Res.string.settings_debrid_size_min, minGb) + else -> stringResource(Res.string.settings_debrid_size_range_value, minGb, maxGb) } private fun sortProfileFor(criteria: List): DebridSortProfile { val normalized = criteria.map { it.key to it.direction } + val bestQuality = DebridStreamSortCriterion.defaultOrder.map { it.key to it.direction } + val legacyQuality = listOf( + DebridStreamSortKey.RESOLUTION to DebridStreamSortDirection.DESC, + DebridStreamSortKey.QUALITY to DebridStreamSortDirection.DESC, + DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC, + ) return when { + normalized.isEmpty() -> DebridSortProfile.ORIGINAL + normalized == bestQuality || normalized == legacyQuality -> DebridSortProfile.BEST_QUALITY normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST normalized.take(2) == listOf( @@ -1246,16 +1744,14 @@ private fun sortProfileFor(criteria: List): DebridSor DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC, ) -> DebridSortProfile.AUDIO normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE - else -> DebridSortProfile.DEFAULT + else -> DebridSortProfile.BEST_QUALITY } } -private fun sortProfileLabel(criteria: List): String = - sortProfileLabel(sortProfileFor(criteria)) - private fun sortCriteriaForProfile(profile: DebridSortProfile): List = when (profile) { - DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridSortProfile.ORIGINAL -> DebridStreamSortCriterion.originalOrder + DebridSortProfile.BEST_QUALITY -> DebridStreamSortCriterion.defaultOrder DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) DebridSortProfile.AUDIO -> listOf( @@ -1281,7 +1777,8 @@ private data class DebridRuleRow( ) private enum class DebridSortProfile { - DEFAULT, + ORIGINAL, + BEST_QUALITY, LARGEST, SMALLEST, AUDIO, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 1fcd9d36..aa094af3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -60,6 +60,7 @@ import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.ExternalPlayerApp import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.IosHardwareDecoderMode +import com.nuvio.app.features.player.localizedLabel import com.nuvio.app.features.player.IosTargetPrimaries import com.nuvio.app.features.player.IosTargetTransfer import com.nuvio.app.features.player.PlayerSettingsRepository @@ -78,6 +79,7 @@ import com.nuvio.app.isIos import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt @@ -743,42 +745,42 @@ private fun PlaybackSettingsSection( if (isIos) { SettingsSection( - title = "iOS video output", + title = stringResource(Res.string.settings_playback_ios_video_output), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Hardware decoder", - description = autoPlayPlayerSettings.iosHardwareDecoderMode.label, + title = stringResource(Res.string.settings_playback_ios_hardware_decoder), + description = autoPlayPlayerSettings.iosHardwareDecoderMode.localizedLabel(), isTablet = isTablet, onClick = { showIosHardwareDecoderDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Extended dynamic range", - description = "Default Metal output mode for new playback sessions.", + title = stringResource(Res.string.settings_playback_ios_extended_dynamic_range), + description = stringResource(Res.string.settings_playback_ios_extended_dynamic_range_desc), checked = autoPlayPlayerSettings.iosExtendedDynamicRangeEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setIosExtendedDynamicRangeEnabled, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Display color hint", - description = "Let mpv target the active display color space by default.", + title = stringResource(Res.string.settings_playback_ios_display_color_hint), + description = stringResource(Res.string.settings_playback_ios_display_color_hint_desc), checked = autoPlayPlayerSettings.iosTargetColorspaceHintEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setIosTargetColorspaceHintEnabled, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Target primaries", + title = stringResource(Res.string.settings_playback_ios_target_primaries), description = autoPlayPlayerSettings.iosTargetPrimaries.label, isTablet = isTablet, onClick = { showIosTargetPrimariesDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Target transfer", + title = stringResource(Res.string.settings_playback_ios_target_transfer), description = autoPlayPlayerSettings.iosTargetTransfer.label, isTablet = isTablet, onClick = { showIosTargetTransferDialog = true }, @@ -1201,7 +1203,7 @@ private fun PlaybackSettingsSection( if (showIosHardwareDecoderDialog) { IosEnumSelectionDialog( - title = "Hardware decoder", + title = stringResource(Res.string.settings_playback_ios_hw_decoder_dialog), options = IosHardwareDecoderMode.entries, selected = autoPlayPlayerSettings.iosHardwareDecoderMode, label = { it.label }, @@ -1215,7 +1217,7 @@ private fun PlaybackSettingsSection( if (showIosTargetPrimariesDialog) { IosEnumSelectionDialog( - title = "Target primaries", + title = stringResource(Res.string.settings_playback_ios_target_primaries_dialog), options = IosTargetPrimaries.entries, selected = autoPlayPlayerSettings.iosTargetPrimaries, label = { it.label }, @@ -1229,7 +1231,7 @@ private fun PlaybackSettingsSection( if (showIosTargetTransferDialog) { IosEnumSelectionDialog( - title = "Target transfer", + title = stringResource(Res.string.settings_playback_ios_target_transfer_dialog), options = IosTargetTransfer.entries, selected = autoPlayPlayerSettings.iosTargetTransfer, label = { it.label }, @@ -2917,6 +2919,7 @@ private fun IntroDbApiKeyDialog( var value by remember { mutableStateOf(initialValue) } var isVerifying by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + val invalidKeyMessage = stringResource(Res.string.settings_playback_introdb_invalid_key) BasicAlertDialog(onDismissRequest = { if (!isVerifying) onDismiss() }) { Surface( @@ -2985,7 +2988,7 @@ private fun IntroDbApiKeyDialog( if (isValid) { onSave(trimmed) } else { - errorMessage = "Invalid API Key or connection failed" + errorMessage = invalidKeyMessage } } }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 79987dcf..5f3bc74c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -19,6 +19,7 @@ data class StreamItem( val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, + val badges: List = emptyList(), ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -63,6 +64,15 @@ data class StreamItem( get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } +data class StreamBadge( + val name: String, + val imageURL: String = "", + val tagColor: String = "", + val tagStyle: String = "", + val textColor: String = "", + val borderColor: String = "", +) + private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index e3591a71..1aff8fcb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import kotlinx.coroutines.launch @@ -278,15 +279,26 @@ object StreamsRepository { } } - fun launchDebridAvailability(group: AddonStreamGroup) { - if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return + fun publishAddonGroupAfterCacheCheck(group: AddonStreamGroup) { + if (group.addonId !in installedAddonIds || group.streams.isEmpty()) { + publishAddonGroup(presentDebridGroup(group)) + return + } val eligibleGroupIds = setOf(group.addonId) + val shouldWaitForCacheCheck = LocalDebridAvailabilityService.hasPendingCacheCheck( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ) + if (!shouldWaitForCacheCheck) { + publishAddonGroup(presentDebridGroup(group)) + return + } + val checkingGroup = LocalDebridAvailabilityService.markChecking( groups = listOf(group), eligibleGroupIds = eligibleGroupIds, ).firstOrNull() ?: group - publishAddonGroup(checkingGroup) val availabilityJob = launch { val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( @@ -506,8 +518,7 @@ object StreamsRepository { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { val result = completion.group - publishAddonGroup(result) - launchDebridAvailability(result) + publishAddonGroupAfterCacheCheck(result) } is StreamLoadCompletion.PluginScraper -> { @@ -918,6 +929,8 @@ private fun String.fallbackRepositoryLabel(): String { val withoutManifest = withoutQuery.removeSuffix("/manifest.json") val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') return host.ifBlank { - withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" } + withoutManifest.substringAfterLast('/').ifBlank { + runBlocking { getString(Res.string.streams_plugin_repository_fallback) } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index e1c3269b..347a56cf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -88,6 +90,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.BadgeChipDefaults +import com.nuvio.app.features.debrid.ImportedBadgeChip +import com.nuvio.app.features.debrid.ImportedBadgeChipSize import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository @@ -1031,9 +1036,19 @@ private fun StreamCard( ) } - Spacer(modifier = Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - StreamFileSizeBadge(stream = stream) + val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } + if (badgeImages.isNotEmpty() || stream.behaviorHints.videoSize != null) { + Spacer(modifier = Modifier.height(5.dp)) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + badgeImages.forEach { badge -> + StreamImportedBadge(badge = badge) + } + StreamFileSizeBadge(stream = stream) + } } } } @@ -1201,6 +1216,18 @@ private fun StreamItem.instantServiceLabel(): String? { return "- $providerLabel Instant" } +@Composable +private fun StreamImportedBadge(badge: StreamBadge) { + ImportedBadgeChip( + imageURL = badge.imageURL, + name = badge.name, + tagColor = badge.tagColor, + tagStyle = badge.tagStyle, + borderColor = badge.borderColor, + size = ImportedBadgeChipSize.STREAM, + ) +} + @Composable private fun StreamFileSizeBadge(stream: StreamItem) { val bytes = stream.behaviorHints.videoSize ?: return @@ -1213,18 +1240,23 @@ private fun StreamFileSizeBadge(stream: StreamItem) { "${round(mib).toInt()} ${localizedByteUnit("MB")}" } + val badgeShape = BadgeChipDefaults.shape Box( modifier = Modifier - .clip(RoundedCornerShape(12.dp)) + .height(ImportedBadgeChipSize.STREAM.containerHeight) + .clip(badgeShape) .background(Color(0xFF0A0C0C)) - .padding(horizontal = 8.dp, vertical = 3.dp), + .border(1.dp, Color(0xFF0A0C0C), badgeShape) + .padding(horizontal = BadgeChipDefaults.fileSizeHorizontalPadding), + contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.streams_size, sizeLabel), style = MaterialTheme.typography.labelSmall.copy( - fontSize = 11.sp, + fontSize = BadgeChipDefaults.fileSizeFontSize, + lineHeight = BadgeChipDefaults.fileSizeLineHeight, fontWeight = FontWeight.SemiBold, - letterSpacing = 0.2.sp, + letterSpacing = BadgeChipDefaults.fileSizeLetterSpacing, ), color = Color.White, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index 3fed8022..cba36b92 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -283,7 +283,7 @@ object TraktAuthRepository { refreshUserSettings() publish( isLoading = false, - statusMessage = "Connected to Trakt", + statusMessage = localizedString(Res.string.trakt_connected_status), errorMessage = null, ) } @@ -316,7 +316,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - statusMessage = "Disconnected from Trakt", + statusMessage = localizedString(Res.string.trakt_disconnected_status), errorMessage = null, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt index a2bd8a03..973e5495 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -90,7 +90,9 @@ object TraktCommentsRepository { return TraktCommentsPage(emptyList(), page, 0, 0) } if (response.status !in 200..299) { - throw IllegalStateException("Failed to load Trakt comments (${response.status})") + throw IllegalStateException( + getString(Res.string.details_comments_trakt_load_failed_with_code, response.status), + ) } val dtos = commentsJson.decodeFromString>(response.body) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index d7b005d2..8ed4dd37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -51,3 +51,6 @@ internal fun extractTraktYear(value: String?): Int? { if (value.isNullOrBlank()) return null return Regex("(\\d{4})").find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() } + +internal fun TraktExternalIds.hasAnyId(): Boolean = + trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 03cdfc67..e18f18a1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -1,10 +1,9 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.httpGetTextWithHeaders -import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.library.LibraryItem -import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -32,7 +31,8 @@ import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" private const val WATCHLIST_KEY = "trakt:watchlist" private const val PERSONAL_LIST_PREFIX = "trakt:list:" -private const val LIST_FETCH_CONCURRENCY = 4 +private const val LIST_FETCH_CONCURRENCY = 3 +private const val TRAKT_PAGE_LIMIT = 1_000 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L private const val FORCE_REFRESH_DEDUP_MS = 10_000L @@ -270,11 +270,11 @@ object TraktLibraryRepository { val resolvedItem = resolveOptimisticItem(state, item) updatedEntriesByList[listKey] = listOf(resolvedItem) + updatedEntriesByList[listKey].orEmpty().filterNot { - contentKey(it.id, it.type) == contentKey + allContentKeys(it).contains(contentKey) } } else { updatedEntriesByList[listKey] = updatedEntriesByList[listKey].orEmpty().filterNot { - contentKey(it.id, it.type) == contentKey + allContentKeys(it).contains(contentKey) } } } @@ -289,8 +289,9 @@ object TraktLibraryRepository { state: TraktLibraryUiState, item: LibraryItem, ): LibraryItem { + val itemKey = contentKey(item.id, item.type) val existing = state.allItems.firstOrNull { - contentKey(it.id, it.type) == contentKey(item.id, item.type) + allContentKeys(it).contains(itemKey) } val base = existing ?: item val savedAt = base.savedAtEpochMs.takeIf { it > 0L } @@ -312,21 +313,33 @@ object TraktLibraryRepository { val membershipByContent = mutableMapOf>() normalizedEntriesByList.forEach { (listKey, entries) -> entries.forEach { entry -> - membershipByContent - .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } - .add(listKey) + allContentKeys(entry).forEach { key -> + membershipByContent + .getOrPut(key) { mutableSetOf() } + .add(listKey) + } } } - val allItems = normalizedEntriesByList.values + val entriesWithMembership = normalizedEntriesByList.mapValues { (_, entries) -> + entries.map { entry -> + entry.copy(listKeys = membershipByContent[contentKey(entry.id, entry.type)].orEmpty()) + } + } + + val allItemsByContent = linkedMapOf() + entriesWithMembership.values .flatten() - .distinctBy { contentKey(it.id, it.type) } .sortedByDescending { it.savedAtEpochMs } + .forEach { entry -> + val key = contentKey(entry.id, entry.type) + allItemsByContent[key] = entry.copy(listKeys = membershipByContent[key].orEmpty()) + } return TraktLibraryUiState( listTabs = listTabs, - entriesByList = normalizedEntriesByList, - allItems = allItems, + entriesByList = entriesWithMembership, + allItems = allItemsByContent.values.toList().sortedByDescending { it.savedAtEpochMs }, membershipByContent = membershipByContent.mapValues { it.value.toSet() }, isLoading = false, hasLoaded = true, @@ -366,26 +379,9 @@ object TraktLibraryRepository { }, ) - val membershipByContent = mutableMapOf>() - entriesByList.forEach { (listKey, entries) -> - entries.forEach { entry -> - membershipByContent - .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } - .add(listKey) - } - } - - val allItems = entriesByList.values - .flatten() - .distinctBy { contentKey(it.id, it.type) } - .sortedByDescending { it.savedAtEpochMs } - - TraktLibraryUiState( + rebuildUiState( listTabs = allTabs, entriesByList = entriesByList, - allItems = allItems, - membershipByContent = membershipByContent.mapValues { it.value.toSet() }, - hasLoaded = true, ) } @@ -441,6 +437,8 @@ object TraktLibraryRepository { key = WATCHLIST_KEY, title = getString(Res.string.trakt_watchlist), type = TraktListType.WATCHLIST, + sortBy = "rank", + sortHow = "asc", ), ) return watchlistTabs + fetchPersonalLists(headers) @@ -465,12 +463,12 @@ object TraktLibraryRepository { } val personalEntries = personalTabs.associate { tab -> tab.key to async { - val listId = tab.traktListId?.toString().orEmpty() + val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty() if (listId.isBlank()) { emptyList() } else { listSemaphore.withPermit { - fetchPersonalListItems(headers, listId) + fetchPersonalListItems(headers, tab) } } } @@ -495,86 +493,184 @@ object TraktLibraryRepository { } private suspend fun fetchPersonalLists(headers: Map): List { - val payload = httpGetTextWithHeaders( - url = "$BASE_URL/users/me/lists", - headers = headers, - ) + val payload = getJson(headers = headers, url = "$BASE_URL/users/me/lists") val lists = json.decodeFromString>(payload) - return lists.mapNotNull { list -> - val traktId = list.ids?.trakt ?: return@mapNotNull null - TraktListTab( - key = "$PERSONAL_LIST_PREFIX$traktId", - title = list.name?.ifBlank { null } ?: getString(Res.string.trakt_list_fallback_title, traktId), - type = TraktListType.PERSONAL, - traktListId = traktId, - slug = list.ids.slug, - description = list.description, - ) - } + return lists + .filter { it.type.equals("personal", ignoreCase = true) } + .mapNotNull { list -> + val traktId = list.ids?.trakt + val listIdPath = traktId?.toString() ?: list.ids?.slug ?: return@mapNotNull null + val fallbackTitle = traktId?.let { getString(Res.string.trakt_list_fallback_title, it) } + ?: "List $listIdPath" + TraktListTab( + key = "$PERSONAL_LIST_PREFIX$listIdPath", + title = list.name?.ifBlank { null } ?: fallbackTitle, + type = TraktListType.PERSONAL, + traktListId = traktId, + slug = list.ids?.slug, + description = list.description, + privacy = TraktListPrivacy.fromApi(list.privacy), + sortBy = list.sortBy, + sortHow = list.sortHow, + ) + } } private suspend fun fetchWatchlistItems(headers: Map): List { - val (moviesPayload, showsPayload) = coroutineScope { + val (movieItems, showItems) = coroutineScope { val moviesDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/watchlist/movies?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/watchlist/movies/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT" + }, ) } val showsDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/sync/watchlist/shows?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/watchlist/shows/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT" + }, ) } moviesDeferred.await() to showsDeferred.await() } - val movieItems = json.decodeFromString>(moviesPayload) - val showItems = json.decodeFromString>(showsPayload) return (movieItems + showItems) .mapNotNull(::mapToLibraryItem) - .sortedByDescending { it.savedAtEpochMs } + .sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs }, + ) } private suspend fun fetchPersonalListItems( headers: Map, - listId: String, + tab: TraktListTab, ): List { - val (moviesPayload, showsPayload) = coroutineScope { + val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty() + if (listId.isBlank()) return emptyList() + + val sortQuery = buildSortQuery(tab.sortBy, tab.sortHow) + val (movieItems, showItems) = coroutineScope { val moviesDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/users/me/lists/$listId/items/movies?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/lists/$listId/items/movie?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery" + }, ) } val showsDeferred = async { - httpGetTextWithHeaders( - url = "$BASE_URL/users/me/lists/$listId/items/shows?extended=full,images", + fetchPagedListItems( headers = headers, + urlForPage = { page -> + "$BASE_URL/users/me/lists/$listId/items/show?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery" + }, ) } moviesDeferred.await() to showsDeferred.await() } - val movieItems = json.decodeFromString>(moviesPayload) - val showItems = json.decodeFromString>(showsPayload) return (movieItems + showItems) .mapNotNull(::mapToLibraryItem) - .sortedByDescending { it.savedAtEpochMs } + .sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs }, + ) + } + + private suspend fun fetchPagedListItems( + headers: Map, + urlForPage: (Int) -> String, + ): List { + val items = mutableListOf() + var page = 1 + + while (true) { + val response = getJsonResponse(headers = headers, url = urlForPage(page)) + val pageItems = json.decodeFromString>(response.body) + items.addAll(pageItems) + + val pageCount = response.headerInt("x-pagination-page-count") ?: page + if (page >= pageCount || pageItems.size < TRAKT_PAGE_LIMIT) break + page += 1 + } + + return items + } + + private suspend fun getJson(headers: Map, url: String): String = + getJsonResponse(headers, url).body + + private suspend fun getJsonResponse( + headers: Map, + url: String, + ): RawHttpResponse { + val response = httpRequestRaw( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json") + headers, + body = "", + followRedirects = true, + ) + if (response.status !in 200..299) { + throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + } + if (response.body.isBlank()) { + throw IllegalStateException("Empty response body") + } + return response + } + + private suspend fun postJson( + url: String, + body: String, + headers: Map, + ): RawHttpResponse { + val response = httpRequestRaw( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ) + headers, + body = body, + followRedirects = true, + ) + if (response.status !in 200..299) { + throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + } + return response + } + + private fun RawHttpResponse.headerInt(name: String): Int? = + headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) } + ?.value + ?.substringBefore(',') + ?.trim() + ?.toIntOrNull() + + private fun buildSortQuery(sortBy: String?, sortHow: String?): String = buildString { + sortBy?.takeIf { it.isNotBlank() }?.let { append("&sort_by=").append(it) } + sortHow?.takeIf { it.isNotBlank() }?.let { append("&sort_how=").append(it) } } private suspend fun addToWatchlist(headers: Map, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + val response = postJson( url = "$BASE_URL/sync/watchlist", body = body, headers = headers, ) + if (!isSuccessfulAddResponse(response.body)) { + throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt watchlist")) + } } private suspend fun removeFromWatchlist(headers: Map, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + postJson( url = "$BASE_URL/sync/watchlist/remove", body = body, headers = headers, @@ -582,26 +678,32 @@ object TraktLibraryRepository { } private suspend fun addToPersonalList(headers: Map, listId: String, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + val response = postJson( url = "$BASE_URL/users/me/lists/$listId/items", body = body, headers = headers, ) + if (!isSuccessfulAddResponse(response.body)) { + throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt list")) + } } private suspend fun removeFromPersonalList(headers: Map, listId: String, item: LibraryItem) { - val body = buildMutationBody(item) ?: return - httpPostJsonWithHeaders( + val body = buildMutationBody(item) + postJson( url = "$BASE_URL/users/me/lists/$listId/items/remove", body = body, headers = headers, ) } - private suspend fun buildMutationBody(item: LibraryItem): String? { + private suspend fun buildMutationBody(item: LibraryItem): String { val type = normalizeType(item.type) val ids = resolveIds(item) + if (!ids.hasAnyId()) { + throw IllegalStateException("Missing compatible Trakt IDs") + } val request = if (type == "movie") { TraktListItemsMutationRequestDto( @@ -627,36 +729,41 @@ object TraktLibraryRepository { return json.encodeToString(request) } - private suspend fun resolveIds(item: LibraryItem): TraktIdsDto? { - val rawId = item.id.trim() - val imdb = imdbRegex.find(rawId)?.value - val tmdbFromId = rawId.removePrefix("tmdb:").toIntOrNull() - val traktFromId = rawId.removePrefix("trakt:").toIntOrNull() - - val normalizedType = if (normalizeType(item.type) == "movie") "movie" else "tv" - val resolvedImdb = imdb ?: tmdbFromId?.let { TmdbService.tmdbToImdb(it, normalizedType) } - - if (resolvedImdb.isNullOrBlank() && tmdbFromId == null && traktFromId == null) { - return null - } + private fun resolveIds(item: LibraryItem): TraktIdsDto { + val parsed = parseTraktContentIds(item.id) return TraktIdsDto( - imdb = resolvedImdb, - tmdb = tmdbFromId, - trakt = traktFromId, + imdb = item.imdbId ?: parsed.imdb, + tmdb = item.tmdbId ?: parsed.tmdb, + trakt = item.traktId ?: parsed.trakt, ) } private fun mapToLibraryItem(item: TraktListItemDto): LibraryItem? { val movie = item.movie val show = item.show - val media = movie ?: show ?: return null - val type = if (movie != null) "movie" else "series" + val type = when (item.type?.lowercase()) { + "movie" -> "movie" + "show" -> "series" + else -> return null + } + val media = when (type) { + "movie" -> movie + else -> show + } ?: return null val ids = media.ids - val id = ids?.imdb - ?: ids?.tmdb?.let { "tmdb:$it" } - ?: ids?.trakt?.let { "trakt:$it" } + val fallbackId = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + item.id != null -> "trakt-item:${item.id}" + !media.title.isNullOrBlank() -> "${type}:${media.title.lowercase()}:${media.year ?: 0}" + else -> null + } ?: return null + + val id = normalizeTraktContentId( + ids?.toExternalIds(), + fallback = fallbackId, + ).takeIf { it.isNotBlank() } ?: return null val poster = media.images.traktBestPosterUrl() @@ -679,12 +786,50 @@ object TraktLibraryRepository { releaseInfo = media.year?.toString(), imdbRating = media.rating?.toString(), genres = media.genres.orEmpty(), + traktRank = item.rank, + imdbId = ids?.imdb?.takeIf { it.isNotBlank() }, + tmdbId = ids?.tmdb, + traktId = ids?.trakt, savedAtEpochMs = savedAt, ) } - private fun contentKey(itemId: String, itemType: String): String = - "${normalizeType(itemType)}:${itemId.trim()}" + private fun contentKey(itemId: String, itemType: String): String { + val parsed = parseTraktContentIds(itemId) + val normalizedId = normalizeTraktContentId(parsed, fallback = itemId.trim()) + val stableId = normalizedId.ifBlank { itemId.trim() } + return "${normalizeType(itemType)}:$stableId" + } + + private fun allContentKeys(entry: LibraryItem): Set { + val type = normalizeType(entry.type) + val keys = mutableSetOf(contentKey(entry.id, entry.type)) + entry.imdbId?.takeIf { it.isNotBlank() }?.let { keys.add("$type:$it") } + entry.tmdbId?.let { keys.add("$type:tmdb:$it") } + entry.traktId?.let { keys.add("$type:trakt:$it") } + return keys + } + + private fun isSuccessfulAddResponse(body: String): Boolean { + if (body.isBlank()) return false + val parsed = runCatching { + json.decodeFromString(body) + }.getOrNull() ?: return false + val added = parsed.added + val existing = parsed.existing + val addCount = (added?.movies ?: 0) + (added?.shows ?: 0) + (added?.seasons ?: 0) + (added?.episodes ?: 0) + val existingCount = (existing?.movies ?: 0) + (existing?.shows ?: 0) + (existing?.seasons ?: 0) + (existing?.episodes ?: 0) + return addCount > 0 || existingCount > 0 + } + + private fun errorMessageForStatus(status: Int, defaultMessage: String): String = + when (status) { + 401, 403 -> "Trakt authorization expired" + 404 -> "Trakt list not found" + 420 -> "Trakt list limit reached" + 429 -> "Trakt rate limit reached" + else -> "$defaultMessage ($status)" + } private fun normalizeType(type: String): String { val normalized = type.trim().lowercase() @@ -701,7 +846,15 @@ object TraktLibraryRepository { return yearText.toIntOrNull() } - private val imdbRegex = Regex("tt\\d+") + private fun TraktIdsDto.hasAnyId(): Boolean = + trakt != null || !imdb.isNullOrBlank() || tmdb != null + + private fun TraktIdsDto.toExternalIds(): TraktExternalIds = + TraktExternalIds( + trakt = trakt, + imdb = imdb, + tmdb = tmdb, + ) } @Serializable @@ -714,6 +867,10 @@ private data class StoredTraktLibraryPayload( private data class TraktListSummaryDto( val name: String? = null, val description: String? = null, + val privacy: String? = null, + val type: String? = null, + @SerialName("sort_by") val sortBy: String? = null, + @SerialName("sort_how") val sortHow: String? = null, val ids: TraktListIdsDto? = null, ) @@ -725,7 +882,10 @@ private data class TraktListIdsDto( @Serializable private data class TraktListItemDto( + val rank: Int? = null, + val id: Long? = null, @SerialName("listed_at") val listedAt: String? = null, + val type: String? = null, val movie: TraktMediaDto? = null, val show: TraktMediaDto? = null, ) @@ -754,6 +914,21 @@ private data class TraktListItemsMutationRequestDto( val shows: List? = null, ) +@Serializable +private data class TraktListItemsMutationResponseDto( + val added: TraktListMutationCountDto? = null, + val existing: TraktListMutationCountDto? = null, + val deleted: TraktListMutationCountDto? = null, +) + +@Serializable +private data class TraktListMutationCountDto( + val movies: Int? = null, + val shows: Int? = null, + val seasons: Int? = null, + val episodes: Int? = null, +) + @Serializable private data class TraktListMovieRequestItemDto( val title: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt index 75e355cc..6ee9ac7e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktModels.kt @@ -46,6 +46,19 @@ enum class TraktListType { PERSONAL, } +@Serializable +enum class TraktListPrivacy(val apiValue: String) { + PRIVATE("private"), + LINK("link"), + FRIENDS("friends"), + PUBLIC("public"); + + companion object { + fun fromApi(value: String?): TraktListPrivacy = + entries.firstOrNull { it.apiValue.equals(value, ignoreCase = true) } ?: PRIVATE + } +} + @Serializable data class TraktListTab( val key: String, @@ -54,6 +67,9 @@ data class TraktListTab( val traktListId: Long? = null, val slug: String? = null, val description: String? = null, + val privacy: TraktListPrivacy? = null, + val sortBy: String? = null, + val sortHow: String? = null, ) data class TraktMembershipSnapshot( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt index e1468245..4b66e8f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -12,11 +12,27 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape import io.ktor.http.encodeURLParameter import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.collections_editor_trakt_fallback_title +import nuvio.composeapp.generated.resources.collections_trakt_credentials_missing +import nuvio.composeapp.generated.resources.collections_trakt_error_with_code +import nuvio.composeapp.generated.resources.collections_trakt_invalid_list_id_or_url +import nuvio.composeapp.generated.resources.collections_trakt_list_items_count +import nuvio.composeapp.generated.resources.collections_trakt_list_likes_count +import nuvio.composeapp.generated.resources.collections_trakt_list_not_found_or_private +import nuvio.composeapp.generated.resources.collections_editor_trakt_load_failed +import nuvio.composeapp.generated.resources.collections_trakt_missing_list_id +import nuvio.composeapp.generated.resources.collections_trakt_missing_numeric_id +import nuvio.composeapp.generated.resources.collections_trakt_public_list +import nuvio.composeapp.generated.resources.collections_trakt_rate_limit_reached +import nuvio.composeapp.generated.resources.collections_trakt_request_failed +import org.jetbrains.compose.resources.getString import kotlin.math.roundToInt data class TraktPublicListImportMetadata( @@ -44,7 +60,7 @@ object TraktPublicListSourceResolver { private val json = Json { ignoreUnknownKeys = true } suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { - val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID") + val listId = source.traktListId?.takeIf { it > 0L } ?: error(getString(Res.string.collections_trakt_missing_list_id)) val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) val type = mediaType.toTraktType() val sortBy = TraktListSort.normalize(source.sortBy) @@ -60,7 +76,7 @@ object TraktPublicListSourceResolver { ), ) if (response.status !in 200..299) { - error(errorMessageFor(response.status, "Could not load Trakt list")) + error(errorMessageFor(response.status, getString(Res.string.collections_editor_trakt_load_failed))) } val rawItems = json.decodeFromString>(response.body) @@ -76,12 +92,12 @@ object TraktPublicListSourceResolver { } suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) { - val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL") + val idPath = parseTraktListPath(input) ?: error(getString(Res.string.collections_trakt_invalid_list_id_or_url)) val list = requestJson( endpoint = "lists/$idPath", query = mapOf("extended" to "full,images"), ) - val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID") + val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error(getString(Res.string.collections_trakt_missing_numeric_id)) TraktPublicListImportMetadata( title = list.name?.takeIf { it.isNotBlank() }, coverImageUrl = list.images?.posters.firstTraktImageUrl(), @@ -132,7 +148,7 @@ object TraktPublicListSourceResolver { ): T { val response = requestRaw(endpoint = endpoint, query = query) if (response.status !in 200..299) { - error(errorMessageFor(response.status, "Trakt request failed")) + error(errorMessageFor(response.status, getString(Res.string.collections_trakt_request_failed))) } return runCatching { json.decodeFromString(response.body) } .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } } @@ -144,7 +160,7 @@ object TraktPublicListSourceResolver { query: Map = emptyMap(), ): RawHttpResponse { if (TraktConfig.CLIENT_ID.isBlank()) { - error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).") + error(getString(Res.string.collections_trakt_credentials_missing)) } val url = buildTraktUrl(endpoint, query) return httpRequestRaw( @@ -237,21 +253,25 @@ object TraktPublicListSourceResolver { private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? { val id = ids?.trakt ?: return null - val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id" - val owner = user?.username?.takeIf { it.isNotBlank() } - val stats = buildList { - itemCount?.let { add("$it items") } - (likeCount ?: likes)?.let { add("$it likes") } + return runBlocking { + val listTitle = name?.takeIf { it.isNotBlank() } + ?: getString(Res.string.collections_editor_trakt_fallback_title, id) + val owner = user?.username?.takeIf { it.isNotBlank() } + val stats = buildList { + itemCount?.let { add(getString(Res.string.collections_trakt_list_items_count, it)) } + (likeCount ?: likes)?.let { add(getString(Res.string.collections_trakt_list_likes_count, it)) } + } + val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ") + .ifBlank { getString(Res.string.collections_trakt_public_list) } + TraktPublicListSearchResult( + traktListId = id, + title = listTitle, + subtitle = subtitle, + coverImageUrl = images?.posters.firstTraktImageUrl(), + sortBy = sortBy, + sortHow = sortHow, + ) } - val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ").ifBlank { "Trakt public list" } - return TraktPublicListSearchResult( - traktListId = id, - title = listTitle, - subtitle = subtitle, - coverImageUrl = images?.posters.firstTraktImageUrl(), - sortBy = sortBy, - sortHow = sortHow, - ) } private fun parseTraktListPath(input: String): String? { @@ -292,11 +312,11 @@ object TraktPublicListSourceResolver { ?.trim() ?.toIntOrNull() - private fun errorMessageFor(code: Int, fallback: String): String { - return when (code) { - 401, 403, 404 -> "Trakt list not found or not public" - 429 -> "Trakt rate limit reached" - else -> "$fallback ($code)" + private fun errorMessageFor(code: Int, fallback: String): String = runBlocking { + when (code) { + 401, 403, 404 -> getString(Res.string.collections_trakt_list_not_found_or_private) + 429 -> getString(Res.string.collections_trakt_rate_limit_reached) + else -> getString(Res.string.collections_trakt_error_with_code, fallback, code) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 44ce4441..75427d6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -49,6 +49,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @@ -106,7 +107,7 @@ private val appUpdaterJson = Json { } private class NoChannelReleaseException : IllegalStateException( - "No cmp-rewrite release has been published yet.", + runBlocking { getString(Res.string.updates_no_channel_release) }, ) private object VersionUtils { @@ -158,7 +159,7 @@ private object AppUpdaterRepository { body = "", ) if (response.status !in 200..299) { - error("GitHub releases API error: ${response.status}") + error(getString(Res.string.updates_github_api_error, response.status)) } val releases = appUpdaterJson.decodeFromString>(response.body) @@ -167,10 +168,10 @@ private object AppUpdaterRepository { val tag = release.tagName?.takeIf { it.isNotBlank() } ?: release.name?.takeIf { it.isNotBlank() } - ?: error("Release has no tag or name") + ?: error(getString(Res.string.updates_release_missing_title)) val asset = chooseBestApkAsset(release.assets) - ?: error("No APK asset found in the cmp-rewrite release") + ?: error(getString(Res.string.updates_apk_asset_missing)) AppUpdate( tag = tag, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index fffae188..66050e9b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -147,13 +147,14 @@ object WatchedRepository { markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror) } - internal fun markWatchedFromPlaybackCompletion(item: WatchedItem) { - markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip) + internal fun markWatchedFromPlaybackCompletion(item: WatchedItem, syncRemote: Boolean = true) { + markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip, syncRemote = syncRemote) } private fun markWatched( items: Collection, traktHistorySync: WatchedTraktHistorySync, + syncRemote: Boolean = true, ) { ensureLoaded() if (items.isEmpty()) return @@ -167,7 +168,9 @@ object WatchedRepository { } publish() persist() - pushMarksToServer(timestampedItems, traktHistorySync) + if (syncRemote) { + pushMarksToServer(timestampedItems, traktHistorySync) + } } fun unmarkWatched(item: WatchedItem) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index 0b73570a..984dfbec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -117,7 +117,7 @@ object WatchingActions { ) } - fun onProgressEntryUpdated(entry: WatchProgressEntry) { + fun onProgressEntryUpdated(entry: WatchProgressEntry, syncRemote: Boolean = true) { if (!entry.isCompleted) return val watchedItem = WatchedItem( @@ -129,9 +129,9 @@ object WatchingActions { episode = entry.episodeNumber, markedAtEpochMs = entry.lastUpdatedEpochMs, ) - WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem) + WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem, syncRemote = syncRemote) - if (!entry.isEpisode) return + if (!syncRemote || !entry.isEpisode) return actionScope.launch { val meta = runCatching { MetaDetailsRepository.fetch( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt index 93704067..dd227de8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.json.Json @Serializable private data class StoredContinueWatchingPreferences( val isVisible: Boolean = true, - val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, + val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Card, val upNextFromFurthestEpisode: Boolean = true, @SerialName("use_episode_thumbnails_in_cw") val useEpisodeThumbnails: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 013d1c44..29c41fa3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -15,6 +15,7 @@ internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress" @Serializable enum class ContinueWatchingSectionStyle { + Card, Wide, Poster, } @@ -179,7 +180,7 @@ data class ContinueWatchingItem( data class ContinueWatchingPreferencesUiState( val isVisible: Boolean = true, - val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, + val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Card, val upNextFromFurthestEpisode: Boolean = true, val useEpisodeThumbnails: Boolean = true, val showUnairedNextUp: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index fba8ae33..7de51f8c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -3,7 +3,10 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.AddonsUiState +import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot @@ -26,6 +29,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,8 +39,9 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withTimeoutOrNull -private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L +private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 30L * 60L * 1000L private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4 +private const val WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT = 64 private data class RemoteMetadataResolutionResult( val key: Pair, @@ -44,6 +49,20 @@ private data class RemoteMetadataResolutionResult( val meta: MetaDetails?, ) +private data class MetadataProviderReadiness( + val providers: List, + val isRefreshing: Boolean, +) { + val fingerprint: String + get() = providers.map(AddonManifest::transportUrl).sorted().joinToString(separator = "|") + + val isReady: Boolean + get() = providers.isNotEmpty() && !isRefreshing + + val isSettledWithoutProviders: Boolean + get() = !isRefreshing && providers.isEmpty() +} + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("WatchProgressRepository") @@ -57,6 +76,7 @@ object WatchProgressRepository { private var metadataResolutionJob: Job? = null private var isPullingNuvioSyncFromServer = false private var hasCompletedInitialNuvioSyncPull = false + private var lastAddonMetadataReadyFingerprint: String? = null internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { @@ -102,6 +122,12 @@ object WatchProgressRepository { } } + syncScope.launch { + AddonRepository.uiState.collectLatest { state -> + retryMetadataResolutionWhenAddonMetaProvidersReady(state) + } + } + syncScope.launch { while (true) { delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS) @@ -147,6 +173,7 @@ object WatchProgressRepository { metadataResolutionJob?.cancel() hasLoaded = false currentProfileId = 1 + lastAddonMetadataReadyFingerprint = null entriesByVideoId.clear() TraktProgressRepository.clearLocalState() TraktSettingsRepository.clearLocalState() @@ -156,6 +183,7 @@ object WatchProgressRepository { private fun loadFromDisk(profileId: Int) { currentProfileId = profileId hasLoaded = true + lastAddonMetadataReadyFingerprint = null entriesByVideoId.clear() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -263,30 +291,48 @@ object WatchProgressRepository { isCompleted = isWatchProgressComplete(position, duration, false), ) + private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { + if (!hasLoaded || shouldUseTraktProgress()) return + + val readiness = state.metadataProviderReadiness() + if (!readiness.isReady) return + + val fingerprint = readiness.fingerprint + if (fingerprint == lastAddonMetadataReadyFingerprint) return + lastAddonMetadataReadyFingerprint = fingerprint + + if (metadataResolutionJob?.isActive == true) return + resolveRemoteMetadata() + } + private fun resolveRemoteMetadata() { val missingMetadataEntries = entriesByVideoId.values .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } - val entriesToResolve = missingMetadataEntries.continueWatchingEntries(limit = ContinueWatchingLimit) + val entriesToResolve = missingMetadataEntries.continueWatchingEntries( + limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, + ) val needsResolution = entriesToResolve .groupBy { it.parentMetaId to it.contentType } - if (needsResolution.isEmpty()) { - return - } + if (needsResolution.isEmpty()) return metadataResolutionJob?.cancel() metadataResolutionJob = syncScope.launch { - withTimeoutOrNull(30_000L) { - AddonRepository.awaitManifestsLoaded() - } ?: run { - log.w { "Timed out waiting for addon manifests" } - return@launch + val providerReadiness = awaitReadyMetadataProviders() ?: return@launch + lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint + + val supportedNeedsResolution = needsResolution.filter { (key, _) -> + val (metaId, metaType) = key + providerReadiness.providers.any { provider -> + provider.supportsMetaRequest(type = metaType, id = metaId) + } } + if (supportedNeedsResolution.isEmpty()) return@launch var resolvedEntries = 0 val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) val resolutionResults = coroutineScope { - needsResolution.map { (key, entries) -> + supportedNeedsResolution.map { (key, entries) -> async { semaphore.withPermit { fetchRemoteMetadataGroup(key = key, entries = entries) @@ -356,20 +402,36 @@ object WatchProgressRepository { ) } + private suspend fun awaitReadyMetadataProviders(): MetadataProviderReadiness? { + val current = AddonRepository.uiState.value.metadataProviderReadiness() + if (current.isReady) return current + if (current.isSettledWithoutProviders) return null + + val settled = withTimeoutOrNull(30_000L) { + AddonRepository.uiState.first { state -> + val readiness = state.metadataProviderReadiness() + readiness.isReady || readiness.isSettledWithoutProviders + }.metadataProviderReadiness() + } + return settled?.takeIf { it.isReady } + } + fun upsertPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, + syncRemote: Boolean = true, ) { ensureLoaded() - upsert(session = session, snapshot = snapshot, persist = true) + upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote) } fun flushPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, + syncRemote: Boolean = true, ) { ensureLoaded() - upsert(session = session, snapshot = snapshot, persist = true) + upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote) } fun clearProgress(videoId: String) { @@ -501,6 +563,7 @@ object WatchProgressRepository { session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, persist: Boolean, + syncRemote: Boolean, ) { val positionMs = snapshot.positionMs.coerceAtLeast(0L) val durationMs = snapshot.durationMs.coerceAtLeast(0L) @@ -553,9 +616,11 @@ object WatchProgressRepository { if (entry.poster.isNullOrBlank() || entry.background.isNullOrBlank()) { resolveRemoteMetadata() } - pushScrobbleToServer(entry) + if (syncRemote) { + pushScrobbleToServer(entry) + } if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) { - WatchingActions.onProgressEntryUpdated(entry) + WatchingActions.onProgressEntryUpdated(entry, syncRemote = syncRemote) } } @@ -622,4 +687,24 @@ object WatchProgressRepository { return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId) } + private fun AddonsUiState.metadataProviderReadiness(): MetadataProviderReadiness { + val enabled = addons.enabledAddons() + val providers = enabled + .mapNotNull { addon -> addon.manifest } + .filter { manifest -> manifest.hasMetaResource() } + return MetadataProviderReadiness( + providers = providers, + isRefreshing = enabled.any { addon -> addon.isRefreshing }, + ) + } + + private fun AddonManifest.hasMetaResource(): Boolean = + resources.any { resource -> resource.name == "meta" } + + private fun AddonManifest.supportsMetaRequest(type: String, id: String): Boolean = + resources.any { resource -> + resource.name == "meta" && + resource.types.contains(type) && + (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { prefix -> id.startsWith(prefix) }) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt index d97f29c5..50b1bcba 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -39,6 +39,170 @@ class DebridStreamPresentationTest { assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv") } + @Test + fun `blank templates preserve original stream name and description`() { + val stream = localTorboxStream( + name = "Original torrent", + filename = "Movie.2026.1080p.WEB-DL.H265-GRP.mkv", + size = 4_000_000_000, + ).copy(description = "Original addon details") + + val formatted = DebridStreamFormatter().format( + stream = stream, + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamNameTemplate = "", + streamDescriptionTemplate = "", + ), + ) + + assertEquals("Original torrent", formatted.name) + assertEquals("Original addon details", formatted.description) + } + + @Test + fun `formats imported badge matches from fusion badge rules`() { + val stream = localTorboxStream( + filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv", + size = 40_000_000_000, + ) + + val formatted = DebridStreamFormatter().format( + stream = stream, + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamNameTemplate = "{stream.rseMatched::join(' | ')}", + streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing-remux\"]}", + streamBadgeRules = StreamBadgeRules( + imports = listOf( + StreamBadgeImport( + sourceUrl = "https://example.test/media-badges.json", + isActive = false, + filters = listOf( + StreamBadgeFilter( + name = "REMUX", + pattern = "(?i)\\bremux\\b", + imageURL = "https://example.test/remux.png", + tagColor = "#27C04F", + tagStyle = "filled", + textColor = "#FFFFFF", + borderColor = "#27C04F", + ), + StreamBadgeFilter( + name = "Disabled", + pattern = "(?i)\\bbluray\\b", + isEnabled = false, + ), + ), + ), + StreamBadgeImport( + sourceUrl = "https://example.test/audio-badges.json", + isActive = true, + filters = listOf( + StreamBadgeFilter( + name = "TRUEHD", + pattern = "(?i)\\btruehd\\b", + imageURL = "https://example.test/truehd.png", + tagColor = "#B968FF", + tagStyle = "filled", + textColor = "#FFFFFF", + borderColor = "#B968FF", + ), + ), + ), + ), + ), + ), + ) + + assertEquals("TRUEHD", formatted.name) + assertEquals("missing-remux", formatted.description) + assertEquals(listOf("TRUEHD"), formatted.badges.map { it.name }) + assertEquals(listOf("https://example.test/truehd.png"), formatted.badges.map { it.imageURL }) + } + + @Test + fun `parses fusion badge url payload shape`() { + val importedRules = StreamBadgeRulesParser.parse( + sourceUrl = "https://example.test/fusion-tags-ume.json", + payload = """ + { + "filters": [ + { + "borderColor": "#27C04F", + "groupId": "media", + "id": "remux", + "imageURL": "https://example.test/remux.png", + "isEnabled": true, + "name": "REMUX", + "pattern": "(?i)\\bremux\\b", + "tagColor": "#27C04F", + "tagStyle": "filled", + "textColor": "#FFFFFF", + "type": "filter" + } + ], + "groups": [ + { + "color": "#96CEB4", + "id": "media", + "isExpanded": true, + "name": "Media Source" + } + ] + } + """.trimIndent(), + ) + + assertEquals("https://example.test/fusion-tags-ume.json", importedRules.sourceUrl) + assertEquals(1, importedRules.filters.size) + assertEquals("REMUX", importedRules.filters.single().name) + assertEquals("(?i)\\bremux\\b", importedRules.filters.single().pattern) + assertEquals("https://example.test/remux.png", importedRules.filters.single().imageURL) + assertEquals("Media Source", importedRules.groups.single().name) + } + + @Test + fun `attaches imported badge urls to presented debrid streams`() { + val stream = localTorboxStream( + filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv", + size = 40_000_000_000, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(stream), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamBadgeRules = StreamBadgeRules( + imports = listOf( + StreamBadgeImport( + sourceUrl = "https://example.test/badges.json", + filters = listOf( + StreamBadgeFilter( + name = "REMUX 1", + pattern = "(?i)\\bremux\\b", + imageURL = "https://example.test/remux-t1.png", + ), + ), + ), + ), + ), + ), + ).single().streams.single() + + assertEquals(listOf("REMUX 1"), presented.badges.map { it.name }) + assertEquals("https://example.test/remux-t1.png", presented.badges.single().imageURL) + } + @Test fun `default formatter replaces addon source labels for managed streams`() { val stream = premiumizeDirectStream( @@ -69,6 +233,79 @@ class DebridStreamPresentationTest { assertFalse(name.contains("Comet", ignoreCase = true)) } + @Test + fun `preserves original addon order by default`() { + val low = localTorboxStream( + name = "Low", + filename = "Movie.720p.BluRay.x264-GRP.mkv", + size = 4_000_000_000, + ) + val large = localTorboxStream( + name = "Large", + filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv", + size = 40_000_000_000, + ) + val mid = localTorboxStream( + name = "Mid", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(low, large, mid), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + ), + ).single().streams + + assertEquals(listOf("720p TB Instant", "2160p TB Instant", "1080p TB Instant"), presented.map { it.name }) + } + + @Test + fun `sorts by best quality when quality criteria are selected`() { + val low = localTorboxStream( + name = "Low", + filename = "Movie.720p.BluRay.x264-GRP.mkv", + size = 4_000_000_000, + ) + val large = localTorboxStream( + name = "Large", + filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv", + size = 40_000_000_000, + ) + val mid = localTorboxStream( + name = "Mid", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(low, large, mid), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamPreferences = DebridStreamPreferences( + sortCriteria = DebridStreamSortCriterion.defaultOrder, + ), + ), + ).single().streams + + assertEquals(listOf("2160p TB Instant", "1080p TB Instant", "720p TB Instant"), presented.map { it.name }) + } + @Test fun `applies debrid sort filters and limits without removing normal urls`() { val low = localTorboxStream( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index f0ac0f9f..2f658b97 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.library +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktListType import kotlin.test.Test @@ -40,6 +42,29 @@ class LibraryRepositoryTest { assertEquals("banner", preview.banner) } + @Test + fun `metadata mappings keep imdb ids for Trakt-compatible sync`() { + val previewItem = MetaPreview( + id = "tt1234567", + type = "movie", + name = "Movie", + ).toLibraryItem(savedAtEpochMs = 1L) + val detailsItem = MetaDetails( + id = "tt7654321", + type = "series", + name = "Show", + ).toLibraryItem(savedAtEpochMs = 2L) + val tmdbOnlyItem = MetaPreview( + id = "tmdb:42", + type = "movie", + name = "TMDB", + ).toLibraryItem(savedAtEpochMs = 3L) + + assertEquals("tt1234567", previewItem.imdbId) + assertEquals("tt7654321", detailsItem.imdbId) + assertEquals(null, tmdbOnlyItem.imdbId) + } + @Test fun `library tabs include local Nuvio library before Trakt tabs`() { val traktTab = TraktListTab( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktIdUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktIdUtilsTest.kt new file mode 100644 index 00000000..fceb56a8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktIdUtilsTest.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktIdUtilsTest { + + @Test + fun `parse content ids supports imdb tmdb trakt and numeric aliases`() { + assertEquals(TraktExternalIds(imdb = "tt1234567"), parseTraktContentIds("tt1234567:movie")) + assertEquals(TraktExternalIds(tmdb = 42), parseTraktContentIds("tmdb:42")) + assertEquals(TraktExternalIds(trakt = 99), parseTraktContentIds("trakt:99")) + assertEquals(TraktExternalIds(trakt = 77), parseTraktContentIds("77:show")) + } + + @Test + fun `normalize content id prefers imdb then tmdb then trakt then fallback`() { + assertEquals( + "tt1234567", + normalizeTraktContentId(TraktExternalIds(trakt = 99, imdb = "tt1234567", tmdb = 42), fallback = "fallback"), + ) + assertEquals("tmdb:42", normalizeTraktContentId(TraktExternalIds(trakt = 99, tmdb = 42))) + assertEquals("trakt:99", normalizeTraktContentId(TraktExternalIds(trakt = 99))) + assertEquals("fallback", normalizeTraktContentId(TraktExternalIds(), fallback = "fallback")) + } + + @Test + fun `external ids report slug-only identifiers as present`() { + assertTrue(TraktExternalIds(slug = "favorites").hasAnyId()) + assertTrue(TraktExternalIds(tmdb = 42).hasAnyId()) + assertFalse(TraktExternalIds().hasAnyId()) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt index 01acbee9..6b06204e 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt @@ -1,5 +1,10 @@ package com.nuvio.app.features.updater +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.updates_not_available +import org.jetbrains.compose.resources.getString + actual object AppUpdaterPlatform { actual val isSupported: Boolean = false @@ -13,12 +18,12 @@ actual object AppUpdaterPlatform { assetUrl: String, assetName: String, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, - ): Result = Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + ): Result = Result.failure(IllegalStateException(getString(Res.string.updates_not_available))) actual fun canRequestPackageInstalls(): Boolean = false actual fun openUnknownSourcesSettings() = Unit actual fun installDownloadedApk(path: String): Result = - Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) } \ No newline at end of file diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt index d8b00417..7981f311 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt @@ -1,6 +1,12 @@ package com.nuvio.app.features.plugins +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.plugins_manifest_name_missing +import nuvio.composeapp.generated.resources.plugins_manifest_no_providers +import nuvio.composeapp.generated.resources.plugins_manifest_version_missing +import org.jetbrains.compose.resources.getString internal object PluginManifestParser { private val json = Json { @@ -9,9 +15,15 @@ internal object PluginManifestParser { fun parse(payload: String): PluginManifest { val manifest = json.decodeFromString(payload) - require(manifest.name.isNotBlank()) { "Manifest name is missing." } - require(manifest.version.isNotBlank()) { "Manifest version is missing." } - require(manifest.scrapers.isNotEmpty()) { "Manifest has no providers." } + require(manifest.name.isNotBlank()) { + runBlocking { getString(Res.string.plugins_manifest_name_missing) } + } + require(manifest.version.isNotBlank()) { + runBlocking { getString(Res.string.plugins_manifest_version_missing) } + } + require(manifest.scrapers.isNotEmpty()) { + runBlocking { getString(Res.string.plugins_manifest_no_providers) } + } return manifest } } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index 32e0562f..88439c51 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -25,6 +26,14 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url +import nuvio.composeapp.generated.resources.plugins_error_enter_valid_url +import nuvio.composeapp.generated.resources.plugins_error_provider_not_found +import nuvio.composeapp.generated.resources.plugins_repository_already_installed +import nuvio.composeapp.generated.resources.plugins_repository_install_failed +import nuvio.composeapp.generated.resources.plugins_repository_refresh_failed +import org.jetbrains.compose.resources.getString @Serializable private data class PluginRow( @@ -145,11 +154,11 @@ actual object PluginRepository { val manifestUrl = try { normalizeManifestUrl(rawUrl) } catch (error: IllegalArgumentException) { - return AddPluginRepositoryResult.Error(error.message ?: "Enter a valid plugin URL") + return AddPluginRepositoryResult.Error(error.message ?: getString(Res.string.plugins_error_enter_valid_url)) } if (_uiState.value.repositories.any { it.manifestUrl == manifestUrl }) { - return AddPluginRepositoryResult.Error("That plugin repository is already installed.") + return AddPluginRepositoryResult.Error(getString(Res.string.plugins_repository_already_installed)) } return try { @@ -168,7 +177,7 @@ actual object PluginRepository { pushToServer() AddPluginRepositoryResult.Success(repo) } catch (error: Throwable) { - AddPluginRepositoryResult.Error(error.message ?: "Unable to install plugin repository") + AddPluginRepositoryResult.Error(error.message ?: getString(Res.string.plugins_repository_install_failed)) } } @@ -232,7 +241,7 @@ actual object PluginRepository { if (existing.manifestUrl == manifestUrl) { existing.copy( isRefreshing = false, - errorMessage = error.message ?: "Unable to refresh repository", + errorMessage = error.message ?: runBlocking { getString(Res.string.plugins_repository_refresh_failed) }, ) } else { existing @@ -294,7 +303,7 @@ actual object PluginRepository { actual suspend fun testScraper(scraperId: String): Result> { initialize() val scraper = _uiState.value.scrapers.find { it.id == scraperId } - ?: return Result.failure(IllegalArgumentException("Provider not found")) + ?: return Result.failure(IllegalArgumentException(getString(Res.string.plugins_error_provider_not_found))) val mediaType = if (scraper.supportsType("movie")) "movie" else "tv" val season = if (mediaType == "tv") 1 else null @@ -564,7 +573,7 @@ actual object PluginRepository { private fun normalizeManifestUrl(rawUrl: String): String { val trimmed = rawUrl.trim() - require(trimmed.isNotEmpty()) { "Enter a plugin repository URL." } + require(trimmed.isNotEmpty()) { runBlocking { getString(Res.string.plugins_error_enter_repo_url) } } val normalizedScheme = when { trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt index 8d792b5e..3a003d8e 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -22,6 +22,10 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.generic_unknown +import org.jetbrains.compose.resources.getString import kotlin.random.Random private const val PLUGIN_TIMEOUT_MS = 60_000L @@ -438,7 +442,7 @@ internal object PluginRuntime { ?.takeIf { it.isNotEmpty() } PluginRuntimeResult( - title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", + title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: runBlocking { getString(Res.string.generic_unknown) }, name = item.stringOrNull("name"), url = url, quality = item.stringOrNull("quality"), diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt index 71b7e4e3..7243ae70 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -40,6 +40,44 @@ import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.tmdb.TmdbSettingsRepository import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.plugins_badge_disabled +import nuvio.composeapp.generated.resources.plugins_badge_enabled +import nuvio.composeapp.generated.resources.plugins_badge_providers +import nuvio.composeapp.generated.resources.plugins_badge_refreshing +import nuvio.composeapp.generated.resources.plugins_badge_repos +import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_missing +import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_set +import nuvio.composeapp.generated.resources.plugins_button_install_repo +import nuvio.composeapp.generated.resources.plugins_button_installing +import nuvio.composeapp.generated.resources.plugins_button_test_provider +import nuvio.composeapp.generated.resources.plugins_button_testing +import nuvio.composeapp.generated.resources.plugins_cd_delete_repo +import nuvio.composeapp.generated.resources.plugins_cd_refresh_repo +import nuvio.composeapp.generated.resources.plugins_empty_providers +import nuvio.composeapp.generated.resources.plugins_empty_repos_subtitle +import nuvio.composeapp.generated.resources.plugins_empty_repos_title +import nuvio.composeapp.generated.resources.plugins_enable_globally_desc +import nuvio.composeapp.generated.resources.plugins_enable_globally_title +import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url +import nuvio.composeapp.generated.resources.plugins_group_by_repo_desc +import nuvio.composeapp.generated.resources.plugins_group_by_repo_title +import nuvio.composeapp.generated.resources.plugins_input_manifest_placeholder +import nuvio.composeapp.generated.resources.plugins_message_installed +import nuvio.composeapp.generated.resources.plugins_provider_disabled_by_repo +import nuvio.composeapp.generated.resources.plugins_provider_no_description +import nuvio.composeapp.generated.resources.plugins_provider_version +import nuvio.composeapp.generated.resources.plugins_repo_fallback_label +import nuvio.composeapp.generated.resources.plugins_repo_version +import nuvio.composeapp.generated.resources.plugins_section_add_repo +import nuvio.composeapp.generated.resources.plugins_section_installed_repos +import nuvio.composeapp.generated.resources.plugins_section_overview +import nuvio.composeapp.generated.resources.plugins_section_providers +import nuvio.composeapp.generated.resources.plugins_test_error_title +import nuvio.composeapp.generated.resources.plugins_test_failed +import nuvio.composeapp.generated.resources.plugins_test_results_count +import nuvio.composeapp.generated.resources.plugins_tmdb_required_message +import org.jetbrains.compose.resources.stringResource @Composable fun PluginsSettingsPageContent( @@ -79,29 +117,43 @@ fun PluginsSettingsPageContent( ) } + val repoFallbackLabel = stringResource(Res.string.plugins_repo_fallback_label) + val testFailedDefault = stringResource(Res.string.plugins_test_failed) + val testErrorTitle = stringResource(Res.string.plugins_test_error_title) + val installedTemplate = stringResource(Res.string.plugins_message_installed) + val enterRepoUrlError = stringResource(Res.string.plugins_error_enter_repo_url) + Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { - NuvioSectionLabel("OVERVIEW") + NuvioSectionLabel(stringResource(Res.string.plugins_section_overview)) NuvioSurfaceCard { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - NuvioInfoBadge(text = "${sortedRepos.size} repos") - NuvioInfoBadge(text = "${sortedScrapers.size} providers") + NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_repos, sortedRepos.size)) + NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, sortedScrapers.size)) NuvioInfoBadge( - text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled", + text = if (uiState.pluginsEnabled) { + stringResource(Res.string.plugins_badge_enabled) + } else { + stringResource(Res.string.plugins_badge_disabled) + }, ) NuvioInfoBadge( - text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing", + text = if (hasTmdbApiKey) { + stringResource(Res.string.plugins_badge_tmdb_key_set) + } else { + stringResource(Res.string.plugins_badge_tmdb_key_missing) + }, ) } if (!hasTmdbApiKey) { Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.", + text = stringResource(Res.string.plugins_tmdb_required_message), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, ) @@ -114,13 +166,13 @@ fun PluginsSettingsPageContent( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Enable plugin providers globally", + text = stringResource(Res.string.plugins_enable_globally_title), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Use plugin providers during stream discovery.", + text = stringResource(Res.string.plugins_enable_globally_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -143,13 +195,13 @@ fun PluginsSettingsPageContent( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Group plugin providers by repository", + text = stringResource(Res.string.plugins_group_by_repo_title), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "In Streams, show one provider per repository instead of one per source.", + text = stringResource(Res.string.plugins_group_by_repo_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -162,7 +214,7 @@ fun PluginsSettingsPageContent( } } - NuvioSectionLabel("ADD REPOSITORY") + NuvioSectionLabel(stringResource(Res.string.plugins_section_add_repo)) NuvioSurfaceCard { NuvioInputField( value = repositoryUrl, @@ -170,16 +222,20 @@ fun PluginsSettingsPageContent( repositoryUrl = it message = null }, - placeholder = "Plugin manifest URL", + placeholder = stringResource(Res.string.plugins_input_manifest_placeholder), ) Spacer(modifier = Modifier.height(16.dp)) NuvioPrimaryButton( - text = if (isAdding) "Installing..." else "Install Plugin Repository", + text = if (isAdding) { + stringResource(Res.string.plugins_button_installing) + } else { + stringResource(Res.string.plugins_button_install_repo) + }, enabled = repositoryUrl.isNotBlank() && !isAdding, onClick = { val requested = repositoryUrl.trim() if (requested.isBlank()) { - message = "Enter a plugin repository URL." + message = enterRepoUrlError return@NuvioPrimaryButton } isAdding = true @@ -188,7 +244,7 @@ fun PluginsSettingsPageContent( when (val result = PluginRepository.addRepository(requested)) { is AddPluginRepositoryResult.Success -> { repositoryUrl = "" - message = "Installed ${result.repository.name}." + message = installedTemplate.format(result.repository.name) } is AddPluginRepositoryResult.Error -> { message = result.message @@ -208,17 +264,17 @@ fun PluginsSettingsPageContent( } } - NuvioSectionLabel("INSTALLED REPOSITORIES") + NuvioSectionLabel(stringResource(Res.string.plugins_section_installed_repos)) if (sortedRepos.isEmpty()) { NuvioSurfaceCard { Text( - text = "No plugin repositories installed yet.", + text = stringResource(Res.string.plugins_empty_repos_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Add a repository URL to install provider plugins for stream discovery.", + text = stringResource(Res.string.plugins_empty_repos_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -242,7 +298,7 @@ fun PluginsSettingsPageContent( repo.version?.let { version -> Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Version $version", + text = stringResource(Res.string.plugins_repo_version, version), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -259,13 +315,13 @@ fun PluginsSettingsPageContent( Row(verticalAlignment = Alignment.CenterVertically) { NuvioIconActionButton( icon = Icons.Rounded.Refresh, - contentDescription = "Refresh plugin repository", + contentDescription = stringResource(Res.string.plugins_cd_refresh_repo), tint = MaterialTheme.colorScheme.primary, onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) }, ) NuvioIconActionButton( icon = Icons.Rounded.Delete, - contentDescription = "Delete plugin repository", + contentDescription = stringResource(Res.string.plugins_cd_delete_repo), tint = MaterialTheme.colorScheme.error, onClick = { PluginRepository.removeRepository(repo.manifestUrl) }, ) @@ -276,9 +332,9 @@ fun PluginsSettingsPageContent( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - NuvioInfoBadge(text = "${repo.scraperCount} providers") + NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, repo.scraperCount)) if (repo.isRefreshing) { - NuvioInfoBadge(text = "Refreshing") + NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_refreshing)) } } repo.errorMessage?.let { errorMessage -> @@ -293,11 +349,11 @@ fun PluginsSettingsPageContent( } } - NuvioSectionLabel("PROVIDERS") + NuvioSectionLabel(stringResource(Res.string.plugins_section_providers)) if (sortedScrapers.isEmpty()) { NuvioSurfaceCard { Text( - text = "No providers available yet.", + text = stringResource(Res.string.plugins_empty_providers), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -307,7 +363,7 @@ fun PluginsSettingsPageContent( val scraperResults = testResults[scraper.id] val isTestingThisScraper = testingScraperId == scraper.id val repositoryName = repositoryNameByUrl[scraper.repositoryUrl] - ?: scraper.repositoryUrl.fallbackRepositoryLabel() + ?: scraper.repositoryUrl.fallbackRepositoryLabel(repoFallbackLabel) NuvioSurfaceCard { Row( @@ -342,7 +398,9 @@ fun PluginsSettingsPageContent( overflow = TextOverflow.Ellipsis, ) Text( - text = scraper.description.ifBlank { "No description" }, + text = scraper.description.ifBlank { + stringResource(Res.string.plugins_provider_no_description) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -363,15 +421,19 @@ fun PluginsSettingsPageContent( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | ")) - NuvioInfoBadge(text = "v${scraper.version}") + NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_version, scraper.version)) if (!scraper.manifestEnabled) { - NuvioInfoBadge(text = "Disabled by repo") + NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_disabled_by_repo)) } } Spacer(modifier = Modifier.height(12.dp)) NuvioPrimaryButton( - text = if (isTestingThisScraper) "Testing..." else "Test Provider", + text = if (isTestingThisScraper) { + stringResource(Res.string.plugins_button_testing) + } else { + stringResource(Res.string.plugins_button_test_provider) + }, enabled = hasTmdbApiKey && !isTestingThisScraper, onClick = { testingScraperId = scraper.id @@ -383,8 +445,8 @@ fun PluginsSettingsPageContent( .onFailure { error -> testResults[scraper.id] = listOf( PluginRuntimeResult( - title = "Error", - name = error.message ?: "Provider test failed", + title = testErrorTitle, + name = error.message ?: testFailedDefault, url = "about:error", ), ) @@ -399,7 +461,7 @@ fun PluginsSettingsPageContent( HorizontalDivider(color = MaterialTheme.colorScheme.outline) Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Test results (${scraperResults.size})", + text = stringResource(Res.string.plugins_test_results_count, scraperResults.size), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -441,11 +503,11 @@ fun PluginsSettingsPageContent( } } -private fun String.fallbackRepositoryLabel(): String { +private fun String.fallbackRepositoryLabel(fallback: String): String { val withoutQuery = substringBefore("?") val withoutManifest = withoutQuery.removeSuffix("/manifest.json") val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') return host.ifBlank { - withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" } + withoutManifest.substringAfterLast('/').ifBlank { fallback } } } diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/plugins/PluginRepository.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/plugins/PluginRepository.ios.kt index 6139ed02..4fcefb8b 100644 --- a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/plugins/PluginRepository.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/plugins/PluginRepository.ios.kt @@ -3,6 +3,9 @@ package com.nuvio.app.features.plugins import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.plugins_error_unavailable_build +import org.jetbrains.compose.resources.getString actual object PluginRepository { private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false)) @@ -18,7 +21,7 @@ actual object PluginRepository { actual suspend fun pullFromServer(profileId: Int) = Unit actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult = - AddPluginRepositoryResult.Error("Plugins are not available in this build.") + AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build)) actual fun removeRepository(manifestUrl: String) = Unit @@ -35,7 +38,7 @@ actual object PluginRepository { actual fun getEnabledScrapersForType(type: String): List = emptyList() actual suspend fun testScraper(scraperId: String): Result> = - Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) + Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) actual suspend fun executeScraper( scraper: PluginScraper, @@ -44,5 +47,5 @@ actual object PluginRepository { season: Int?, episode: Int?, ): Result> = - Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) + Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build))) } \ No newline at end of file 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 7dc979e2..8b6cfc11 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 @@ -15,6 +15,11 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.isSuccess +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.network_empty_response_body +import nuvio.composeapp.generated.resources.network_request_failed_http +import org.jetbrains.compose.resources.getString import platform.Foundation.NSUserDefaults actual object AddonStorage { @@ -83,10 +88,10 @@ actual suspend fun httpGetText(url: String): String = .let { response -> val payload = response.bodyAsText() if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") + error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) }) } if (payload.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) }) } payload } @@ -101,10 +106,10 @@ actual suspend fun httpPostJson(url: String, body: String): String = .let { response -> val payload = response.bodyAsText() if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") + error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) }) } if (payload.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) }) } payload } @@ -123,10 +128,10 @@ actual suspend fun httpGetTextWithHeaders( .let { response -> val payload = response.bodyAsText() if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") + error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) }) } if (payload.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) }) } payload } @@ -148,10 +153,10 @@ actual suspend fun httpPostJsonWithHeaders( .let { response -> val payload = response.bodyAsText() if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") + error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) }) } if (payload.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) }) } payload } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index d11d9c64..b53748f1 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -28,6 +28,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val streamBadgeRulesKey = "debrid_stream_badge_rules" private fun syncKeys(): List = listOf( enabledKey, @@ -142,6 +143,12 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + + actual fun saveStreamBadgeRules(rules: String) { + saveString(streamBadgeRulesKey, rules) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 733bec21..a0bfc724 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -10,6 +10,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.download_failed +import nuvio.composeapp.generated.resources.downloads_error_finalize_file_failed +import nuvio.composeapp.generated.resources.downloads_error_open_partial_file_failed +import nuvio.composeapp.generated.resources.downloads_error_partial_file_not_open +import nuvio.composeapp.generated.resources.downloads_error_write_partial_file_failed +import nuvio.composeapp.generated.resources.network_request_failed_http +import org.jetbrains.compose.resources.getString import platform.Foundation.NSError import platform.Foundation.NSDate import platform.Foundation.NSData @@ -99,7 +108,7 @@ internal actual object DownloadsPlatformDownloader { } if (result.statusCode !in 200..299) { - error("Request failed with HTTP ${result.statusCode}") + error(runBlocking { getString(Res.string.network_request_failed_http, result.statusCode) }) } val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L @@ -118,7 +127,7 @@ internal actual object DownloadsPlatformDownloader { error = null, ) if (!moved) { - error("Failed to finalize download file") + error(runBlocking { getString(Res.string.downloads_error_finalize_file_failed) }) } val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" @@ -127,7 +136,7 @@ internal actual object DownloadsPlatformDownloader { } catch (_: CancellationException) { handle.cancelNativeTask() } catch (error: Throwable) { - onFailure(error.message ?: "Download failed") + onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) }) } } @@ -248,7 +257,7 @@ private class IosDownloadDelegate( ) outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run { - fileError = IllegalStateException("Failed to open partial download file") + fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_open_partial_file_failed) }) null } @@ -266,7 +275,7 @@ private class IosDownloadDelegate( if (fileError != null) return val file = outputFile ?: run { - fileError = IllegalStateException("Partial download file is not open") + fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_partial_file_not_open) }) return } @@ -278,7 +287,7 @@ private class IosDownloadDelegate( file, ).toLong() if (wrote != bytesToWrite) { - fileError = IllegalStateException("Failed to write partial download file") + fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_write_partial_file_failed) }) return } fflush(file) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 6c79b413..1a980a2b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -16,6 +16,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_error_mpv_unavailable +import org.jetbrains.compose.resources.getString private const val TAG = "NuvioiOSPlayer" @@ -49,7 +52,7 @@ actual fun PlatformPlayerSurface( if (bridge == null) { LaunchedEffect(Unit) { - latestOnError.value("MPV player engine not available. Please rebuild the app.") + latestOnError.value(getString(Res.string.player_error_mpv_unavailable)) } return } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt index 01acbee9..6b06204e 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt @@ -1,5 +1,10 @@ package com.nuvio.app.features.updater +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.updates_not_available +import org.jetbrains.compose.resources.getString + actual object AppUpdaterPlatform { actual val isSupported: Boolean = false @@ -13,12 +18,12 @@ actual object AppUpdaterPlatform { assetUrl: String, assetName: String, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, - ): Result = Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + ): Result = Result.failure(IllegalStateException(getString(Res.string.updates_not_available))) actual fun canRequestPackageInstalls(): Boolean = false actual fun openUnknownSourcesSettings() = Unit actual fun installDownloadedApk(path: String): Result = - Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) } \ No newline at end of file diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 05c1dbc6..e0cd4648 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=67 -MARKETING_VERSION=0.1.25 +CURRENT_PROJECT_VERSION=69 +MARKETING_VERSION=0.1.27