diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt
index 502c14b1..52c7e112 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt
@@ -168,6 +168,24 @@ internal actual object DownloadsPlatformDownloader {
if (!tempFile.exists()) return true
return runCatching { tempFile.delete() }.getOrDefault(false)
}
+
+ actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
+ localFileUri
+ ?.toLocalFileOrNull()
+ ?.takeIf { it.exists() }
+ ?.let { return it.toURI().toString() }
+
+ val context = appContext ?: return null
+ val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
+ ?: localFileUri
+ ?.toLocalFileOrNull()
+ ?.name
+ ?.takeIf { it.isNotBlank() }
+ ?: return null
+ val downloadsDir = File(context.filesDir, "downloads")
+ val localFile = File(downloadsDir, fileName)
+ return localFile.takeIf { it.exists() }?.toURI()?.toString()
+ }
}
private class AndroidDownloadsTaskHandle(
diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml
index 930f8bda..5599b948 100644
--- a/composeApp/src/androidMain/res/xml/locale_config.xml
+++ b/composeApp/src/androidMain/res/xml/locale_config.xml
@@ -3,7 +3,7 @@
-
+
diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index 58880bec..f107fc43 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -1,6 +1,6 @@
- Reconnaissance ouverte et crédits du projet
+ Reconnaissance et crédits du projet
Retour
Annuler
Fermer
@@ -26,29 +26,29 @@
Actualisation
%1$d ressources
Indisponible
- Configurer l\'addon
- Supprimer l\'addon
+ 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.
- Aucune addons installée.
- Veuillez saisir une URL d\'addon.
- URL de l\'addon
- Installer l\'addon
+ Aucun addon installé.
+ Veuillez saisir 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ée
- Déplacer l\'addon vers le bas
- Déplacer l\'addon vers le haut
+ Addon installé
+ Déplacer l'addon vers le bas
+ Déplacer l'addon vers le haut
Actif
Addons
Catalogues
- Actualiser l\'addon
- Ajouter une addon
- Addons installées
+ 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
@@ -58,28 +58,28 @@
Ajouter un catalogue
Ajouter un dossier
Tous les genres
- Ajoutez des catalogues depuis vos extensions installées pour définir ce qu\'affiche ce dossier.
+ Ajoutez des catalogues depuis vos 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.
+ 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.
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
@@ -88,7 +88,7 @@
Apparence
Informations de base
Sources de catalogue
- Choisissez les catalogues d\'extension que ce dossier doit regrouper.
+ Choisissez les catalogues d'addon que ce dossier doit regrouper.
Sélectionner des catalogues
Sélectionner un genre
%1$d sélectionné(s)
@@ -98,26 +98,26 @@
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
Chaîne
Collection
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.
+ 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.
- Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n\'avez pas besoin de ce filtre.
+ Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB.
+ Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre.
Liste publique TMDB
ID de chaîne
ID de collection
@@ -129,12 +129,12 @@
Marvel Studios, 420 ou URL de société
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.
+ Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739.
Exemple : https://www.themoviedb.org/list/8504994 ou 8504994.
Titre affiché
Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source.
Films Marvel, Originaux Netflix, Pixar
- 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
@@ -145,7 +145,7 @@
Les deux
Tri
Filtres
- Laissez les champs vides si vous n\'avez pas besoin de ce filtre.
+ Laissez les champs vides si vous n'avez pas besoin de ce filtre.
Genres rapides
Langues rapides
Pays rapides
@@ -155,7 +155,7 @@
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.
Date de sortie ou de diffusion depuis
- Date de sortie ou de diffusion jusqu\'au
+ Date de sortie ou de diffusion jusqu'au
Utilisez le format AAAA-MM-JJ, ex. 2024-01-01.
Note minimale
Note maximale
@@ -164,7 +164,7 @@
Utilisez 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
+ Pays d'origine
Utilisez 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.
@@ -200,7 +200,7 @@
Inde
Royaume-Uni
Super-héros
- Adapté d\'un roman
+ Adapté d'un roman
Voyage dans le temps
Espace
Marvel
@@ -314,13 +314,13 @@
Ajouter un profil
Effacer la recherche
Découvrir
- Les extensions installées n\'ont retourné 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 une extension avant de rechercher.
- Aucune extension active
- Les catalogues installés n\'ont retourné aucun résultat pour cette requête.
+ Installez et validez au moins un addon avant de rechercher.
+ Aucun addon active
+ Les catalogues installés n'ont retourné aucun résultat pour cette requête.
Aucun résultat trouvé
- Vos extensions installées n\'exposent pas de catalogue de recherche.
+ Vos addons installés n'exposent pas de catalogue de recherche.
Aucun catalogue de recherche
Rechercher des films, séries…
Recherches récentes
@@ -328,11 +328,11 @@
À propos
Général
Compte
- Extensions
+ Addons
Apparence
Contenu et découverte
Continuer à regarder
- Écran d\'accueil
+ Écran d'accueil
Intégrations
Notes MDBList
Écran méta
@@ -347,15 +347,15 @@
À PROPOS
Gérez votre compte, déconnectez-vous ou supprimez-le.
COMPTE
- Ajustez la présentation de l\'accueil et les préférences visuelles.
- Rechercher de nouvelles versions de l\'application.
+ Ajustez 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 extensions et sources de découverte.
+ Gérez les addons et sources de découverte.
Gérez vos 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.
+ Gérez les alertes de sortie d'épisodes et envoyez 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.
@@ -369,8 +369,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
@@ -407,31 +407,31 @@
Adresse e-mail
Non connecté
Se déconnecter
- Vous serez redirigé vers l\'écran de connexion.
+ Vous serez 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.
+ Ajustez la largeur partagée des cartes d'affiches et les rayons des coins.
AFFICHAGE
ACCUEIL
THÈME
Collection • %1$s
Nom affiché
- Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l\'écran d\'accueil.
- Aucun catalogue d\'accueil
+ Installez 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 l'accueil en focus
%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
+ Retirez l'épingle de la collection pour la déplacer
Épinglé
Épinglé en haut
Réorganiser
@@ -442,7 +442,7 @@
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. Choisissez jusqu'à 2 catalogues sources ci-dessous.
%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.
Visible
@@ -451,7 +451,7 @@
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.
+ Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application.
Masquer les étiquettes
Mode paysage pour les affiches dans les rayons
Aperçu en direct
@@ -470,36 +470,36 @@
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 une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur.
Invite de reprise au démarrage
STYLE DE CARTE
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 vous revoyez des épisodes précédents.
+ La suite depuis l'épisode le plus avancé
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.
+ 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.
+ Créez 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.
+ 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.
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
@@ -508,21 +508,21 @@
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.
+ Cartes d'épisodes
+ Choisissez 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.
Groupe %1$d
Plus comme ceci
Rayon de recommandations.
@@ -533,14 +533,14 @@
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.
+ Regroupez les sections en onglets comme dans l'application TV. Assignez 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.
+ 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.
Il y a actuellement %1$d alertes de sortie programmées sur cet appareil.
ALERTES
@@ -548,11 +548,11 @@
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.
+ Sauvegardez d'abord une série dans votre 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.
+ L\'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties.
Contributeurs
Supporters
Ouvrir GitHub
@@ -582,19 +582,19 @@
Nov
Déc
%1$s %2$s %3$s
- Toutes les extensions
+ Toutes les addons
Tous les plugins
- Extensions autorisées
+ Addons autorisés
Plugins autorisés
Anime Skip
ID client AnimeSkip
Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com.
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
@@ -606,18 +606,18 @@
Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut.
Vitesse au maintien
Maintenir pour accélérer
- Maintenez appuyé n\'importe où sur la surface du lecteur pour augmenter temporairement la vitesse.
+ Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse.
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.
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$d 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
@@ -625,7 +625,7 @@
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\'extension 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+
@@ -661,18 +661,18 @@
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
- Toutes les extensions
- Considérer les streams de toutes les extensions installées.
+ Toutes les addons
+ Considérer les streams de toutes les addons installés.
Toutes les sources
- Considérer les streams des extensions et des plugins.
+ Considérer les streams des addons et des plugins.
Plugins activés uniquement
Considérer uniquement les streams des plugins activés.
- Extensions installées uniquement
- Considérer uniquement les streams des extensions installées.
+ Addons installés uniquement
+ Considérer uniquement les streams des addons installés.
Mode de sélection du stream
Premier stream disponible
Lire automatiquement le premier stream trouvé.
@@ -680,28 +680,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.
+ Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage.
%1$d%
Instantané
%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.
+ Ajoutez votre 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\'extension sur l\'écran de détails lorsqu\'un ID TMDB ou IMDb est disponible.
+ 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.
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
@@ -717,9 +717,9 @@
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
@@ -737,13 +737,13 @@
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.
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.
+ 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.
Score du public
IMDb
@@ -763,11 +763,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
@@ -856,32 +856,32 @@
Non
Mettre à jour
Oui
- Voulez-vous quitter l\'application ?
- Quitter l\'application
- Ce catalogue n\'a retourné aucun élément.
+ Voulez-vous quitter l'application ?
+ Quitter l'application
+ Ce catalogue n'a retourné aucun élément.
Aucun titre trouvé
Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez.
Réalisateur
Échec du chargement
Plus comme ceci
Saisons
- Cette extension a retourné des vidéos pour la série, mais aucune n\'incluait de numéros de saison ou d\'épisode.
- Cette extension n\'a fourni aucune métadonnée d\'épisode pour cette série.
- Cette extension 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 retourné 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.
Afficher moins
Afficher plus ▾
Scénariste
Tous les genres
Catalogue
%1$s • %2$s
- Le catalogue sélectionné n\'a retourné 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 extensions installées 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 retourné 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 une extension avant d\'explorer les catalogues dans Découvrir.
+ Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir.
Sélectionner un catalogue
Sélectionner un genre
Sélectionner un type
@@ -894,9 +894,9 @@
Marquer comme vu
Suivant
%1$s vu
- Installez et validez au moins une extension avant de charger des lignes de catalogue à l\'accueil.
- Les extensions installées n\'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.
- Aucune ligne d\'accueil disponible
+ 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
Voir les détails
Contrôles pour lire et enregistrer.
Actions
@@ -906,7 +906,7 @@
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
Synopsis, notes, genres et crédits principaux.
@@ -915,7 +915,7 @@
Production
Rayon de bandes-annonces et raccourcis de lecture.
De nouveau en ligne
- Impossible d\'atteindre les serveurs
+ Impossible d'atteindre les serveurs
Pas de connexion Internet
(âge %1$d)
Né(e) le %1$s%2$s
@@ -933,7 +933,7 @@
Code PIN oublié ?
Code PIN incorrect
Bloqué. Réessayez dans %1$ds
- Les options d\'avatar apparaîtront ici une fois le catalogue chargé.
+ Les options d'avatar apparaîtront ici une fois le catalogue chargé.
Avatar : %1$s
Choisir un avatar
Choisissez un avatar ci-dessous.
@@ -949,32 +949,32 @@
Gérer les profils
Nom du profil
Nouveau profil
- Extensions principales désactivées
- Extensions principales activées
+ Addons principaux désactivés
+ Addons principaux activés
Supprimer le code PIN pour %1$s
Supprimer le verrouillage PIN
Enregistrement…
Sécurité
- Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d\'y accéder.
+ Ajoutez un code PIN si vous souhaitez 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.
Configurer le verrouillage PIN
Profil sans nom
- Utiliser les extensions principales
- Partager la configuration des extensions du profil principal plutôt que de gérer une liste séparée.
+ Utiliser les addons principales
+ Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée.
Qui regarde ?
Téléchargé
Reprendre
Scrapers actifs
- Vérification d\'autres extensions…
+ Vérification d'autres addons…
Copier le lien du stream
Télécharger le fichier
- Les extensions de streams installées n\'ont pas retourné de réponse valide.
+ Les addons de streams installés n'ont pas retourné de réponse valide.
Impossible de charger les streams
- Installez d\'abord une extension pour charger les streams de ce titre.
- Vos extensions installées ne fournissent pas de streams pour ce type de titre.
- Aucune extension de streams disponible
- Aucune de vos extensions installées n\'a retourné de streams pour ce titre.
+ 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.
+ Aucun addon de streams disponible
+ Aucune de vos addons installés n'a retourné de streams pour ce titre.
S%1$d E%2$d
Épisode
S%1$dE%2$d - %3$s
@@ -995,10 +995,10 @@
%1$s • %2$s
Échec de la vérification des mises à jour
Échec du téléchargement
- Téléchargement %1$d%
- Impossible de démarrer l\'installation
+ 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.
+ Activez l'installation d'applications pour Nuvio puis revenez pour continuer.
Téléchargement de la mise à jour…
Aucune mise à jour trouvée.
Une nouvelle version est prête à être installée.
@@ -1008,22 +1008,22 @@
Autoriser les installations pour continuer
Mise à jour disponible
Statut de la mise à jour
- Cette extension est déjà installée.
- Veuillez saisir une URL d\'extension valide
+ Cet addon est déjà installé.
+ Veuillez saisir 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
Impossible de charger les commentaires
- Impossible de charger les détails depuis aucune extension.
+ Impossible de charger les détails depuis aucun addon.
Réseaux
- Aucune extension ne fournit de métadonnées pour ce contenu.
+ Aucun addon ne fournit de métadonnées pour ce contenu.
Téléchargement échoué
Affiche la progression en direct et les contrôles de téléchargement.
Téléchargements
@@ -1036,18 +1036,18 @@
Supprimer %1$s de votre 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 sauvegardé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.
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.
+ 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.
- Ce profil utilise les extensions principales.
+ Ce profil utilise les addons principales.
Impossible de charger %1$s
Source
Intégré
@@ -1058,7 +1058,7 @@
Réponse de jeton Trakt invalide
Impossible de charger la bibliothèque Trakt
Liste %1$d
- Trakt n\'a pas retourné de code d\'autorisation
+ Trakt n'a pas retourné de code d'autorisation
Identifiants Trakt manquants
Impossible de charger la progression Trakt
Impossible de terminer la connexion Trakt
@@ -1066,18 +1066,18 @@
Liste de suivi
Bande-annonce
Inconnu
- Extension
+ Addon
Enregistré
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.
+ 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.
JSON invalide : %1$s
- Extension introuvable : %1$s
+ Addon introuvable : %1$s
Janvier
Février
Mars
@@ -1112,7 +1112,7 @@
Classification
Détails du film
Langue originale
- Pays d\'origine
+ Pays d'origine
Informations de sortie
Durée
Affiches
@@ -1127,7 +1127,7 @@
Format de stream non pris en charge pour les téléchargements
Corps de réponse vide
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é
La requête de téléchargement a échoué
%1$s - %2$s
Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails.
@@ -1148,7 +1148,7 @@
%1$s • %2$s est maintenant disponible
Un nouvel épisode est maintenant disponible
%1$s est maintenant disponible
- Sorties d\'épisodes
+ Sorties d'épisodes
Créateur
Réalisateur
Scénariste
diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml
index 1e6ce43e..fca49b5c 100644
--- a/composeApp/src/commonMain/composeResources/values-it/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml
@@ -94,7 +94,7 @@
Quadrato
Orizzontale
Combina tutti i cataloghi in una singola scheda
- Mostra scheda \"Tutti\"
+ Mostra scheda "Tutti"
Riproduci la GIF configurata al posto della copertina statica quando disponibile.
Mostra GIF se configurata
%1$d sorgenti · %2$s
@@ -297,7 +297,7 @@
Usa sfondi neri assoluti per schermi OLED.
Lingua app
Scegli lingua
- Mostra, nascondi e personalizza lo stile della riga \"Continua a guardare\".
+ Mostra, nascondi e personalizza lo stile della riga "Continua a guardare".
Regola la larghezza delle locandine e i preset del raggio degli angoli.
DISPLAY
HOME
@@ -355,15 +355,15 @@
Richiesta ripresa all'avvio
STILE SCHEDA
ALL'AVVIO
- COMPORTAMENTO \"PROSSIMO EPISODIO\"
+ COMPORTAMENTO "PROSSIMO EPISODIO"
VISIBILITÀ
- Mostra la riga \"Continua a guardare\" nella schermata Home.
+ Mostra la riga "Continua a guardare" nella schermata Home.
Mostra Continua a guardare
Locandina
Scheda focalizzata sulla locandina
Orizzontale
Scheda orizzontale ricca di informazioni
- Se abilitato, \"Prossimo episodio\" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti.
+ Se abilitato, "Prossimo episodio" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti.
Prossimo episodio dall'ultimo visto
HOME
SORGENTI
@@ -502,7 +502,7 @@
Forzati
Nessuno
Preferisci Binge Group
- Durante la riproduzione automatica, preferisci un flusso dello stesso \"binge group\" di quello attuale.
+ Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale.
Lingua audio preferita
Lingua sottotitoli preferita
Preset
@@ -544,7 +544,7 @@
Mostra overlay di caricamento
Mostra una schermata di caricamento all'avvio della riproduzione di un flusso.
Salta Intro/Outro/Recap
- Mostra il pulsante \"salta\" durante i segmenti rilevati di introduzione, chiusura e riassunto.
+ Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto.
Ambito sorgente
Tutti gli addon
Considera i flussi da tutti gli addon installati.
@@ -819,7 +819,7 @@
Scegli un avatar
Scegli un avatar qui sotto.
Crea profilo
- Tutti i dati di \"%1$s\" verranno eliminati permanentemente.
+ Tutti i dati di "%1$s" verranno eliminati permanentemente.
Elimina profilo
Aggiungi profilo
Modifica profilo
@@ -1040,4 +1040,134 @@
KB
MB
GB
+ %1$d selezionati
+ %1$d cataloghi
+ %1$d selezionati
+ Sorgenti TMDB
+ Lista pubblica
+ Produzione
+ Network
+ Collezione
+ Persona
+ Regista
+ Personalizzato
+ Scegli una sorgente pronta all\'uso. Puoi modificarla o rimuoverla dopo averla aggiunta.
+ Incolla l\'URL di una lista pubblica TMDB o solo l\'ID numerico dall\'URL.
+ Cerca per nome dello studio, oppure incolla l\'ID/URL di una casa di produzione TMDB per aggiungerla direttamente.
+ Inserisci un ID network. I network più comuni sono disponibili nei Preset e nei filtri rapidi.
+ Cerca il nome di una collezione di film o incolla l\'ID collezione da TMDB.
+ Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sul cast.
+ Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sulla regia.
+ Crea una riga dinamica TMDB usando filtri opzionali. Lascia i campi vuoti se non ti serve un filtro specifico.
+ Lista pubblica TMDB
+ ID Network
+ ID Collezione
+ ID Persona
+ Nome, ID o URL casa di produzione
+ ID o URL TMDB
+ https://www.themoviedb.org/list/8504994 o 8504994
+ 213 per Netflix, 49 per HBO, 2739 per Disney+
+ 10 per Star Wars Collection
+ Marvel Studios, 420, o URL società
+ 31 per Tom Hanks, o URL persona
+ Esempi: Marvel Studios, 420, o https://www.themoviedb.org/company/420.
+ Esempio: Star Wars Collection, Harry Potter Collection, o URL collezione.
+ Esempi ID: Netflix 213, HBO 49, Disney+ 2739.
+ Esempio: https://www.themoviedb.org/list/8504994 o 8504994.
+ Esempio: https://www.themoviedb.org/person/31-tom-hanks o 31.
+ Titolo visualizzato
+ Appare come nome della riga/scheda. Se vuoto, Nuvio ne creerà uno dalla sorgente.
+ Film Marvel, Originali Netflix, Pixar
+ Film con Tom Hanks, Attori preferiti
+ Film di Christopher Nolan, Registi preferiti
+ Migliori film d\'azione, Drama coreani, Animazione 2024
+ Risultati della ricerca
+ Collezione TMDB
+ Società TMDB %1$d
+ Collezione TMDB %1$d
+ Tipo
+ Film
+ Serie TV
+ Entrambi
+ Ordina
+ Filtri
+ Lascia i campi vuoti se non ti serve quel filtro.
+ Generi rapidi
+ Lingue rapide
+ Paesi rapidi
+ Parole chiave rapide
+ Studi rapidi
+ Network rapidi
+ ID Generi
+ Usa i numeri dei generi TMDB. Separa con la virgola per AND, o con la barra verticale (pipe) per OR.
+ Data uscita dal
+ Data uscita al
+ Usa AAAA-MM-GG, ad esempio 2024-01-01.
+ Voto minimo
+ Voto massimo
+ Valutazione TMDB da 0 a 10. Esempio: 7.0.
+ Voti minimi
+ Usa questo per evitare titoli poco noti con pochi voti. Esempio: 100.
+ Lingua originale
+ Usa codici lingua a due lettere, ad esempio it, en, ko.
+ Paese d\'origine
+ Usa codici paese a due lettere, ad esempio IT, US, KR.
+ ID Parole chiave
+ Usa i numeri delle parole chiave TMDB. I suggerimenti rapidi contengono esempi comuni.
+ 9715 per supereroi
+ ID Società
+ Usa gli ID degli studi/società. I suggerimenti rapidi contengono esempi comuni.
+ 420 per Marvel Studios
+ ID Network
+ Solo per le serie TV. Usa ID network come Netflix (213) o HBO (49).
+ 213 per Netflix
+ Anno
+ Usa l\'anno a quattro cifre, ad esempio 2024.
+ Preset
+ Cerca
+ Aggiungi sorgente
+ Azione
+ Avventura
+ Animazione
+ Commedia
+ Horror
+ Fantascienza
+ Dramma
+ Crime
+ Reality
+ Inglese
+ Coreano
+ Giapponese
+ Hindi
+ Spagnolo
+ Stati Uniti
+ Corea
+ Giappone
+ India
+ Regno Unito
+ Supereroi
+ Basato su un romanzo
+ Viaggio nel tempo
+ Spazio
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Originale
+ Popolari
+ Più votati
+ Recenti
+ Lista TMDB
+ Collezione film TMDB
+ Produzione
+ Network
+ Persona
+ Regista
+ TMDB Discover
diff --git a/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings
similarity index 79%
rename from composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml
rename to composeApp/src/commonMain/composeResources/values-pt/strings
index 9cf50f3c..e5049d24 100644
--- a/composeApp/src/commonMain/composeResources/values-pt-rPT/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pt/strings
@@ -1,6 +1,6 @@
Reconhecimento aberto e créditos do projeto
- Retroceder
+ Voltar
Cancelar
Fechar
Eliminar
@@ -27,13 +27,13 @@
Indisponível
Configurar addon
Eliminar addon
- Adiciona um URL de manifesto para começares a carregar catálogos, metadados, transmissões ou legendas no Nuvio.
- Ainda não existem addons instalados.
+ Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio.
+ Ainda não tens addons instalados.
Introduz um URL de addon.
URL do Addon
Instalar Addon
A carregar detalhes do manifesto...
- A validar o URL do manifesto e a carregar os detalhes do addon antes da instalação.
+ A validar o URL do manifesto e a carregar detalhes do addon antes de instalar.
A verificar Addon
Falha na Instalação
%1$s foi validado e adicionado com sucesso.
@@ -46,19 +46,19 @@
Atualizar addon
Adicionar Addon
Addons Instalados
- Visão Geral
- %1$d regras de ID
+ Resumo
+ %1$d regras de id
Versão %1$s
Selecionado
Copiar JSON
%1$d coleção(ões), %2$d pasta(s)
- Eliminar \"%1$s\"? Esta ação não pode ser desfeita.
+ Eliminar "%1$s"? Não poderás desfazer esta ação.
Eliminar Coleção
Adicionar Catálogo
Adicionar Pasta
Todos os géneros
- Adiciona catálogos dos teus addons instalados para definires o que esta pasta mostra.
- Ainda não existem fontes de catálogo
+ Adiciona catálogos dos teis addons instalados para definires o que esta pasta mostra.
+ Ainda sem fontes de catálogo
Escolher
Emoji
URL da Imagem
@@ -68,15 +68,15 @@
Concluído
Editar Coleção
Editar Pasta
- Define a identidade da pasta, a apresentação e as fontes de catálogo com a mesma estrutura do editor de coleções principal.
+ Define a identidade da pasta, apresentação e fontes de catálogo com a mesma estrutura do editor de coleções principal.
Adiciona uma para começar.
- Ainda não existem pastas
+ Ainda sem pastas
Pastas
Filtro de Género
Mostrar apenas a imagem de capa
Ocultar Título
Nova Pasta
- Mostrar esta coleção acima de todos os catálogos normais do ecrã inicial. Múltiplas coleções afixadas seguem a ordem de criação.
+ Mostrar esta coleção acima de todos os catálogos normais do início. Múltiplas coleções afixadas seguem a ordem de criação.
Afixar Acima dos Catálogos
URL da imagem de fundo (opcional)
Nome da pasta
@@ -84,7 +84,7 @@
Nome da coleção
Guardar Alterações
Guardar
- Aparência
+ Aspeto
Básico
Fontes de Catálogo
Escolhe os catálogos de addons que esta pasta deve agregar.
@@ -93,46 +93,55 @@
%1$d selecionados
%1$d catálogos
%1$d selecionados
- Poster
+ Póster
Quadrado
Panorâmico
Combinar todos os catálogos num único separador
Mostrar Separador \"Tudo\"
- Reproduzir o GIF configurado em vez da capa estática, quando disponível.
- Mostrar GIF quando Configurado
+ Reproduzir o GIF configurado em vez da capa estática quando disponível.
+ Mostrar GIF Quando Configurado
%1$d fonte(s) · %2$s
- Forma do Cartão
+ Formato do Cartão
Linhas
Separadores
Modo de Visualização
Fontes TMDB
Lista Pública
Produção
- Rede/Canal
+ Canal/Rede
Coleção
+ Pessoa
+ Realizador
Personalizado
- Escolhe uma fonte predefinida. Podes editar ou remover após adicionar.
- Cola o URL de uma lista pública do TMDB ou apenas o número presente no URL.
- Pesquisa pelo nome do estúdio ou cola o ID/URL de uma empresa no TMDB para adicionar diretamente.
- Introduz um ID de rede. As redes comuns estão disponíveis nas Predefinições e filtros rápidos.
- Pesquisa pelo nome de uma coleção de filmes ou cola o ID da coleção do TMDB.
- Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios quando não precisares de um filtro.
- Lista pública TMDB
- ID da Rede
+ Escolhe uma fonte pronta a usar. Podes editá-la ou removê-la depois de adicionar.
+ Cola um URL de uma lista pública do TMDB ou apenas o número do URL.
+ Pesquisa pelo nome do estúdio, ou cola um ID/URL de empresa do TMDB para adicionar diretamente.
+ Introduz um ID de canal. Os canais comuns estão disponíveis nos Presets e filtros rápidos.
+ Pesquisa o nome de uma coleção de filmes ou cola o ID da coleção do TMDB.
+ Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no elenco.
+ Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no trabalho de realização.
+ Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios se não precisares do filtro.
+ Lista pública do TMDB
+ ID do Canal
ID da Coleção
+ ID da Pessoa
Nome da produtora, ID ou URL
ID ou URL do TMDB
https://www.themoviedb.org/list/8504994 ou 8504994
213 para Netflix, 49 para HBO, 2739 para Disney+
10 para Coleção Star Wars
Marvel Studios, 420 ou URL da empresa
+ 31 para Tom Hanks ou URL da pessoa
Exemplos: Marvel Studios, 420 ou https://www.themoviedb.org/company/420.
Exemplo: Coleção Star Wars, Coleção Harry Potter ou um URL de coleção.
- Exemplos de IDs: Netflix 213, HBO 49, Disney+ 2739.
+ IDs de exemplo: Netflix 213, HBO 49, Disney+ 2739.
Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994.
- Título a exibir
- Exibido como o nome da linha/separador. Se estiver em branco, o Nuvio cria um a partir da fonte.
- Filmes Marvel, Originais Netflix, Pixar
+ Exemplo: https://www.themoviedb.org/person/31-tom-hanks ou 31.
+ Título de exibição
+ Aparece como o nome da linha/separador. Se estiver vazio, o Nuvio cria um a partir da fonte.
+ Filmes da Marvel, Originais Netflix, Pixar
+ Filmes do Tom Hanks, Atores Favoritos
+ Filmes do Christopher Nolan, Realizadores Favoritos
Melhores Filmes de Ação, Dramas Coreanos, Animação 2024
Resultados da Pesquisa
Coleção TMDB
@@ -144,21 +153,21 @@
Ambos
Ordenar
Filtros
- Deixa os campos vazios quando não precisares de um filtro.
+ Deixa os campos vazios se não precisares desse filtro.
Géneros rápidos
Idiomas rápidos
Países rápidos
Palavras-chave rápidas
Estúdios rápidos
- Redes rápidas
+ Canais rápidos
IDs de Género
- Usa números de género TMDB. Separa múltiplos com vírgulas para AND, ou barras verticais para OR.
- Data de lançamento desde
- Data de lançamento até
+ Usa números de género do TMDB. Separa múltiplos com vírgulas para E, ou barras verticais para OU.
+ Data de lançamento/emissão de
+ Data de lançamento/emissão até
Usa AAAA-MM-DD, por exemplo 2024-01-01.
- Avaliação mínima
- Avaliação máxima
- Avaliação TMDB de 0 a 10. Exemplo: 7.0.
+ Classificação mínima
+ Classificação máxima
+ Classificação TMDB de 0 a 10. Exemplo: 7.0.
Mínimo de votos
Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100.
Idioma original
@@ -166,17 +175,17 @@
País de origem
Usa códigos de país de duas letras, por exemplo US, KR, JP, IN.
IDs de Palavras-chave
- Usa números de palavras-chave TMDB. Os botões rápidos preenchem exemplos comuns.
+ Usa números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns.
9715 para super-herói
IDs de Empresas
- Usa IDs de estúdio/empresa. Os botões rápidos preenchem exemplos comuns.
+ Usa IDs de estúdios/empresas. Os botões rápidos preenchem exemplos comuns.
420 para Marvel Studios
- IDs de Redes
- Apenas para séries. Usa IDs de rede como Netflix 213 ou HBO 49.
+ IDs de Canais
+ Apenas para séries. Usa IDs de canais como Netflix 213 ou HBO 49.
213 para Netflix
Ano
Usa um ano com quatro dígitos, por exemplo 2024.
- Predefinições
+ Presets
Pesquisar
Adicionar Fonte
Ação
@@ -199,7 +208,7 @@
Índia
Reino Unido
Super-herói
- Baseado em Romance/Livro
+ Baseado num Livro
Viagem no Tempo
Espaço
Marvel
@@ -212,23 +221,26 @@
Disney+
Prime Video
Hulu
+ Original
Popular
Melhor Classificados
- Recentes
+ Recente
Lista TMDB
Coleção de Filmes TMDB
Produção
- Rede/Canal
- Descobrir TMDB
+ Canal
+ Pessoa
+ Realizador
+ Descoberta TMDB
Cria uma para organizares os teus catálogos.
- Ainda não existem coleções
+ Ainda sem coleções
%1$d pasta(s)
Nenhum item encontrado
Pasta não encontrada
Coleções
Importar Coleções
JSON
- Cola o JSON das tuas coleções abaixo.
+ Cola abaixo o JSON das tuas coleções.
Importar
Nova Coleção
Afixado
@@ -245,28 +257,28 @@
Criar Conta
Não tens uma conta?
E-mail
- ou
+ ou
Palavra-passe
Inicia sessão para acederes à tua biblioteca e progresso
Iniciar Sessão
- Regista-te para sincronizares os teus dados entre dispositivos
- Registar
+ Cria conta para sincronizares os teus dados entre dispositivos
+ Criar Conta
Os teus dados serão guardados apenas localmente
- Transmite tudo, em qualquer lugar
+ Tudo em stream, em qualquer lugar
Bem-vindo de volta
Biblioteca
Biblioteca Trakt
Início
Biblioteca
Perfil
- Pesquisa
+ Pesquisar
Faixas de Áudio
Áudio
Integrado
Ajuste Inferior
Fechar reprodutor
Cor
- A reproduzir
+ A reproduzir agora
E%1$d
S%1$dE%2$d
S%1$dE%2$d • %3$s
@@ -276,15 +288,15 @@
Bloquear controlos
Nenhuma faixa de áudio disponível
Nenhum episódio disponível
- Nenhuma transmissão encontrada
+ Nenhum stream encontrado
Nenhum
Contorno
Episódios
Fontes
- Transmissões
+ Streams
Erro de reprodução
A reproduzir
- Toca para obter legendas
+ Toca para procurar legendas
Voltar
Repor Predefinições
Preencher
@@ -298,13 +310,13 @@
Avançar 10 segundos
Fontes
Estilo
- Leg.
+ Legs
Legendas
Brilho %1$s
Volume %1$s
Sem som
Transferido
- Emitido em
+ Emissão
A anunciar
Toca para desbloquear
Faixa %1$d
@@ -317,9 +329,9 @@
Falha na pesquisa
Instala e valida pelo menos um addon antes de pesquisares.
Sem addons ativos
- Os catálogos pesquisáveis instalados não encontraram correspondências para esta consulta.
+ Os catálogos pesquisáveis instalados não encontraram nada para esta procura.
Nenhum resultado encontrado
- Os teus addons instalados não disponibilizam pesquisa de catálogo.
+ Os teus addons instalados não permitem pesquisa em catálogos.
Sem catálogos pesquisáveis
Pesquisa filmes, séries...
Pesquisas Recentes
@@ -328,7 +340,7 @@
Geral
Conta
Addons
- Aparência
+ Aspeto
Conteúdo e Descoberta
Continuar a Ver
Ecrã Inicial
@@ -338,7 +350,7 @@
Notificações
Reprodução
Plugins
- Personalização de Posters
+ Personalização de Pósteres
Definições
Apoiantes e Colaboradores
Enriquecimento TMDB
@@ -346,16 +358,16 @@
SOBRE
Gere a tua conta, termina sessão ou elimina-a.
CONTA
- Ajusta a apresentação inicial e preferências visuais.
- Verifica se existem novas versões da aplicação.
- Procurar atualizações
+ Afina a apresentação do início e as preferências visuais.
+ Verifica se há novas versões da aplicação.
+ Verificar atualizações
Gere addons e fontes de descoberta.
Gere os teus filmes e episódios transferidos.
Transferências
GERAL
Liga os serviços TMDB e MDBList.
Gere alertas de lançamento de episódios e envia uma notificação de teste.
- Mudar para um perfil diferente.
+ Muda para um perfil diferente.
Mudar de Perfil
Liga o Trakt, sincroniza listas e guarda títulos diretamente no Trakt.
A carregar as tuas listas do Trakt…
@@ -363,12 +375,12 @@
Doar
Ver detalhes
Remover
- Começar do início
+ Ver do início
Reproduzir
%1$d/10
Crítica
Spoiler
- Ainda não existem críticas do Trakt disponíveis.
+ Ainda não há críticas do Trakt disponíveis.
%1$d gostos
Este comentário contém spoilers.
Este comentário contém spoilers e foi ocultado.
@@ -377,57 +389,57 @@
%1$s (%2$d)
Trailers
Sem episódios concluídos
- Ainda não existem transferências
+ Ainda sem transferências
%1$d episódio(s) transferido(s)
- Ativas
+ Ativo
Filmes
Séries
- Ver Transferências
- Concluída • %1$s
+ Mostrar Transferências
+ Concluído • %1$s
A transferir • %1$s
Falhou
- Pausada • %1$s
+ Pausado • %1$s
Visto
Temporada %1$d
Especiais
- Continua de onde ficaste
+ Continua de onde paraste
Adicionar à biblioteca
Marcar como não visto
Marcar como visto
Remover da biblioteca
Ver Tudo
Reproduzir manualmente
- Logótipo %1$s
+ Logótipo de %1$s
Conta
Eliminar Conta
- Isto eliminará permanentemente a tua conta e todos os dados associados.
+ Isto irá eliminar permanentemente a tua conta e todos os dados associados.
Esta ação não pode ser desfeita. Todos os teus dados, perfis e histórico de sincronização serão removidos permanentemente.
Eliminar Conta?
E-mail
Sessão não iniciada
Terminar Sessão
- Serás redirecionado para o ecrã de início de sessão.
+ Irás regressar ao ecrã de início de sessão.
Terminar Sessão?
Estado
Anónimo
Sessão Iniciada
Preto AMOLED
- Utiliza fundos pretos puros para ecrãs OLED.
+ Usa fundos pretos puros para ecrãs OLED.
Idioma da Aplicação
Escolher Idioma
- Mostra, oculta e personaliza o aspeto da secção \"Continuar a Ver\".
- Ajusta a largura e o arredondamento dos cantos dos posters.
+ Mostra, oculta e personaliza a linha \"Continuar a Ver\".
+ Ajusta a largura do cartão e as predefinições do raio dos cantos.
ECRÃ
INÍCIO
TEMA
Coleção • %1$s
Nome de Exibição
Instala um addon com catálogos compatíveis para configurares as linhas do Ecrã Inicial.
- Sem catálogos iniciais
- Fonte do destaque (Hero)
+ Sem catálogos para o início
+ Fonte do Destaque
Oculto
Manter Início focado
- %1$s • Limite atingido (máx. %2$d)
+ %1$s • Limite atingido (máx %2$d)
Nenhuma fonte de destaque selecionada
Fora do destaque
Remove a afixação no topo da coleção para a moveres
@@ -446,59 +458,59 @@
Abre um catálogo apenas quando precisares de o renomear ou reordenar.
Visível
Reprodutor, legendas e reprodução automática
- Arredondamento do Cartão
- ESTILO DO CARTÃO DO POSTER
+ Raio do Cartão
+ ESTILO DO CARTÃO
Largura do Cartão
Personalizado
- Personaliza a largura e o arredondamento dos cantos para os cartões de poster em toda a aplicação.
+ Personaliza a largura e o raio dos cantos dos cartões em toda a aplicação.
Ocultar etiquetas
- Modo panorâmico para posters em prateleira
- Pré-visualização em Tempo Real
+ Modo panorâmico para pósteres em linha
+ Pré-visualização ao Vivo
%1$s (%2$s)
- Raio dos cantos: %1$ddp
+ Raio do canto: %1$ddp
Altura: %1$ddp
Largura: %1$ddp
Clássico
Pílula
Arredondado
Afiado
- Subtil
+ Suave
Equilibrado
- Conforto
+ Confortável
Compacto
Denso
Grande
Padrão
- Mostra um aviso para continuares de onde ficaste ao abrir a aplicação após saíres do reprodutor.
+ Mostra um aviso para continuares de onde paraste ao abrir a aplicação após saíres do reprodutor.
Aviso de retoma ao iniciar
ESTILO DO CARTÃO
AO INICIAR
COMPORTAMENTO DO SEGUINTE
VISIBILIDADE
- Exibe a secção \"Continuar a Ver\" no ecrã Inicial.
+ Exibe a linha \"Continuar a Ver\" no ecrã inicial.
Mostrar Continuar a Ver
- Poster
+ Póster
Cartão focado na imagem
Panorâmico
- Cartão horizontal denso em informação
- Quando ativado, o \"Seguinte\" continua sempre a partir do último episódio visto. Quando desativado, segue a partir do episódio visto mais recentemente. Útil se costumas rever episódios anteriores.
- Seguinte a partir do último episódio
+ Cartão horizontal rico em informação
+ Quando ativado, o \"Seguinte\" continua sempre a partir do episódio mais avançado que foi visto. Quando desativado, segue a partir do último visto. Útil se costumas rever episódios antigos.
+ Seguinte do episódio mais avançado
INÍCIO
FONTES
Instala, remove, atualiza e ordena as tuas fontes de conteúdo.
Instala repositórios de scrapers JavaScript e testa fornecedores internamente.
Controla quais os catálogos que aparecem no Início e por que ordem.
- Desativa secções de detalhes e reordena tudo abaixo do Destaque.
+ Desativa secções de detalhes e reordena tudo o que aparece abaixo do Destaque.
Cria agrupamentos de catálogos personalizados com pastas exibidas no Início.
INTEGRAÇÕES
- Melhora as páginas de detalhes com imagens TMDB, créditos, metadados de episódios e mais.
- Adiciona classificações externas do IMDb, Rotten Tomatoes, Metacritic e outros.
+ Melhora as páginas de detalhes com imagens, créditos e metadados do TMDB.
+ Adiciona classificações do IMDb, Rotten Tomatoes, Metacritic e outros às páginas de detalhes.
Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações.
Obtém uma chave em https://mdblist.com/preferences e cola-a aqui.
Chave API
Chave API MDBList
Ativar classificações MDBList
- Mostra classificações externas do MDBList nas páginas de metadados quando o ID do IMDb estiver disponível.
+ Mostra classificações externas do MDBList nas páginas de metadados quando um ID IMDb está disponível.
CHAVE API
FORNECEDORES DE CLASSIFICAÇÃO
MDBLIST
@@ -507,19 +519,19 @@
Elenco
Lista do elenco principal.
Fundo Cinemático
- Fundo desfocado atrás do conteúdo, semelhante ao ecrã de transmissão.
+ Fundo desfocado atrás do conteúdo, semelhante ao ecrã de stream.
Coleção
Linha de coleções ou franchises relacionados.
Comentários
Secção de comentários do Trakt.
Detalhes
- Duração, estado, lançamento, idioma e informações relacionadas.
+ Duração, estado, lançamento, idioma e info relacionada.
Cartões de Episódio
Escolhe como os episódios são apresentados no ecrã de metadados.
Horizontal
- Cartões em linha tipo imagem de fundo
+ Cartões em linha tipo miniatura
Lista
- Cartões empilhados focados nos detalhes
+ Cartões empilhados com foco no detalhe
Episódios
Lista de temporadas e episódios para séries.
Grupo %1$d
@@ -529,29 +541,29 @@
Resumo
Sinopse, classificações, géneros e créditos principais.
Produção
- Estúdios e redes/canais.
- APARÊNCIA
+ Estúdios e canais.
+ ASPETO
SECÇÕES
Grupo de Separadores %1$d
- Esquema em Separadores
- Agrupa secções em separadores. Atribui até 3 secções por grupo de separadores.
+ Layout de Separadores
+ Agrupa secções em separadores como na aplicação de TV. Atribui até 3 secções por grupo.
Trailers
Linha de trailers e atalhos de reprodução.
As notificações estão atualmente desativadas no Nuvio.
Alertas de lançamento de episódios
- Agenda notificações locais quando um novo episódio de uma série guardada ficar disponível.
- As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas.
- %1$d alertas de lançamento estão agendados neste dispositivo.
+ Agenda notificações locais para quando um novo episódio de uma série guardada ficar disponível.
+ As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas e notificações de teste.
+ %1$d alertas de lançamento agendados neste dispositivo.
ALERTAS
TESTE
Enviar Notificação de Teste
- A enviar notificação de teste...
+ A enviar Notificação de Teste...
Enviar uma notificação de teste local para %1$s.
Guarda primeiro uma série na tua biblioteca para testares as notificações.
Notificação de teste
Comunidade
- Conhece as pessoas que constroem e apoiam o Nuvio em Mobile, TV e Web.
- API de Apoiantes não configurada.
+ Vê quem está a construir e a apoiar o Nuvio em Mobile, TV e Web.
+ A API de Apoiantes não está configurada. Adiciona DONATIONS_BASE_URL ao local.properties.
Colaboradores
Apoiantes
Abrir GitHub
@@ -563,10 +575,10 @@
Não foi possível carregar os apoiantes
Nenhum colaborador encontrado.
Nenhum apoiante encontrado.
- Incapaz de carregar colaboradores.
- Incapaz de carregar apoiantes.
- Não foi possível carregar os colaboradores agora.
- Não foi possível carregar os apoiantes agora.
+ Não foi possível carregar os colaboradores.
+ Não foi possível carregar os apoiantes.
+ Não foi possível carregar os colaboradores neste momento.
+ Não foi possível carregar os apoiantes neste momento.
%1$d commits no total
Jan
Fev
@@ -587,10 +599,10 @@
Plugins Permitidos
Anime Skip
ID de Cliente AnimeSkip
- Introduz o teu ID de cliente API do AnimeSkip.
- Pesquisar também no AnimeSkip por marcas de tempo para saltar (requer ID de cliente).
- Reproduzir Episódio Seguinte Automaticamente
- Encontra e reproduz automaticamente o episódio seguinte quando o limite é atingido.
+ Insere o teu ID de cliente da API AnimeSkip. Obtém um em anime-skip.com.
+ Pesquisa também no AnimeSkip por marcas de tempo para saltar partes (requer ID de cliente).
+ Reproduzir Próximo Episódio Automaticamente
+ Procura e reproduz automaticamente o próximo episódio quando o limite for atingido.
Apenas Dispositivo
Preferir Aplicação (FFmpeg)
Preferir Dispositivo
@@ -602,29 +614,29 @@
%1$d hora
%1$d horas
Ativar libass
- Usa o libass para renderização de legendas ASS/SSA em vez do renderizador padrão.
- Velocidade ao Premir
- Premir para Acelerar
- Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade.
+ Usa o libass para a renderização de legendas ASS/SSA em vez do renderizador padrão.
+ Manter Velocidade
+ Manter para Acelerar
+ Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade de reprodução.
Padrão regex inválido
Duração do Cache do Último Link
Mapear DV7 para HEVC
- Alternativa de Dolby Vision Profile 7 para HEVC em dispositivos não suportados.
+ Fallback de Dolby Vision Profile 7 para HEVC para dispositivos não suportados.
Minutos Antes do Fim
Mostra o cartão do próximo episódio estes minutos antes do fim.
%1$d min
Nenhum item disponível
Não definido
- Predefinição
+ Padrão
Idioma do Dispositivo
Forçadas
Nenhum
Preferir Grupo de Maratona
- Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo que a atual.
+ Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo de maratona da atual.
Idioma de Áudio Preferido
Idioma de Legendas Preferido
Predefinições
- Compara com nome da stream, etiqueta, descrição, addon e URL.
+ Compara com o nome da transmissão, etiqueta, descrição, addon e URL.
Padrão Regex
4K|2160p|Remux
Qualquer 1080p+
@@ -642,16 +654,16 @@
Fontes WEB
Tipo de Renderização
Padrão (Cues)
- Canvas de Efeitos
- OpenGL de Efeitos
- Canvas de Sobreposição
- OpenGL de Sobreposição
+ Effects Canvas
+ Effects OpenGL
+ Overlay Canvas
+ Overlay OpenGL
Reutilizar Último Link
Reproduz automaticamente a última transmissão funcional para este filme/episódio enquanto o cache for válido.
Idioma de Áudio Secundário
Idioma de Legendas Secundário
DESCODIFICADOR
- EPISÓDIO SEGUINTE
+ PRÓXIMO EPISÓDIO
REPRODUTOR
SALTAR SEGMENTOS
REPRODUÇÃO AUTOMÁTICA
@@ -659,91 +671,91 @@
LEGENDAS E ÁUDIO
RENDERIZAÇÃO DE LEGENDAS
%1$d selecionados
- Mostrar Sobreposição de Carga
- Mostra a animação de carregamento inicial enquanto uma transmissão começa.
+ Mostrar Sobreposição de Carregamento
+ Mostra a sobreposição inicial enquanto uma transmissão começa a ser reproduzida.
Saltar Introdução/Créditos/Resumo
Mostra o botão de saltar durante segmentos detetados de introdução, créditos e resumo.
Âmbito da Fonte
Todos os Addons
- Considerar transmissões de todos os addons instalados.
+ Considera transmissões de todos os addons instalados.
Todas as Fontes
- Considerar transmissões tanto de addons como de plugins.
- Apenas Plugins Ativados
- Considerar apenas transmissões de plugins ativados.
+ Considera transmissões de addons e plugins.
+ Apenas Plugins Ativos
+ Considera apenas transmissões de plugins ativos.
Apenas Addons Instalados
- Considerar apenas transmissões de addons instalados.
+ Considera apenas transmissões de addons instalados.
Modo de Seleção de Transmissão
- Primeira Disponível
+ Primeira Transmissão Disponível
Reproduz automaticamente a primeira transmissão encontrada.
Manual
- Selecionar transmissões manualmente de cada vez.
+ Seleciona as transmissões manualmente de cada vez.
Correspondência Regex
- Auto-seleciona uma stream que corresponda a um padrão regex.
+ Seleciona automaticamente uma transmissão que corresponda a um padrão regex.
Tempo Limite da Transmissão
- Quanto tempo esperar por transmissões antes da auto-seleção.
+ Quanto tempo esperar pelas transmissões antes da seleção automática.
Minutos Antes do Fim
Modo de Limite
Minutos Antes do Fim
Percentagem
- Percentagem de Limite
- Mostra o cartão do próximo episódio quando a reprodução atinge esta percentagem.
- %1$d%
+ Percentagem Limite
+ Mostra o cartão do próximo episódio quando a reprodução atingir esta percentagem.
+ %1$d%%
Instantâneo
%1$ds
Ilimitado
- Reprodução Tunelada
- Ativa a reprodução tunelada para menor latência na sincronização áudio/vídeo.
- Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento.
+ Reprodução em Túnel
+ Ativa a reprodução em túnel para menor latência na sincronização de áudio/vídeo.
+ Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento de dados.
Chave API TMDB
Ativar enriquecimento TMDB
- Usa a tua chave API do TMDB para enriquecer metadados no ecrã de detalhes quando um ID TMDB ou IMDb está disponível.
- Introduz a tua chave API v3 do TMDB.
+ Usa a tua chave API do TMDB para enriquecer os metadados do addon no ecrã de detalhes quando um ID TMDB ou IMDb está disponível.
+ Insere a tua chave API TMDB v3.
Código de idioma
- Arte Visual
- Substitui fundo, poster e logótipo por arte do TMDB.
+ Imagens (Artwork)
+ Substitui o fundo, o póster e o logótipo por imagens do TMDB.
Informação básica
- Usa título, sinopse, géneros e classificação do TMDB.
+ Usa o título, sinopse, géneros e classificação do TMDB.
Coleções
- Mostra linhas de franchises e coleções para filmes quando disponíveis.
+ Mostra linhas de franchises e coleções para filmes quando o TMDB as fornece.
Créditos
Usa criadores, realizadores, argumentistas e fotos do elenco do TMDB.
Detalhes
Usa info de lançamento, duração, classificação etária, estado, país e idioma do TMDB.
Episódios
- Usa títulos, miniaturas, descrições e durações de episódios do TMDB para séries.
+ Usa títulos de episódios, miniaturas, descrições e durações do TMDB para séries.
Mais como este
Mostra recomendações do TMDB no fundo das páginas de detalhes.
- Redes/Canais
- Usa metadados de redes do TMDB para títulos de TV.
+ Canais (Networks)
+ Usa metadados de canais do TMDB para títulos de TV.
Produtoras
Usa metadados de produtoras do TMDB no ecrã de detalhes.
- Posters de temporadas
- Usa posters de temporadas do TMDB no seletor de temporadas para séries.
+ Pósteres de temporadas
+ Usa pósteres de temporadas do TMDB no seletor de temporadas do ecrã de metadados.
Trailers
- Procura e exibe a secção de trailers do TMDB nas páginas de detalhes.
+ Obtém e mostra a secção de trailers de vídeo do TMDB nas páginas de detalhes.
Chave API pessoal
Idioma preferido
- Define o código de idioma TMDB para metadados localizados (ex: `pt-PT`, `en-US`).
+ Define o código de idioma do TMDB para metadados localizados, por exemplo: `pt-PT`, `en-GB` ou `en-US`.
CREDENCIAIS
LOCALIZAÇÃO
MÓDULOS
TMDB
- Após aprovação, serás redirecionado de volta automaticamente.
+ Após a aprovação, serás redirecionado de volta automaticamente.
AUTENTICAÇÃO
Comentários
- Mostrar comentários do Trakt nos detalhes de filmes e séries
- Ligar ao Trakt
+ Mostra comentários do Trakt nos detalhes de filmes e séries
+ Ligar Trakt
Ligado como %1$s
Utilizador Trakt
Desligar
Falha ao abrir o navegador
FUNCIONALIDADES
Conclui o início de sessão do Trakt no teu navegador
- Monitoriza o que vês, guarda na lista de interesse ou listas personalizadas e mantém a biblioteca sincronizada.
- Faltam credenciais do Trakt em local.properties.
+ Regista o que vês, guarda na lista de interesses ou em listas personalizadas e mantém a tua biblioteca sincronizada com o Trakt.
+ Faltam credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).
Abrir Login do Trakt
- As tuas ações de \"Guardar\" podem agora visar a lista de interesse e listas pessoais do Trakt.
- Inicia sessão com o Trakt para ativar gravação baseada em listas e modo de biblioteca Trakt.
+ As tuas ações de Guardar podem agora visar a lista de interesses e listas pessoais do Trakt.
+ Inicia sessão com o Trakt para ativares o salvamento em listas e o modo de biblioteca Trakt.
Pontuação do Público
IMDb
Letterboxd
@@ -769,7 +781,7 @@
Saltar Créditos
Saltar Resumo
Nenhuma legenda encontrada
- Africâner
+ Africânder
Albanês
Amárico
Árabe
@@ -788,7 +800,7 @@
Croata
Checo
Dinamarquês
- Holandês
+ Neerlandês
Inglês
Estónio
Filipino
@@ -838,7 +850,7 @@
Suaíli
Sueco
Tâmil
- Telugo
+ Telugu
Tailandês
Turco
Ucraniano
@@ -864,7 +876,7 @@
Falha ao carregar
Mais Como Este
Temporadas
- Este addon devolveu vídeos, mas nenhum inclui números de temporada ou episódio.
+ Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio.
Este addon não forneceu metadados de episódios para esta série.
Os episódios ainda não foram publicados por este addon.
O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários.
@@ -875,12 +887,12 @@
Catálogo
%1$s • %2$s
O catálogo selecionado falhou ao devolver itens de descoberta.
- Não foi possível carregar descoberta
- Os addons instalados não expõem catálogos compatíveis para descoberta.
+ Não foi possível carregar a descoberta
+ Os addons instalados não expõem catálogos compatíveis com o painel de descoberta.
Sem catálogos de descoberta
- O catálogo e filtros selecionados não devolveram nenhuns itens.
+ O catálogo e os filtros selecionados não devolveram nenhum item.
Nenhum título encontrado
- Instala e valida pelo menos um addon antes de navegar nos catálogos.
+ Instala e valida pelo menos um addon antes de navegar nos catálogos de descoberta.
Selecionar Catálogo
Selecionar Género
Selecionar Tipo
@@ -891,11 +903,11 @@
Marcar %1$s como vista
Marcar como não visto
Marcar como visto
- Próximo
+ A seguir
%1$s visto
- Instala e valida pelo menos um addon antes de carregar o Início.
- Os addons instalados não expõem catálogos compatíveis de momento.
- Nenhuma linha disponível no Início
+ Instala e valida pelo menos um addon antes de carregar as linhas de catálogo no Início.
+ Os addons instalados não expõem atualmente catálogos compatíveis sem extras obrigatórios.
+ Sem linhas disponíveis no início
Ver Detalhes
Controlos de reprodução e gravação.
Ações
@@ -903,34 +915,34 @@
Linha de coleção ou franchise relacionada.
Coleção
Secção de comentários do Trakt.
- Duração, estado, lançamento, idioma e informações relacionadas.
+ Duração, estado, lançamento, idioma e info relacionada.
Detalhes
Lista de temporadas e episódios para séries.
Linha de recomendações.
Mais Como Este
Sinopse, classificações, géneros e créditos principais.
- Visão Geral
- Estúdios e redes/canais.
+ Resumo
+ Estúdios e canais.
Produção
Linha de trailers e atalhos de reprodução.
Novamente online
- Não é possível contactar os servidores
- Sem ligação à internet
+ Não foi possível contactar os servidores
+ Sem ligação à Internet
(%1$d anos)
Nascimento: %1$s%2$s
Falecimento: %1$s
Conhecido por: %1$s
- Mais recente
- Não foi possível carregar os detalhes de %1$s
+ Mais Recente
+ Não foi possível carregar detalhes de %1$s
Popular
Algo correu mal
- Próximos lançamentos
- Apagar
+ Brevemente
+ Retroceder
Cancelar
Introduzir PIN
- Introduz o PIN para %1$s
+ Introduzir PIN de %1$s
Esqueceste-te do PIN?
- PIN incorreto
+ PIN Incorreto
Bloqueado. Tenta novamente em %1$ds
As opções de avatar aparecerão aqui quando o catálogo carregar.
Avatar: %1$s
@@ -948,8 +960,8 @@
Gerir Perfis
Nome do perfil
Novo perfil
- Addons primários desativados
- Addons primários ativados
+ Addons principais desativados
+ Addons principais ativos
Remover PIN de %1$s
Remover Bloqueio por PIN
A guardar...
@@ -959,18 +971,18 @@
Seleciona um avatar para este perfil.
Definir Bloqueio por PIN
Perfil sem nome
- Usar Addons Primários
+ Usar Addons Principais
Partilha a configuração de addons do perfil principal em vez de gerir uma lista separada.
Quem está a ver?
- Descarregado
+ Transferido
Retomar
Scrapers ativos
A verificar mais addons…
- Copiar link da stream
- Descarregar ficheiro
+ Copiar link da transmissão
+ Transferir ficheiro
Os addons de transmissão instalados falharam ao devolver uma resposta válida.
Não foi possível carregar as transmissões
- Instala um addon primeiro para carregar transmissões para este título.
+ Instala primeiro um addon para carregar transmissões para este título.
Os teus addons instalados não fornecem transmissões para este tipo de título.
Nenhum addon de transmissão disponível
Nenhum dos teus addons instalados devolveu transmissões para este título.
@@ -980,31 +992,31 @@
A obter…
A procurar fonte…
A procurar transmissões…
- Link da stream copiado
- Nenhum link direto disponível
- Nenhuns metadados disponíveis
+ Link da transmissão copiado
+ Nenhum link direto de transmissão disponível
+ Nenhum metadado disponível
Atualizar transmissões
- Retomar de %1$d%
+ Retomar de %1$d%%
Retomar de %1$s
TAMANHO %1$s
Fechar trailer
- Não é possível reproduzir o trailer
- Falha ao carregar listas do Trakt
- Falha ao atualizar listas do Trakt
+ Não foi possível reproduzir o trailer
+ Falha ao carregar as listas do Trakt
+ Falha ao atualizar as listas do Trakt
%1$s • %2$s
- Falha ao verificar atualizações
- Falha no download
- A descarregar %1$d%
+ Falha na verificação de atualizações
+ Falha na transferência
+ A transferir %1$d%%
Não foi possível iniciar a instalação
Estás a usar a versão mais recente.
Ativa a instalação de aplicações para o Nuvio, depois volta e continua.
- A descarregar atualização...
+ A transferir atualização...
Nenhuma atualização encontrada.
Uma nova versão está pronta para instalar.
- As atualizações na aplicação não estão disponíveis nesta versão.
- A preparar download
+ As atualizações na aplicação não estão disponíveis nesta versão (build).
+ A preparar a transferência
Notas de lançamento
- Permitir que a instalação continue
+ Permitir que as instalações continuem
Atualização disponível
Estado da atualização
Esse addon já está instalado.
@@ -1014,55 +1026,55 @@
Falha ao eliminar conta
Falha ao iniciar sessão
Falha ao terminar sessão
- Falha ao criar conta
+ Falha no registo
Não foi possível carregar os itens do catálogo.
- A Seguir
- A Seguir • T%1$dE%2$d
- Logótipo de %1$s
+ A seguir
+ A seguir • T%1$dE%2$d
+ Logótipo %1$s
Falha ao carregar comentários
Não foi possível carregar detalhes de nenhum addon.
- Redes/Canais
+ Canais/Redes
Nenhum addon fornece metadados para este conteúdo.
- Falha no download
- Mostra o progresso e controlos de downloads em direto.
- Downloads
- Download concluído
- A descarregar %1$s • %2$s
- A descarregar %1$s • %2$s / %3$s
- Falha no download
- Em pausa %1$s
+ Falha na transferência
+ Mostra o progresso e controlos das transferências em direto.
+ Transferências
+ Transferência concluída
+ A transferir %1$s • %2$s
+ A transferir %1$s • %2$s / %3$s
+ Falha na transferência
+ Pausado %1$s
Remover
Remover %1$s da tua biblioteca?
Remover da Biblioteca?
Filme
- Alertas para quando um novo episódio de uma série guardada é lançado.
+ Alertas quando um novo episódio de uma série guardada é lançado.
Pré-visualização do alerta de lançamento de episódio.
Falha ao enviar uma notificação de teste.
Notificação de teste enviada para %1$s.
- Não é possível reproduzir esta transmissão.
+ Não foi possível reproduzir esta transmissão.
O PIN deste perfil mudou. Liga-te uma vez para atualizar o bloqueio neste dispositivo.
- Não foi possível remover o bloqueio por PIN. Tenta novamente.
- Liga-te à internet para remover o bloqueio por PIN.
+ Não foi possível remover o PIN. Tenta novamente.
+ Liga-te à Internet para remover o bloqueio por PIN.
Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te e desbloqueia-o online primeiro.
Não foi possível definir o PIN. Tenta novamente.
- Liga-te à internet para definir um PIN.
- Este perfil utiliza addons primários.
+ Liga-te à Internet para definir um PIN.
+ Este perfil utiliza addons principais.
Falha ao carregar %1$s
Transmissão
Incorporado
Autorização negada
- Conclui o início de sessão do Trakt no teu navegador
+ Conclui o início de sessão no Trakt no teu browser
Callback do Trakt inválido
Estado de callback do Trakt inválido
Resposta de token do Trakt inválida
- Falha ao carregar biblioteca do Trakt
+ Falha ao carregar a biblioteca do Trakt
Lista %1$d
O Trakt não devolveu um código de autorização
- Faltam credenciais do Trakt
- Falha ao carregar progresso do Trakt
+ Credenciais do Trakt em falta
+ Falha ao carregar o progresso do Trakt
Falha ao concluir o início de sessão no Trakt
Utilizador Trakt
- Lista de interesse
+ Lista de Interesses
Trailer
Desconhecido
Addon
@@ -1102,51 +1114,51 @@
Nov
Dez
Produtora
- Rede/Canal
+ Canal
Não foi possível carregar %1$s
Popular
Recente
%1$s • %2$s
Melhor Classificados
- Certificação
+ Classificação Etária
Detalhes do Filme
Idioma Original
País de Origem
Info de Lançamento
Duração
- Posters
+ Pósteres
Texto
Detalhes da Série
Estado
Vídeos
- Ficheiro
- Nenhum link direto disponível
- Download anterior substituído
- Download iniciado
- Formato de transmissão não suportado para downloads
+ FICH.
+ Nenhum link direto de transmissão disponível
+ Transferência anterior substituída
+ Transferência iniciada
+ Formato de transmissão não suportado para transferências
Corpo da resposta vazio
- O pedido falhou com HTTP %1$d
- O sistema de downloads não foi inicializado
- Pedido de download falhou
+ Pedido falhou com HTTP %1$d
+ O sistema de transferências não foi inicializado
+ Pedido de transferência falhou
%1$s - %2$s
- Os títulos guardados aparecerão aqui após tocares em \"Guardar\" no ecrã de detalhes.
+ Os títulos guardados aparecerão aqui depois de tocares em Guardar no ecrã de detalhes.
A tua biblioteca está vazia
Não foi possível carregar a biblioteca
Outro
Biblioteca
- Liga o Trakt e guarda títulos na tua lista de interesse ou listas pessoais.
- A tua biblioteca Trakt está vazia
- Não foi possível carregar a biblioteca Trakt
+ Liga-te ao Trakt e guarda títulos na tua lista de interesses ou listas pessoais.
+ A tua biblioteca do Trakt está vazia
+ Não foi possível carregar a biblioteca do Trakt
Biblioteca Trakt
Anime
Canais
Filmes
Séries
- TV
- %1$s já está disponível
- %1$s • %2$s já está disponível
- Um novo episódio já está disponível
- %1$s já está disponível
+ Televisão
+ %1$s lançado(s)
+ %1$s • %2$s já lançado(s)
+ Um novo episódio lançado
+ %1$s já lançado(s)
Lançamentos de Episódios
Criador
Realizador
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 1d7de0e3..7cbb3f3e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -188,6 +188,27 @@
Presets
Search
Add Source
+ Add Trakt List
+ Edit Trakt List
+ Trakt Lists
+ Trakt list
+ Search title, Trakt URL, or list ID
+ Use a public Trakt list URL or numeric list ID, or search by name.
+ Weekend Watch, Award Winners
+ Search Results
+ Trending Lists
+ Popular Lists
+ Direction
+ Ascending
+ Descending
+ List Order
+ Recently Added
+ Title
+ Released
+ Runtime
+ Popular
+ Percentage
+ Votes
Action
Adventure
Animation
@@ -1091,6 +1112,7 @@
Folder %1$d in '%2$s' has blank id.
Folder '%1$s' in '%2$s' has blank title.
Source %1$d in folder '%2$s' has blank fields.
+ Source %1$d in folder '%2$s' is missing a Trakt list ID.
Invalid JSON: %1$s
Addon not found: %1$s
January
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 4e68adf2..f9e85f6c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -596,7 +596,9 @@ private fun MainAppContent(
NetworkCondition.ServersUnreachable,
-> {
offlineLaunchRouteHandled = true
- val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable }
+ val hasPlayableDownload = downloadsUiState.completedItems.any {
+ DownloadsRepository.playableLocalFileUri(it) != null
+ }
if (hasPlayableDownload) {
selectedTab = AppScreenTab.Settings
navController.navigate(DownloadsSettingsRoute) {
@@ -689,7 +691,7 @@ private fun MainAppContent(
episodeNumber = episodeNumber,
videoId = videoId,
)
- val localSourceUrl = downloadedItem?.localFileUri
+ val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) {
val launchId = PlayerLaunchStore.put(
PlayerLaunch(
@@ -1327,6 +1329,7 @@ private fun MainAppContent(
)
)
StreamsRepository.consumeAutoPlay()
+ StreamsRepository.cancelLoading()
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo { inclusive = true }
}
@@ -1405,6 +1408,7 @@ private fun MainAppContent(
initialProgressFraction = resolvedResumeProgressFraction,
)
)
+ StreamsRepository.cancelLoading()
navController.navigate(
PlayerRoute(launchId = launchId)
)
@@ -1531,7 +1535,7 @@ private fun MainAppContent(
DownloadsScreen(
onBack = onBack,
onOpenDownload = { item ->
- val sourceUrl = item.localFileUri ?: return@DownloadsScreen
+ val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
val resumeEntry = item.videoId
.takeIf { it.isNotBlank() }
?.let(WatchProgressRepository::progressForVideo)
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 f7597072..0a31a9d7 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
@@ -2,6 +2,8 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.trakt.TraktPublicListSearchResult
+import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -27,6 +29,8 @@ data class CollectionEditorUiState(
val showFolderEditor: Boolean = false,
val showCatalogPicker: Boolean = false,
val showTmdbSourcePicker: Boolean = false,
+ val showTraktSourcePicker: Boolean = false,
+ val editingTraktSourceIndex: Int? = null,
val genrePickerSourceIndex: Int? = null,
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
val tmdbInput: String = "",
@@ -38,6 +42,16 @@ data class CollectionEditorUiState(
val tmdbCompanyResults: List = emptyList(),
val tmdbCollectionResults: List = emptyList(),
val tmdbSearchError: String? = null,
+ val traktInput: String = "",
+ val traktTitleInput: String = "",
+ val traktMediaType: TmdbCollectionMediaType = TmdbCollectionMediaType.MOVIE,
+ val traktMediaBoth: Boolean = true,
+ val traktSortBy: String = TraktListSort.RANK.value,
+ val traktSortHow: String = TraktSortHow.ASC.value,
+ val traktSearchResults: List = emptyList(),
+ val traktTrendingResults: List = emptyList(),
+ val traktPopularResults: List = emptyList(),
+ val traktSearchError: String? = null,
)
enum class TmdbBuilderMode {
@@ -246,7 +260,7 @@ object CollectionEditorRepository {
fun updateCatalogSourceGenre(index: Int, genre: String?) {
val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources
- if (index !in sources.indices || sources[index].isTmdb) return
+ if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
val updated = sources.toMutableList()
updated[index] = updated[index].copy(genre = genre)
_uiState.value = _uiState.value.copy(
@@ -258,7 +272,11 @@ object CollectionEditorRepository {
val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources
val existingIndex = sources.indexOfFirst {
- !it.isTmdb && it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId
+ !it.isTmdb &&
+ !it.isTrakt &&
+ it.addonId == catalog.addonId &&
+ it.type == catalog.type &&
+ it.catalogId == catalog.catalogId
}
if (existingIndex >= 0) {
removeCatalogSource(existingIndex)
@@ -271,6 +289,8 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(
showCatalogPicker = true,
showTmdbSourcePicker = false,
+ showTraktSourcePicker = false,
+ editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
)
}
@@ -283,6 +303,8 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(
showTmdbSourcePicker = true,
showCatalogPicker = false,
+ showTraktSourcePicker = false,
+ editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
tmdbSearchError = null,
)
@@ -292,14 +314,139 @@ object CollectionEditorRepository {
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
}
+ fun showTraktSourcePicker() {
+ _uiState.value = _uiState.value.copy(
+ showTraktSourcePicker = true,
+ showCatalogPicker = false,
+ showTmdbSourcePicker = false,
+ editingTraktSourceIndex = null,
+ genrePickerSourceIndex = null,
+ traktInput = "",
+ traktTitleInput = "",
+ traktMediaType = TmdbCollectionMediaType.MOVIE,
+ traktMediaBoth = true,
+ traktSortBy = TraktListSort.RANK.value,
+ traktSortHow = TraktSortHow.ASC.value,
+ traktSearchResults = emptyList(),
+ traktSearchError = null,
+ )
+ loadTraktFeaturedLists()
+ }
+
+ fun hideTraktSourcePicker() {
+ _uiState.value = _uiState.value.copy(
+ showTraktSourcePicker = false,
+ editingTraktSourceIndex = null,
+ traktSearchError = null,
+ )
+ }
+
+ fun editTraktSource(index: Int) {
+ val folder = _uiState.value.editingFolder ?: return
+ val source = folder.resolvedSources.getOrNull(index) ?: return
+ if (!source.isTrakt) return
+ _uiState.value = _uiState.value.copy(
+ showTraktSourcePicker = true,
+ showCatalogPicker = false,
+ showTmdbSourcePicker = false,
+ editingTraktSourceIndex = index,
+ genrePickerSourceIndex = null,
+ traktInput = source.traktListId?.toString().orEmpty(),
+ traktTitleInput = source.title.orEmpty(),
+ traktMediaType = TmdbCollectionMediaType.fromString(source.mediaType),
+ traktMediaBoth = false,
+ traktSortBy = TraktListSort.normalize(source.sortBy),
+ traktSortHow = TraktSortHow.normalize(source.sortHow),
+ traktSearchResults = emptyList(),
+ traktSearchError = null,
+ )
+ loadTraktFeaturedLists()
+ }
+
+ fun setTraktInput(value: String) {
+ _uiState.value = _uiState.value.copy(traktInput = value, traktSearchError = null)
+ }
+
+ fun setTraktTitleInput(value: String) {
+ _uiState.value = _uiState.value.copy(traktTitleInput = value)
+ }
+
+ fun setTraktMediaType(value: TmdbCollectionMediaType) {
+ _uiState.value = _uiState.value.copy(traktMediaType = value, traktMediaBoth = false)
+ }
+
+ fun setTraktMediaBoth(value: Boolean) {
+ _uiState.value = _uiState.value.copy(
+ traktMediaBoth = value,
+ traktMediaType = if (value) TmdbCollectionMediaType.MOVIE else _uiState.value.traktMediaType,
+ )
+ }
+
+ fun setTraktSortBy(value: String) {
+ _uiState.value = _uiState.value.copy(traktSortBy = TraktListSort.normalize(value))
+ }
+
+ fun setTraktSortHow(value: String) {
+ _uiState.value = _uiState.value.copy(traktSortHow = TraktSortHow.normalize(value))
+ }
+
+ fun searchTraktLists() {
+ 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")
+ return
+ }
+
+ scope.launch {
+ val results = if (query.isTraktListIdentifierInput()) {
+ runCatching {
+ val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
+ val id = metadata.traktListId ?: error("Could not load Trakt list")
+ listOf(
+ TraktPublicListSearchResult(
+ traktListId = id,
+ title = metadata.title ?: "Trakt List $id",
+ subtitle = "Resolved Trakt list",
+ coverImageUrl = metadata.coverImageUrl,
+ ),
+ )
+ }
+ } else {
+ runCatching { TraktPublicListSourceResolver.searchPublicLists(query) }
+ }
+ val mapped = results.getOrDefault(emptyList())
+ _uiState.value = _uiState.value.copy(
+ traktSearchResults = mapped,
+ traktSearchError = results.exceptionOrNull()?.message
+ ?: if (mapped.isEmpty()) "No Trakt lists found" else null,
+ )
+ }
+ }
+
+ private fun loadTraktFeaturedLists() {
+ scope.launch {
+ val trending = runCatching { TraktPublicListSourceResolver.trendingPublicLists() }
+ val popular = runCatching { TraktPublicListSourceResolver.popularPublicLists() }
+ _uiState.value = _uiState.value.copy(
+ traktTrendingResults = trending.getOrDefault(_uiState.value.traktTrendingResults),
+ traktPopularResults = popular.getOrDefault(_uiState.value.traktPopularResults),
+ traktSearchError = _uiState.value.traktSearchError
+ ?: trending.exceptionOrNull()?.message
+ ?: popular.exceptionOrNull()?.message,
+ )
+ }
+ }
+
fun showGenrePicker(index: Int) {
val folder = _uiState.value.editingFolder ?: return
val sources = folder.resolvedSources
- if (index !in sources.indices || sources[index].isTmdb) return
+ if (index !in sources.indices || sources[index].addonCatalogSource() == null) return
_uiState.value = _uiState.value.copy(
genrePickerSourceIndex = index,
showCatalogPicker = false,
showTmdbSourcePicker = false,
+ showTraktSourcePicker = false,
)
}
@@ -322,6 +469,8 @@ object CollectionEditorRepository {
showFolderEditor = false,
showCatalogPicker = false,
showTmdbSourcePicker = false,
+ showTraktSourcePicker = false,
+ editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
)
}
@@ -332,6 +481,8 @@ object CollectionEditorRepository {
showFolderEditor = false,
showCatalogPicker = false,
showTmdbSourcePicker = false,
+ showTraktSourcePicker = false,
+ editingTraktSourceIndex = null,
genrePickerSourceIndex = null,
)
}
@@ -546,6 +697,103 @@ object CollectionEditorRepository {
)
}
+ fun addTraktSourceFromInput() {
+ val state = _uiState.value
+ val input = state.traktInput.trim()
+ if (input.isBlank()) {
+ _uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL")
+ return
+ }
+
+ scope.launch {
+ 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",
+ )
+ return@launch
+ }
+
+ val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" }
+ addTraktSourcesToFolder(
+ sources = selectedTraktMediaTypes(state).map { mediaType ->
+ CollectionSource(
+ provider = "trakt",
+ title = titleForMedia(title, mediaType, state.traktMediaBoth),
+ traktListId = listId,
+ mediaType = mediaType.name,
+ sortBy = TraktListSort.normalize(state.traktSortBy),
+ sortHow = TraktSortHow.normalize(state.traktSortHow),
+ )
+ },
+ coverImageUrl = resolved.coverImageUrl,
+ )
+ }
+ }
+
+ 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,
+ )
+ }
+
+ private fun addTraktSourcesToFolder(sources: List, coverImageUrl: String? = null) {
+ val state = _uiState.value
+ val folder = state.editingFolder ?: return
+ val editingIndex = state.editingTraktSourceIndex
+ val existingKeys = folder.resolvedSources
+ .mapIndexedNotNull { index, source ->
+ collectionSourceKey(source).takeUnless { index == editingIndex }
+ }
+ .toMutableSet()
+ val newSources = sources.filter { existingKeys.add(collectionSourceKey(it)) }
+ if (newSources.isEmpty()) return
+
+ val updatedSources = if (
+ editingIndex != null &&
+ editingIndex in folder.resolvedSources.indices &&
+ folder.resolvedSources[editingIndex].isTrakt
+ ) {
+ folder.resolvedSources.toMutableList().also {
+ it.removeAt(editingIndex)
+ it.addAll(editingIndex, newSources)
+ }
+ } else {
+ folder.resolvedSources + newSources
+ }
+ val shouldApplyCover = !coverImageUrl.isNullOrBlank() && folder.coverImageUrl.isNullOrBlank()
+ val updatedFolder = if (shouldApplyCover) {
+ folder.withSources(updatedSources)
+ .copy(coverImageUrl = coverImageUrl, coverEmoji = null)
+ } else {
+ folder.withSources(updatedSources)
+ }
+
+ _uiState.value = state.copy(
+ editingFolder = updatedFolder,
+ showTraktSourcePicker = false,
+ editingTraktSourceIndex = null,
+ traktInput = "",
+ traktTitleInput = "",
+ traktSearchResults = emptyList(),
+ traktSearchError = null,
+ )
+ }
+
fun save(): Boolean {
val state = _uiState.value
if (state.title.isBlank()) return false
@@ -593,10 +841,18 @@ private fun CollectionFolder.withSources(nextSources: List): C
)
private fun collectionSourceKey(source: CollectionSource): String =
- if (source.isTmdb) {
- "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
- } else {
- "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
+ when {
+ source.isTmdb -> {
+ "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
+ }
+
+ source.isTrakt -> {
+ "trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
+ }
+
+ else -> {
+ "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
+ }
}
private fun selectedMediaTypes(
@@ -630,7 +886,22 @@ private fun titleForMedia(
return "$title $suffix"
}
+private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List =
+ if (state.traktMediaBoth) {
+ listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
+ } else {
+ listOf(state.traktMediaType)
+ }
+
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
tmdbSourceType
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
?: TmdbCollectionSourceType.DISCOVER
+
+private fun String.isTraktListIdentifierInput(): Boolean {
+ val trimmed = trim()
+ if (trimmed.isBlank()) return false
+ if (trimmed.toLongOrNull() != null) return true
+ if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true
+ return Regex("""[?&]id=([^/]+)""").containsMatchIn(trimmed)
+}
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 a47e36ab..1114ac1b 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
@@ -21,6 +21,7 @@ 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -32,7 +33,6 @@ import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.Search
-import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
@@ -68,6 +68,7 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.features.home.PosterShape
+import com.nuvio.app.features.trakt.TraktPublicListSearchResult
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import sh.calvin.reorderable.ReorderableCollectionItemScope
@@ -107,6 +108,14 @@ fun CollectionEditorScreen(
return
}
+ if (state.showTraktSourcePicker) {
+ TraktSourcePickerScreen(
+ state = state,
+ onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
+ )
+ return
+ }
+
val genrePickerIndex = state.genrePickerSourceIndex
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
@@ -158,6 +167,14 @@ fun CollectionEditorScreen(
return
}
+ if (state.showTraktSourcePicker) {
+ TraktSourcePickerScreen(
+ state = state,
+ onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
+ )
+ return
+ }
+
Box(modifier = Modifier.fillMaxSize()) {
NuvioScreen(
modifier = Modifier.fillMaxSize(),
@@ -704,7 +721,10 @@ private fun FolderEditorPage(
FolderEditorSection(
title = stringResource(Res.string.collections_editor_section_catalog_sources),
actions = {
- Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
@@ -714,6 +734,15 @@ private fun FolderEditorPage(
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(Res.string.source_tmdb))
}
+ TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) {
+ Icon(
+ imageVector = Icons.Rounded.Add,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(Res.string.collections_editor_add_trakt_source))
+ }
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
Icon(
imageVector = Icons.Rounded.Add,
@@ -752,6 +781,12 @@ private fun FolderEditorPage(
source = source,
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
)
+ } else if (source.isTrakt) {
+ FolderTraktSourceCard(
+ source = source,
+ onEdit = { CollectionEditorRepository.editTraktSource(index) },
+ onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
+ )
} else if (addonSource != null) {
FolderCatalogSourceCard(
source = addonSource,
@@ -1393,6 +1428,208 @@ private fun TmdbSourcePickerScreen(
}
}
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun TraktSourcePickerScreen(
+ state: CollectionEditorUiState,
+ onBack: () -> Unit,
+) {
+ val bottomInset = nuvioSafeBottomPadding()
+ val searchResultsTitle = stringResource(Res.string.collections_editor_trakt_search_results)
+ val trendingTitle = stringResource(Res.string.collections_editor_trakt_trending)
+ val popularTitle = stringResource(Res.string.collections_editor_trakt_popular)
+
+ PlatformBackHandler(enabled = true) {
+ onBack()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ NuvioScreen(modifier = Modifier.fillMaxSize()) {
+ stickyHeader {
+ NuvioScreenHeader(
+ title = if (state.editingTraktSourceIndex != null) {
+ stringResource(Res.string.collections_editor_edit_trakt_source)
+ } else {
+ stringResource(Res.string.collections_editor_trakt_sources)
+ },
+ onBack = onBack,
+ )
+ }
+
+ item {
+ NuvioSurfaceCard {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ TmdbLabeledField(
+ label = stringResource(Res.string.collections_editor_trakt_list),
+ value = state.traktInput,
+ onValueChange = { CollectionEditorRepository.setTraktInput(it) },
+ placeholder = stringResource(Res.string.collections_editor_trakt_input_placeholder),
+ helper = stringResource(Res.string.collections_editor_trakt_input_helper),
+ )
+ TmdbLabeledField(
+ label = stringResource(Res.string.collections_editor_tmdb_display_title),
+ value = state.traktTitleInput,
+ onValueChange = { CollectionEditorRepository.setTraktTitleInput(it) },
+ placeholder = stringResource(Res.string.collections_editor_trakt_title_placeholder),
+ helper = stringResource(Res.string.collections_editor_tmdb_title_helper),
+ )
+ if (state.traktSearchError != null) {
+ Text(
+ text = state.traktSearchError,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_type)) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ FilterChip(
+ selected = state.traktMediaType == TmdbCollectionMediaType.MOVIE && !state.traktMediaBoth,
+ onClick = {
+ CollectionEditorRepository.setTraktMediaBoth(false)
+ CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.MOVIE)
+ },
+ label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) },
+ )
+ FilterChip(
+ selected = state.traktMediaType == TmdbCollectionMediaType.TV && !state.traktMediaBoth,
+ onClick = {
+ CollectionEditorRepository.setTraktMediaBoth(false)
+ CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.TV)
+ },
+ label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) },
+ )
+ FilterChip(
+ selected = state.traktMediaBoth,
+ onClick = { CollectionEditorRepository.setTraktMediaBoth(true) },
+ label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) },
+ )
+ }
+ }
+ }
+
+ item {
+ PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_sort)) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ traktSortOptions().forEach { (value, label) ->
+ FilterChip(
+ selected = state.traktSortBy == value,
+ onClick = { CollectionEditorRepository.setTraktSortBy(value) },
+ label = { Text(label) },
+ )
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(Res.string.collections_editor_trakt_direction),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ FilterChip(
+ selected = state.traktSortHow == TraktSortHow.ASC.value,
+ onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.ASC.value) },
+ label = { Text(stringResource(Res.string.collections_editor_trakt_ascending)) },
+ )
+ FilterChip(
+ selected = state.traktSortHow == TraktSortHow.DESC.value,
+ onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.DESC.value) },
+ label = { Text(stringResource(Res.string.collections_editor_trakt_descending)) },
+ )
+ }
+ }
+ }
+ }
+
+ TraktResultSection(
+ title = searchResultsTitle,
+ results = state.traktSearchResults,
+ )
+ TraktResultSection(
+ title = trendingTitle,
+ results = state.traktTrendingResults,
+ )
+ TraktResultSection(
+ title = popularTitle,
+ results = state.traktPopularResults,
+ )
+
+ item {
+ Spacer(modifier = Modifier.height(96.dp + bottomInset))
+ }
+ }
+
+ Surface(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f),
+ tonalElevation = 6.dp,
+ shadowElevation = 10.dp,
+ ) {
+ PickerActionBar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .padding(bottom = bottomInset),
+ ) {
+ TextButton(onClick = { CollectionEditorRepository.searchTraktLists() }) {
+ Icon(
+ imageVector = Icons.Rounded.Search,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(stringResource(Res.string.collections_editor_tmdb_search))
+ }
+ NuvioPrimaryButton(
+ text = if (state.editingTraktSourceIndex != null) {
+ stringResource(Res.string.collections_editor_save)
+ } else {
+ stringResource(Res.string.collections_editor_add_source)
+ },
+ modifier = Modifier.weight(1f),
+ enabled = state.traktInput.isNotBlank(),
+ onClick = { CollectionEditorRepository.addTraktSourceFromInput() },
+ )
+ }
+ }
+ }
+}
+
+private fun LazyListScope.TraktResultSection(
+ title: String,
+ results: List,
+) {
+ if (results.isEmpty()) return
+ item {
+ PickerSectionLabel(title)
+ }
+ itemsIndexed(results) { _, result ->
+ PickerOptionRow(
+ title = result.title,
+ subtitle = result.subtitle,
+ selected = false,
+ onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) },
+ )
+ }
+}
+
@Composable
private fun PickerPanel(
title: String,
@@ -1790,6 +2027,63 @@ private fun FolderTmdbSourceCard(
}
}
+@Composable
+private fun FolderTraktSourceCard(
+ source: CollectionSource,
+ onEdit: () -> Unit,
+ onRemove: () -> Unit,
+) {
+ NuvioSurfaceCard {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_trakt),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = stringResource(Res.string.source_trakt),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(
+ onClick = onEdit,
+ modifier = Modifier.size(36.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Edit,
+ contentDescription = stringResource(Res.string.action_edit),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ IconButton(
+ onClick = onRemove,
+ modifier = Modifier.size(36.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Close,
+ contentDescription = stringResource(Res.string.action_remove),
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+
+ Text(
+ text = traktSourceSubtitle(source),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun FolderCatalogSourceCard(
@@ -1965,6 +2259,53 @@ private fun tmdbSortLabel(sort: TmdbCollectionSort): String =
TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
}
+@Composable
+private fun traktSortOptions(): List> =
+ listOf(
+ TraktListSort.RANK.value to stringResource(Res.string.collections_editor_trakt_sort_list_order),
+ TraktListSort.ADDED.value to stringResource(Res.string.collections_editor_trakt_sort_recently_added),
+ TraktListSort.TITLE.value to stringResource(Res.string.collections_editor_trakt_sort_title),
+ TraktListSort.RELEASED.value to stringResource(Res.string.collections_editor_trakt_sort_released),
+ TraktListSort.RUNTIME.value to stringResource(Res.string.collections_editor_trakt_sort_runtime),
+ TraktListSort.POPULARITY.value to stringResource(Res.string.collections_editor_trakt_sort_popular),
+ TraktListSort.PERCENTAGE.value to stringResource(Res.string.collections_editor_trakt_sort_percentage),
+ TraktListSort.VOTES.value to stringResource(Res.string.collections_editor_trakt_sort_votes),
+ )
+
+@Composable
+private fun traktSortLabel(value: String?): String =
+ when (TraktListSort.normalize(value)) {
+ TraktListSort.ADDED.value -> stringResource(Res.string.collections_editor_trakt_sort_recently_added)
+ TraktListSort.TITLE.value -> stringResource(Res.string.collections_editor_trakt_sort_title)
+ TraktListSort.RELEASED.value -> stringResource(Res.string.collections_editor_trakt_sort_released)
+ TraktListSort.RUNTIME.value -> stringResource(Res.string.collections_editor_trakt_sort_runtime)
+ TraktListSort.POPULARITY.value -> stringResource(Res.string.collections_editor_trakt_sort_popular)
+ TraktListSort.PERCENTAGE.value -> stringResource(Res.string.collections_editor_trakt_sort_percentage)
+ TraktListSort.VOTES.value -> stringResource(Res.string.collections_editor_trakt_sort_votes)
+ else -> stringResource(Res.string.collections_editor_trakt_sort_list_order)
+ }
+
+@Composable
+private fun traktDirectionLabel(value: String?): String =
+ when (TraktSortHow.normalize(value)) {
+ TraktSortHow.DESC.value -> stringResource(Res.string.collections_editor_trakt_descending)
+ else -> stringResource(Res.string.collections_editor_trakt_ascending)
+ }
+
+@Composable
+private fun traktSourceSubtitle(source: CollectionSource): String {
+ val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {
+ TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies)
+ TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series)
+ }
+ return listOf(
+ media,
+ traktSortLabel(source.sortBy),
+ traktDirectionLabel(source.sortHow),
+ "ID ${source.traktListId ?: ""}".trim(),
+ ).joinToString(" • ")
+}
+
@Composable
private fun tmdbSourceSubtitle(source: CollectionSource): String {
val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt
index 660e8a45..fa04e5f8 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt
@@ -144,17 +144,27 @@ internal object CollectionJsonPreserver {
private fun unifiedSourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
- return if (provider.equals("tmdb", ignoreCase = true)) {
- val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
- val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
- val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
- val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
- "$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
- } else {
- val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
- val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
- val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
- "$provider|$addonId|$type|$catalogId"
+ return when {
+ provider.equals("tmdb", ignoreCase = true) -> {
+ val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
+ val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ "$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
+ }
+ provider.equals("trakt", ignoreCase = true) -> {
+ val listId = obj["traktListId"]?.jsonPrimitive?.contentOrNull ?: return null
+ val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ val sortHow = obj["sortHow"]?.jsonPrimitive?.contentOrNull.orEmpty()
+ "$provider|$listId|$mediaType|$sortBy|$sortHow"
+ }
+ else -> {
+ val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
+ val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
+ val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
+ "$provider|$addonId|$type|$catalogId"
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
index f0780ad2..3a6a6013 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt
@@ -41,15 +41,20 @@ data class CollectionSource(
val tmdbSourceType: String? = null,
val title: String? = null,
val tmdbId: Int? = null,
+ val traktListId: Long? = null,
val mediaType: String? = null,
val sortBy: String? = null,
+ val sortHow: String? = null,
val filters: TmdbCollectionFilters? = null,
) {
val isTmdb: Boolean
get() = provider.equals("tmdb", ignoreCase = true)
+ val isTrakt: Boolean
+ get() = provider.equals("trakt", ignoreCase = true)
+
fun addonCatalogSource(): CollectionCatalogSource? {
- if (isTmdb) return null
+ if (isTmdb || isTrakt) return null
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null
@@ -62,6 +67,9 @@ data class CollectionSource(
}
}
+internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
+ isTrakt && (traktListId == null || traktListId <= 0L)
+
@Serializable
enum class TmdbCollectionSourceType {
LIST,
@@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) {
FIRST_AIR_DATE_DESC("first_air_date.desc"),
}
+enum class TraktListSort(val value: String) {
+ RANK("rank"),
+ ADDED("added"),
+ TITLE("title"),
+ RELEASED("released"),
+ RUNTIME("runtime"),
+ POPULARITY("popularity"),
+ PERCENTAGE("percentage"),
+ VOTES("votes");
+
+ companion object {
+ fun normalize(value: String?): String {
+ val raw = value?.trim()?.lowercase().orEmpty()
+ return entries.firstOrNull { it.value == raw }?.value ?: RANK.value
+ }
+ }
+}
+
+enum class TraktSortHow(val value: String) {
+ ASC("asc"),
+ DESC("desc");
+
+ companion object {
+ fun normalize(value: String?): String {
+ val raw = value?.trim()?.lowercase().orEmpty()
+ return entries.firstOrNull { it.value == raw }?.value ?: ASC.value
+ }
+ }
+}
+
@Immutable
@Serializable
data class TmdbCollectionFilters(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
index 0e9553ae..39916184 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt
@@ -23,6 +23,7 @@ import nuvio.composeapp.generated.resources.collections_import_error_folder_blan
import nuvio.composeapp.generated.resources.collections_import_error_folder_blank_title
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
import nuvio.composeapp.generated.resources.collections_import_error_source_blank_fields
+import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
import org.jetbrains.compose.resources.getString
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -185,7 +186,20 @@ object CollectionRepository {
)
}
f.resolvedSources.forEachIndexed { si, s ->
- val invalidAddon = !s.isTmdb &&
+ if (s.hasInvalidTraktListId()) {
+ return ValidationResult(
+ valid = false,
+ error = runBlocking {
+ getString(
+ Res.string.collections_import_error_trakt_list_id,
+ si + 1,
+ f.title,
+ )
+ },
+ )
+ }
+
+ val invalidAddon = !s.isTmdb && !s.isTrakt &&
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
val invalidTmdb = s.isTmdb &&
s.tmdbSourceType.isNullOrBlank()
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 36698b25..65c0101e 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
@@ -10,6 +10,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.home.HomeCatalogSection
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.stableKey
+import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -148,6 +149,25 @@ object FolderDetailRepository {
isLoading = true,
),
)
+ } 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"
+ }
+ add(
+ FolderTab(
+ label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
+ typeLabel = typeLabel,
+ source = source,
+ type = type,
+ catalogId = traktCatalogId(source),
+ supportsPagination = true,
+ isLoading = true,
+ ),
+ )
} else {
val catalogSource = source.addonCatalogSource() ?: return@forEach
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
@@ -188,7 +208,7 @@ object FolderDetailRepository {
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
val catalogSource = source.addonCatalogSource()
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
- if (!source.isTmdb && resolvedCatalog == null) {
+ if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
updateTab(tabIndex) {
it.copy(
isLoading = false,
@@ -254,7 +274,12 @@ object FolderDetailRepository {
private fun loadTabPage(index: Int, reset: Boolean) {
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: return
- if (!currentTab.source?.isTmdb.orFalse() && currentTab.manifestUrl == null) return
+ val currentSource = currentTab.source
+ if (
+ currentSource?.isTmdb != true &&
+ currentSource?.isTrakt != true &&
+ currentTab.manifestUrl == null
+ ) return
updateTab(index) { tab ->
if (reset) {
@@ -277,13 +302,18 @@ object FolderDetailRepository {
val job = scope.launch {
runCatching {
val source = currentTab.source
- if (source?.isTmdb == true) {
- TmdbCollectionSourceResolver.resolve(
+ when {
+ source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
source = source,
page = if (reset) 1 else requestedSkip,
)
- } else {
- fetchCatalogPage(
+
+ source?.isTrakt == true -> TraktPublicListSourceResolver.resolve(
+ source = source,
+ page = if (reset) 1 else requestedSkip,
+ )
+
+ else -> fetchCatalogPage(
manifestUrl = requireNotNull(currentTab.manifestUrl),
type = currentTab.type,
catalogId = currentTab.catalogId,
@@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String =
append("_")
append(source.mediaType?.lowercase().orEmpty())
}
+
+private fun traktCatalogId(source: CollectionSource): String =
+ listOf(
+ "trakt",
+ "list",
+ source.traktListId?.toString().orEmpty(),
+ source.mediaType?.lowercase().orEmpty(),
+ TraktListSort.normalize(source.sortBy),
+ TraktSortHow.normalize(source.sortHow),
+ ).joinToString("_")
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
index 3c3374fa..bf4b6744 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
@@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
- val candidates = sortedEpisodes
- .dropWhile { episode ->
- buildPlaybackVideoId(
- content = WatchingContentRef(type = type, id = id),
- seasonNumber = episode.season,
- episodeNumber = episode.episode,
- fallbackVideoId = episode.id,
- ) != watchedVideoId
+ var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
+ buildPlaybackVideoId(
+ content = WatchingContentRef(type = type, id = id),
+ seasonNumber = episode.season,
+ episodeNumber = episode.episode,
+ fallbackVideoId = episode.id,
+ ) == watchedVideoId
+ }
+
+ // Fallback: if the seed wasn't found by season+episode (anime with absolute
+ // numbering on Trakt vs multi-season on addon), try global index matching.
+ if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
+ val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
+ if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
+ val globalIndex = episodeNumber - 1
+ if (globalIndex in sortedEpisodes.indices) {
+ watchedIndex = globalIndex
+ }
}
- .drop(1)
+ }
+
+ if (watchedIndex < 0) return null
+
+ val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
+ val candidates = sortedEpisodes
+ .drop(watchedIndex + 1)
.filter { episode ->
shouldSurfaceNextEpisode(
- watchedSeasonNumber = seasonNumber,
+ watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.season,
todayIsoDate = todayIsoDate,
releasedDate = episode.released,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt
index b2a331ad..9fb32ced 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt
@@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader {
fun removeFile(localFileUri: String?): Boolean
fun removePartialFile(destinationFileName: String): Boolean
+
+ fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String?
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt
index f6e715ba..7ed74677 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt
@@ -43,7 +43,7 @@ object DownloadsRepository {
val normalizedVideoId = videoId?.trim().orEmpty()
if (normalizedVideoId.isBlank()) return null
return _uiState.value.items.firstOrNull { item ->
- item.videoId == normalizedVideoId && item.isPlayable && !item.localFileUri.isNullOrBlank()
+ item.videoId == normalizedVideoId && item.hasPlayableLocalFile()
}
}
@@ -64,20 +64,42 @@ object DownloadsRepository {
item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == seasonNumber &&
item.episodeNumber == episodeNumber &&
- item.isPlayable &&
- !item.localFileUri.isNullOrBlank()
+ item.hasPlayableLocalFile()
}
} else {
items.firstOrNull { item ->
item.parentMetaId == normalizedParentMetaId &&
item.seasonNumber == null &&
item.episodeNumber == null &&
- item.isPlayable &&
- !item.localFileUri.isNullOrBlank()
+ item.hasPlayableLocalFile()
}
}
}
+ fun playableLocalFileUri(item: DownloadItem): String? {
+ ensureLoaded()
+ if (item.status != DownloadStatus.Completed) return null
+ val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
+ localFileUri = item.localFileUri,
+ destinationFileName = item.fileName,
+ ) ?: return null
+
+ if (resolvedUri != item.localFileUri) {
+ mutateItem(item.id) { current ->
+ if (current.fileName == item.fileName) {
+ current.copy(
+ localFileUri = resolvedUri,
+ updatedAtEpochMs = DownloadsClock.nowEpochMs(),
+ )
+ } else {
+ current
+ }
+ }
+ }
+
+ return resolvedUri
+ }
+
fun enqueueFromStream(
contentType: String,
videoId: String,
@@ -117,7 +139,7 @@ object DownloadsRepository {
if (existing != null) {
replacedExisting = true
activeHandles.remove(existing.id)?.cancel()
- DownloadsPlatformDownloader.removeFile(existing.localFileUri)
+ DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri)
DownloadsPlatformDownloader.removePartialFile(existing.fileName)
currentItems.removeAll { it.id == existing.id }
}
@@ -191,6 +213,14 @@ object DownloadsRepository {
}
}
+ fun pauseActiveDownloads() {
+ ensureLoaded()
+ _uiState.value.items
+ .filter { it.status == DownloadStatus.Downloading }
+ .map { it.id }
+ .forEach(::pauseDownload)
+ }
+
fun resumeDownload(downloadId: String) {
ensureLoaded()
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
@@ -217,7 +247,7 @@ object DownloadsRepository {
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
activeHandles.remove(downloadId)?.cancel()
- DownloadsPlatformDownloader.removeFile(item.localFileUri)
+ DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri)
DownloadsPlatformDownloader.removePartialFile(item.fileName)
publish(_uiState.value.items.filterNot { it.id == downloadId })
@@ -233,9 +263,10 @@ object DownloadsRepository {
return
}
+ var shouldPersistNormalized = false
val normalized = DownloadsCodec.decodeItems(payload)
.map { item ->
- if (item.status == DownloadStatus.Downloading) {
+ val statusNormalized = if (item.status == DownloadStatus.Downloading) {
item.copy(
status = DownloadStatus.Paused,
errorMessage = null,
@@ -243,10 +274,19 @@ object DownloadsRepository {
} else {
item
}
+
+ val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized)
+ if (localUriNormalized != item) {
+ shouldPersistNormalized = true
+ }
+ localUriNormalized
}
_uiState.value = DownloadsUiState(normalized)
notifyLiveStatusPlatform()
+ if (shouldPersistNormalized) {
+ persist()
+ }
}
private fun startDownload(item: DownloadItem) {
@@ -359,6 +399,26 @@ object DownloadsRepository {
append(nextDownloadOrdinal.toString(36))
}
}
+
+ private fun normalizeCompletedLocalFileUri(item: DownloadItem): DownloadItem {
+ if (item.status != DownloadStatus.Completed) return item
+ val resolvedUri = DownloadsPlatformDownloader.resolveLocalFileUri(
+ localFileUri = item.localFileUri,
+ destinationFileName = item.fileName,
+ ) ?: return item
+ return if (resolvedUri != item.localFileUri) {
+ item.copy(localFileUri = resolvedUri)
+ } else {
+ item
+ }
+ }
+
+ private fun DownloadItem.hasPlayableLocalFile(): Boolean =
+ status == DownloadStatus.Completed &&
+ DownloadsPlatformDownloader.resolveLocalFileUri(
+ localFileUri = localFileUri,
+ destinationFileName = fileName,
+ ) != null
}
@Serializable
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 79274569..2db78246 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
@@ -868,7 +868,7 @@ fun PlayerScreen(
}
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
- val localFileUri = downloadItem.localFileUri ?: return
+ val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
showNextEpisodeCard = false
showSourcesPanel = false
showEpisodesPanel = false
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt
index a5062582..8002195d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt
@@ -4,7 +4,7 @@ import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.lang_english
import nuvio.composeapp.generated.resources.lang_french
import nuvio.composeapp.generated.resources.lang_spanish
-import nuvio.composeapp.generated.resources.lang_portuguese_portugal
+import nuvio.composeapp.generated.resources.lang_portuguese
import nuvio.composeapp.generated.resources.lang_turkish
import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_greek
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 98d6e7e3..674e3352 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
@@ -424,8 +424,32 @@ object StreamsRepository {
}
}
+ fun cancelLoading() {
+ activeJob?.cancel()
+ activeJob = null
+ _uiState.update { current ->
+ if (!current.isAnyLoading && current.groups.none { it.isLoading }) {
+ current
+ } else {
+ val updatedGroups = current.groups.map { group ->
+ if (group.isLoading) group.copy(isLoading = false) else group
+ }
+ current.copy(
+ groups = updatedGroups,
+ isAnyLoading = false,
+ emptyStateReason = if (updatedGroups.isEmpty()) {
+ current.emptyStateReason
+ } else {
+ updatedGroups.toEmptyStateReason(anyLoading = false)
+ },
+ )
+ }
+ }
+ }
+
fun clear() {
activeJob?.cancel()
+ activeJob = null
activeRequestKey = null
_uiState.value = StreamsUiState()
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt
new file mode 100644
index 00000000..50fa7baf
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt
@@ -0,0 +1,491 @@
+package com.nuvio.app.features.trakt
+
+import co.touchlab.kermit.Logger
+import com.nuvio.app.features.addons.httpGetTextWithHeaders
+import com.nuvio.app.features.details.MetaDetailsRepository
+import com.nuvio.app.features.details.MetaVideo
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+private const val BASE_URL = "https://api.trakt.tv"
+
+/**
+ * Handles episode number remapping between addon metadata (which may use multi-season
+ * numbering for anime) and Trakt (which often uses absolute/single-season numbering).
+ *
+ * Example: An addon lists "Attack on Titan" as S1E1–S1E25, S2E1–S2E12, etc.
+ * Trakt may list it as S1E1–S1E87 (absolute numbering).
+ *
+ * This service detects the mismatch and provides bidirectional mapping.
+ */
+object TraktEpisodeMappingService {
+ private val log = Logger.withTag("TraktEpMapSvc")
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private val cacheMutex = Mutex()
+ private val mappingCache = mutableMapOf()
+ private val reverseMappingCache = mutableMapOf()
+ private val addonEpisodesCache = mutableMapOf>()
+ private val traktEpisodesCache = mutableMapOf>()
+ // In-flight dedup: prevents multiple concurrent coroutines from fetching
+ // the same show's addon episodes simultaneously.
+ private val addonEpisodesInFlight = mutableMapOf>>()
+
+ // ── Public API ────────────────────────────────────────────────────────
+
+ /**
+ * Resolves the Trakt-side season/episode for a given addon season/episode.
+ * Used when pushing watched status TO Trakt (forward mapping: addon → Trakt).
+ *
+ * Returns null if no remapping is needed (same structure) or if mapping fails.
+ */
+ suspend fun resolveEpisodeMapping(
+ contentId: String?,
+ contentType: String?,
+ videoId: String?,
+ season: Int?,
+ episode: Int?,
+ ): EpisodeMappingEntry? {
+ val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
+ cacheMutex.withLock {
+ mappingCache[key]?.let { return it }
+ }
+
+ val requestedSeason = season ?: return null
+ val requestedEpisode = episode ?: return null
+ val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
+ val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
+
+ val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
+ if (addonEpisodes.isEmpty()) return null
+
+ val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null
+ val traktEpisodes = getTraktEpisodes(showLookupId)
+ if (traktEpisodes.isEmpty()) return null
+
+ if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
+ return null
+ }
+
+ val mapped = remapEpisodeByTitleOrIndex(
+ requestedSeason = requestedSeason,
+ requestedEpisode = requestedEpisode,
+ requestedVideoId = videoId,
+ requestedTitle = null,
+ addonEpisodes = addonEpisodes,
+ traktEpisodes = traktEpisodes,
+ ) ?: return null
+
+ cacheMutex.withLock {
+ mappingCache[key] = mapped
+ }
+ return mapped
+ }
+
+ /**
+ * Resolves the addon-side season/episode for a given Trakt season/episode.
+ * Used when reading progress FROM Trakt to find the correct addon episode
+ * (reverse mapping: Trakt → addon).
+ *
+ * Returns null if no remapping is needed or if mapping fails.
+ */
+ suspend fun resolveAddonEpisodeMapping(
+ contentId: String?,
+ contentType: String?,
+ season: Int?,
+ episode: Int?,
+ episodeTitle: String? = null,
+ ): EpisodeMappingEntry? {
+ val requestedSeason = season ?: return null
+ val requestedEpisode = episode ?: return null
+ val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null
+ val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null
+
+ val reverseKey = reverseCacheKey(
+ contentId = resolvedContentId,
+ contentType = resolvedContentType,
+ season = requestedSeason,
+ episode = requestedEpisode,
+ title = episodeTitle,
+ )
+ cacheMutex.withLock {
+ reverseMappingCache[reverseKey]?.let { return it }
+ }
+
+ val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType)
+ if (addonEpisodes.isEmpty()) return null
+
+ val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null
+ val traktEpisodes = getTraktEpisodes(showLookupId)
+ if (traktEpisodes.isEmpty()) return null
+
+ val addonHasEpisode = addonEpisodes.any {
+ it.season == requestedSeason && it.episode == requestedEpisode
+ }
+ if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) {
+ return null
+ }
+
+ val mapped = reverseRemapEpisodeByTitleOrIndex(
+ requestedSeason = requestedSeason,
+ requestedEpisode = requestedEpisode,
+ requestedTitle = episodeTitle,
+ addonEpisodes = addonEpisodes,
+ traktEpisodes = traktEpisodes,
+ ) ?: return null
+
+ cacheMutex.withLock {
+ reverseMappingCache[reverseKey] = mapped
+ }
+ return mapped
+ }
+
+ suspend fun getCachedEpisodeMapping(
+ contentId: String?,
+ contentType: String?,
+ videoId: String?,
+ season: Int?,
+ episode: Int?,
+ ): EpisodeMappingEntry? {
+ val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null
+ return cacheMutex.withLock { mappingCache[key] }
+ }
+
+ suspend fun prefetchEpisodeMapping(
+ contentId: String?,
+ contentType: String?,
+ videoId: String?,
+ season: Int?,
+ episode: Int?,
+ ): EpisodeMappingEntry? {
+ return resolveEpisodeMapping(contentId, contentType, videoId, season, episode)
+ }
+
+ fun clearCache() {
+ mappingCache.clear()
+ reverseMappingCache.clear()
+ addonEpisodesCache.clear()
+ traktEpisodesCache.clear()
+ }
+
+ // ── Season structure comparison ───────────────────────────────────────
+
+ private fun hasSameSeasonStructure(
+ addonEpisodes: List,
+ traktEpisodes: List,
+ ): Boolean {
+ val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season }
+ val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season }
+ return addonSeasons == traktSeasons
+ }
+
+ // ── Forward mapping: addon → Trakt ──────────────────────────────────
+
+ private fun remapEpisodeByTitleOrIndex(
+ requestedSeason: Int,
+ requestedEpisode: Int,
+ requestedVideoId: String?,
+ requestedTitle: String?,
+ addonEpisodes: List,
+ traktEpisodes: List,
+ ): EpisodeMappingEntry? {
+ // Find the addon episode entry
+ val addonEntry = addonEpisodes.firstOrNull {
+ it.season == requestedSeason && it.episode == requestedEpisode
+ } ?: addonEpisodes.firstOrNull {
+ !requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId
+ } ?: return null
+
+ // Try title match first
+ val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
+ if (!titleToMatch.isNullOrBlank()) {
+ val titleMatch = traktEpisodes.firstOrNull { target ->
+ !target.title.isNullOrBlank() &&
+ normalizeTitle(target.title) == normalizeTitle(titleToMatch)
+ }
+ if (titleMatch != null) {
+ return titleMatch
+ }
+ }
+
+ // Fallback: global index mapping
+ val addonIndex = addonEpisodes.indexOf(addonEntry)
+ if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null
+
+ return traktEpisodes[addonIndex]
+ }
+
+ // ── Reverse mapping: Trakt → addon ──────────────────────────────────
+
+ private fun reverseRemapEpisodeByTitleOrIndex(
+ requestedSeason: Int,
+ requestedEpisode: Int,
+ requestedTitle: String?,
+ addonEpisodes: List,
+ traktEpisodes: List,
+ ): EpisodeMappingEntry? {
+ // Find the Trakt episode entry
+ val traktEntry = traktEpisodes.firstOrNull {
+ it.season == requestedSeason && it.episode == requestedEpisode
+ } ?: return null
+
+ // Try title match first
+ val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle
+ if (!titleToMatch.isNullOrBlank()) {
+ val titleMatch = addonEpisodes.firstOrNull { target ->
+ !target.title.isNullOrBlank() &&
+ normalizeTitle(target.title) == normalizeTitle(titleToMatch)
+ }
+ if (titleMatch != null) {
+ return titleMatch
+ }
+ }
+
+ // Fallback: global index mapping
+ val traktIndex = traktEpisodes.indexOf(traktEntry)
+ if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null
+
+ return addonEpisodes[traktIndex]
+ }
+
+ // ── Addon episodes fetching (with dedup) ───────────────────────────
+
+ private suspend fun getAddonEpisodes(
+ contentId: String,
+ contentType: String,
+ ): List {
+ val cacheKey = addonEpisodesCacheKey(contentId, contentType)
+
+ // Fast path: cache hit
+ cacheMutex.withLock {
+ addonEpisodesCache[cacheKey]?.let { return it }
+ }
+
+ // Dedup: if another coroutine is already fetching this show, await its result.
+ val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
+ if (existingDeferred != null) {
+ return try { existingDeferred.await() } catch (_: Exception) { emptyList() }
+ }
+
+ // Register ourselves as the in-flight fetcher.
+ val deferred = CompletableDeferred>()
+ val weOwn = cacheMutex.withLock {
+ // Double-check: cache or another flight may have appeared while we waited.
+ addonEpisodesCache[cacheKey]?.let { return it }
+ if (addonEpisodesInFlight.containsKey(cacheKey)) {
+ false
+ } else {
+ addonEpisodesInFlight[cacheKey] = deferred
+ true
+ }
+ }
+ if (!weOwn) {
+ val other = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] }
+ return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() }
+ }
+
+ return try {
+ val addonEpisodes = fetchAddonEpisodes(contentId, contentType)
+ if (addonEpisodes.isNotEmpty()) {
+ cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes }
+ }
+ deferred.complete(addonEpisodes)
+ addonEpisodes
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ deferred.completeExceptionally(e)
+ emptyList()
+ } finally {
+ cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) }
+ }
+ }
+
+ private suspend fun fetchAddonEpisodes(
+ contentId: String,
+ contentType: String,
+ ): List {
+ val typeCandidates = buildList {
+ val normalized = contentType.lowercase()
+ if (normalized.isNotBlank()) add(normalized)
+ if (normalized in listOf("series", "tv")) {
+ add("series")
+ add("tv")
+ }
+ }.distinct()
+ if (typeCandidates.isEmpty()) return emptyList()
+
+ val idCandidates = buildList {
+ add(contentId)
+ if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':'))
+ if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':'))
+ }.distinct()
+
+ for (type in typeCandidates) {
+ for (candidateId in idCandidates) {
+ val meta = withTimeoutOrNull(3_500L) {
+ MetaDetailsRepository.fetch(type = type, id = candidateId)
+ } ?: continue
+ val episodes = meta.videos.toEpisodeMappingEntries()
+ if (episodes.isNotEmpty()) return episodes
+ }
+ }
+ return emptyList()
+ }
+
+ // ── Trakt episodes fetching ─────────────────────────────────────────
+
+ private suspend fun getTraktEpisodes(showLookupId: String): List {
+ cacheMutex.withLock {
+ traktEpisodesCache[showLookupId]?.let { return it }
+ }
+
+ val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList()
+
+ // Trakt API: GET /shows/{id}/seasons?extended=episodes
+ val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes"
+ val payload = runCatching {
+ httpGetTextWithHeaders(url = url, headers = headers)
+ }.onFailure { e ->
+ if (e is CancellationException) throw e
+ log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" }
+ }.getOrNull() ?: return emptyList()
+
+ val traktEpisodes = parseTraktSeasonsPayload(payload)
+ if (traktEpisodes.isNotEmpty()) {
+ cacheMutex.withLock {
+ traktEpisodesCache[showLookupId] = traktEpisodes
+ }
+ }
+ return traktEpisodes
+ }
+
+ private fun parseTraktSeasonsPayload(payload: String): List {
+ val seasons = runCatching {
+ json.decodeFromString>(payload)
+ }.getOrNull() ?: return emptyList()
+
+ return seasons
+ .asSequence()
+ .filter { (it.number ?: 0) > 0 } // Skip specials (season 0)
+ .sortedBy { it.number }
+ .flatMap { seasonDto ->
+ seasonDto.episodes.orEmpty().asSequence().mapNotNull { episodeDto ->
+ val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null
+ val episodeNumber = episodeDto.number ?: return@mapNotNull null
+ EpisodeMappingEntry(
+ season = seasonNumber,
+ episode = episodeNumber,
+ title = episodeDto.title,
+ )
+ }
+ }
+ .toList()
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────
+
+ private fun resolveShowLookupId(contentId: String?, videoId: String?): String? {
+ val contentIds = parseTraktContentIds(contentId)
+ if (contentIds.hasAnyId()) {
+ return when {
+ !contentIds.imdb.isNullOrBlank() -> contentIds.imdb
+ contentIds.trakt != null -> contentIds.trakt.toString()
+ contentIds.tmdb != null -> contentIds.tmdb.toString()
+ else -> null
+ }
+ }
+
+ val videoIds = parseTraktContentIds(videoId)
+ return when {
+ !videoIds.imdb.isNullOrBlank() -> videoIds.imdb
+ videoIds.trakt != null -> videoIds.trakt.toString()
+ videoIds.tmdb != null -> videoIds.tmdb.toString()
+ else -> null
+ }
+ }
+
+ private fun TraktExternalIds.hasAnyId(): Boolean =
+ !imdb.isNullOrBlank() || trakt != null || tmdb != null
+
+ private fun cacheKey(
+ contentId: String?,
+ contentType: String?,
+ videoId: String?,
+ season: Int?,
+ episode: Int?,
+ ): String? {
+ val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null
+ val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
+ val resolvedSeason = season ?: return null
+ val resolvedEpisode = episode ?: return null
+ val resolvedVideoId = videoId?.trim().orEmpty()
+ return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode"
+ }
+
+ private fun reverseCacheKey(
+ contentId: String,
+ contentType: String,
+ season: Int,
+ episode: Int,
+ title: String?,
+ ): String {
+ val normalizedTitle = title?.trim()?.lowercase().orEmpty()
+ return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle"
+ }
+
+ private fun addonEpisodesCacheKey(contentId: String, contentType: String): String {
+ return "${contentType.trim().lowercase()}|${contentId.trim()}"
+ }
+
+ private fun List.toEpisodeMappingEntries(): List {
+ return asSequence()
+ .mapNotNull { video ->
+ val season = video.season ?: return@mapNotNull null
+ val episode = video.episode ?: return@mapNotNull null
+ if (season <= 0) return@mapNotNull null
+ EpisodeMappingEntry(
+ season = season,
+ episode = episode,
+ title = video.title.takeIf { it.isNotBlank() },
+ videoId = video.id.takeIf { it.isNotBlank() },
+ )
+ }
+ .distinctBy { it.videoId ?: "${it.season}:${it.episode}" }
+ .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode))
+ .toList()
+ }
+
+ private fun normalizeTitle(title: String?): String =
+ title.orEmpty().trim().lowercase()
+ .replace(Regex("[^a-z0-9]"), "")
+}
+
+// ── Data classes ────────────────────────────────────────────────────────
+
+data class EpisodeMappingEntry(
+ val season: Int,
+ val episode: Int,
+ val title: String? = null,
+ val videoId: String? = null,
+)
+
+// ── Trakt API DTOs for seasons endpoint ─────────────────────────────────
+
+@Serializable
+private data class TraktSeasonDto(
+ @SerialName("number") val number: Int? = null,
+ @SerialName("episodes") val episodes: List? = null,
+)
+
+@Serializable
+private data class TraktSeasonEpisodeDto(
+ @SerialName("number") val number: Int? = null,
+ @SerialName("season") val season: Int? = null,
+ @SerialName("title") val title: String? = null,
+)
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 b036b984..d7b005d2 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
@@ -7,6 +7,7 @@ internal data class TraktExternalIds(
val trakt: Int? = null,
val imdb: String? = null,
val tmdb: Int? = null,
+ val slug: String? = null,
)
internal fun parseTraktContentIds(contentId: String?): TraktExternalIds {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt
new file mode 100644
index 00000000..b6acf748
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt
@@ -0,0 +1,60 @@
+package com.nuvio.app.features.trakt
+
+import kotlinx.serialization.Serializable
+
+private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE)
+
+@Serializable
+internal data class TraktImagesDto(
+ val fanart: List? = null,
+ val poster: List? = null,
+ val logo: List? = null,
+ val clearart: List? = null,
+ val banner: List? = null,
+ val thumb: List? = null,
+)
+
+internal fun List?.firstTraktImageUrl(): String? {
+ return orEmpty()
+ .firstOrNull { it.isNotBlank() }
+ ?.toTraktImageUrl()
+}
+
+internal fun String.toTraktImageUrl(): String {
+ val normalized = trim()
+ return when {
+ normalized.startsWith("https://", ignoreCase = true) -> normalized
+ normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}"
+ normalized.startsWith("//") -> "https:$normalized"
+ traktHostPattern.containsMatchIn(normalized) -> "https://$normalized"
+ else -> normalized
+ }
+}
+
+internal fun TraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl()
+
+internal fun TraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl()
+
+internal fun TraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl()
+
+internal fun TraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl()
+
+internal fun TraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl()
+
+internal fun TraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl()
+
+internal fun TraktImagesDto?.traktBestPosterUrl(): String? {
+ return traktPosterUrl() ?: traktFanartUrl()
+}
+
+internal fun TraktImagesDto?.traktBestBackdropUrl(): String? {
+ return traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl()
+}
+
+internal fun TraktImagesDto?.traktBestLandscapeUrl(): String? {
+ return traktThumbUrl() ?: traktFanartUrl() ?: traktBannerUrl() ?: traktPosterUrl()
+}
+
+internal fun TraktImagesDto?.traktBestLogoUrl(): String? {
+ return traktLogoUrl() ?: traktClearartUrl()
+}
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 4e2468e8..0dc06966 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,20 +1,15 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
-import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
-import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.tmdb.TmdbService
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
@@ -38,8 +32,6 @@ 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 METADATA_FETCH_TIMEOUT_MS = 3_500L
-private const val METADATA_FETCH_CONCURRENCY = 5
private const val LIST_FETCH_CONCURRENCY = 4
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
@@ -68,7 +60,6 @@ object TraktLibraryRepository {
private var hasLoaded = false
private val refreshMutex = Mutex()
- private var hydrationJob: Job? = null
private var lastRefreshAtMs: Long = 0L
private var lastListTabsRefreshAtMs: Long = 0L
@@ -91,8 +82,6 @@ object TraktLibraryRepository {
}
fun onProfileChanged() {
- hydrationJob?.cancel()
- hydrationJob = null
hasLoaded = false
lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L
@@ -101,8 +90,6 @@ object TraktLibraryRepository {
}
fun clearLocalState() {
- hydrationJob?.cancel()
- hydrationJob = null
hasLoaded = false
lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L
@@ -154,8 +141,6 @@ object TraktLibraryRepository {
return
}
- AddonRepository.initialize()
-
val headers = TraktAuthRepository.authorizedHeaders()
if (headers == null) {
_uiState.value = TraktLibraryUiState()
@@ -173,7 +158,6 @@ object TraktLibraryRepository {
hasLoaded = true,
errorMessage = null,
)
- hydrateMissingMetadataAsync(_uiState.value)
}
}.onFailure { error ->
if (error is CancellationException) throw error
@@ -195,7 +179,6 @@ object TraktLibraryRepository {
errorMessage = null,
)
persistSnapshot(_uiState.value)
- hydrateMissingMetadataAsync(_uiState.value)
lastRefreshAtMs = now
}
}
@@ -421,7 +404,6 @@ object TraktLibraryRepository {
entriesByList = cached.entriesByList,
)
_uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true)
- hydrateMissingMetadataAsync(_uiState.value)
}
private fun persistSnapshot(state: TraktLibraryUiState) {
@@ -432,59 +414,6 @@ object TraktLibraryRepository {
TraktLibraryStorage.savePayload(json.encodeToString(payload))
}
- private fun hydrateMissingMetadataAsync(state: TraktLibraryUiState) {
- if (state.entriesByList.isEmpty()) return
- if (state.allItems.none(::shouldHydrateTraktLibraryItem)) return
-
- hydrationJob?.cancel()
- hydrationJob = scope.launch {
- val hydratedEntriesByList = runCatching {
- hydrateEntriesFromAddonMeta(state.entriesByList)
- }.onFailure { error ->
- if (error is CancellationException) throw error
- log.w { "Background Trakt metadata hydration failed: ${error.message}" }
- }.getOrNull() ?: return@launch
-
- refreshMutex.withLock {
- val current = _uiState.value
- if (current.entriesByList.isEmpty()) return@withLock
-
- val mergedEntriesByList = mergeHydratedEntries(
- currentEntriesByList = current.entriesByList,
- hydratedEntriesByList = hydratedEntriesByList,
- )
- if (mergedEntriesByList == current.entriesByList) return@withLock
-
- val rebuilt = rebuildUiState(
- listTabs = current.listTabs,
- entriesByList = mergedEntriesByList,
- ).copy(
- isLoading = current.isLoading,
- hasLoaded = current.hasLoaded,
- errorMessage = current.errorMessage,
- )
-
- _uiState.value = rebuilt
- persistSnapshot(rebuilt)
- }
- }
- }
-
- private fun mergeHydratedEntries(
- currentEntriesByList: Map>,
- hydratedEntriesByList: Map>,
- ): Map> {
- val hydratedByContentKey = hydratedEntriesByList.values
- .flatten()
- .associateBy { contentKey(it.id, it.type) }
-
- return currentEntriesByList.mapValues { (_, entries) ->
- entries.map { entry ->
- hydratedByContentKey[contentKey(entry.id, entry.type)] ?: entry
- }
- }
- }
-
private suspend fun fetchListTabs(headers: Map): List {
val watchlistTabs = listOf(
TraktListTab(
@@ -544,83 +473,6 @@ object TraktLibraryRepository {
entriesByList.toMap()
}
- private suspend fun hydrateEntriesFromAddonMeta(
- entriesByList: Map>,
- ): Map> = coroutineScope {
- if (entriesByList.isEmpty()) return@coroutineScope entriesByList
-
- val uniqueItems = entriesByList.values
- .flatten()
- .distinctBy { contentKey(it.id, it.type) }
- if (uniqueItems.isEmpty()) return@coroutineScope entriesByList
-
- val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY)
- val hydratedByKey = uniqueItems
- .map { item ->
- async {
- semaphore.withPermit {
- val hydrated = hydrateItemFromAddonMeta(item)
- contentKey(item.id, item.type) to hydrated
- }
- }
- }
- .awaitAll()
- .toMap()
-
- entriesByList.mapValues { (_, entries) ->
- entries.map { entry -> hydratedByKey[contentKey(entry.id, entry.type)] ?: entry }
- }
- }
-
- private suspend fun hydrateItemFromAddonMeta(item: LibraryItem): LibraryItem {
- if (!shouldHydrateTraktLibraryItem(item)) {
- return item
- }
-
- val typeCandidates = if (normalizeType(item.type) == "movie") {
- listOf("movie")
- } else {
- listOf("series", "tv")
- }
-
- val idCandidates = buildList {
- add(item.id)
- if (item.id.startsWith("tmdb:")) {
- add(item.id.substringAfter(':'))
- }
- if (item.id.startsWith("trakt:")) {
- add(item.id.substringAfter(':'))
- }
- }.distinct()
-
- if (idCandidates.isEmpty()) {
- return item
- }
-
- for (type in typeCandidates) {
- for (id in idCandidates) {
- val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) {
- MetaDetailsRepository.fetch(type = type, id = id)
- }
- if (meta == null) continue
-
- val shouldOverrideName = item.name.isBlank() || item.name == item.id
- return item.copy(
- name = if (shouldOverrideName) meta.name else item.name,
- poster = item.poster.orValidImageUrl(meta.poster),
- banner = item.banner.orValidImageUrl(meta.background),
- logo = item.logo.orValidImageUrl(meta.logo),
- description = item.description.orIfBlank(meta.description),
- releaseInfo = item.releaseInfo.orIfBlank(meta.releaseInfo),
- imdbRating = item.imdbRating.orIfBlank(meta.imdbRating),
- genres = if (item.genres.isEmpty()) meta.genres else item.genres,
- )
- }
- }
-
- return item
- }
-
private suspend fun fetchPersonalLists(headers: Map): List {
val payload = httpGetTextWithHeaders(
url = "$BASE_URL/users/me/lists",
@@ -786,10 +638,9 @@ object TraktLibraryRepository {
?: ids?.trakt?.let { "trakt:$it" }
?: return null
- val poster = media.images?.poster.firstNonBlankImageUrl()
- ?: media.images?.fanart.firstNonBlankImageUrl()
- val banner = media.images?.banner.firstNonBlankImageUrl()
- val logo = media.images?.logo.firstNonBlankImageUrl()
+ val poster = media.images.traktBestPosterUrl()
+ val banner = media.images.traktBestBackdropUrl()
+ val logo = media.images.traktBestLogoUrl()
val savedAt = item.listedAt
?.takeIf { it.isNotBlank() }
@@ -829,34 +680,6 @@ object TraktLibraryRepository {
return yearText.toIntOrNull()
}
- private fun String?.orIfBlank(fallback: String?): String? {
- val current = this?.trim().takeUnless { it.isNullOrBlank() }
- if (current != null) return current
- return fallback?.trim().takeUnless { it.isNullOrBlank() }
- }
-
- private fun String?.orValidImageUrl(fallback: String?): String? {
- val current = this.normalizeImageUrl()
- if (current != null) return current
- return fallback.normalizeImageUrl()
- }
-
- private fun List?.firstNonBlankImageUrl(): String? {
- return this
- ?.asSequence()
- ?.mapNotNull { it.normalizeImageUrl() }
- ?.firstOrNull()
- }
-
- private fun String?.normalizeImageUrl(): String? {
- val value = this?.trim().takeUnless { it.isNullOrBlank() } ?: return null
- val normalized = if (value.startsWith("//")) "https:$value" else value
- return normalized.takeIf {
- it.startsWith("https://", ignoreCase = true) ||
- it.startsWith("http://", ignoreCase = true)
- }
- }
-
private val imdbRegex = Regex("tt\\d+")
}
@@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload(
val entriesByList: Map> = emptyMap(),
)
-internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean {
- val missingDisplayName = item.name.isBlank() || item.name == item.id
- return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank()
-}
-
@Serializable
private data class TraktListSummaryDto(
val name: String? = null,
@@ -902,14 +720,6 @@ private data class TraktMediaDto(
val images: TraktImagesDto? = null,
)
-@Serializable
-private data class TraktImagesDto(
- val fanart: List? = null,
- val poster: List? = null,
- val logo: List? = null,
- val banner: List? = null,
-)
-
@Serializable
private data class TraktIdsDto(
val trakt: Int? = null,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
index 6d10a78c..6ca02f0b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
@@ -434,9 +434,31 @@ object TraktProgressRepository {
entries.map { entry ->
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
- val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) {
- meta.videos.firstOrNull { video ->
- video.season == entry.seasonNumber && video.episode == entry.episodeNumber
+ var resolvedSeason = entry.seasonNumber
+ var resolvedEpisode = entry.episodeNumber
+
+ val episode = if (resolvedSeason != null && resolvedEpisode != null) {
+ // Try direct match first
+ val directMatch = meta.videos.firstOrNull { video ->
+ video.season == resolvedSeason && video.episode == resolvedEpisode
+ }
+ if (directMatch != null) {
+ directMatch
+ } else {
+ // Fallback: reverse-remap from Trakt numbering to addon numbering
+ val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season }
+ if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) {
+ val sorted = meta.videos
+ .filter { it.season != null && it.episode != null }
+ .sortedWith(compareBy({ it.season }, { it.episode }))
+ val globalIndex = resolvedEpisode!! - 1
+ if (globalIndex in sorted.indices) {
+ val remapped = sorted[globalIndex]
+ resolvedSeason = remapped.season
+ resolvedEpisode = remapped.episode
+ remapped
+ } else null
+ } else null
}
} else {
null
@@ -447,6 +469,8 @@ object TraktProgressRepository {
logo = entry.logo ?: meta.logo,
poster = entry.poster ?: meta.poster,
background = entry.background ?: meta.background,
+ seasonNumber = resolvedSeason ?: entry.seasonNumber,
+ episodeNumber = resolvedEpisode ?: entry.episodeNumber,
episodeTitle = entry.episodeTitle ?: episode?.title,
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
pauseDescription = entry.pauseDescription
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
new file mode 100644
index 00000000..e1468245
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt
@@ -0,0 +1,380 @@
+package com.nuvio.app.features.trakt
+
+import co.touchlab.kermit.Logger
+import com.nuvio.app.features.addons.RawHttpResponse
+import com.nuvio.app.features.addons.httpRequestRaw
+import com.nuvio.app.features.catalog.CatalogPage
+import com.nuvio.app.features.collection.CollectionSource
+import com.nuvio.app.features.collection.TmdbCollectionMediaType
+import com.nuvio.app.features.collection.TraktListSort
+import com.nuvio.app.features.collection.TraktSortHow
+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.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import kotlin.math.roundToInt
+
+data class TraktPublicListImportMetadata(
+ val title: String? = null,
+ val coverImageUrl: String? = null,
+ val traktListId: Long? = null,
+)
+
+data class TraktPublicListSearchResult(
+ val traktListId: Long,
+ val title: String,
+ val subtitle: String,
+ val coverImageUrl: String? = null,
+ val sortBy: String? = null,
+ val sortHow: String? = null,
+)
+
+object TraktPublicListSourceResolver {
+ const val PAGE_LIMIT = 50
+
+ private const val BASE_URL = "https://api.trakt.tv"
+ private const val API_VERSION = "2"
+
+ private val log = Logger.withTag("TraktPublicListSource")
+ 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 mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
+ val type = mediaType.toTraktType()
+ val sortBy = TraktListSort.normalize(source.sortBy)
+ val sortHow = TraktSortHow.normalize(source.sortHow)
+ val response = requestRaw(
+ endpoint = "lists/$listId/items/$type",
+ query = mapOf(
+ "extended" to "full,images",
+ "page" to page.toString(),
+ "limit" to PAGE_LIMIT.toString(),
+ "sort_by" to sortBy,
+ "sort_how" to sortHow,
+ ),
+ )
+ if (response.status !in 200..299) {
+ error(errorMessageFor(response.status, "Could not load Trakt list"))
+ }
+
+ val rawItems = json.decodeFromString>(response.body)
+ val items = rawItems
+ .mapNotNull { it.toPreview(mediaType) }
+ .distinctBy { "${it.type}:${it.id}" }
+ val pageCount = response.headerInt("x-pagination-page-count") ?: page
+ CatalogPage(
+ items = items,
+ rawItemCount = items.size,
+ nextSkip = if (page < pageCount && items.isNotEmpty()) page + 1 else null,
+ )
+ }
+
+ suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) {
+ val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt 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")
+ TraktPublicListImportMetadata(
+ title = list.name?.takeIf { it.isNotBlank() },
+ coverImageUrl = list.images?.posters.firstTraktImageUrl(),
+ traktListId = id,
+ )
+ }
+
+ suspend fun searchPublicLists(query: String): List = withContext(Dispatchers.Default) {
+ val trimmed = query.trim()
+ if (trimmed.isBlank()) return@withContext emptyList()
+ requestJson>(
+ endpoint = "search/list",
+ query = mapOf(
+ "query" to trimmed,
+ "extended" to "full,images",
+ "page" to "1",
+ "limit" to "20",
+ ),
+ ).mapNotNull { it.toPublicListResult() }
+ }
+
+ suspend fun trendingPublicLists(): List =
+ loadProminentLists("lists/trending")
+
+ suspend fun popularPublicLists(): List =
+ loadProminentLists("lists/popular")
+
+ fun parseTraktListId(input: String): Long? =
+ parseTraktListPath(input)?.toLongOrNull()
+
+ private suspend fun loadProminentLists(endpoint: String): List =
+ withContext(Dispatchers.Default) {
+ requestJson>(
+ endpoint = endpoint,
+ query = mapOf(
+ "extended" to "full,images",
+ "page" to "1",
+ "limit" to "20",
+ ),
+ ).mapNotNull { item ->
+ item.list?.toPublicListResult(likeCount = item.likeCount)
+ }
+ }
+
+ private suspend inline fun requestJson(
+ endpoint: String,
+ query: Map = emptyMap(),
+ ): T {
+ val response = requestRaw(endpoint = endpoint, query = query)
+ if (response.status !in 200..299) {
+ error(errorMessageFor(response.status, "Trakt request failed"))
+ }
+ return runCatching { json.decodeFromString(response.body) }
+ .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
+ .getOrThrow()
+ }
+
+ private suspend fun requestRaw(
+ endpoint: String,
+ query: Map = emptyMap(),
+ ): RawHttpResponse {
+ if (TraktConfig.CLIENT_ID.isBlank()) {
+ error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).")
+ }
+ val url = buildTraktUrl(endpoint, query)
+ return httpRequestRaw(
+ method = "GET",
+ url = url,
+ headers = mapOf(
+ "Accept" to "application/json",
+ "trakt-api-version" to API_VERSION,
+ "trakt-api-key" to TraktConfig.CLIENT_ID,
+ ),
+ body = "",
+ )
+ }
+
+ private fun buildTraktUrl(endpoint: String, query: Map): String {
+ val trimmedEndpoint = endpoint.trim().trim('/')
+ val queryString = query.entries
+ .filter { (_, value) -> value.isNotBlank() }
+ .joinToString("&") { (key, value) ->
+ "${key.encodeURLParameter()}=${value.encodeURLParameter()}"
+ }
+ return if (queryString.isBlank()) {
+ "$BASE_URL/$trimmedEndpoint"
+ } else {
+ "$BASE_URL/$trimmedEndpoint?$queryString"
+ }
+ }
+
+ private fun PublicTraktListItemDto.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? {
+ return when (mediaType) {
+ TmdbCollectionMediaType.MOVIE -> movie?.toPreview()
+ TmdbCollectionMediaType.TV -> show?.toPreview()
+ }
+ }
+
+ private fun PublicTraktMovieDto.toPreview(): MetaPreview? {
+ val title = title?.takeIf { it.isNotBlank() } ?: return null
+ val fallback = when {
+ ids?.trakt != null -> "trakt:${ids.trakt}"
+ !ids?.slug.isNullOrBlank() -> "movie:${ids.slug}"
+ else -> null
+ }
+ val contentId = normalizeTraktContentId(ids, fallback)
+ if (contentId.isBlank()) return null
+ return MetaPreview(
+ id = contentId,
+ type = "movie",
+ name = title,
+ poster = images.traktBestPosterUrl(),
+ banner = images.traktBestBackdropUrl(),
+ logo = images.traktBestLogoUrl(),
+ posterShape = PosterShape.Poster,
+ description = overview?.takeIf { it.isNotBlank() },
+ releaseInfo = year?.toString() ?: released?.take(4),
+ rawReleaseDate = released,
+ imdbRating = rating?.formatRating(),
+ genres = genres.orEmpty(),
+ )
+ }
+
+ private fun PublicTraktShowDto.toPreview(): MetaPreview? {
+ val title = title?.takeIf { it.isNotBlank() } ?: return null
+ val fallback = when {
+ ids?.trakt != null -> "trakt:${ids.trakt}"
+ !ids?.slug.isNullOrBlank() -> "series:${ids.slug}"
+ else -> null
+ }
+ val contentId = normalizeTraktContentId(ids, fallback)
+ if (contentId.isBlank()) return null
+ return MetaPreview(
+ id = contentId,
+ type = "series",
+ name = title,
+ poster = images.traktBestPosterUrl(),
+ banner = images.traktBestBackdropUrl(),
+ logo = images.traktBestLogoUrl(),
+ posterShape = PosterShape.Poster,
+ description = overview?.takeIf { it.isNotBlank() },
+ releaseInfo = year?.toString() ?: firstAired?.take(4),
+ rawReleaseDate = firstAired,
+ imdbRating = rating?.formatRating(),
+ genres = genres.orEmpty(),
+ )
+ }
+
+ private fun PublicTraktSearchResultDto.toPublicListResult(): TraktPublicListSearchResult? {
+ if (!type.equals("list", ignoreCase = true)) return null
+ return list?.toPublicListResult()
+ }
+
+ 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") }
+ }
+ 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? {
+ val trimmed = input.trim()
+ if (trimmed.isBlank()) return null
+ trimmed.toLongOrNull()?.let { return it.toString() }
+ Regex("""[?&]id=([^/]+)""")
+ .find(trimmed)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return it }
+ Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE)
+ .find(trimmed)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return it }
+ Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE)
+ .find(trimmed)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return it }
+ return trimmed.takeIf { it.matches(Regex("""[A-Za-z0-9_-]+""")) }
+ }
+
+ private fun TmdbCollectionMediaType.toTraktType(): String =
+ when (this) {
+ TmdbCollectionMediaType.MOVIE -> "movie"
+ TmdbCollectionMediaType.TV -> "show"
+ }
+
+ private fun RawHttpResponse.headerInt(name: String): Int? =
+ headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }
+ ?.value
+ ?.substringBefore(',')
+ ?.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 Double.formatRating(): String =
+ ((this * 10).roundToInt() / 10.0).toString()
+
+@Serializable
+private data class PublicTraktSearchResultDto(
+ val type: String? = null,
+ val list: PublicTraktListSummaryDto? = null,
+)
+
+@Serializable
+private data class PublicTraktProminentListDto(
+ @SerialName("like_count") val likeCount: Int? = null,
+ val list: PublicTraktListSummaryDto? = null,
+)
+
+@Serializable
+private data class PublicTraktListSummaryDto(
+ val name: String? = null,
+ val description: String? = null,
+ @SerialName("sort_by") val sortBy: String? = null,
+ @SerialName("sort_how") val sortHow: String? = null,
+ @SerialName("item_count") val itemCount: Int? = null,
+ val likes: Int? = null,
+ val ids: PublicTraktListIdsDto? = null,
+ val user: PublicTraktUserDto? = null,
+ val images: PublicTraktListImagesDto? = null,
+)
+
+@Serializable
+private data class PublicTraktListImagesDto(
+ val posters: List? = null,
+)
+
+@Serializable
+private data class PublicTraktListIdsDto(
+ val trakt: Long? = null,
+ val slug: String? = null,
+)
+
+@Serializable
+private data class PublicTraktUserDto(
+ val username: String? = null,
+)
+
+@Serializable
+private data class PublicTraktListItemDto(
+ val rank: Int? = null,
+ val id: Long? = null,
+ @SerialName("listed_at") val listedAt: String? = null,
+ val type: String? = null,
+ val movie: PublicTraktMovieDto? = null,
+ val show: PublicTraktShowDto? = null,
+)
+
+@Serializable
+private data class PublicTraktMovieDto(
+ val title: String? = null,
+ val year: Int? = null,
+ val ids: TraktExternalIds? = null,
+ val overview: String? = null,
+ val released: String? = null,
+ val rating: Double? = null,
+ val genres: List? = null,
+ val images: TraktImagesDto? = null,
+)
+
+@Serializable
+private data class PublicTraktShowDto(
+ val title: String? = null,
+ val year: Int? = null,
+ val ids: TraktExternalIds? = null,
+ val overview: String? = null,
+ @SerialName("first_aired") val firstAired: String? = null,
+ val rating: Double? = null,
+ val genres: List? = null,
+ val images: TraktImagesDto? = null,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
index 359eec29..59c074ee 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
@@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter(
compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
)
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
+ var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
+ buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) == watchedVideoId
+ }
+
+ // Fallback: if the seed wasn't found by season+episode (anime with absolute
+ // numbering on Trakt vs multi-season on addon), try global index matching.
+ if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
+ val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
+ if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
+ val globalIndex = episodeNumber - 1
+ if (globalIndex in sortedEpisodes.indices) {
+ watchedIndex = globalIndex
+ }
+ }
+ }
+
+ if (watchedIndex < 0) return null
+
+ val watchedEpisodeSeason = sortedEpisodes[watchedIndex].seasonNumber
val candidates = sortedEpisodes
- .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
- .drop(1)
+ .drop(watchedIndex + 1)
.filter { episode ->
shouldSurfaceNextEpisode(
- watchedSeasonNumber = seasonNumber,
+ watchedSeasonNumber = watchedEpisodeSeason,
candidateSeasonNumber = episode.seasonNumber,
todayIsoDate = todayIsoDate,
releasedDate = episode.releasedDate,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
index 237f9dcf..27c6fcd1 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
@@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode(
{ it.markedAtEpochMs },
)
} else {
- compareBy { it.markedAtEpochMs }
+ compareBy(
+ { it.markedAtEpochMs },
+ { normalizeSeasonNumber(it.seasonNumber) },
+ { it.episodeNumber },
+ )
}
val allMarkers = buildList {
progressRecords
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
index 714dbcf7..5a63de88 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
@@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.trakt.TraktAuthRepository
+import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.watched.WatchedItem
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
@@ -92,7 +93,30 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
}
}
- return result
+ // Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN)
+ // but addon uses multi-season, remap pulled episodes to addon numbering.
+ val remappedResult = mutableListOf()
+ for (item in result) {
+ if (item.season == null || item.episode == null || item.type != "series") {
+ remappedResult += item
+ continue
+ }
+ val mapped = runCatching {
+ TraktEpisodeMappingService.resolveAddonEpisodeMapping(
+ contentId = item.id,
+ contentType = item.type,
+ season = item.season,
+ episode = item.episode,
+ )
+ }.getOrNull()
+ if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) {
+ remappedResult += item.copy(season = mapped.season, episode = mapped.episode)
+ } else {
+ remappedResult += item
+ }
+ }
+
+ return remappedResult
}
// ── push (add to history) ───────────────────────────────────────────
@@ -107,6 +131,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val shows = mutableListOf()
items.forEach { item ->
+ if (!item.shouldSyncToTraktHistory()) return@forEach
+
val ids = parseIds(item.id) ?: return@forEach
val normalizedType = item.type.trim().lowercase()
@@ -161,16 +187,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
)
}
- } else {
- // Series-level mark (no season/episode) → mark entire show
- shows += TraktHistoryShowRequestDto(
- title = item.name.takeIf { it.isNotBlank() },
- year = parseYear(item.releaseInfo),
- ids = ids,
- )
}
}
+ if (movies.isEmpty() && shows.isEmpty()) return
+
val body = json.encodeToString(
TraktHistoryAddRequestDto(
movies = movies.takeIf { it.isNotEmpty() },
@@ -178,7 +199,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
)
- runCatching {
+ val responseText = runCatching {
httpPostJsonWithHeaders(
url = "$BASE_URL/sync/history",
body = body,
@@ -187,6 +208,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to push watched items to Trakt: ${e.message}" }
+ }.getOrNull()
+
+ // Retry with remapped numbering for episodes that Trakt didn't recognize
+ // (anime with different season structures between addon and Trakt).
+ if (responseText != null && shows.isNotEmpty()) {
+ val episodeItems = items.filter {
+ it.season != null && it.episode != null &&
+ it.type.trim().lowercase() !in listOf("movie", "film")
+ }
+ if (episodeItems.isNotEmpty()) {
+ retryWithRemappedEpisodes(headers, episodeItems)
+ }
+ }
+ }
+
+ private suspend fun retryWithRemappedEpisodes(
+ headers: Map,
+ items: Collection,
+ ) {
+ val remappedShows = mutableListOf()
+
+ for (item in items) {
+ val season = item.season ?: continue
+ val episode = item.episode ?: continue
+ val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
+ contentId = item.id,
+ contentType = item.type,
+ videoId = null,
+ season = season,
+ episode = episode,
+ ) ?: continue
+ if (mapped.season == season && mapped.episode == episode) continue
+
+ val ids = parseIds(item.id) ?: continue
+ val existing = remappedShows.firstOrNull { it.ids == ids }
+ if (existing != null) {
+ val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season }
+ if (seasonDto != null) {
+ (seasonDto.episodes as? MutableList)?.add(
+ TraktHistoryEpisodeRequestDto(
+ number = mapped.episode,
+ watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
+ ),
+ )
+ } else {
+ (existing.seasons as? MutableList)?.add(
+ TraktHistorySeasonRequestDto(
+ number = mapped.season,
+ episodes = mutableListOf(
+ TraktHistoryEpisodeRequestDto(
+ number = mapped.episode,
+ watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
+ ),
+ ),
+ ),
+ )
+ }
+ } else {
+ remappedShows += TraktHistoryShowRequestDto(
+ title = item.name.takeIf { it.isNotBlank() },
+ year = parseYear(item.releaseInfo),
+ ids = ids,
+ seasons = mutableListOf(
+ TraktHistorySeasonRequestDto(
+ number = mapped.season,
+ episodes = mutableListOf(
+ TraktHistoryEpisodeRequestDto(
+ number = mapped.episode,
+ watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null,
+ ),
+ ),
+ ),
+ ),
+ )
+ }
+ }
+
+ if (remappedShows.isEmpty()) return
+
+ val retryBody = json.encodeToString(
+ TraktHistoryAddRequestDto(
+ movies = null,
+ shows = remappedShows,
+ ),
+ )
+
+ runCatching {
+ httpPostJsonWithHeaders(
+ url = "$BASE_URL/sync/history",
+ body = retryBody,
+ headers = headers,
+ )
+ }.onFailure { e ->
+ if (e is CancellationException) throw e
+ log.w { "Failed to push remapped episodes to Trakt: ${e.message}" }
}
}
@@ -202,6 +318,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val shows = mutableListOf()
items.forEach { item ->
+ if (!item.shouldSyncToTraktHistory()) return@forEach
+
val ids = parseIds(item.id) ?: return@forEach
val normalizedType = item.type.trim().lowercase()
@@ -225,15 +343,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
),
)
- } else {
- shows += TraktHistoryShowRequestDto(
- title = item.name.takeIf { it.isNotBlank() },
- year = parseYear(item.releaseInfo),
- ids = ids,
- )
}
}
+ if (movies.isEmpty() && shows.isEmpty()) return
+
val body = json.encodeToString(
TraktHistoryRemoveRequestDto(
movies = movies.takeIf { it.isNotEmpty() },
@@ -251,6 +365,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
if (e is CancellationException) throw e
log.w { "Failed to remove watched items from Trakt: ${e.message}" }
}
+
+ // Retry removal with remapped numbering for anime cases
+ val episodeItems = items.filter {
+ it.season != null && it.episode != null &&
+ it.type.trim().lowercase() !in listOf("movie", "film")
+ }
+ if (episodeItems.isNotEmpty()) {
+ retryDeleteWithRemappedEpisodes(headers, episodeItems)
+ }
+ }
+
+ private suspend fun retryDeleteWithRemappedEpisodes(
+ headers: Map,
+ items: Collection,
+ ) {
+ val remappedShowDtos = mutableListOf()
+
+ for (item in items) {
+ val season = item.season ?: continue
+ val episode = item.episode ?: continue
+ val mapped = TraktEpisodeMappingService.resolveEpisodeMapping(
+ contentId = item.id,
+ contentType = item.type,
+ videoId = null,
+ season = season,
+ episode = episode,
+ ) ?: continue
+ if (mapped.season == season && mapped.episode == episode) continue
+
+ val ids = parseIds(item.id) ?: continue
+ remappedShowDtos += TraktHistoryShowRequestDto(
+ title = item.name.takeIf { it.isNotBlank() },
+ year = parseYear(item.releaseInfo),
+ ids = ids,
+ seasons = listOf(
+ TraktHistorySeasonRequestDto(
+ number = mapped.season,
+ episodes = listOf(
+ TraktHistoryEpisodeRequestDto(number = mapped.episode),
+ ),
+ ),
+ ),
+ )
+ }
+
+ if (remappedShowDtos.isEmpty()) return
+
+ val retryBody = json.encodeToString(
+ TraktHistoryRemoveRequestDto(
+ movies = null,
+ shows = remappedShowDtos,
+ ),
+ )
+
+ runCatching {
+ httpPostJsonWithHeaders(
+ url = "$BASE_URL/sync/history/remove",
+ body = retryBody,
+ headers = headers,
+ )
+ }.onFailure { e ->
+ if (e is CancellationException) throw e
+ log.w { "Failed to remove remapped episodes from Trakt: ${e.message}" }
+ }
}
// ── helpers ─────────────────────────────────────────────────────────
@@ -348,6 +526,13 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
private fun Int.pad4(): String = "$this".padStart(4, '0')
}
+internal fun WatchedItem.shouldSyncToTraktHistory(): Boolean {
+ val normalizedType = type.trim().lowercase()
+ return normalizedType == "movie" ||
+ normalizedType == "film" ||
+ (season != null && episode != null)
+}
+
// ── DTOs for pull (GET /sync/watched) ───────────────────────────────────
@Serializable
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
new file mode 100644
index 00000000..66f227dd
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt
@@ -0,0 +1,181 @@
+package com.nuvio.app.features.collection
+
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class CollectionSourceSerializationTest {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ prettyPrint = false
+ }
+
+ @Test
+ fun traktSourceRoundTripsWithPublicListShape() {
+ val collection = Collection(
+ id = "collection-1",
+ title = "Favorites",
+ folders = listOf(
+ CollectionFolder(
+ id = "folder-1",
+ title = "Lists",
+ sources = listOf(
+ CollectionSource(
+ provider = "trakt",
+ title = "Criterion Movies",
+ traktListId = 123456L,
+ mediaType = TmdbCollectionMediaType.MOVIE.name,
+ sortBy = TraktListSort.ADDED.value,
+ sortHow = TraktSortHow.DESC.value,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ val encoded = json.encodeToString(listOf(collection))
+ assertTrue(encoded.contains(""""provider":"trakt""""))
+ assertTrue(encoded.contains(""""traktListId":123456"""))
+ assertTrue(encoded.contains(""""sortHow":"desc""""))
+
+ val decoded = json.decodeFromString>(encoded)
+ val source = decoded.single().folders.single().resolvedSources.single()
+ assertTrue(source.isTrakt)
+ assertEquals(123456L, source.traktListId)
+ assertEquals(TmdbCollectionMediaType.MOVIE.name, source.mediaType)
+ assertEquals(TraktListSort.ADDED.value, source.sortBy)
+ assertEquals(TraktSortHow.DESC.value, source.sortHow)
+ }
+
+ @Test
+ fun importedTraktSourceWithoutListIdIsRejected() {
+ val payload = """
+ [
+ {
+ "id": "collection-1",
+ "title": "Favorites",
+ "folders": [
+ {
+ "id": "folder-1",
+ "title": "Lists",
+ "sources": [
+ {
+ "provider": "trakt",
+ "title": "Missing List",
+ "mediaType": "MOVIE",
+ "sortBy": "rank",
+ "sortHow": "asc"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ """.trimIndent()
+
+ val source = json.decodeFromString>(payload)
+ .single()
+ .folders
+ .single()
+ .resolvedSources
+ .single()
+
+ assertTrue(source.hasInvalidTraktListId())
+ }
+
+ @Test
+ fun legacyAddonCatalogSourcesRemainCompatible() {
+ val payload = """
+ [
+ {
+ "id": "collection-1",
+ "title": "Favorites",
+ "folders": [
+ {
+ "id": "folder-1",
+ "title": "Movies",
+ "catalogSources": [
+ {
+ "addonId": "addon-1",
+ "type": "movie",
+ "catalogId": "top",
+ "genre": "Action"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ """.trimIndent()
+
+ val collection = json.decodeFromString>(payload).single()
+ val source = collection.folders.single().resolvedSources.single()
+ val addonSource = source.addonCatalogSource()
+
+ assertNotNull(addonSource)
+ assertEquals("addon-1", addonSource.addonId)
+ assertEquals("movie", addonSource.type)
+ assertEquals("top", addonSource.catalogId)
+ assertEquals("Action", addonSource.genre)
+ }
+
+ @Test
+ fun sourceKeyPreservationKeepsUnknownTraktFields() {
+ val raw = json.parseToJsonElement(
+ """
+ [
+ {
+ "id": "collection-1",
+ "title": "Favorites",
+ "folders": [
+ {
+ "id": "folder-1",
+ "title": "Lists",
+ "sources": [
+ {
+ "provider": "trakt",
+ "title": "Criterion Movies",
+ "traktListId": 123456,
+ "mediaType": "MOVIE",
+ "sortBy": "rank",
+ "sortHow": "asc",
+ "customField": "keep-me"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ """.trimIndent(),
+ )
+ val collection = Collection(
+ id = "collection-1",
+ title = "Favorites",
+ folders = listOf(
+ CollectionFolder(
+ id = "folder-1",
+ title = "Lists",
+ sources = listOf(
+ CollectionSource(
+ provider = "trakt",
+ title = "Criterion Movies",
+ traktListId = 123456L,
+ mediaType = TmdbCollectionMediaType.MOVIE.name,
+ sortBy = TraktListSort.RANK.value,
+ sortHow = TraktSortHow.ASC.value,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ val merged = CollectionJsonPreserver.merge(json, raw, listOf(collection)).toString()
+ assertTrue(merged.contains(""""customField":"keep-me""""))
+ assertTrue(merged.contains(""""traktListId":123456"""))
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt
new file mode 100644
index 00000000..c432735f
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt
@@ -0,0 +1,44 @@
+package com.nuvio.app.features.trakt
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class TraktImageUtilsTest {
+
+ @Test
+ fun normalizesTraktHostedImageUrls() {
+ assertEquals(
+ "https://media.trakt.tv/images/movies/poster.jpg.webp",
+ listOf("media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
+ )
+ assertEquals(
+ "https://media.trakt.tv/images/movies/poster.jpg.webp",
+ listOf("//media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
+ )
+ assertEquals(
+ "https://media.trakt.tv/images/movies/poster.jpg.webp",
+ listOf("http://media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(),
+ )
+ }
+
+ @Test
+ fun selectsBestTraktImages() {
+ val images = TraktImagesDto(
+ fanart = listOf("media.trakt.tv/images/movies/fanart.jpg.webp"),
+ logo = listOf("media.trakt.tv/images/movies/logo.png.webp"),
+ thumb = listOf("media.trakt.tv/images/movies/thumb.jpg.webp"),
+ )
+
+ assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestPosterUrl())
+ assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestBackdropUrl())
+ assertEquals("https://media.trakt.tv/images/movies/thumb.jpg.webp", images.traktBestLandscapeUrl())
+ assertEquals("https://media.trakt.tv/images/movies/logo.png.webp", images.traktBestLogoUrl())
+ }
+
+ @Test
+ fun returnsNullWhenTraktImagesAreMissing() {
+ assertNull(emptyList().firstTraktImageUrl())
+ assertNull(TraktImagesDto().traktBestPosterUrl())
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt
deleted file mode 100644
index a6b053a4..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package com.nuvio.app.features.trakt
-
-import com.nuvio.app.features.home.PosterShape
-import com.nuvio.app.features.library.LibraryItem
-import kotlin.test.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-class TraktLibraryRepositoryTest {
-
- @Test
- fun `hydration skips items that already have core library data`() {
- val item = LibraryItem(
- id = "tt1234567",
- type = "movie",
- name = "Example",
- poster = "https://image.tmdb.org/t/p/w500/poster.jpg",
- banner = null,
- logo = null,
- description = null,
- releaseInfo = "2024",
- imdbRating = null,
- genres = emptyList(),
- posterShape = PosterShape.Poster,
- savedAtEpochMs = 1L,
- )
-
- assertFalse(shouldHydrateTraktLibraryItem(item))
- }
-
- @Test
- fun `hydration keeps filling missing poster metadata`() {
- val item = LibraryItem(
- id = "tt7654321",
- type = "series",
- name = "Example Show",
- poster = null,
- banner = null,
- logo = null,
- description = "",
- releaseInfo = "2025",
- imdbRating = null,
- genres = emptyList(),
- posterShape = PosterShape.Poster,
- savedAtEpochMs = 1L,
- )
-
- assertTrue(shouldHydrateTraktLibraryItem(item))
- }
-}
\ No newline at end of file
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt
new file mode 100644
index 00000000..2174b5dc
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt
@@ -0,0 +1,49 @@
+package com.nuvio.app.features.trakt
+
+import com.nuvio.app.features.collection.TraktListSort
+import com.nuvio.app.features.collection.TraktSortHow
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class TraktPublicListSourceResolverTest {
+ @Test
+ fun parsesNumericTraktListIdsFromInputs() {
+ assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("123456"))
+ assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/lists/123456"))
+ assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/users/nuvio/lists/123456"))
+ assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://example.com/import?id=123456"))
+ assertNull(TraktPublicListSourceResolver.parseTraktListId(""))
+ }
+
+ @Test
+ fun normalizesTraktSortValues() {
+ assertEquals("rank", TraktListSort.normalize(null))
+ assertEquals("added", TraktListSort.normalize(" ADDED "))
+ assertEquals("rank", TraktListSort.normalize("unknown"))
+
+ assertEquals("asc", TraktSortHow.normalize(null))
+ assertEquals("desc", TraktSortHow.normalize(" DESC "))
+ assertEquals("asc", TraktSortHow.normalize("sideways"))
+ }
+
+ @Test
+ fun normalizesTraktImageUrls() {
+ assertEquals(
+ "https://media.trakt.tv/images/poster.jpg",
+ "media.trakt.tv/images/poster.jpg".toTraktImageUrl(),
+ )
+ assertEquals(
+ "https://media.trakt.tv/images/poster.jpg",
+ "http://media.trakt.tv/images/poster.jpg".toTraktImageUrl(),
+ )
+ assertEquals(
+ "https://cdn.example.com/poster.jpg",
+ "https://cdn.example.com/poster.jpg".toTraktImageUrl(),
+ )
+ assertEquals(
+ "https://media.trakt.tv/images/poster.jpg",
+ listOf("", "media.trakt.tv/images/poster.jpg").firstTraktImageUrl(),
+ )
+ }
+}
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 2ce2b26a..733bec21 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
@@ -1,9 +1,8 @@
package com.nuvio.app.features.downloads
import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.CPointer
import kotlinx.cinterop.convert
-import kotlinx.cinterop.usePinned
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import platform.Foundation.NSDate
+import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSHTTPURLResponse
import platform.Foundation.NSHomeDirectory
@@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData
import platform.Foundation.NSURLResponse
import platform.Foundation.NSURLSession
import platform.Foundation.NSURLSessionConfiguration
-import platform.Foundation.NSURLSessionDownloadDelegateProtocol
-import platform.Foundation.NSURLSessionDownloadTask
+import platform.Foundation.NSURLSessionDataDelegateProtocol
+import platform.Foundation.NSURLSessionDataTask
import platform.Foundation.NSURLSessionTask
import platform.Foundation.setHTTPMethod
import platform.Foundation.setValue
import platform.Foundation.timeIntervalSince1970
import platform.darwin.NSObject
-import platform.posix.fopen
+import platform.posix.FILE
import platform.posix.fclose
-import platform.posix.fread
+import platform.posix.fflush
+import platform.posix.fopen
import platform.posix.fwrite
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
@@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents(
backgroundSessionCompletionHandlers[identifier] = completionHandler
}
+fun pauseDownloadsForAppBackground() {
+ DownloadsRepository.pauseActiveDownloads()
+}
+
@OptIn(ExperimentalForeignApi::class)
internal actual object DownloadsPlatformDownloader {
actual fun start(
@@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader {
actual fun removeFile(localFileUri: String?): Boolean {
if (localFileUri.isNullOrBlank()) return false
val path = localFileUri.toLocalPath() ?: return false
- return removePathIfExists(path)
+ if (NSFileManager.defaultManager.fileExistsAtPath(path)) {
+ return removePathIfExists(path)
+ }
+
+ val fileName = path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return false
+ return removePathIfExists("${downloadsDirectoryPath()}/$fileName")
}
actual fun removePartialFile(destinationFileName: String): Boolean {
val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part"
return removePathIfExists(tempPath)
}
+
+ actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
+ localFileUri?.toLocalPath()
+ ?.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) }
+ ?.let { path ->
+ return NSURL.fileURLWithPath(path).absoluteString ?: "file://$path"
+ }
+
+ val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
+ ?: localFileUri?.toLocalPath()?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
+ ?: return null
+ val currentPath = "${downloadsDirectoryPath()}/$fileName"
+ return if (NSFileManager.defaultManager.fileExistsAtPath(currentPath)) {
+ NSURL.fileURLWithPath(currentPath).absoluteString ?: "file://$currentPath"
+ } else {
+ null
+ }
+ }
}
private class IosDownloadsTaskHandle(
private val job: Job,
) : DownloadsTaskHandle {
- private var task: NSURLSessionDownloadTask? = null
+ private var task: NSURLSessionTask? = null
private var session: NSURLSession? = null
- fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) {
+ fun attach(task: NSURLSessionTask, session: NSURLSession) {
this.task = task
this.session = session
}
@@ -177,10 +205,14 @@ private class IosDownloadDelegate(
private val resumeFromBytes: Long,
private val tempPath: String,
private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
-) : NSObject(), NSURLSessionDownloadDelegateProtocol {
+) : NSObject(), NSURLSessionDataDelegateProtocol {
private val completion = CompletableDeferred()
private var result: IosDownloadResult? = null
private var fileError: Throwable? = null
+ private var outputFile: CPointer? = null
+ private var startingBytesForResponse = 0L
+ private var bytesWrittenForResponse = 0L
+ private var totalBytesForResponse: Long? = null
private var lastProgressBytes = -1L
private var lastProgressTimestampSeconds = 0.0
@@ -188,12 +220,13 @@ private class IosDownloadDelegate(
override fun URLSession(
session: NSURLSession,
- downloadTask: NSURLSessionDownloadTask,
- didFinishDownloadingToURL: NSURL,
+ dataTask: NSURLSessionDataTask,
+ didReceiveResponse: NSURLResponse,
+ completionHandler: (Long) -> Unit,
) {
- val httpResponse = downloadTask.response as? NSHTTPURLResponse
+ val httpResponse = didReceiveResponse as? NSHTTPURLResponse
val statusCode = httpResponse?.statusCode?.toInt() ?: 200
- result = IosDownloadResult(
+ val nextResult = IosDownloadResult(
statusCode = statusCode,
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
contentLength = httpResponse
@@ -201,51 +234,59 @@ private class IosDownloadDelegate(
?.toLongOrNull()
?.takeIf { it > 0L },
)
+ result = nextResult
- if (statusCode !in 200..299) return
+ if (statusCode in 200..299) {
+ val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
+ startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L
+ bytesWrittenForResponse = 0L
+ totalBytesForResponse = resolveTotalBytes(
+ startingBytes = startingBytesForResponse,
+ isPartialResume = isPartialResume,
+ contentRangeHeader = nextResult.contentRange,
+ contentLength = nextResult.contentLength,
+ )
- val sourcePath = didFinishDownloadingToURL.path
- if (sourcePath.isNullOrBlank()) {
- fileError = IllegalStateException("Downloaded file was not available")
- return
+ outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
+ fileError = IllegalStateException("Failed to open partial download file")
+ null
+ }
+
+ reportProgress(startingBytesForResponse, totalBytesForResponse)
}
- val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
- val stored = if (isPartialResume) {
- appendFile(sourcePath, tempPath)
- } else {
- removePathIfExists(tempPath) &&
- NSFileManager.defaultManager.moveItemAtPath(
- srcPath = sourcePath,
- toPath = tempPath,
- error = null,
- )
- }
-
- if (!stored) {
- fileError = IllegalStateException("Failed to store download file")
- }
+ completionHandler(1L)
}
override fun URLSession(
session: NSURLSession,
- downloadTask: NSURLSessionDownloadTask,
- didWriteData: Long,
- totalBytesWritten: Long,
- totalBytesExpectedToWrite: Long,
+ dataTask: NSURLSessionDataTask,
+ didReceiveData: NSData,
) {
- val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt()
- val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) {
- resumeFromBytes
- } else {
- 0L
+ if (fileError != null) return
+
+ val file = outputFile ?: run {
+ fileError = IllegalStateException("Partial download file is not open")
+ return
}
- val expectedTotal = totalBytesExpectedToWrite
- .takeIf { it > 0L }
- ?.let { startingBytes + it }
+
+ val bytesToWrite = didReceiveData.length.toLong()
+ val wrote = fwrite(
+ didReceiveData.bytes,
+ 1.convert(),
+ bytesToWrite.convert(),
+ file,
+ ).toLong()
+ if (wrote != bytesToWrite) {
+ fileError = IllegalStateException("Failed to write partial download file")
+ return
+ }
+ fflush(file)
+
+ bytesWrittenForResponse += bytesToWrite
reportProgress(
- downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L),
- totalBytes = expectedTotal,
+ downloadedBytes = startingBytesForResponse + bytesWrittenForResponse,
+ totalBytes = totalBytesForResponse,
)
}
@@ -254,6 +295,8 @@ private class IosDownloadDelegate(
task: NSURLSessionTask,
didCompleteWithError: NSError?,
) {
+ closeOutputFile()
+
if (didCompleteWithError != null) {
completion.completeExceptionally(
IllegalStateException(didCompleteWithError.localizedDescription),
@@ -275,6 +318,14 @@ private class IosDownloadDelegate(
backgroundSessionCompletionHandlers.remove(identifier)?.invoke()
}
+ private fun closeOutputFile() {
+ outputFile?.let { file ->
+ fflush(file)
+ fclose(file)
+ }
+ outputFile = null
+ }
+
private fun reportProgress(
downloadedBytes: Long,
totalBytes: Long?,
@@ -374,9 +425,11 @@ private suspend fun performDownloadRequest(
val session = NSURLSession.sessionWithConfiguration(
configuration = configuration,
delegate = delegate,
- delegateQueue = NSOperationQueue(),
+ delegateQueue = NSOperationQueue().apply {
+ maxConcurrentOperationCount = 1
+ },
)
- val task = session.downloadTaskWithRequest(nativeRequest)
+ val task = session.dataTaskWithRequest(nativeRequest)
handle.attach(task, session)
onProgress(resumeFromBytes.coerceAtLeast(0L), null)
@@ -389,44 +442,6 @@ private suspend fun performDownloadRequest(
}
}
-@OptIn(ExperimentalForeignApi::class)
-private fun appendFile(sourcePath: String, destinationPath: String): Boolean {
- val source = fopen(sourcePath, "rb") ?: return false
- val destination = fopen(destinationPath, "ab") ?: run {
- fclose(source)
- return false
- }
- val buffer = ByteArray(16 * 1024)
-
- return try {
- while (true) {
- val read = buffer.usePinned { pinned ->
- fread(
- pinned.addressOf(0),
- 1.convert(),
- buffer.size.convert(),
- source,
- ).toInt()
- }
- if (read <= 0) break
-
- val wrote = buffer.usePinned { pinned ->
- fwrite(
- pinned.addressOf(0),
- 1.convert(),
- read.convert(),
- destination,
- ).toInt()
- }
- if (wrote != read) return false
- }
- true
- } finally {
- fclose(source)
- fclose(destination)
- }
-}
-
@OptIn(ExperimentalForeignApi::class)
private fun fileSizeOrNull(path: String): Long? {
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null)
@@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? {
}
private fun String.toLocalPath(): String? {
- if (startsWith("file://")) {
- return removePrefix("file://")
+ val value = trim()
+ if (value.startsWith("file:")) {
+ return NSURL(string = value).path ?: value.removePrefix("file://")
}
- return takeIf { it.isNotBlank() }
+ return value.takeIf { it.isNotBlank() }
}
private fun resolveTotalBytes(
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index e702a219..d7b9fb66 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=48
+CURRENT_PROJECT_VERSION=49
MARKETING_VERSION=0.1.0
diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift
index cf78e051..26d80c43 100644
--- a/iosApp/iosApp/OrientationLockCoordinator.swift
+++ b/iosApp/iosApp/OrientationLockCoordinator.swift
@@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
)
}
+ func applicationDidEnterBackground(_ application: UIApplication) {
+ DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground()
+ }
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift
index ae08f457..9839d1f0 100644
--- a/iosApp/iosApp/Player/MPVPlayerBridge.swift
+++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift
@@ -137,12 +137,22 @@ struct TrackInfo {
let selected: Bool
}
+private struct PendingLoadRequest {
+ let urlString: String
+ let audioUrl: String?
+ let requestHeaders: [String: String]
+ let queuedAtUptime: TimeInterval
+}
+
// MARK: - MPV Player View Controller
final class MPVPlayerViewController: UIViewController {
private let errorStateLock = NSLock()
private var metalLayer = MetalLayer()
+ private var lastAppliedDrawableSize: CGSize = .zero
+ private var pendingLoadRequest: PendingLoadRequest?
+ private var pendingLoadRetryWorkItem: DispatchWorkItem?
private var mpv: OpaquePointer?
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
private var recentPlaybackLogs: [String] = []
@@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
+ view.layer.masksToBounds = true
- metalLayer.frame = view.bounds
- metalLayer.contentsScale = UIScreen.main.nativeScale
+ metalLayer.contentsGravity = .resize
+ metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
metalLayer.framebufferOnly = true
metalLayer.backgroundColor = UIColor.black.cgColor
view.layer.addSublayer(metalLayer)
+ layoutMetalLayer()
setupMpv()
setupNotifications()
@@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController {
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
- metalLayer.frame = view.bounds
+ layoutMetalLayer()
+ attemptStartPendingLoad()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
refreshImmersiveSystemUI()
+ attemptStartPendingLoad()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
+ layoutMetalLayer()
refreshImmersiveSystemUI()
+ attemptStartPendingLoad()
+ }
+
+ private func layoutMetalLayer() {
+ let bounds = view.bounds
+ guard bounds.width > 1, bounds.height > 1 else { return }
+
+ let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
+ let drawableSize = CGSize(
+ width: (bounds.width * scale).rounded(.toNearestOrAwayFromZero),
+ height: (bounds.height * scale).rounded(.toNearestOrAwayFromZero)
+ )
+
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ metalLayer.contentsScale = scale
+ metalLayer.frame = CGRect(origin: .zero, size: bounds.size)
+ if drawableSize != lastAppliedDrawableSize {
+ metalLayer.drawableSize = drawableSize
+ lastAppliedDrawableSize = drawableSize
+ }
+ CATransaction.commit()
}
// MARK: - MPV Setup
@@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
+ let request = PendingLoadRequest(
+ urlString: urlString,
+ audioUrl: audioUrl,
+ requestHeaders: requestHeaders,
+ queuedAtUptime: ProcessInfo.processInfo.systemUptime
+ )
+
+ if Thread.isMainThread {
+ queueLoad(request)
+ } else {
+ DispatchQueue.main.async { [weak self] in
+ self?.queueLoad(request)
+ }
+ }
+ }
+
+ private func queueLoad(_ request: PendingLoadRequest) {
+ pendingLoadRequest = request
+ attemptStartPendingLoad()
+ }
+
+ private func attemptStartPendingLoad() {
+ guard let request = pendingLoadRequest else { return }
guard mpv != nil else { return }
+ layoutMetalLayer()
+ guard isViewportReadyForPlayback(queuedAtUptime: request.queuedAtUptime) else {
+ schedulePendingLoadRetry()
+ return
+ }
+
+ pendingLoadRequest = nil
+ pendingLoadRetryWorkItem?.cancel()
+ pendingLoadRetryWorkItem = nil
+ startLoad(request)
+ }
+
+ private func startLoad(_ request: PendingLoadRequest) {
+ guard mpv != nil else { return }
+ layoutMetalLayer()
clearPlaybackError()
- let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders)
+ let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders)
activeRequestHeaders = sanitizedHeaders
applyRequestHeaders(sanitizedHeaders)
isPlayerLoading = true
isPlayerEnded = false
- command("loadfile", args: [urlString, "replace"])
- if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ command("loadfile", args: [request.urlString, "replace"])
+ if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
}
}
}
+ private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool {
+ guard isViewLoaded, view.window != nil else { return false }
+ let bounds = view.bounds
+ guard bounds.width > 1, bounds.height > 1 else { return false }
+ if bounds.width >= bounds.height { return true }
+
+ let age = ProcessInfo.processInfo.systemUptime - queuedAtUptime
+ return age >= 0.9
+ }
+
+ private func schedulePendingLoadRetry() {
+ guard pendingLoadRetryWorkItem == nil else { return }
+
+ let workItem = DispatchWorkItem { [weak self] in
+ self?.pendingLoadRetryWorkItem = nil
+ self?.attemptStartPendingLoad()
+ }
+ pendingLoadRetryWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
+ }
+
func playPlayback() {
guard mpv != nil else { return }
setFlag("pause", false)
@@ -350,8 +446,8 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
case 2: // Zoom
- checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
- checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big"))
+ checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
+ checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
default: // Fit
checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
@@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController {
func destroyPlayer() {
NotificationCenter.default.removeObserver(self)
+ pendingLoadRetryWorkItem?.cancel()
+ pendingLoadRetryWorkItem = nil
+ pendingLoadRequest = nil
clearPlaybackError()
guard let ctx = mpv else { return }
mpv = nil // nil first so event loop stops reading