mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into introdb
This commit is contained in:
commit
68a82962da
36 changed files with 3166 additions and 848 deletions
|
|
@ -168,6 +168,24 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
if (!tempFile.exists()) return true
|
if (!tempFile.exists()) return true
|
||||||
return runCatching { tempFile.delete() }.getOrDefault(false)
|
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(
|
private class AndroidDownloadsTaskHandle(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<locale android:name="en"/>
|
<locale android:name="en"/>
|
||||||
<locale android:name="fr"/>
|
<locale android:name="fr"/>
|
||||||
<locale android:name="es"/>
|
<locale android:name="es"/>
|
||||||
<locale android:name="pt-PT"/>
|
<locale android:name="pt"/>
|
||||||
<locale android:name="tr"/>
|
<locale android:name="tr"/>
|
||||||
<locale android:name="it"/>
|
<locale android:name="it"/>
|
||||||
<locale android:name="el"/>
|
<locale android:name="el"/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="about_supporters_contributors_subtitle">Reconnaissance ouverte et crédits du projet</string>
|
<string name="about_supporters_contributors_subtitle">Reconnaissance et crédits du projet</string>
|
||||||
<string name="action_back">Retour</string>
|
<string name="action_back">Retour</string>
|
||||||
<string name="action_cancel">Annuler</string>
|
<string name="action_cancel">Annuler</string>
|
||||||
<string name="action_close">Fermer</string>
|
<string name="action_close">Fermer</string>
|
||||||
|
|
@ -26,29 +26,29 @@
|
||||||
<string name="addons_badge_refreshing">Actualisation</string>
|
<string name="addons_badge_refreshing">Actualisation</string>
|
||||||
<string name="addons_badge_resources">%1$d ressources</string>
|
<string name="addons_badge_resources">%1$d ressources</string>
|
||||||
<string name="addons_badge_unavailable">Indisponible</string>
|
<string name="addons_badge_unavailable">Indisponible</string>
|
||||||
<string name="addons_configure">Configurer l\'addon</string>
|
<string name="addons_configure">Configurer l'addon</string>
|
||||||
<string name="addons_delete">Supprimer l\'addon</string>
|
<string name="addons_delete">Supprimer l'addon</string>
|
||||||
<string name="addons_empty_subtitle">Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio.</string>
|
<string name="addons_empty_subtitle">Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio.</string>
|
||||||
<string name="addons_empty_title">Aucune addons installée.</string>
|
<string name="addons_empty_title">Aucun addon installé.</string>
|
||||||
<string name="addons_error_enter_url">Veuillez saisir une URL d\'addon.</string>
|
<string name="addons_error_enter_url">Veuillez saisir une URL d'addon.</string>
|
||||||
<string name="addons_input_placeholder">URL de l\'addon</string>
|
<string name="addons_input_placeholder">URL de l'addon</string>
|
||||||
<string name="addons_install_button">Installer l\'addon</string>
|
<string name="addons_install_button">Installer l'addon</string>
|
||||||
<string name="addons_loading_manifest_details">Chargement des détails du manifeste…</string>
|
<string name="addons_loading_manifest_details">Chargement des détails du manifeste…</string>
|
||||||
<string name="addons_modal_checking_message">Validation de l\'URL du manifeste et chargement des détails de l\'addon avant installation.</string>
|
<string name="addons_modal_checking_message">Validation de l'URL du manifeste et chargement des détails de l'addon avant installation.</string>
|
||||||
<string name="addons_modal_checking_title">Vérification de l\'addon</string>
|
<string name="addons_modal_checking_title">Vérification de l'addon</string>
|
||||||
<string name="addons_modal_failure_title">Échec de l\'installation</string>
|
<string name="addons_modal_failure_title">Échec de l'installation</string>
|
||||||
<string name="addons_modal_success_message">%1$s a été validé et ajouté avec succès.</string>
|
<string name="addons_modal_success_message">%1$s a été validé et ajouté avec succès.</string>
|
||||||
<string name="addons_modal_success_title">Addon installée</string>
|
<string name="addons_modal_success_title">Addon installé</string>
|
||||||
<string name="addons_move_down">Déplacer l\'addon vers le bas</string>
|
<string name="addons_move_down">Déplacer l'addon vers le bas</string>
|
||||||
<string name="addons_move_up">Déplacer l\'addon vers le haut</string>
|
<string name="addons_move_up">Déplacer l'addon vers le haut</string>
|
||||||
<string name="addons_overview_active">Actif</string>
|
<string name="addons_overview_active">Actif</string>
|
||||||
<string name="addons_overview_addons">Addons</string>
|
<string name="addons_overview_addons">Addons</string>
|
||||||
<string name="addons_overview_catalogs">Catalogues</string>
|
<string name="addons_overview_catalogs">Catalogues</string>
|
||||||
<string name="addons_refresh">Actualiser l\'addon</string>
|
<string name="addons_refresh">Actualiser l'addon</string>
|
||||||
<string name="addons_section_add_addon">Ajouter une addon</string>
|
<string name="addons_section_add_addon">Ajouter un addon</string>
|
||||||
<string name="addons_section_installed">Addons installées</string>
|
<string name="addons_section_installed">Addons installés</string>
|
||||||
<string name="addons_section_overview">Aperçu</string>
|
<string name="addons_section_overview">Aperçu</string>
|
||||||
<string name="addons_summary_id_rules">%1$d règles d\'ID</string>
|
<string name="addons_summary_id_rules">%1$d règles d'ID</string>
|
||||||
<string name="addons_version_format">Version %1$s</string>
|
<string name="addons_version_format">Version %1$s</string>
|
||||||
<string name="cd_selected">Sélectionné</string>
|
<string name="cd_selected">Sélectionné</string>
|
||||||
<string name="collections_copy_json">Copier le JSON</string>
|
<string name="collections_copy_json">Copier le JSON</string>
|
||||||
|
|
@ -58,28 +58,28 @@
|
||||||
<string name="collections_editor_add_catalog">Ajouter un catalogue</string>
|
<string name="collections_editor_add_catalog">Ajouter un catalogue</string>
|
||||||
<string name="collections_editor_add_folder">Ajouter un dossier</string>
|
<string name="collections_editor_add_folder">Ajouter un dossier</string>
|
||||||
<string name="collections_editor_all_genres">Tous les genres</string>
|
<string name="collections_editor_all_genres">Tous les genres</string>
|
||||||
<string name="collections_editor_catalog_sources_empty_subtitle">Ajoutez des catalogues depuis vos extensions installées pour définir ce qu\'affiche ce dossier.</string>
|
<string name="collections_editor_catalog_sources_empty_subtitle">Ajoutez des catalogues depuis vos addons installés pour définir ce qu'affiche ce dossier.</string>
|
||||||
<string name="collections_editor_catalog_sources_empty_title">Aucune source de catalogue</string>
|
<string name="collections_editor_catalog_sources_empty_title">Aucune source de catalogue</string>
|
||||||
<string name="collections_editor_choose_genre">Choisir</string>
|
<string name="collections_editor_choose_genre">Choisir</string>
|
||||||
<string name="collections_editor_cover_emoji">Emoji</string>
|
<string name="collections_editor_cover_emoji">Emoji</string>
|
||||||
<string name="collections_editor_cover_image_url">URL de l\'image</string>
|
<string name="collections_editor_cover_image_url">URL de l'image</string>
|
||||||
<string name="collections_editor_cover_none">Aucune</string>
|
<string name="collections_editor_cover_none">Aucune</string>
|
||||||
<string name="collections_editor_cover">Couverture</string>
|
<string name="collections_editor_cover">Couverture</string>
|
||||||
<string name="collections_editor_create_collection">Créer une collection</string>
|
<string name="collections_editor_create_collection">Créer une collection</string>
|
||||||
<string name="collections_editor_done">Terminé</string>
|
<string name="collections_editor_done">Terminé</string>
|
||||||
<string name="collections_editor_edit_collection">Modifier la collection</string>
|
<string name="collections_editor_edit_collection">Modifier la collection</string>
|
||||||
<string name="collections_editor_edit_folder">Modifier le dossier</string>
|
<string name="collections_editor_edit_folder">Modifier le dossier</string>
|
||||||
<string name="collections_editor_folder_editor_help">Configurez l\'identité, la présentation et les sources de catalogue du dossier avec la même structure que l\'éditeur principal de collections.</string>
|
<string name="collections_editor_folder_editor_help">Configurez l'identité, la présentation et les sources de catalogue du dossier avec la même structure que l'éditeur principal de collections.</string>
|
||||||
<string name="collections_editor_folder_empty_subtitle">Ajoutez-en un pour commencer.</string>
|
<string name="collections_editor_folder_empty_subtitle">Ajoutez-en un pour commencer.</string>
|
||||||
<string name="collections_editor_folder_empty_title">Aucun dossier</string>
|
<string name="collections_editor_folder_empty_title">Aucun dossier</string>
|
||||||
<string name="collections_editor_folders">Dossiers</string>
|
<string name="collections_editor_folders">Dossiers</string>
|
||||||
<string name="collections_editor_genre_filter">Filtre de genre</string>
|
<string name="collections_editor_genre_filter">Filtre de genre</string>
|
||||||
<string name="collections_editor_hide_title_desc">Afficher uniquement l\'image de couverture</string>
|
<string name="collections_editor_hide_title_desc">Afficher uniquement l'image de couverture</string>
|
||||||
<string name="collections_editor_hide_title">Masquer le titre</string>
|
<string name="collections_editor_hide_title">Masquer le titre</string>
|
||||||
<string name="collections_editor_new_folder">Nouveau dossier</string>
|
<string name="collections_editor_new_folder">Nouveau dossier</string>
|
||||||
<string name="collections_editor_pin_above_desc">Affiche cette collection au-dessus de tous les catalogues normaux de l\'accueil. Plusieurs collections épinglées suivent l\'ordre de création.</string>
|
<string name="collections_editor_pin_above_desc">Affiche cette collection au-dessus de tous les catalogues normaux de l'accueil. Plusieurs collections épinglées suivent l'ordre de création.</string>
|
||||||
<string name="collections_editor_pin_above">Épingler au-dessus des catalogues</string>
|
<string name="collections_editor_pin_above">Épingler au-dessus des catalogues</string>
|
||||||
<string name="collections_editor_placeholder_backdrop">URL de l\'image de fond (facultatif)</string>
|
<string name="collections_editor_placeholder_backdrop">URL de l'image de fond (facultatif)</string>
|
||||||
<string name="collections_editor_placeholder_folder">Nom du dossier</string>
|
<string name="collections_editor_placeholder_folder">Nom du dossier</string>
|
||||||
<string name="collections_editor_placeholder_gif">URL du GIF animé (se lit uniquement au focus)</string>
|
<string name="collections_editor_placeholder_gif">URL du GIF animé (se lit uniquement au focus)</string>
|
||||||
<string name="collections_editor_placeholder_name">Nom de la collection</string>
|
<string name="collections_editor_placeholder_name">Nom de la collection</string>
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
<string name="collections_editor_section_appearance">Apparence</string>
|
<string name="collections_editor_section_appearance">Apparence</string>
|
||||||
<string name="collections_editor_section_basics">Informations de base</string>
|
<string name="collections_editor_section_basics">Informations de base</string>
|
||||||
<string name="collections_editor_section_catalog_sources">Sources de catalogue</string>
|
<string name="collections_editor_section_catalog_sources">Sources de catalogue</string>
|
||||||
<string name="collections_editor_select_catalogs_description">Choisissez les catalogues d\'extension que ce dossier doit regrouper.</string>
|
<string name="collections_editor_select_catalogs_description">Choisissez les catalogues d'addon que ce dossier doit regrouper.</string>
|
||||||
<string name="collections_editor_select_catalogs">Sélectionner des catalogues</string>
|
<string name="collections_editor_select_catalogs">Sélectionner des catalogues</string>
|
||||||
<string name="collections_editor_select_genre">Sélectionner un genre</string>
|
<string name="collections_editor_select_genre">Sélectionner un genre</string>
|
||||||
<string name="collections_editor_selected_count">%1$d sélectionné(s)</string>
|
<string name="collections_editor_selected_count">%1$d sélectionné(s)</string>
|
||||||
|
|
@ -98,26 +98,26 @@
|
||||||
<string name="collections_editor_shape_square">Carré</string>
|
<string name="collections_editor_shape_square">Carré</string>
|
||||||
<string name="collections_editor_shape_wide">Large</string>
|
<string name="collections_editor_shape_wide">Large</string>
|
||||||
<string name="collections_editor_show_all_tab_desc">Combiner tous les catalogues en un seul onglet</string>
|
<string name="collections_editor_show_all_tab_desc">Combiner tous les catalogues en un seul onglet</string>
|
||||||
<string name="collections_editor_show_all_tab">Afficher l\'onglet « Tout »</string>
|
<string name="collections_editor_show_all_tab">Afficher l'onglet « Tout »</string>
|
||||||
<string name="collections_editor_show_gif_when_configured_desc">Lire le GIF configuré à la place de la couverture statique lorsqu\'il est disponible.</string>
|
<string name="collections_editor_show_gif_when_configured_desc">Lire le GIF configuré à la place de la couverture statique lorsqu'il est disponible.</string>
|
||||||
<string name="collections_editor_show_gif_when_configured">Afficher le GIF si configuré</string>
|
<string name="collections_editor_show_gif_when_configured">Afficher le GIF si configuré</string>
|
||||||
<string name="collections_editor_source_count">%1$d source(s) · %2$s</string>
|
<string name="collections_editor_source_count">%1$d source(s) · %2$s</string>
|
||||||
<string name="collections_editor_tile_shape">Forme de la tuile</string>
|
<string name="collections_editor_tile_shape">Forme de la tuile</string>
|
||||||
<string name="collections_editor_view_mode_rows">Lignes</string>
|
<string name="collections_editor_view_mode_rows">Lignes</string>
|
||||||
<string name="collections_editor_view_mode_tabs">Onglets</string>
|
<string name="collections_editor_view_mode_tabs">Onglets</string>
|
||||||
<string name="collections_editor_view_mode">Mode d\'affichage</string>
|
<string name="collections_editor_view_mode">Mode d'affichage</string>
|
||||||
<string name="collections_editor_tmdb_sources">Sources TMDB</string>
|
<string name="collections_editor_tmdb_sources">Sources TMDB</string>
|
||||||
<string name="collections_editor_tmdb_public_list_mode">Liste publique</string>
|
<string name="collections_editor_tmdb_public_list_mode">Liste publique</string>
|
||||||
<string name="collections_editor_tmdb_production_mode">Production</string>
|
<string name="collections_editor_tmdb_production_mode">Production</string>
|
||||||
<string name="collections_editor_tmdb_network_mode">Chaîne</string>
|
<string name="collections_editor_tmdb_network_mode">Chaîne</string>
|
||||||
<string name="collections_editor_tmdb_collection_mode">Collection</string>
|
<string name="collections_editor_tmdb_collection_mode">Collection</string>
|
||||||
<string name="collections_editor_tmdb_custom_mode">Personnalisé</string>
|
<string name="collections_editor_tmdb_custom_mode">Personnalisé</string>
|
||||||
<string name="collections_editor_tmdb_help_presets">Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l\'avoir ajoutée.</string>
|
<string name="collections_editor_tmdb_help_presets">Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée.</string>
|
||||||
<string name="collections_editor_tmdb_help_list">Collez une URL de liste publique TMDB ou uniquement le numéro de l\'URL.</string>
|
<string name="collections_editor_tmdb_help_list">Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL.</string>
|
||||||
<string name="collections_editor_tmdb_help_production">Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement.</string>
|
<string name="collections_editor_tmdb_help_production">Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement.</string>
|
||||||
<string name="collections_editor_tmdb_help_network">Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides.</string>
|
<string name="collections_editor_tmdb_help_network">Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides.</string>
|
||||||
<string name="collections_editor_tmdb_help_collection">Recherchez le nom d\'une collection de films ou collez l\'ID de collection TMDB.</string>
|
<string name="collections_editor_tmdb_help_collection">Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB.</string>
|
||||||
<string name="collections_editor_tmdb_help_discover">Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n\'avez pas besoin de ce filtre.</string>
|
<string name="collections_editor_tmdb_help_discover">Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre.</string>
|
||||||
<string name="collections_editor_tmdb_public_list">Liste publique TMDB</string>
|
<string name="collections_editor_tmdb_public_list">Liste publique TMDB</string>
|
||||||
<string name="collections_editor_tmdb_network_id">ID de chaîne</string>
|
<string name="collections_editor_tmdb_network_id">ID de chaîne</string>
|
||||||
<string name="collections_editor_tmdb_collection_id">ID de collection</string>
|
<string name="collections_editor_tmdb_collection_id">ID de collection</string>
|
||||||
|
|
@ -129,12 +129,12 @@
|
||||||
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 ou URL de société</string>
|
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 ou URL de société</string>
|
||||||
<string name="collections_editor_tmdb_search_helper">Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420.</string>
|
<string name="collections_editor_tmdb_search_helper">Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420.</string>
|
||||||
<string name="collections_editor_tmdb_collection_helper">Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection.</string>
|
<string name="collections_editor_tmdb_collection_helper">Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection.</string>
|
||||||
<string name="collections_editor_tmdb_network_helper">Exemples d\'ID : Netflix 213, HBO 49, Disney+ 2739.</string>
|
<string name="collections_editor_tmdb_network_helper">Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739.</string>
|
||||||
<string name="collections_editor_tmdb_list_helper">Exemple : https://www.themoviedb.org/list/8504994 ou 8504994.</string>
|
<string name="collections_editor_tmdb_list_helper">Exemple : https://www.themoviedb.org/list/8504994 ou 8504994.</string>
|
||||||
<string name="collections_editor_tmdb_display_title">Titre affiché</string>
|
<string name="collections_editor_tmdb_display_title">Titre affiché</string>
|
||||||
<string name="collections_editor_tmdb_title_helper">Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source.</string>
|
<string name="collections_editor_tmdb_title_helper">Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source.</string>
|
||||||
<string name="collections_editor_tmdb_title_placeholder">Films Marvel, Originaux Netflix, Pixar</string>
|
<string name="collections_editor_tmdb_title_placeholder">Films Marvel, Originaux Netflix, Pixar</string>
|
||||||
<string name="collections_editor_tmdb_discover_title_placeholder">Meilleurs films d\'action, drames coréens, animation 2024</string>
|
<string name="collections_editor_tmdb_discover_title_placeholder">Meilleurs films d'action, drames coréens, animation 2024</string>
|
||||||
<string name="collections_editor_tmdb_search_results">Résultats de recherche</string>
|
<string name="collections_editor_tmdb_search_results">Résultats de recherche</string>
|
||||||
<string name="collections_editor_tmdb_collection">Collection TMDB</string>
|
<string name="collections_editor_tmdb_collection">Collection TMDB</string>
|
||||||
<string name="collections_editor_tmdb_company_fallback">Société TMDB %1$d</string>
|
<string name="collections_editor_tmdb_company_fallback">Société TMDB %1$d</string>
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
<string name="collections_editor_tmdb_both">Les deux</string>
|
<string name="collections_editor_tmdb_both">Les deux</string>
|
||||||
<string name="collections_editor_tmdb_sort">Tri</string>
|
<string name="collections_editor_tmdb_sort">Tri</string>
|
||||||
<string name="collections_editor_tmdb_filters">Filtres</string>
|
<string name="collections_editor_tmdb_filters">Filtres</string>
|
||||||
<string name="collections_editor_tmdb_filters_helper">Laissez les champs vides si vous n\'avez pas besoin de ce filtre.</string>
|
<string name="collections_editor_tmdb_filters_helper">Laissez les champs vides si vous n'avez pas besoin de ce filtre.</string>
|
||||||
<string name="collections_editor_tmdb_quick_genres">Genres rapides</string>
|
<string name="collections_editor_tmdb_quick_genres">Genres rapides</string>
|
||||||
<string name="collections_editor_tmdb_quick_languages">Langues rapides</string>
|
<string name="collections_editor_tmdb_quick_languages">Langues rapides</string>
|
||||||
<string name="collections_editor_tmdb_quick_countries">Pays rapides</string>
|
<string name="collections_editor_tmdb_quick_countries">Pays rapides</string>
|
||||||
|
|
@ -155,7 +155,7 @@
|
||||||
<string name="collections_editor_tmdb_genres">ID de genre</string>
|
<string name="collections_editor_tmdb_genres">ID de genre</string>
|
||||||
<string name="collections_editor_tmdb_genres_helper">Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU.</string>
|
<string name="collections_editor_tmdb_genres_helper">Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU.</string>
|
||||||
<string name="collections_editor_tmdb_date_from">Date de sortie ou de diffusion depuis</string>
|
<string name="collections_editor_tmdb_date_from">Date de sortie ou de diffusion depuis</string>
|
||||||
<string name="collections_editor_tmdb_date_to">Date de sortie ou de diffusion jusqu\'au</string>
|
<string name="collections_editor_tmdb_date_to">Date de sortie ou de diffusion jusqu'au</string>
|
||||||
<string name="collections_editor_tmdb_date_helper">Utilisez le format AAAA-MM-JJ, ex. 2024-01-01.</string>
|
<string name="collections_editor_tmdb_date_helper">Utilisez le format AAAA-MM-JJ, ex. 2024-01-01.</string>
|
||||||
<string name="collections_editor_tmdb_rating_min">Note minimale</string>
|
<string name="collections_editor_tmdb_rating_min">Note minimale</string>
|
||||||
<string name="collections_editor_tmdb_rating_max">Note maximale</string>
|
<string name="collections_editor_tmdb_rating_max">Note maximale</string>
|
||||||
|
|
@ -164,7 +164,7 @@
|
||||||
<string name="collections_editor_tmdb_votes_helper">Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100.</string>
|
<string name="collections_editor_tmdb_votes_helper">Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100.</string>
|
||||||
<string name="collections_editor_tmdb_language">Langue originale</string>
|
<string name="collections_editor_tmdb_language">Langue originale</string>
|
||||||
<string name="collections_editor_tmdb_language_helper">Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi.</string>
|
<string name="collections_editor_tmdb_language_helper">Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi.</string>
|
||||||
<string name="collections_editor_tmdb_country">Pays d\'origine</string>
|
<string name="collections_editor_tmdb_country">Pays d'origine</string>
|
||||||
<string name="collections_editor_tmdb_country_helper">Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN.</string>
|
<string name="collections_editor_tmdb_country_helper">Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN.</string>
|
||||||
<string name="collections_editor_tmdb_keywords">ID de mots-clés</string>
|
<string name="collections_editor_tmdb_keywords">ID de mots-clés</string>
|
||||||
<string name="collections_editor_tmdb_keywords_helper">Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants.</string>
|
<string name="collections_editor_tmdb_keywords_helper">Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants.</string>
|
||||||
|
|
@ -200,7 +200,7 @@
|
||||||
<string name="collections_editor_tmdb_country_india">Inde</string>
|
<string name="collections_editor_tmdb_country_india">Inde</string>
|
||||||
<string name="collections_editor_tmdb_country_uk">Royaume-Uni</string>
|
<string name="collections_editor_tmdb_country_uk">Royaume-Uni</string>
|
||||||
<string name="collections_editor_tmdb_keyword_superhero">Super-héros</string>
|
<string name="collections_editor_tmdb_keyword_superhero">Super-héros</string>
|
||||||
<string name="collections_editor_tmdb_keyword_based_on_novel">Adapté d\'un roman</string>
|
<string name="collections_editor_tmdb_keyword_based_on_novel">Adapté d'un roman</string>
|
||||||
<string name="collections_editor_tmdb_keyword_time_travel">Voyage dans le temps</string>
|
<string name="collections_editor_tmdb_keyword_time_travel">Voyage dans le temps</string>
|
||||||
<string name="collections_editor_tmdb_keyword_space">Espace</string>
|
<string name="collections_editor_tmdb_keyword_space">Espace</string>
|
||||||
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
|
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
|
||||||
|
|
@ -314,13 +314,13 @@
|
||||||
<string name="compose_profile_add_profile">Ajouter un profil</string>
|
<string name="compose_profile_add_profile">Ajouter un profil</string>
|
||||||
<string name="compose_search_clear">Effacer la recherche</string>
|
<string name="compose_search_clear">Effacer la recherche</string>
|
||||||
<string name="compose_search_discover_title">Découvrir</string>
|
<string name="compose_search_discover_title">Découvrir</string>
|
||||||
<string name="compose_search_empty_failed_message">Les extensions installées n\'ont retourné aucun résultat de recherche valide.</string>
|
<string name="compose_search_empty_failed_message">Les addons installés n'ont retourné aucun résultat de recherche valide.</string>
|
||||||
<string name="compose_search_empty_failed_title">La recherche a échoué</string>
|
<string name="compose_search_empty_failed_title">La recherche a échoué</string>
|
||||||
<string name="compose_search_empty_no_active_addons_message">Installez et validez au moins une extension avant de rechercher.</string>
|
<string name="compose_search_empty_no_active_addons_message">Installez et validez au moins un addon avant de rechercher.</string>
|
||||||
<string name="compose_search_empty_no_active_addons_title">Aucune extension active</string>
|
<string name="compose_search_empty_no_active_addons_title">Aucun addon active</string>
|
||||||
<string name="compose_search_empty_no_results_message">Les catalogues installés n\'ont retourné aucun résultat pour cette requête.</string>
|
<string name="compose_search_empty_no_results_message">Les catalogues installés n'ont retourné aucun résultat pour cette requête.</string>
|
||||||
<string name="compose_search_empty_no_results_title">Aucun résultat trouvé</string>
|
<string name="compose_search_empty_no_results_title">Aucun résultat trouvé</string>
|
||||||
<string name="compose_search_empty_no_search_catalogs_message">Vos extensions installées n\'exposent pas de catalogue de recherche.</string>
|
<string name="compose_search_empty_no_search_catalogs_message">Vos addons installés n'exposent pas de catalogue de recherche.</string>
|
||||||
<string name="compose_search_empty_no_search_catalogs_title">Aucun catalogue de recherche</string>
|
<string name="compose_search_empty_no_search_catalogs_title">Aucun catalogue de recherche</string>
|
||||||
<string name="compose_search_placeholder">Rechercher des films, séries…</string>
|
<string name="compose_search_placeholder">Rechercher des films, séries…</string>
|
||||||
<string name="compose_search_recent_searches">Recherches récentes</string>
|
<string name="compose_search_recent_searches">Recherches récentes</string>
|
||||||
|
|
@ -328,11 +328,11 @@
|
||||||
<string name="compose_settings_category_about">À propos</string>
|
<string name="compose_settings_category_about">À propos</string>
|
||||||
<string name="compose_settings_category_general">Général</string>
|
<string name="compose_settings_category_general">Général</string>
|
||||||
<string name="compose_settings_page_account">Compte</string>
|
<string name="compose_settings_page_account">Compte</string>
|
||||||
<string name="compose_settings_page_addons">Extensions</string>
|
<string name="compose_settings_page_addons">Addons</string>
|
||||||
<string name="compose_settings_page_appearance">Apparence</string>
|
<string name="compose_settings_page_appearance">Apparence</string>
|
||||||
<string name="compose_settings_page_content_discovery">Contenu et découverte</string>
|
<string name="compose_settings_page_content_discovery">Contenu et découverte</string>
|
||||||
<string name="compose_settings_page_continue_watching">Continuer à regarder</string>
|
<string name="compose_settings_page_continue_watching">Continuer à regarder</string>
|
||||||
<string name="compose_settings_page_homescreen">Écran d\'accueil</string>
|
<string name="compose_settings_page_homescreen">Écran d'accueil</string>
|
||||||
<string name="compose_settings_page_integrations">Intégrations</string>
|
<string name="compose_settings_page_integrations">Intégrations</string>
|
||||||
<string name="compose_settings_page_mdblist_ratings">Notes MDBList</string>
|
<string name="compose_settings_page_mdblist_ratings">Notes MDBList</string>
|
||||||
<string name="compose_settings_page_meta_screen">Écran méta</string>
|
<string name="compose_settings_page_meta_screen">Écran méta</string>
|
||||||
|
|
@ -347,15 +347,15 @@
|
||||||
<string name="compose_settings_root_about_section">À PROPOS</string>
|
<string name="compose_settings_root_about_section">À PROPOS</string>
|
||||||
<string name="compose_settings_root_account_description">Gérez votre compte, déconnectez-vous ou supprimez-le.</string>
|
<string name="compose_settings_root_account_description">Gérez votre compte, déconnectez-vous ou supprimez-le.</string>
|
||||||
<string name="compose_settings_root_account_section">COMPTE</string>
|
<string name="compose_settings_root_account_section">COMPTE</string>
|
||||||
<string name="compose_settings_root_appearance_description">Ajustez la présentation de l\'accueil et les préférences visuelles.</string>
|
<string name="compose_settings_root_appearance_description">Ajustez la présentation de l'accueil et les préférences visuelles.</string>
|
||||||
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l\'application.</string>
|
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l'application.</string>
|
||||||
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
|
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
|
||||||
<string name="compose_settings_root_content_discovery_description">Gérez les extensions et sources de découverte.</string>
|
<string name="compose_settings_root_content_discovery_description">Gérez les addons et sources de découverte.</string>
|
||||||
<string name="compose_settings_root_downloads_description">Gérez vos films et épisodes téléchargés.</string>
|
<string name="compose_settings_root_downloads_description">Gérez vos films et épisodes téléchargés.</string>
|
||||||
<string name="compose_settings_root_downloads_title">Téléchargements</string>
|
<string name="compose_settings_root_downloads_title">Téléchargements</string>
|
||||||
<string name="compose_settings_root_general_section">GÉNÉRAL</string>
|
<string name="compose_settings_root_general_section">GÉNÉRAL</string>
|
||||||
<string name="compose_settings_root_integrations_description">Connectez les services TMDB et MDBList.</string>
|
<string name="compose_settings_root_integrations_description">Connectez les services TMDB et MDBList.</string>
|
||||||
<string name="compose_settings_root_notifications_description">Gérez les alertes de sortie d\'épisodes et envoyez une notification de test.</string>
|
<string name="compose_settings_root_notifications_description">Gérez les alertes de sortie d'épisodes et envoyez une notification de test.</string>
|
||||||
<string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string>
|
<string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string>
|
||||||
<string name="compose_settings_root_switch_profile_title">Changer de profil</string>
|
<string name="compose_settings_root_switch_profile_title">Changer de profil</string>
|
||||||
<string name="compose_settings_root_trakt_description">Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt.</string>
|
<string name="compose_settings_root_trakt_description">Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt.</string>
|
||||||
|
|
@ -369,8 +369,8 @@
|
||||||
<string name="detail_comments_badge_rating">%1$d/10</string>
|
<string name="detail_comments_badge_rating">%1$d/10</string>
|
||||||
<string name="detail_comments_badge_review">Avis</string>
|
<string name="detail_comments_badge_review">Avis</string>
|
||||||
<string name="detail_comments_badge_spoiler">Spoiler</string>
|
<string name="detail_comments_badge_spoiler">Spoiler</string>
|
||||||
<string name="detail_comments_empty">Aucun avis Trakt pour l\'instant.</string>
|
<string name="detail_comments_empty">Aucun avis Trakt pour l'instant.</string>
|
||||||
<string name="detail_comments_likes">%1$d j\'aime</string>
|
<string name="detail_comments_likes">%1$d j'aime</string>
|
||||||
<string name="detail_comments_spoiler_card">Ce commentaire contient des spoilers.</string>
|
<string name="detail_comments_spoiler_card">Ce commentaire contient des spoilers.</string>
|
||||||
<string name="detail_comments_spoiler_hidden_sheet">Ce commentaire contient des spoilers et a été masqué.</string>
|
<string name="detail_comments_spoiler_hidden_sheet">Ce commentaire contient des spoilers et a été masqué.</string>
|
||||||
<string name="detail_comments_title">Commentaires</string>
|
<string name="detail_comments_title">Commentaires</string>
|
||||||
|
|
@ -407,31 +407,31 @@
|
||||||
<string name="settings_account_email">Adresse e-mail</string>
|
<string name="settings_account_email">Adresse e-mail</string>
|
||||||
<string name="settings_account_not_signed_in">Non connecté</string>
|
<string name="settings_account_not_signed_in">Non connecté</string>
|
||||||
<string name="settings_account_sign_out">Se déconnecter</string>
|
<string name="settings_account_sign_out">Se déconnecter</string>
|
||||||
<string name="settings_account_sign_out_confirm_message">Vous serez redirigé vers l\'écran de connexion.</string>
|
<string name="settings_account_sign_out_confirm_message">Vous serez redirigé vers l'écran de connexion.</string>
|
||||||
<string name="settings_account_sign_out_confirm_title">Se déconnecter ?</string>
|
<string name="settings_account_sign_out_confirm_title">Se déconnecter ?</string>
|
||||||
<string name="settings_account_status">Statut</string>
|
<string name="settings_account_status">Statut</string>
|
||||||
<string name="settings_account_status_anonymous">Anonyme</string>
|
<string name="settings_account_status_anonymous">Anonyme</string>
|
||||||
<string name="settings_account_status_signed_in">Connecté</string>
|
<string name="settings_account_status_signed_in">Connecté</string>
|
||||||
<string name="settings_appearance_amoled_black">Noir AMOLED</string>
|
<string name="settings_appearance_amoled_black">Noir AMOLED</string>
|
||||||
<string name="settings_appearance_amoled_description">Utilise des fonds noirs purs pour les écrans OLED.</string>
|
<string name="settings_appearance_amoled_description">Utilise des fonds noirs purs pour les écrans OLED.</string>
|
||||||
<string name="settings_appearance_app_language">Langue de l\'application</string>
|
<string name="settings_appearance_app_language">Langue de l'application</string>
|
||||||
<string name="settings_appearance_app_language_sheet_title">Choisir la langue</string>
|
<string name="settings_appearance_app_language_sheet_title">Choisir la langue</string>
|
||||||
<string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string>
|
<string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string>
|
||||||
<string name="settings_appearance_poster_customization_description">Ajustez la largeur partagée des cartes d\'affiches et les rayons des coins.</string>
|
<string name="settings_appearance_poster_customization_description">Ajustez la largeur partagée des cartes d'affiches et les rayons des coins.</string>
|
||||||
<string name="settings_appearance_section_display">AFFICHAGE</string>
|
<string name="settings_appearance_section_display">AFFICHAGE</string>
|
||||||
<string name="settings_appearance_section_home">ACCUEIL</string>
|
<string name="settings_appearance_section_home">ACCUEIL</string>
|
||||||
<string name="settings_appearance_section_theme">THÈME</string>
|
<string name="settings_appearance_section_theme">THÈME</string>
|
||||||
<string name="settings_homescreen_collection_with_addon">Collection • %1$s</string>
|
<string name="settings_homescreen_collection_with_addon">Collection • %1$s</string>
|
||||||
<string name="settings_homescreen_display_name">Nom affiché</string>
|
<string name="settings_homescreen_display_name">Nom affiché</string>
|
||||||
<string name="settings_homescreen_empty_message">Installez une extension avec des catalogues compatibles avec les tableaux pour configurer les lignes de l\'écran d\'accueil.</string>
|
<string name="settings_homescreen_empty_message">Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil.</string>
|
||||||
<string name="settings_homescreen_empty_title">Aucun catalogue d\'accueil</string>
|
<string name="settings_homescreen_empty_title">Aucun catalogue d'accueil</string>
|
||||||
<string name="settings_homescreen_hero_source">Source Hero</string>
|
<string name="settings_homescreen_hero_source">Source Hero</string>
|
||||||
<string name="settings_homescreen_hidden">Masqué</string>
|
<string name="settings_homescreen_hidden">Masqué</string>
|
||||||
<string name="settings_homescreen_keep_home_focused">Garder l\'accueil en focus</string>
|
<string name="settings_homescreen_keep_home_focused">Garder l'accueil en focus</string>
|
||||||
<string name="settings_homescreen_limit_reached">%1$s • Limite atteinte (max. %2$d)</string>
|
<string name="settings_homescreen_limit_reached">%1$s • Limite atteinte (max. %2$d)</string>
|
||||||
<string name="settings_homescreen_no_sources_selected">Aucune source Hero sélectionnée</string>
|
<string name="settings_homescreen_no_sources_selected">Aucune source Hero sélectionnée</string>
|
||||||
<string name="settings_homescreen_not_in_hero">Absent du Hero</string>
|
<string name="settings_homescreen_not_in_hero">Absent du Hero</string>
|
||||||
<string name="settings_homescreen_pin_to_move_toast">Retirez l\'épingle de la collection pour la déplacer</string>
|
<string name="settings_homescreen_pin_to_move_toast">Retirez l'épingle de la collection pour la déplacer</string>
|
||||||
<string name="settings_homescreen_pinned">Épinglé</string>
|
<string name="settings_homescreen_pinned">Épinglé</string>
|
||||||
<string name="settings_homescreen_pinned_to_top">Épinglé en haut</string>
|
<string name="settings_homescreen_pinned_to_top">Épinglé en haut</string>
|
||||||
<string name="settings_homescreen_reorder">Réorganiser</string>
|
<string name="settings_homescreen_reorder">Réorganiser</string>
|
||||||
|
|
@ -442,7 +442,7 @@
|
||||||
<string name="settings_homescreen_section_hero_sources">SOURCES HERO</string>
|
<string name="settings_homescreen_section_hero_sources">SOURCES HERO</string>
|
||||||
<string name="settings_homescreen_selected_count">%1$d sur %2$d sélectionnés</string>
|
<string name="settings_homescreen_selected_count">%1$d sur %2$d sélectionnés</string>
|
||||||
<string name="settings_homescreen_show_hero">Afficher le Hero</string>
|
<string name="settings_homescreen_show_hero">Afficher le Hero</string>
|
||||||
<string name="settings_homescreen_show_hero_description">Afficher un carrousel Hero en vedette en haut de l\'accueil. Choisissez jusqu\'à 2 catalogues sources ci-dessous.</string>
|
<string name="settings_homescreen_show_hero_description">Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous.</string>
|
||||||
<string name="settings_homescreen_summary">%1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées</string>
|
<string name="settings_homescreen_summary">%1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées</string>
|
||||||
<string name="settings_homescreen_summary_hint">Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser.</string>
|
<string name="settings_homescreen_summary_hint">Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser.</string>
|
||||||
<string name="settings_homescreen_visible">Visible</string>
|
<string name="settings_homescreen_visible">Visible</string>
|
||||||
|
|
@ -451,7 +451,7 @@
|
||||||
<string name="settings_poster_card_style">STYLE DE CARTE D\'AFFICHE</string>
|
<string name="settings_poster_card_style">STYLE DE CARTE D\'AFFICHE</string>
|
||||||
<string name="settings_poster_card_width">Largeur de carte</string>
|
<string name="settings_poster_card_width">Largeur de carte</string>
|
||||||
<string name="settings_poster_custom">Personnalisé</string>
|
<string name="settings_poster_custom">Personnalisé</string>
|
||||||
<string name="settings_poster_description">Personnalisez la largeur de carte et le rayon des coins pour les cartes d\'affiches partagées dans toute l\'application.</string>
|
<string name="settings_poster_description">Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application.</string>
|
||||||
<string name="settings_poster_hide_labels">Masquer les étiquettes</string>
|
<string name="settings_poster_hide_labels">Masquer les étiquettes</string>
|
||||||
<string name="settings_poster_landscape_mode">Mode paysage pour les affiches dans les rayons</string>
|
<string name="settings_poster_landscape_mode">Mode paysage pour les affiches dans les rayons</string>
|
||||||
<string name="settings_poster_live_preview">Aperçu en direct</string>
|
<string name="settings_poster_live_preview">Aperçu en direct</string>
|
||||||
|
|
@ -470,36 +470,36 @@
|
||||||
<string name="settings_poster_width_dense">Dense</string>
|
<string name="settings_poster_width_dense">Dense</string>
|
||||||
<string name="settings_poster_width_large">Grand</string>
|
<string name="settings_poster_width_large">Grand</string>
|
||||||
<string name="settings_poster_width_standard">Standard</string>
|
<string name="settings_poster_width_standard">Standard</string>
|
||||||
<string name="settings_continue_watching_resume_prompt_description">Afficher une invite pour reprendre là où vous en étiez à l\'ouverture de l\'application après avoir quitté le lecteur.</string>
|
<string name="settings_continue_watching_resume_prompt_description">Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur.</string>
|
||||||
<string name="settings_continue_watching_resume_prompt_title">Invite de reprise au démarrage</string>
|
<string name="settings_continue_watching_resume_prompt_title">Invite de reprise au démarrage</string>
|
||||||
<string name="settings_continue_watching_section_card_style">STYLE DE CARTE</string>
|
<string name="settings_continue_watching_section_card_style">STYLE DE CARTE</string>
|
||||||
<string name="settings_continue_watching_section_on_launch">AU DÉMARRAGE</string>
|
<string name="settings_continue_watching_section_on_launch">AU DÉMARRAGE</string>
|
||||||
<string name="settings_continue_watching_section_up_next_behavior">COMPORTEMENT DE LA SUITE</string>
|
<string name="settings_continue_watching_section_up_next_behavior">COMPORTEMENT DE LA SUITE</string>
|
||||||
<string name="settings_continue_watching_section_visibility">VISIBILITÉ</string>
|
<string name="settings_continue_watching_section_visibility">VISIBILITÉ</string>
|
||||||
<string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur l\'écran d\'accueil.</string>
|
<string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur l'écran d'accueil.</string>
|
||||||
<string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string>
|
<string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string>
|
||||||
<string name="settings_continue_watching_style_poster">Affiche</string>
|
<string name="settings_continue_watching_style_poster">Affiche</string>
|
||||||
<string name="settings_continue_watching_style_poster_description">Carte d\'affiche centrée sur la couverture</string>
|
<string name="settings_continue_watching_style_poster_description">Carte d'affiche centrée sur la couverture</string>
|
||||||
<string name="settings_continue_watching_style_wide">Large</string>
|
<string name="settings_continue_watching_style_wide">Large</string>
|
||||||
<string name="settings_continue_watching_style_wide_description">Carte horizontale riche en informations</string>
|
<string name="settings_continue_watching_style_wide_description">Carte horizontale riche en informations</string>
|
||||||
<string name="settings_continue_watching_up_next_description">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.</string>
|
<string name="settings_continue_watching_up_next_description">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.</string>
|
||||||
<string name="settings_continue_watching_up_next_title">La suite depuis l\'épisode le plus avancé</string>
|
<string name="settings_continue_watching_up_next_title">La suite depuis l'épisode le plus avancé</string>
|
||||||
<string name="settings_content_discovery_section_home">ACCUEIL</string>
|
<string name="settings_content_discovery_section_home">ACCUEIL</string>
|
||||||
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
||||||
<string name="settings_content_discovery_addons_description">Installez, supprimez, mettez à jour et ordonnez vos sources de contenu.</string>
|
<string name="settings_content_discovery_addons_description">Installez, supprimez, mettez à jour et ordonnez vos sources de contenu.</string>
|
||||||
<string name="settings_content_discovery_plugins_description">Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne.</string>
|
<string name="settings_content_discovery_plugins_description">Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne.</string>
|
||||||
<string name="settings_content_discovery_homescreen_description">Contrôlez quels catalogues apparaissent à l\'accueil et dans quel ordre.</string>
|
<string name="settings_content_discovery_homescreen_description">Contrôlez quels catalogues apparaissent à l'accueil et dans quel ordre.</string>
|
||||||
<string name="settings_content_discovery_meta_screen_description">Désactivez des sections de détails et réorganisez tout sous le Hero.</string>
|
<string name="settings_content_discovery_meta_screen_description">Désactivez des sections de détails et réorganisez tout sous le Hero.</string>
|
||||||
<string name="settings_content_discovery_collections_description">Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l\'accueil.</string>
|
<string name="settings_content_discovery_collections_description">Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l'accueil.</string>
|
||||||
<string name="settings_integrations_section_title">INTÉGRATIONS</string>
|
<string name="settings_integrations_section_title">INTÉGRATIONS</string>
|
||||||
<string name="settings_integrations_tmdb_description">Enrichissez les pages de détails avec de l\'art, des crédits, des métadonnées d\'épisodes et plus depuis TMDB.</string>
|
<string name="settings_integrations_tmdb_description">Enrichissez les pages de détails avec de l'art, des crédits, des métadonnées d'épisodes et plus depuis TMDB.</string>
|
||||||
<string name="settings_integrations_mdblist_description">Ajoutez des notes externes d\'IMDb, Rotten Tomatoes, Metacritic et d\'autres aux pages de détails.</string>
|
<string name="settings_integrations_mdblist_description">Ajoutez des notes externes d'IMDb, Rotten Tomatoes, Metacritic et d'autres aux pages de détails.</string>
|
||||||
<string name="settings_mdb_add_api_key_first">Ajoutez votre clé API MDBList ci-dessous avant d\'activer les notes.</string>
|
<string name="settings_mdb_add_api_key_first">Ajoutez votre clé API MDBList ci-dessous avant d'activer les notes.</string>
|
||||||
<string name="settings_mdb_api_key_description">Obtenez une clé sur https://mdblist.com/preferences et collez-la ici.</string>
|
<string name="settings_mdb_api_key_description">Obtenez une clé sur https://mdblist.com/preferences et collez-la ici.</string>
|
||||||
<string name="settings_mdb_api_key_label">Clé API</string>
|
<string name="settings_mdb_api_key_label">Clé API</string>
|
||||||
<string name="settings_mdb_api_key_title">Clé API MDBList</string>
|
<string name="settings_mdb_api_key_title">Clé API MDBList</string>
|
||||||
<string name="settings_mdb_enable_ratings">Activer les notes MDBList</string>
|
<string name="settings_mdb_enable_ratings">Activer les notes MDBList</string>
|
||||||
<string name="settings_mdb_enable_ratings_description">Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu\'un ID IMDb est disponible.</string>
|
<string name="settings_mdb_enable_ratings_description">Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu'un ID IMDb est disponible.</string>
|
||||||
<string name="settings_mdb_section_api_key">CLÉ API</string>
|
<string name="settings_mdb_section_api_key">CLÉ API</string>
|
||||||
<string name="settings_mdb_section_rating_providers">FOURNISSEURS DE NOTES</string>
|
<string name="settings_mdb_section_rating_providers">FOURNISSEURS DE NOTES</string>
|
||||||
<string name="settings_mdb_section_title">MDBLIST</string>
|
<string name="settings_mdb_section_title">MDBLIST</string>
|
||||||
|
|
@ -508,21 +508,21 @@
|
||||||
<string name="settings_meta_cast">Casting</string>
|
<string name="settings_meta_cast">Casting</string>
|
||||||
<string name="settings_meta_cast_description">Liste principale du casting.</string>
|
<string name="settings_meta_cast_description">Liste principale du casting.</string>
|
||||||
<string name="settings_meta_cinematic_background">Fond cinématographique</string>
|
<string name="settings_meta_cinematic_background">Fond cinématographique</string>
|
||||||
<string name="settings_meta_cinematic_background_description">Fond flou derrière le contenu, similaire à l\'écran de streams.</string>
|
<string name="settings_meta_cinematic_background_description">Fond flou derrière le contenu, similaire à l'écran de streams.</string>
|
||||||
<string name="settings_meta_collection">Collection</string>
|
<string name="settings_meta_collection">Collection</string>
|
||||||
<string name="settings_meta_collection_description">Rayon de collection ou de franchise associée.</string>
|
<string name="settings_meta_collection_description">Rayon de collection ou de franchise associée.</string>
|
||||||
<string name="settings_meta_comments">Commentaires</string>
|
<string name="settings_meta_comments">Commentaires</string>
|
||||||
<string name="settings_meta_comments_description">Section de commentaires Trakt.</string>
|
<string name="settings_meta_comments_description">Section de commentaires Trakt.</string>
|
||||||
<string name="settings_meta_details">Détails</string>
|
<string name="settings_meta_details">Détails</string>
|
||||||
<string name="settings_meta_details_description">Durée, statut, sortie, langue et informations associées.</string>
|
<string name="settings_meta_details_description">Durée, statut, sortie, langue et informations associées.</string>
|
||||||
<string name="settings_meta_episode_cards">Cartes d\'épisodes</string>
|
<string name="settings_meta_episode_cards">Cartes d'épisodes</string>
|
||||||
<string name="settings_meta_episode_cards_description">Choisissez comment les épisodes sont affichés sur l\'écran de métadonnées.</string>
|
<string name="settings_meta_episode_cards_description">Choisissez comment les épisodes sont affichés sur l'écran de métadonnées.</string>
|
||||||
<string name="settings_meta_episode_style_horizontal">Horizontal</string>
|
<string name="settings_meta_episode_style_horizontal">Horizontal</string>
|
||||||
<string name="settings_meta_episode_style_horizontal_description">Cartes en ligne style fond</string>
|
<string name="settings_meta_episode_style_horizontal_description">Cartes en ligne style fond</string>
|
||||||
<string name="settings_meta_episode_style_list">Liste</string>
|
<string name="settings_meta_episode_style_list">Liste</string>
|
||||||
<string name="settings_meta_episode_style_list_description">Cartes empilées centrées sur les détails</string>
|
<string name="settings_meta_episode_style_list_description">Cartes empilées centrées sur les détails</string>
|
||||||
<string name="settings_meta_episodes">Épisodes</string>
|
<string name="settings_meta_episodes">Épisodes</string>
|
||||||
<string name="settings_meta_episodes_description">Saisons et liste d\'épisodes pour les séries.</string>
|
<string name="settings_meta_episodes_description">Saisons et liste d'épisodes pour les séries.</string>
|
||||||
<string name="settings_meta_group_label">Groupe %1$d</string>
|
<string name="settings_meta_group_label">Groupe %1$d</string>
|
||||||
<string name="settings_meta_more_like_this">Plus comme ceci</string>
|
<string name="settings_meta_more_like_this">Plus comme ceci</string>
|
||||||
<string name="settings_meta_more_like_this_description">Rayon de recommandations.</string>
|
<string name="settings_meta_more_like_this_description">Rayon de recommandations.</string>
|
||||||
|
|
@ -533,14 +533,14 @@
|
||||||
<string name="settings_meta_production_description">Studios et chaînes.</string>
|
<string name="settings_meta_production_description">Studios et chaînes.</string>
|
||||||
<string name="settings_meta_section_appearance">APPARENCE</string>
|
<string name="settings_meta_section_appearance">APPARENCE</string>
|
||||||
<string name="settings_meta_section_sections">SECTIONS</string>
|
<string name="settings_meta_section_sections">SECTIONS</string>
|
||||||
<string name="settings_meta_tab_group_format">Groupe d\'onglets %1$d</string>
|
<string name="settings_meta_tab_group_format">Groupe d'onglets %1$d</string>
|
||||||
<string name="settings_meta_tab_layout">Disposition des onglets</string>
|
<string name="settings_meta_tab_layout">Disposition des onglets</string>
|
||||||
<string name="settings_meta_tab_layout_description">Regroupez les sections en onglets comme dans l\'application TV. Assignez jusqu\'à 3 sections par groupe d\'onglets.</string>
|
<string name="settings_meta_tab_layout_description">Regroupez les sections en onglets comme dans l'application TV. Assignez jusqu'à 3 sections par groupe d'onglets.</string>
|
||||||
<string name="settings_meta_trailers">Bandes-annonces</string>
|
<string name="settings_meta_trailers">Bandes-annonces</string>
|
||||||
<string name="settings_meta_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string>
|
<string name="settings_meta_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string>
|
||||||
<string name="settings_notifications_disabled_in_app">Les notifications sont actuellement désactivées dans Nuvio.</string>
|
<string name="settings_notifications_disabled_in_app">Les notifications sont actuellement désactivées dans Nuvio.</string>
|
||||||
<string name="settings_notifications_episode_release_alerts">Alertes de sortie d\'épisodes</string>
|
<string name="settings_notifications_episode_release_alerts">Alertes de sortie d'épisodes</string>
|
||||||
<string name="settings_notifications_episode_release_alerts_description">Programmez des notifications locales lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible.</string>
|
<string name="settings_notifications_episode_release_alerts_description">Programmez des notifications locales lorsqu'un nouvel épisode d'une série sauvegardée est disponible.</string>
|
||||||
<string name="settings_notifications_permission_disabled">Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test.</string>
|
<string name="settings_notifications_permission_disabled">Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test.</string>
|
||||||
<string name="settings_notifications_scheduled_count">Il y a actuellement %1$d alertes de sortie programmées sur cet appareil.</string>
|
<string name="settings_notifications_scheduled_count">Il y a actuellement %1$d alertes de sortie programmées sur cet appareil.</string>
|
||||||
<string name="settings_notifications_section_alerts">ALERTES</string>
|
<string name="settings_notifications_section_alerts">ALERTES</string>
|
||||||
|
|
@ -548,11 +548,11 @@
|
||||||
<string name="settings_notifications_send_test">Envoyer une notification de test</string>
|
<string name="settings_notifications_send_test">Envoyer une notification de test</string>
|
||||||
<string name="settings_notifications_sending_test">Envoi de la notification de test…</string>
|
<string name="settings_notifications_sending_test">Envoi de la notification de test…</string>
|
||||||
<string name="settings_notifications_test_for_title">Envoyer une notification locale de test pour %1$s.</string>
|
<string name="settings_notifications_test_for_title">Envoyer une notification locale de test pour %1$s.</string>
|
||||||
<string name="settings_notifications_test_requires_saved_show">Sauvegardez d\'abord une série dans votre bibliothèque pour tester les notifications.</string>
|
<string name="settings_notifications_test_requires_saved_show">Sauvegardez d'abord une série dans votre bibliothèque pour tester les notifications.</string>
|
||||||
<string name="settings_notifications_test_title">Notification de test</string>
|
<string name="settings_notifications_test_title">Notification de test</string>
|
||||||
<string name="community_section_title">Communauté</string>
|
<string name="community_section_title">Communauté</string>
|
||||||
<string name="community_section_description">Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web.</string>
|
<string name="community_section_description">Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web.</string>
|
||||||
<string name="community_supporters_not_configured">L\'API des supporters n\'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties.</string>
|
<string name="community_supporters_not_configured">L\'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties.</string>
|
||||||
<string name="community_tab_contributors">Contributeurs</string>
|
<string name="community_tab_contributors">Contributeurs</string>
|
||||||
<string name="community_tab_supporters">Supporters</string>
|
<string name="community_tab_supporters">Supporters</string>
|
||||||
<string name="community_open_github">Ouvrir GitHub</string>
|
<string name="community_open_github">Ouvrir GitHub</string>
|
||||||
|
|
@ -582,19 +582,19 @@
|
||||||
<string name="community_month_nov">Nov</string>
|
<string name="community_month_nov">Nov</string>
|
||||||
<string name="community_month_dec">Déc</string>
|
<string name="community_month_dec">Déc</string>
|
||||||
<string name="community_date_format">%1$s %2$s %3$s</string>
|
<string name="community_date_format">%1$s %2$s %3$s</string>
|
||||||
<string name="settings_playback_all_addons">Toutes les extensions</string>
|
<string name="settings_playback_all_addons">Toutes les addons</string>
|
||||||
<string name="settings_playback_all_plugins">Tous les plugins</string>
|
<string name="settings_playback_all_plugins">Tous les plugins</string>
|
||||||
<string name="settings_playback_allowed_addons">Extensions autorisées</string>
|
<string name="settings_playback_allowed_addons">Addons autorisés</string>
|
||||||
<string name="settings_playback_allowed_plugins">Plugins autorisés</string>
|
<string name="settings_playback_allowed_plugins">Plugins autorisés</string>
|
||||||
<string name="settings_playback_anime_skip">Anime Skip</string>
|
<string name="settings_playback_anime_skip">Anime Skip</string>
|
||||||
<string name="settings_playback_anime_skip_client_id">ID client AnimeSkip</string>
|
<string name="settings_playback_anime_skip_client_id">ID client AnimeSkip</string>
|
||||||
<string name="settings_playback_anime_skip_client_id_description">Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com.</string>
|
<string name="settings_playback_anime_skip_client_id_description">Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com.</string>
|
||||||
<string name="settings_playback_anime_skip_description">Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client).</string>
|
<string name="settings_playback_anime_skip_description">Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client).</string>
|
||||||
<string name="settings_playback_auto_play_next_episode">Lecture automatique de l\'épisode suivant</string>
|
<string name="settings_playback_auto_play_next_episode">Lecture automatique de l'épisode suivant</string>
|
||||||
<string name="settings_playback_auto_play_next_episode_description">Rechercher et lire automatiquement l\'épisode suivant lorsque le seuil est atteint.</string>
|
<string name="settings_playback_auto_play_next_episode_description">Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint.</string>
|
||||||
<string name="settings_playback_decoder_device_only">Appareil uniquement</string>
|
<string name="settings_playback_decoder_device_only">Appareil uniquement</string>
|
||||||
<string name="settings_playback_decoder_prefer_app">Préférer l\'application (FFmpeg)</string>
|
<string name="settings_playback_decoder_prefer_app">Préférer l'application (FFmpeg)</string>
|
||||||
<string name="settings_playback_decoder_prefer_device">Préférer l\'appareil</string>
|
<string name="settings_playback_decoder_prefer_device">Préférer l'appareil</string>
|
||||||
<string name="settings_playback_decoder_priority">Priorité du décodeur</string>
|
<string name="settings_playback_decoder_priority">Priorité du décodeur</string>
|
||||||
<string name="settings_playback_dialog_close">Appuyez en dehors pour fermer</string>
|
<string name="settings_playback_dialog_close">Appuyez en dehors pour fermer</string>
|
||||||
<string name="settings_playback_dialog_save_close">Appuyez en dehors pour enregistrer et fermer</string>
|
<string name="settings_playback_dialog_save_close">Appuyez en dehors pour enregistrer et fermer</string>
|
||||||
|
|
@ -606,18 +606,18 @@
|
||||||
<string name="settings_playback_enable_libass_description">Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut.</string>
|
<string name="settings_playback_enable_libass_description">Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut.</string>
|
||||||
<string name="settings_playback_hold_speed">Vitesse au maintien</string>
|
<string name="settings_playback_hold_speed">Vitesse au maintien</string>
|
||||||
<string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string>
|
<string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string>
|
||||||
<string name="settings_playback_hold_to_speed_description">Maintenez appuyé n\'importe où sur la surface du lecteur pour augmenter temporairement la vitesse.</string>
|
<string name="settings_playback_hold_to_speed_description">Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse.</string>
|
||||||
<string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string>
|
<string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string>
|
||||||
<string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string>
|
<string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string>
|
||||||
<string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string>
|
<string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string>
|
||||||
<string name="settings_playback_map_dv7_to_hevc_description">Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles.</string>
|
<string name="settings_playback_map_dv7_to_hevc_description">Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles.</string>
|
||||||
<string name="settings_playback_minutes_before_end">Minutes avant la fin</string>
|
<string name="settings_playback_minutes_before_end">Minutes avant la fin</string>
|
||||||
<string name="settings_playback_minutes_before_end_description">Afficher la carte de l\'épisode suivant ce nombre de minutes avant la fin.</string>
|
<string name="settings_playback_minutes_before_end_description">Afficher la carte de l'épisode suivant ce nombre de minutes avant la fin.</string>
|
||||||
<string name="settings_playback_minutes_value">%1$d min</string>
|
<string name="settings_playback_minutes_value">%1$d min</string>
|
||||||
<string name="settings_playback_no_items_available">Aucun élément disponible</string>
|
<string name="settings_playback_no_items_available">Aucun élément disponible</string>
|
||||||
<string name="settings_playback_not_set">Non défini</string>
|
<string name="settings_playback_not_set">Non défini</string>
|
||||||
<string name="settings_playback_option_default">Par défaut</string>
|
<string name="settings_playback_option_default">Par défaut</string>
|
||||||
<string name="settings_playback_option_device_language">Langue de l\'appareil</string>
|
<string name="settings_playback_option_device_language">Langue de l'appareil</string>
|
||||||
<string name="settings_playback_option_forced">Forcé</string>
|
<string name="settings_playback_option_forced">Forcé</string>
|
||||||
<string name="settings_playback_option_none">Aucun</string>
|
<string name="settings_playback_option_none">Aucun</string>
|
||||||
<string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string>
|
<string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string>
|
||||||
|
|
@ -625,7 +625,7 @@
|
||||||
<string name="settings_playback_preferred_audio_language">Langue audio préférée</string>
|
<string name="settings_playback_preferred_audio_language">Langue audio préférée</string>
|
||||||
<string name="settings_playback_preferred_subtitle_language">Langue des sous-titres préférée</string>
|
<string name="settings_playback_preferred_subtitle_language">Langue des sous-titres préférée</string>
|
||||||
<string name="settings_playback_presets">Préréglages</string>
|
<string name="settings_playback_presets">Préréglages</string>
|
||||||
<string name="settings_playback_regex_matches_against">Correspond au nom du stream, à l\'étiquette, à la description, à l\'extension et à l\'URL.</string>
|
<string name="settings_playback_regex_matches_against">Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL.</string>
|
||||||
<string name="settings_playback_regex_pattern">Modèle regex</string>
|
<string name="settings_playback_regex_pattern">Modèle regex</string>
|
||||||
<string name="settings_playback_regex_placeholder">4K|2160p|Remux</string>
|
<string name="settings_playback_regex_placeholder">4K|2160p|Remux</string>
|
||||||
<string name="settings_playback_regex_preset_any_1080p">N\'importe quel 1080p+</string>
|
<string name="settings_playback_regex_preset_any_1080p">N\'importe quel 1080p+</string>
|
||||||
|
|
@ -661,18 +661,18 @@
|
||||||
<string name="settings_playback_section_subtitle_rendering">RENDU DES SOUS-TITRES</string>
|
<string name="settings_playback_section_subtitle_rendering">RENDU DES SOUS-TITRES</string>
|
||||||
<string name="settings_playback_selected_count">%1$d sélectionné(s)</string>
|
<string name="settings_playback_selected_count">%1$d sélectionné(s)</string>
|
||||||
<string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string>
|
<string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string>
|
||||||
<string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage d\'un stream.</string>
|
<string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage d'un stream.</string>
|
||||||
<string name="settings_playback_skip_intro_outro_recap">Passer l\'intro/outro/récap</string>
|
<string name="settings_playback_skip_intro_outro_recap">Passer l'intro/outro/récap</string>
|
||||||
<string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments d\'intro, d\'outro et de récapitulatif détectés.</string>
|
<string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés.</string>
|
||||||
<string name="settings_playback_source_scope">Périmètre des sources</string>
|
<string name="settings_playback_source_scope">Périmètre des sources</string>
|
||||||
<string name="settings_playback_source_scope_all_addons">Toutes les extensions</string>
|
<string name="settings_playback_source_scope_all_addons">Toutes les addons</string>
|
||||||
<string name="settings_playback_source_scope_all_addons_description">Considérer les streams de toutes les extensions installées.</string>
|
<string name="settings_playback_source_scope_all_addons_description">Considérer les streams de toutes les addons installés.</string>
|
||||||
<string name="settings_playback_source_scope_all_sources">Toutes les sources</string>
|
<string name="settings_playback_source_scope_all_sources">Toutes les sources</string>
|
||||||
<string name="settings_playback_source_scope_all_sources_description">Considérer les streams des extensions et des plugins.</string>
|
<string name="settings_playback_source_scope_all_sources_description">Considérer les streams des addons et des plugins.</string>
|
||||||
<string name="settings_playback_source_scope_enabled_plugins_only">Plugins activés uniquement</string>
|
<string name="settings_playback_source_scope_enabled_plugins_only">Plugins activés uniquement</string>
|
||||||
<string name="settings_playback_source_scope_enabled_plugins_only_description">Considérer uniquement les streams des plugins activés.</string>
|
<string name="settings_playback_source_scope_enabled_plugins_only_description">Considérer uniquement les streams des plugins activés.</string>
|
||||||
<string name="settings_playback_source_scope_installed_addons_only">Extensions installées uniquement</string>
|
<string name="settings_playback_source_scope_installed_addons_only">Addons installés uniquement</string>
|
||||||
<string name="settings_playback_source_scope_installed_addons_only_description">Considérer uniquement les streams des extensions installées.</string>
|
<string name="settings_playback_source_scope_installed_addons_only_description">Considérer uniquement les streams des addons installés.</string>
|
||||||
<string name="settings_playback_stream_selection_mode">Mode de sélection du stream</string>
|
<string name="settings_playback_stream_selection_mode">Mode de sélection du stream</string>
|
||||||
<string name="settings_playback_stream_selection_mode_first_stream">Premier stream disponible</string>
|
<string name="settings_playback_stream_selection_mode_first_stream">Premier stream disponible</string>
|
||||||
<string name="settings_playback_stream_selection_mode_first_stream_description">Lire automatiquement le premier stream trouvé.</string>
|
<string name="settings_playback_stream_selection_mode_first_stream_description">Lire automatiquement le premier stream trouvé.</string>
|
||||||
|
|
@ -680,28 +680,28 @@
|
||||||
<string name="settings_playback_stream_selection_mode_manual_description">Sélectionner les streams manuellement à chaque fois.</string>
|
<string name="settings_playback_stream_selection_mode_manual_description">Sélectionner les streams manuellement à chaque fois.</string>
|
||||||
<string name="settings_playback_stream_selection_mode_regex">Correspondance regex</string>
|
<string name="settings_playback_stream_selection_mode_regex">Correspondance regex</string>
|
||||||
<string name="settings_playback_stream_selection_mode_regex_description">Sélectionner automatiquement un stream correspondant à un modèle regex.</string>
|
<string name="settings_playback_stream_selection_mode_regex_description">Sélectionner automatiquement un stream correspondant à un modèle regex.</string>
|
||||||
<string name="settings_playback_stream_timeout">Délai d\'expiration du stream</string>
|
<string name="settings_playback_stream_timeout">Délai d'expiration du stream</string>
|
||||||
<string name="settings_playback_stream_timeout_description">Combien de temps attendre les streams avant la sélection automatique.</string>
|
<string name="settings_playback_stream_timeout_description">Combien de temps attendre les streams avant la sélection automatique.</string>
|
||||||
<string name="settings_playback_threshold_minutes">Minutes avant la fin</string>
|
<string name="settings_playback_threshold_minutes">Minutes avant la fin</string>
|
||||||
<string name="settings_playback_threshold_mode">Mode de seuil</string>
|
<string name="settings_playback_threshold_mode">Mode de seuil</string>
|
||||||
<string name="settings_playback_threshold_mode_minutes_before_end">Minutes avant la fin</string>
|
<string name="settings_playback_threshold_mode_minutes_before_end">Minutes avant la fin</string>
|
||||||
<string name="settings_playback_threshold_mode_percentage">Pourcentage</string>
|
<string name="settings_playback_threshold_mode_percentage">Pourcentage</string>
|
||||||
<string name="settings_playback_threshold_percentage">Pourcentage de seuil</string>
|
<string name="settings_playback_threshold_percentage">Pourcentage de seuil</string>
|
||||||
<string name="settings_playback_threshold_percentage_description">Afficher la carte de l\'épisode suivant lorsque la lecture atteint ce pourcentage.</string>
|
<string name="settings_playback_threshold_percentage_description">Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage.</string>
|
||||||
<string name="settings_playback_threshold_percentage_value">%1$d%</string>
|
<string name="settings_playback_threshold_percentage_value">%1$d%</string>
|
||||||
<string name="settings_playback_timeout_instant">Instantané</string>
|
<string name="settings_playback_timeout_instant">Instantané</string>
|
||||||
<string name="settings_playback_timeout_seconds">%1$ds</string>
|
<string name="settings_playback_timeout_seconds">%1$ds</string>
|
||||||
<string name="settings_playback_timeout_unlimited">Illimité</string>
|
<string name="settings_playback_timeout_unlimited">Illimité</string>
|
||||||
<string name="settings_playback_tunneled_playback">Lecture tunnelisée</string>
|
<string name="settings_playback_tunneled_playback">Lecture tunnelisée</string>
|
||||||
<string name="settings_playback_tunneled_playback_description">Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo.</string>
|
<string name="settings_playback_tunneled_playback_description">Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo.</string>
|
||||||
<string name="settings_tmdb_add_api_key_first">Ajoutez votre propre clé API TMDB ci-dessous avant d\'activer l\'enrichissement.</string>
|
<string name="settings_tmdb_add_api_key_first">Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement.</string>
|
||||||
<string name="settings_tmdb_api_key_label">Clé API TMDB</string>
|
<string name="settings_tmdb_api_key_label">Clé API TMDB</string>
|
||||||
<string name="settings_tmdb_enable_enrichment">Activer l\'enrichissement TMDB</string>
|
<string name="settings_tmdb_enable_enrichment">Activer l'enrichissement TMDB</string>
|
||||||
<string name="settings_tmdb_enable_enrichment_description">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.</string>
|
<string name="settings_tmdb_enable_enrichment_description">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.</string>
|
||||||
<string name="settings_tmdb_enter_api_key">Saisissez votre clé API v3 TMDB.</string>
|
<string name="settings_tmdb_enter_api_key">Saisissez votre clé API v3 TMDB.</string>
|
||||||
<string name="settings_tmdb_language_code_label">Code de langue</string>
|
<string name="settings_tmdb_language_code_label">Code de langue</string>
|
||||||
<string name="settings_tmdb_module_artwork">Visuels</string>
|
<string name="settings_tmdb_module_artwork">Visuels</string>
|
||||||
<string name="settings_tmdb_module_artwork_description">Remplacer le fond, l\'affiche et le logo par les visuels TMDB.</string>
|
<string name="settings_tmdb_module_artwork_description">Remplacer le fond, l'affiche et le logo par les visuels TMDB.</string>
|
||||||
<string name="settings_tmdb_module_basic_info">Informations de base</string>
|
<string name="settings_tmdb_module_basic_info">Informations de base</string>
|
||||||
<string name="settings_tmdb_module_basic_info_description">Utiliser le titre, le synopsis, les genres et la note de TMDB.</string>
|
<string name="settings_tmdb_module_basic_info_description">Utiliser le titre, le synopsis, les genres et la note de TMDB.</string>
|
||||||
<string name="settings_tmdb_module_collections">Collections</string>
|
<string name="settings_tmdb_module_collections">Collections</string>
|
||||||
|
|
@ -717,9 +717,9 @@
|
||||||
<string name="settings_tmdb_module_networks">Chaînes</string>
|
<string name="settings_tmdb_module_networks">Chaînes</string>
|
||||||
<string name="settings_tmdb_module_networks_description">Utiliser les métadonnées des chaînes TMDB pour les titres TV.</string>
|
<string name="settings_tmdb_module_networks_description">Utiliser les métadonnées des chaînes TMDB pour les titres TV.</string>
|
||||||
<string name="settings_tmdb_module_production_companies">Sociétés de production</string>
|
<string name="settings_tmdb_module_production_companies">Sociétés de production</string>
|
||||||
<string name="settings_tmdb_module_production_companies_description">Utiliser les métadonnées des sociétés de production TMDB sur l\'écran de détails.</string>
|
<string name="settings_tmdb_module_production_companies_description">Utiliser les métadonnées des sociétés de production TMDB sur l'écran de détails.</string>
|
||||||
<string name="settings_tmdb_module_season_posters">Affiches de saison</string>
|
<string name="settings_tmdb_module_season_posters">Affiches de saison</string>
|
||||||
<string name="settings_tmdb_module_season_posters_description">Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l\'écran de métadonnées pour les séries.</string>
|
<string name="settings_tmdb_module_season_posters_description">Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l'écran de métadonnées pour les séries.</string>
|
||||||
<string name="settings_tmdb_module_trailers">Bandes-annonces</string>
|
<string name="settings_tmdb_module_trailers">Bandes-annonces</string>
|
||||||
<string name="settings_tmdb_module_trailers_description">Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails.</string>
|
<string name="settings_tmdb_module_trailers_description">Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails.</string>
|
||||||
<string name="settings_tmdb_personal_api_key">Clé API personnelle</string>
|
<string name="settings_tmdb_personal_api_key">Clé API personnelle</string>
|
||||||
|
|
@ -737,13 +737,13 @@
|
||||||
<string name="settings_trakt_connected_as">Connecté en tant que %1$s</string>
|
<string name="settings_trakt_connected_as">Connecté en tant que %1$s</string>
|
||||||
<string name="settings_trakt_default_user">Utilisateur Trakt</string>
|
<string name="settings_trakt_default_user">Utilisateur Trakt</string>
|
||||||
<string name="settings_trakt_disconnect">Déconnecter</string>
|
<string name="settings_trakt_disconnect">Déconnecter</string>
|
||||||
<string name="settings_trakt_failed_open_browser">Impossible d\'ouvrir le navigateur</string>
|
<string name="settings_trakt_failed_open_browser">Impossible d'ouvrir le navigateur</string>
|
||||||
<string name="settings_trakt_features">FONCTIONNALITÉS</string>
|
<string name="settings_trakt_features">FONCTIONNALITÉS</string>
|
||||||
<string name="settings_trakt_finish_sign_in">Terminez la connexion Trakt dans votre navigateur</string>
|
<string name="settings_trakt_finish_sign_in">Terminez la connexion Trakt dans votre navigateur</string>
|
||||||
<string name="settings_trakt_intro_description">Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt.</string>
|
<string name="settings_trakt_intro_description">Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt.</string>
|
||||||
<string name="settings_trakt_missing_credentials">Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).</string>
|
<string name="settings_trakt_missing_credentials">Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).</string>
|
||||||
<string name="settings_trakt_open_login">Ouvrir la connexion Trakt</string>
|
<string name="settings_trakt_open_login">Ouvrir la connexion Trakt</string>
|
||||||
<string name="settings_trakt_save_actions_description">Vos actions d\'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles.</string>
|
<string name="settings_trakt_save_actions_description">Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles.</string>
|
||||||
<string name="settings_trakt_sign_in_description">Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt.</string>
|
<string name="settings_trakt_sign_in_description">Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt.</string>
|
||||||
<string name="source_audience_score">Score du public</string>
|
<string name="source_audience_score">Score du public</string>
|
||||||
<string name="source_imdb">IMDb</string>
|
<string name="source_imdb">IMDb</string>
|
||||||
|
|
@ -763,11 +763,11 @@
|
||||||
<string name="player_next_episode">Épisode suivant</string>
|
<string name="player_next_episode">Épisode suivant</string>
|
||||||
<string name="player_next_episode_finding_source">Recherche de la source…</string>
|
<string name="player_next_episode_finding_source">Recherche de la source…</string>
|
||||||
<string name="player_next_episode_playing_via_countdown">Lecture via %1$s dans %2$d…</string>
|
<string name="player_next_episode_playing_via_countdown">Lecture via %1$s dans %2$d…</string>
|
||||||
<string name="player_next_episode_thumbnail">Miniature de l\'épisode suivant</string>
|
<string name="player_next_episode_thumbnail">Miniature de l'épisode suivant</string>
|
||||||
<string name="player_next_episode_unaired">Non diffusé</string>
|
<string name="player_next_episode_unaired">Non diffusé</string>
|
||||||
<string name="player_skip">Passer</string>
|
<string name="player_skip">Passer</string>
|
||||||
<string name="player_skip_intro">Passer l\'intro</string>
|
<string name="player_skip_intro">Passer l'intro</string>
|
||||||
<string name="player_skip_outro">Passer l\'outro</string>
|
<string name="player_skip_outro">Passer l'outro</string>
|
||||||
<string name="player_skip_recap">Passer le récap</string>
|
<string name="player_skip_recap">Passer le récap</string>
|
||||||
<string name="compose_player_no_subtitles_found">Aucun sous-titre trouvé</string>
|
<string name="compose_player_no_subtitles_found">Aucun sous-titre trouvé</string>
|
||||||
<string name="lang_afrikaans">Afrikaans</string>
|
<string name="lang_afrikaans">Afrikaans</string>
|
||||||
|
|
@ -856,32 +856,32 @@
|
||||||
<string name="action_no">Non</string>
|
<string name="action_no">Non</string>
|
||||||
<string name="action_update">Mettre à jour</string>
|
<string name="action_update">Mettre à jour</string>
|
||||||
<string name="action_yes">Oui</string>
|
<string name="action_yes">Oui</string>
|
||||||
<string name="app_exit_message">Voulez-vous quitter l\'application ?</string>
|
<string name="app_exit_message">Voulez-vous quitter l'application ?</string>
|
||||||
<string name="app_exit_title">Quitter l\'application</string>
|
<string name="app_exit_title">Quitter l'application</string>
|
||||||
<string name="catalog_empty_message">Ce catalogue n\'a retourné aucun élément.</string>
|
<string name="catalog_empty_message">Ce catalogue n'a retourné aucun élément.</string>
|
||||||
<string name="catalog_empty_title">Aucun titre trouvé</string>
|
<string name="catalog_empty_title">Aucun titre trouvé</string>
|
||||||
<string name="details_check_connection">Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez.</string>
|
<string name="details_check_connection">Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez.</string>
|
||||||
<string name="details_director">Réalisateur</string>
|
<string name="details_director">Réalisateur</string>
|
||||||
<string name="details_failed_to_load">Échec du chargement</string>
|
<string name="details_failed_to_load">Échec du chargement</string>
|
||||||
<string name="details_more_like_this">Plus comme ceci</string>
|
<string name="details_more_like_this">Plus comme ceci</string>
|
||||||
<string name="details_seasons">Saisons</string>
|
<string name="details_seasons">Saisons</string>
|
||||||
<string name="details_series_missing_numbers">Cette extension a retourné des vidéos pour la série, mais aucune n\'incluait de numéros de saison ou d\'épisode.</string>
|
<string name="details_series_missing_numbers">Cet addon a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode.</string>
|
||||||
<string name="details_series_no_metadata">Cette extension n\'a fourni aucune métadonnée d\'épisode pour cette série.</string>
|
<string name="details_series_no_metadata">Cet addon n'a fourni aucune métadonnée d'épisode pour cette série.</string>
|
||||||
<string name="details_series_unpublished">Cette extension n\'a pas encore publié d\'épisodes.</string>
|
<string name="details_series_unpublished">Cet addon n'a pas encore publié d'épisodes.</string>
|
||||||
<string name="details_servers_unreachable">Votre appareil est en ligne, mais Nuvio n\'a pas pu se connecter aux serveurs nécessaires.</string>
|
<string name="details_servers_unreachable">Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires.</string>
|
||||||
<string name="details_show_less">Afficher moins</string>
|
<string name="details_show_less">Afficher moins</string>
|
||||||
<string name="details_show_more">Afficher plus ▾</string>
|
<string name="details_show_more">Afficher plus ▾</string>
|
||||||
<string name="details_writer">Scénariste</string>
|
<string name="details_writer">Scénariste</string>
|
||||||
<string name="discover_all_genres">Tous les genres</string>
|
<string name="discover_all_genres">Tous les genres</string>
|
||||||
<string name="discover_catalog">Catalogue</string>
|
<string name="discover_catalog">Catalogue</string>
|
||||||
<string name="discover_catalog_context">%1$s • %2$s</string>
|
<string name="discover_catalog_context">%1$s • %2$s</string>
|
||||||
<string name="discover_empty_load_failed_message">Le catalogue sélectionné n\'a retourné aucun élément de découverte.</string>
|
<string name="discover_empty_load_failed_message">Le catalogue sélectionné n'a retourné aucun élément de découverte.</string>
|
||||||
<string name="discover_empty_load_failed_title">Impossible de charger Découvrir</string>
|
<string name="discover_empty_load_failed_title">Impossible de charger Découvrir</string>
|
||||||
<string name="discover_empty_no_catalogs_message">Les extensions installées n\'exposent pas de catalogues compatibles avec le tableau pour Découvrir.</string>
|
<string name="discover_empty_no_catalogs_message">Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir.</string>
|
||||||
<string name="discover_empty_no_catalogs_title">Aucun catalogue de découverte</string>
|
<string name="discover_empty_no_catalogs_title">Aucun catalogue de découverte</string>
|
||||||
<string name="discover_empty_no_results_message">Le catalogue et les filtres sélectionnés n\'ont retourné aucun élément.</string>
|
<string name="discover_empty_no_results_message">Le catalogue et les filtres sélectionnés n'ont retourné aucun élément.</string>
|
||||||
<string name="discover_empty_no_results_title">Aucun titre trouvé</string>
|
<string name="discover_empty_no_results_title">Aucun titre trouvé</string>
|
||||||
<string name="discover_empty_no_active_addons_message">Installez et validez au moins une extension avant d\'explorer les catalogues dans Découvrir.</string>
|
<string name="discover_empty_no_active_addons_message">Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir.</string>
|
||||||
<string name="discover_select_catalog">Sélectionner un catalogue</string>
|
<string name="discover_select_catalog">Sélectionner un catalogue</string>
|
||||||
<string name="discover_select_genre">Sélectionner un genre</string>
|
<string name="discover_select_genre">Sélectionner un genre</string>
|
||||||
<string name="discover_select_type">Sélectionner un type</string>
|
<string name="discover_select_type">Sélectionner un type</string>
|
||||||
|
|
@ -894,9 +894,9 @@
|
||||||
<string name="episode_mark_watched">Marquer comme vu</string>
|
<string name="episode_mark_watched">Marquer comme vu</string>
|
||||||
<string name="home_continue_watching_up_next">Suivant</string>
|
<string name="home_continue_watching_up_next">Suivant</string>
|
||||||
<string name="home_continue_watching_watched">%1$s vu</string>
|
<string name="home_continue_watching_watched">%1$s vu</string>
|
||||||
<string name="home_empty_no_active_addons_message">Installez et validez au moins une extension avant de charger des lignes de catalogue à l\'accueil.</string>
|
<string name="home_empty_no_active_addons_message">Installez et validez au moins un addon avant de charger des lignes de catalogue à l'accueil.</string>
|
||||||
<string name="home_empty_no_rows_message">Les extensions installées n\'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string>
|
<string name="home_empty_no_rows_message">Les addons installés n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string>
|
||||||
<string name="home_empty_no_rows_title">Aucune ligne d\'accueil disponible</string>
|
<string name="home_empty_no_rows_title">Aucune ligne d'accueil disponible</string>
|
||||||
<string name="home_view_details">Voir les détails</string>
|
<string name="home_view_details">Voir les détails</string>
|
||||||
<string name="meta_section_actions_description">Contrôles pour lire et enregistrer.</string>
|
<string name="meta_section_actions_description">Contrôles pour lire et enregistrer.</string>
|
||||||
<string name="meta_section_actions_title">Actions</string>
|
<string name="meta_section_actions_title">Actions</string>
|
||||||
|
|
@ -906,7 +906,7 @@
|
||||||
<string name="meta_section_comments_description">Section de commentaires Trakt.</string>
|
<string name="meta_section_comments_description">Section de commentaires Trakt.</string>
|
||||||
<string name="meta_section_details_description">Durée, statut, date de sortie, langue et informations associées.</string>
|
<string name="meta_section_details_description">Durée, statut, date de sortie, langue et informations associées.</string>
|
||||||
<string name="meta_section_details_title">Détails</string>
|
<string name="meta_section_details_title">Détails</string>
|
||||||
<string name="meta_section_episodes_description">Saisons et liste d\'épisodes pour les séries.</string>
|
<string name="meta_section_episodes_description">Saisons et liste d'épisodes pour les séries.</string>
|
||||||
<string name="meta_section_more_like_this_description">Rayon de recommandations.</string>
|
<string name="meta_section_more_like_this_description">Rayon de recommandations.</string>
|
||||||
<string name="meta_section_more_like_this_title">Plus comme ceci</string>
|
<string name="meta_section_more_like_this_title">Plus comme ceci</string>
|
||||||
<string name="meta_section_overview_description">Synopsis, notes, genres et crédits principaux.</string>
|
<string name="meta_section_overview_description">Synopsis, notes, genres et crédits principaux.</string>
|
||||||
|
|
@ -915,7 +915,7 @@
|
||||||
<string name="meta_section_production_title">Production</string>
|
<string name="meta_section_production_title">Production</string>
|
||||||
<string name="meta_section_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string>
|
<string name="meta_section_trailers_description">Rayon de bandes-annonces et raccourcis de lecture.</string>
|
||||||
<string name="network_back_online">De nouveau en ligne</string>
|
<string name="network_back_online">De nouveau en ligne</string>
|
||||||
<string name="network_cannot_reach_servers">Impossible d\'atteindre les serveurs</string>
|
<string name="network_cannot_reach_servers">Impossible d'atteindre les serveurs</string>
|
||||||
<string name="network_no_internet_connection">Pas de connexion Internet</string>
|
<string name="network_no_internet_connection">Pas de connexion Internet</string>
|
||||||
<string name="person_age">(âge %1$d)</string>
|
<string name="person_age">(âge %1$d)</string>
|
||||||
<string name="person_born">Né(e) le %1$s%2$s</string>
|
<string name="person_born">Né(e) le %1$s%2$s</string>
|
||||||
|
|
@ -933,7 +933,7 @@
|
||||||
<string name="pin_forgot">Code PIN oublié ?</string>
|
<string name="pin_forgot">Code PIN oublié ?</string>
|
||||||
<string name="pin_incorrect">Code PIN incorrect</string>
|
<string name="pin_incorrect">Code PIN incorrect</string>
|
||||||
<string name="pin_locked_try_again">Bloqué. Réessayez dans %1$ds</string>
|
<string name="pin_locked_try_again">Bloqué. Réessayez dans %1$ds</string>
|
||||||
<string name="profile_avatar_options_pending">Les options d\'avatar apparaîtront ici une fois le catalogue chargé.</string>
|
<string name="profile_avatar_options_pending">Les options d'avatar apparaîtront ici une fois le catalogue chargé.</string>
|
||||||
<string name="profile_avatar_selected">Avatar : %1$s</string>
|
<string name="profile_avatar_selected">Avatar : %1$s</string>
|
||||||
<string name="profile_choose_avatar">Choisir un avatar</string>
|
<string name="profile_choose_avatar">Choisir un avatar</string>
|
||||||
<string name="profile_choose_avatar_below">Choisissez un avatar ci-dessous.</string>
|
<string name="profile_choose_avatar_below">Choisissez un avatar ci-dessous.</string>
|
||||||
|
|
@ -949,32 +949,32 @@
|
||||||
<string name="profile_manage_profiles">Gérer les profils</string>
|
<string name="profile_manage_profiles">Gérer les profils</string>
|
||||||
<string name="profile_name_placeholder">Nom du profil</string>
|
<string name="profile_name_placeholder">Nom du profil</string>
|
||||||
<string name="profile_new">Nouveau profil</string>
|
<string name="profile_new">Nouveau profil</string>
|
||||||
<string name="profile_primary_addons_off">Extensions principales désactivées</string>
|
<string name="profile_primary_addons_off">Addons principaux désactivés</string>
|
||||||
<string name="profile_primary_addons_on">Extensions principales activées</string>
|
<string name="profile_primary_addons_on">Addons principaux activés</string>
|
||||||
<string name="profile_remove_pin_for">Supprimer le code PIN pour %1$s</string>
|
<string name="profile_remove_pin_for">Supprimer le code PIN pour %1$s</string>
|
||||||
<string name="profile_remove_pin_lock">Supprimer le verrouillage PIN</string>
|
<string name="profile_remove_pin_lock">Supprimer le verrouillage PIN</string>
|
||||||
<string name="profile_saving">Enregistrement…</string>
|
<string name="profile_saving">Enregistrement…</string>
|
||||||
<string name="profile_security">Sécurité</string>
|
<string name="profile_security">Sécurité</string>
|
||||||
<string name="profile_security_pin_disabled">Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d\'y accéder.</string>
|
<string name="profile_security_pin_disabled">Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d'y accéder.</string>
|
||||||
<string name="profile_security_pin_enabled">Ce profil est protégé par un code PIN.</string>
|
<string name="profile_security_pin_enabled">Ce profil est protégé par un code PIN.</string>
|
||||||
<string name="profile_select_avatar">Sélectionnez un avatar pour ce profil.</string>
|
<string name="profile_select_avatar">Sélectionnez un avatar pour ce profil.</string>
|
||||||
<string name="profile_set_pin_lock">Configurer le verrouillage PIN</string>
|
<string name="profile_set_pin_lock">Configurer le verrouillage PIN</string>
|
||||||
<string name="profile_unnamed">Profil sans nom</string>
|
<string name="profile_unnamed">Profil sans nom</string>
|
||||||
<string name="profile_use_primary_addons">Utiliser les extensions principales</string>
|
<string name="profile_use_primary_addons">Utiliser les addons principales</string>
|
||||||
<string name="profile_use_primary_addons_description">Partager la configuration des extensions du profil principal plutôt que de gérer une liste séparée.</string>
|
<string name="profile_use_primary_addons_description">Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée.</string>
|
||||||
<string name="profile_who_is_watching">Qui regarde ?</string>
|
<string name="profile_who_is_watching">Qui regarde ?</string>
|
||||||
<string name="provider_downloaded">Téléchargé</string>
|
<string name="provider_downloaded">Téléchargé</string>
|
||||||
<string name="resume_prompt_action">Reprendre</string>
|
<string name="resume_prompt_action">Reprendre</string>
|
||||||
<string name="streams_active_scrapers">Scrapers actifs</string>
|
<string name="streams_active_scrapers">Scrapers actifs</string>
|
||||||
<string name="streams_checking_more_addons">Vérification d\'autres extensions…</string>
|
<string name="streams_checking_more_addons">Vérification d'autres addons…</string>
|
||||||
<string name="streams_copy_link">Copier le lien du stream</string>
|
<string name="streams_copy_link">Copier le lien du stream</string>
|
||||||
<string name="streams_download_file">Télécharger le fichier</string>
|
<string name="streams_download_file">Télécharger le fichier</string>
|
||||||
<string name="streams_empty_load_failed_message">Les extensions de streams installées n\'ont pas retourné de réponse valide.</string>
|
<string name="streams_empty_load_failed_message">Les addons de streams installés n'ont pas retourné de réponse valide.</string>
|
||||||
<string name="streams_empty_load_failed_title">Impossible de charger les streams</string>
|
<string name="streams_empty_load_failed_title">Impossible de charger les streams</string>
|
||||||
<string name="streams_empty_no_addons_message">Installez d\'abord une extension pour charger les streams de ce titre.</string>
|
<string name="streams_empty_no_addons_message">Installez d'abord un addon pour charger les streams de ce titre.</string>
|
||||||
<string name="streams_empty_no_stream_addon_message">Vos extensions installées ne fournissent pas de streams pour ce type de titre.</string>
|
<string name="streams_empty_no_stream_addon_message">Vos addons installés ne fournissent pas de streams pour ce type de titre.</string>
|
||||||
<string name="streams_empty_no_stream_addon_title">Aucune extension de streams disponible</string>
|
<string name="streams_empty_no_stream_addon_title">Aucun addon de streams disponible</string>
|
||||||
<string name="streams_empty_no_streams_message">Aucune de vos extensions installées n\'a retourné de streams pour ce titre.</string>
|
<string name="streams_empty_no_streams_message">Aucune de vos addons installés n'a retourné de streams pour ce titre.</string>
|
||||||
<string name="streams_episode_badge">S%1$d E%2$d</string>
|
<string name="streams_episode_badge">S%1$d E%2$d</string>
|
||||||
<string name="streams_episode_fallback_title">Épisode</string>
|
<string name="streams_episode_fallback_title">Épisode</string>
|
||||||
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
|
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
|
||||||
|
|
@ -995,10 +995,10 @@
|
||||||
<string name="updates_asset_line">%1$s • %2$s</string>
|
<string name="updates_asset_line">%1$s • %2$s</string>
|
||||||
<string name="updates_check_failed">Échec de la vérification des mises à jour</string>
|
<string name="updates_check_failed">Échec de la vérification des mises à jour</string>
|
||||||
<string name="updates_download_failed">Échec du téléchargement</string>
|
<string name="updates_download_failed">Échec du téléchargement</string>
|
||||||
<string name="updates_downloading_progress">Téléchargement %1$d%</string>
|
<string name="updates_downloading_progress">Téléchargement %1$d%%</string>
|
||||||
<string name="updates_install_failed">Impossible de démarrer l\'installation</string>
|
<string name="updates_install_failed">Impossible de démarrer l'installation</string>
|
||||||
<string name="updates_latest_version">Vous utilisez la version la plus récente.</string>
|
<string name="updates_latest_version">Vous utilisez la version la plus récente.</string>
|
||||||
<string name="updates_message_allow_installs">Activez l\'installation d\'applications pour Nuvio puis revenez pour continuer.</string>
|
<string name="updates_message_allow_installs">Activez l'installation d'applications pour Nuvio puis revenez pour continuer.</string>
|
||||||
<string name="updates_message_downloading">Téléchargement de la mise à jour…</string>
|
<string name="updates_message_downloading">Téléchargement de la mise à jour…</string>
|
||||||
<string name="updates_message_no_updates">Aucune mise à jour trouvée.</string>
|
<string name="updates_message_no_updates">Aucune mise à jour trouvée.</string>
|
||||||
<string name="updates_message_ready">Une nouvelle version est prête à être installée.</string>
|
<string name="updates_message_ready">Une nouvelle version est prête à être installée.</string>
|
||||||
|
|
@ -1008,22 +1008,22 @@
|
||||||
<string name="updates_title_allow_installs">Autoriser les installations pour continuer</string>
|
<string name="updates_title_allow_installs">Autoriser les installations pour continuer</string>
|
||||||
<string name="updates_title_available">Mise à jour disponible</string>
|
<string name="updates_title_available">Mise à jour disponible</string>
|
||||||
<string name="updates_title_status">Statut de la mise à jour</string>
|
<string name="updates_title_status">Statut de la mise à jour</string>
|
||||||
<string name="addon_already_installed">Cette extension est déjà installée.</string>
|
<string name="addon_already_installed">Cet addon est déjà installé.</string>
|
||||||
<string name="addon_invalid_url">Veuillez saisir une URL d\'extension valide</string>
|
<string name="addon_invalid_url">Veuillez saisir une URL d'addon valide</string>
|
||||||
<string name="addon_load_manifest_failed">Impossible de charger le manifeste</string>
|
<string name="addon_load_manifest_failed">Impossible de charger le manifeste</string>
|
||||||
<string name="app_brand_name">Nuvio</string>
|
<string name="app_brand_name">Nuvio</string>
|
||||||
<string name="auth_account_deletion_failed">Impossible de supprimer le compte</string>
|
<string name="auth_account_deletion_failed">Impossible de supprimer le compte</string>
|
||||||
<string name="auth_sign_in_failed">Échec de la connexion</string>
|
<string name="auth_sign_in_failed">Échec de la connexion</string>
|
||||||
<string name="auth_sign_out_failed">Échec de la déconnexion</string>
|
<string name="auth_sign_out_failed">Échec de la déconnexion</string>
|
||||||
<string name="auth_sign_up_failed">Échec de l\'inscription</string>
|
<string name="auth_sign_up_failed">Échec de l'inscription</string>
|
||||||
<string name="catalog_load_failed">Impossible de charger les éléments du catalogue.</string>
|
<string name="catalog_load_failed">Impossible de charger les éléments du catalogue.</string>
|
||||||
<string name="continue_watching_up_next">À suivre</string>
|
<string name="continue_watching_up_next">À suivre</string>
|
||||||
<string name="continue_watching_up_next_episode">À suivre • S%1$dE%2$d</string>
|
<string name="continue_watching_up_next_episode">À suivre • S%1$dE%2$d</string>
|
||||||
<string name="detail_logo_content_description">logo de %1$s</string>
|
<string name="detail_logo_content_description">logo de %1$s</string>
|
||||||
<string name="details_comments_load_failed">Impossible de charger les commentaires</string>
|
<string name="details_comments_load_failed">Impossible de charger les commentaires</string>
|
||||||
<string name="details_load_failed_all_addons">Impossible de charger les détails depuis aucune extension.</string>
|
<string name="details_load_failed_all_addons">Impossible de charger les détails depuis aucun addon.</string>
|
||||||
<string name="details_networks">Réseaux</string>
|
<string name="details_networks">Réseaux</string>
|
||||||
<string name="details_no_addon_meta">Aucune extension ne fournit de métadonnées pour ce contenu.</string>
|
<string name="details_no_addon_meta">Aucun addon ne fournit de métadonnées pour ce contenu.</string>
|
||||||
<string name="download_failed">Téléchargement échoué</string>
|
<string name="download_failed">Téléchargement échoué</string>
|
||||||
<string name="downloads_channel_description">Affiche la progression en direct et les contrôles de téléchargement.</string>
|
<string name="downloads_channel_description">Affiche la progression en direct et les contrôles de téléchargement.</string>
|
||||||
<string name="downloads_channel_name">Téléchargements</string>
|
<string name="downloads_channel_name">Téléchargements</string>
|
||||||
|
|
@ -1036,18 +1036,18 @@
|
||||||
<string name="library_remove_message">Supprimer %1$s de votre bibliothèque ?</string>
|
<string name="library_remove_message">Supprimer %1$s de votre bibliothèque ?</string>
|
||||||
<string name="library_remove_title">Retirer de la bibliothèque ?</string>
|
<string name="library_remove_title">Retirer de la bibliothèque ?</string>
|
||||||
<string name="media_movie">Film</string>
|
<string name="media_movie">Film</string>
|
||||||
<string name="notifications_channel_episode_releases_description">Alertes lorsqu\'un nouvel épisode d\'une série sauvegardée est disponible.</string>
|
<string name="notifications_channel_episode_releases_description">Alertes lorsqu'un nouvel épisode d'une série sauvegardée est disponible.</string>
|
||||||
<string name="notifications_test_preview_body">Aperçu de l\'alerte de sortie d\'épisode.</string>
|
<string name="notifications_test_preview_body">Aperçu de l'alerte de sortie d'épisode.</string>
|
||||||
<string name="notifications_test_send_failed">Impossible d\'envoyer une notification de test.</string>
|
<string name="notifications_test_send_failed">Impossible d'envoyer une notification de test.</string>
|
||||||
<string name="notifications_test_sent_for">Notification de test envoyée pour %1$s.</string>
|
<string name="notifications_test_sent_for">Notification de test envoyée pour %1$s.</string>
|
||||||
<string name="player_unable_to_play_stream">Impossible de lire ce stream.</string>
|
<string name="player_unable_to_play_stream">Impossible de lire ce stream.</string>
|
||||||
<string name="profile_pin_changed_requires_refresh">Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil.</string>
|
<string name="profile_pin_changed_requires_refresh">Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil.</string>
|
||||||
<string name="profile_pin_clear_failed">Impossible de supprimer le verrouillage PIN. Veuillez réessayer.</string>
|
<string name="profile_pin_clear_failed">Impossible de supprimer le verrouillage PIN. Veuillez réessayer.</string>
|
||||||
<string name="profile_pin_clear_requires_internet">Connectez-vous à Internet pour supprimer le verrouillage PIN.</string>
|
<string name="profile_pin_clear_requires_internet">Connectez-vous à Internet pour supprimer le verrouillage PIN.</string>
|
||||||
<string name="profile_pin_offline_verification_requires_online">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.</string>
|
<string name="profile_pin_offline_verification_requires_online">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.</string>
|
||||||
<string name="profile_pin_set_failed">Impossible de définir le code PIN. Veuillez réessayer.</string>
|
<string name="profile_pin_set_failed">Impossible de définir le code PIN. Veuillez réessayer.</string>
|
||||||
<string name="profile_pin_set_requires_internet">Connectez-vous à Internet pour définir un code PIN.</string>
|
<string name="profile_pin_set_requires_internet">Connectez-vous à Internet pour définir un code PIN.</string>
|
||||||
<string name="profile_primary_addons_required">Ce profil utilise les extensions principales.</string>
|
<string name="profile_primary_addons_required">Ce profil utilise les addons principales.</string>
|
||||||
<string name="streams_failed_to_load_scraper">Impossible de charger %1$s</string>
|
<string name="streams_failed_to_load_scraper">Impossible de charger %1$s</string>
|
||||||
<string name="stream_default_name">Source</string>
|
<string name="stream_default_name">Source</string>
|
||||||
<string name="source_embedded">Intégré</string>
|
<string name="source_embedded">Intégré</string>
|
||||||
|
|
@ -1058,7 +1058,7 @@
|
||||||
<string name="trakt_invalid_token_response">Réponse de jeton Trakt invalide</string>
|
<string name="trakt_invalid_token_response">Réponse de jeton Trakt invalide</string>
|
||||||
<string name="trakt_library_load_failed">Impossible de charger la bibliothèque Trakt</string>
|
<string name="trakt_library_load_failed">Impossible de charger la bibliothèque Trakt</string>
|
||||||
<string name="trakt_list_fallback_title">Liste %1$d</string>
|
<string name="trakt_list_fallback_title">Liste %1$d</string>
|
||||||
<string name="trakt_missing_auth_code">Trakt n\'a pas retourné de code d\'autorisation</string>
|
<string name="trakt_missing_auth_code">Trakt n'a pas retourné de code d'autorisation</string>
|
||||||
<string name="trakt_missing_credentials">Identifiants Trakt manquants</string>
|
<string name="trakt_missing_credentials">Identifiants Trakt manquants</string>
|
||||||
<string name="trakt_progress_load_failed">Impossible de charger la progression Trakt</string>
|
<string name="trakt_progress_load_failed">Impossible de charger la progression Trakt</string>
|
||||||
<string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string>
|
<string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string>
|
||||||
|
|
@ -1066,18 +1066,18 @@
|
||||||
<string name="trakt_watchlist">Liste de suivi</string>
|
<string name="trakt_watchlist">Liste de suivi</string>
|
||||||
<string name="generic_trailer">Bande-annonce</string>
|
<string name="generic_trailer">Bande-annonce</string>
|
||||||
<string name="generic_unknown">Inconnu</string>
|
<string name="generic_unknown">Inconnu</string>
|
||||||
<string name="generic_addon">Extension</string>
|
<string name="generic_addon">Addon</string>
|
||||||
<string name="action_saved">Enregistré</string>
|
<string name="action_saved">Enregistré</string>
|
||||||
<string name="action_play_episode">Lire %1$s</string>
|
<string name="action_play_episode">Lire %1$s</string>
|
||||||
<string name="action_resume_episode">Reprendre %1$s</string>
|
<string name="action_resume_episode">Reprendre %1$s</string>
|
||||||
<string name="collections_import_error_empty_json">Le JSON est vide.</string>
|
<string name="collections_import_error_empty_json">Le JSON est vide.</string>
|
||||||
<string name="collections_import_error_collection_blank_id">La collection %1$d a un ID vide.</string>
|
<string name="collections_import_error_collection_blank_id">La collection %1$d a un ID vide.</string>
|
||||||
<string name="collections_import_error_collection_blank_title">La collection \'%1$s\' a un titre vide.</string>
|
<string name="collections_import_error_collection_blank_title">La collection \'%1$s' a un titre vide.</string>
|
||||||
<string name="collections_import_error_folder_blank_id">Le dossier %1$d dans \'%2$s\' a un ID vide.</string>
|
<string name="collections_import_error_folder_blank_id">Le dossier %1$d dans \'%2$s' a un ID vide.</string>
|
||||||
<string name="collections_import_error_folder_blank_title">Le dossier \'%1$s\' dans \'%2$s\' a un titre vide.</string>
|
<string name="collections_import_error_folder_blank_title">Le dossier \'%1$s\' dans \'%2$s\' a un titre vide.</string>
|
||||||
<string name="collections_import_error_source_blank_fields">La source %1$d dans le dossier \'%2$s\' a des champs vides.</string>
|
<string name="collections_import_error_source_blank_fields">La source %1$d dans le dossier \'%2$s\' a des champs vides.</string>
|
||||||
<string name="collections_import_error_invalid_json">JSON invalide : %1$s</string>
|
<string name="collections_import_error_invalid_json">JSON invalide : %1$s</string>
|
||||||
<string name="collections_folder_addon_not_found">Extension introuvable : %1$s</string>
|
<string name="collections_folder_addon_not_found">Addon introuvable : %1$s</string>
|
||||||
<string name="date_month_january">Janvier</string>
|
<string name="date_month_january">Janvier</string>
|
||||||
<string name="date_month_february">Février</string>
|
<string name="date_month_february">Février</string>
|
||||||
<string name="date_month_march">Mars</string>
|
<string name="date_month_march">Mars</string>
|
||||||
|
|
@ -1112,7 +1112,7 @@
|
||||||
<string name="details_certification">Classification</string>
|
<string name="details_certification">Classification</string>
|
||||||
<string name="details_movie_details">Détails du film</string>
|
<string name="details_movie_details">Détails du film</string>
|
||||||
<string name="details_original_language">Langue originale</string>
|
<string name="details_original_language">Langue originale</string>
|
||||||
<string name="details_origin_country">Pays d\'origine</string>
|
<string name="details_origin_country">Pays d'origine</string>
|
||||||
<string name="details_release_info">Informations de sortie</string>
|
<string name="details_release_info">Informations de sortie</string>
|
||||||
<string name="details_runtime">Durée</string>
|
<string name="details_runtime">Durée</string>
|
||||||
<string name="details_season_view_posters">Affiches</string>
|
<string name="details_season_view_posters">Affiches</string>
|
||||||
|
|
@ -1127,7 +1127,7 @@
|
||||||
<string name="downloads_enqueue_unsupported_format">Format de stream non pris en charge pour les téléchargements</string>
|
<string name="downloads_enqueue_unsupported_format">Format de stream non pris en charge pour les téléchargements</string>
|
||||||
<string name="downloads_error_empty_body">Corps de réponse vide</string>
|
<string name="downloads_error_empty_body">Corps de réponse vide</string>
|
||||||
<string name="downloads_error_http_failed">La requête a échoué avec HTTP %1$d</string>
|
<string name="downloads_error_http_failed">La requête a échoué avec HTTP %1$d</string>
|
||||||
<string name="downloads_error_not_initialized">Le système de téléchargement n\'est pas initialisé</string>
|
<string name="downloads_error_not_initialized">Le système de téléchargement n'est pas initialisé</string>
|
||||||
<string name="downloads_error_request_failed">La requête de téléchargement a échoué</string>
|
<string name="downloads_error_request_failed">La requête de téléchargement a échoué</string>
|
||||||
<string name="home_catalog_default_title">%1$s - %2$s</string>
|
<string name="home_catalog_default_title">%1$s - %2$s</string>
|
||||||
<string name="library_empty_message">Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails.</string>
|
<string name="library_empty_message">Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails.</string>
|
||||||
|
|
@ -1148,7 +1148,7 @@
|
||||||
<string name="notifications_episode_release_body_code_title">%1$s • %2$s est maintenant disponible</string>
|
<string name="notifications_episode_release_body_code_title">%1$s • %2$s est maintenant disponible</string>
|
||||||
<string name="notifications_episode_release_body_generic">Un nouvel épisode est maintenant disponible</string>
|
<string name="notifications_episode_release_body_generic">Un nouvel épisode est maintenant disponible</string>
|
||||||
<string name="notifications_episode_release_body_title">%1$s est maintenant disponible</string>
|
<string name="notifications_episode_release_body_title">%1$s est maintenant disponible</string>
|
||||||
<string name="notifications_channel_episode_releases_name">Sorties d\'épisodes</string>
|
<string name="notifications_channel_episode_releases_name">Sorties d'épisodes</string>
|
||||||
<string name="person_role_creator">Créateur</string>
|
<string name="person_role_creator">Créateur</string>
|
||||||
<string name="person_role_director">Réalisateur</string>
|
<string name="person_role_director">Réalisateur</string>
|
||||||
<string name="person_role_writer">Scénariste</string>
|
<string name="person_role_writer">Scénariste</string>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
<string name="collections_editor_shape_square">Quadrato</string>
|
<string name="collections_editor_shape_square">Quadrato</string>
|
||||||
<string name="collections_editor_shape_wide">Orizzontale</string>
|
<string name="collections_editor_shape_wide">Orizzontale</string>
|
||||||
<string name="collections_editor_show_all_tab_desc">Combina tutti i cataloghi in una singola scheda</string>
|
<string name="collections_editor_show_all_tab_desc">Combina tutti i cataloghi in una singola scheda</string>
|
||||||
<string name="collections_editor_show_all_tab">Mostra scheda \"Tutti\"</string>
|
<string name="collections_editor_show_all_tab">Mostra scheda "Tutti"</string>
|
||||||
<string name="collections_editor_show_gif_when_configured_desc">Riproduci la GIF configurata al posto della copertina statica quando disponibile.</string>
|
<string name="collections_editor_show_gif_when_configured_desc">Riproduci la GIF configurata al posto della copertina statica quando disponibile.</string>
|
||||||
<string name="collections_editor_show_gif_when_configured">Mostra GIF se configurata</string>
|
<string name="collections_editor_show_gif_when_configured">Mostra GIF se configurata</string>
|
||||||
<string name="collections_editor_source_count">%1$d sorgenti · %2$s</string>
|
<string name="collections_editor_source_count">%1$d sorgenti · %2$s</string>
|
||||||
|
|
@ -297,7 +297,7 @@
|
||||||
<string name="settings_appearance_amoled_description">Usa sfondi neri assoluti per schermi OLED.</string>
|
<string name="settings_appearance_amoled_description">Usa sfondi neri assoluti per schermi OLED.</string>
|
||||||
<string name="settings_appearance_app_language">Lingua app</string>
|
<string name="settings_appearance_app_language">Lingua app</string>
|
||||||
<string name="settings_appearance_app_language_sheet_title">Scegli lingua</string>
|
<string name="settings_appearance_app_language_sheet_title">Scegli lingua</string>
|
||||||
<string name="settings_appearance_continue_watching_description">Mostra, nascondi e personalizza lo stile della riga \"Continua a guardare\".</string>
|
<string name="settings_appearance_continue_watching_description">Mostra, nascondi e personalizza lo stile della riga "Continua a guardare".</string>
|
||||||
<string name="settings_appearance_poster_customization_description">Regola la larghezza delle locandine e i preset del raggio degli angoli.</string>
|
<string name="settings_appearance_poster_customization_description">Regola la larghezza delle locandine e i preset del raggio degli angoli.</string>
|
||||||
<string name="settings_appearance_section_display">DISPLAY</string>
|
<string name="settings_appearance_section_display">DISPLAY</string>
|
||||||
<string name="settings_appearance_section_home">HOME</string>
|
<string name="settings_appearance_section_home">HOME</string>
|
||||||
|
|
@ -355,15 +355,15 @@
|
||||||
<string name="settings_continue_watching_resume_prompt_title">Richiesta ripresa all'avvio</string>
|
<string name="settings_continue_watching_resume_prompt_title">Richiesta ripresa all'avvio</string>
|
||||||
<string name="settings_continue_watching_section_card_style">STILE SCHEDA</string>
|
<string name="settings_continue_watching_section_card_style">STILE SCHEDA</string>
|
||||||
<string name="settings_continue_watching_section_on_launch">ALL'AVVIO</string>
|
<string name="settings_continue_watching_section_on_launch">ALL'AVVIO</string>
|
||||||
<string name="settings_continue_watching_section_up_next_behavior">COMPORTAMENTO \"PROSSIMO EPISODIO\"</string>
|
<string name="settings_continue_watching_section_up_next_behavior">COMPORTAMENTO "PROSSIMO EPISODIO"</string>
|
||||||
<string name="settings_continue_watching_section_visibility">VISIBILITÀ</string>
|
<string name="settings_continue_watching_section_visibility">VISIBILITÀ</string>
|
||||||
<string name="settings_continue_watching_show_description">Mostra la riga \"Continua a guardare\" nella schermata Home.</string>
|
<string name="settings_continue_watching_show_description">Mostra la riga "Continua a guardare" nella schermata Home.</string>
|
||||||
<string name="settings_continue_watching_show_title">Mostra Continua a guardare</string>
|
<string name="settings_continue_watching_show_title">Mostra Continua a guardare</string>
|
||||||
<string name="settings_continue_watching_style_poster">Locandina</string>
|
<string name="settings_continue_watching_style_poster">Locandina</string>
|
||||||
<string name="settings_continue_watching_style_poster_description">Scheda focalizzata sulla locandina</string>
|
<string name="settings_continue_watching_style_poster_description">Scheda focalizzata sulla locandina</string>
|
||||||
<string name="settings_continue_watching_style_wide">Orizzontale</string>
|
<string name="settings_continue_watching_style_wide">Orizzontale</string>
|
||||||
<string name="settings_continue_watching_style_wide_description">Scheda orizzontale ricca di informazioni</string>
|
<string name="settings_continue_watching_style_wide_description">Scheda orizzontale ricca di informazioni</string>
|
||||||
<string name="settings_continue_watching_up_next_description">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.</string>
|
<string name="settings_continue_watching_up_next_description">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.</string>
|
||||||
<string name="settings_continue_watching_up_next_title">Prossimo episodio dall'ultimo visto</string>
|
<string name="settings_continue_watching_up_next_title">Prossimo episodio dall'ultimo visto</string>
|
||||||
<string name="settings_content_discovery_section_home">HOME</string>
|
<string name="settings_content_discovery_section_home">HOME</string>
|
||||||
<string name="settings_content_discovery_section_sources">SORGENTI</string>
|
<string name="settings_content_discovery_section_sources">SORGENTI</string>
|
||||||
|
|
@ -502,7 +502,7 @@
|
||||||
<string name="settings_playback_option_forced">Forzati</string>
|
<string name="settings_playback_option_forced">Forzati</string>
|
||||||
<string name="settings_playback_option_none">Nessuno</string>
|
<string name="settings_playback_option_none">Nessuno</string>
|
||||||
<string name="settings_playback_prefer_binge_group">Preferisci Binge Group</string>
|
<string name="settings_playback_prefer_binge_group">Preferisci Binge Group</string>
|
||||||
<string name="settings_playback_prefer_binge_group_description">Durante la riproduzione automatica, preferisci un flusso dello stesso \"binge group\" di quello attuale.</string>
|
<string name="settings_playback_prefer_binge_group_description">Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale.</string>
|
||||||
<string name="settings_playback_preferred_audio_language">Lingua audio preferita</string>
|
<string name="settings_playback_preferred_audio_language">Lingua audio preferita</string>
|
||||||
<string name="settings_playback_preferred_subtitle_language">Lingua sottotitoli preferita</string>
|
<string name="settings_playback_preferred_subtitle_language">Lingua sottotitoli preferita</string>
|
||||||
<string name="settings_playback_presets">Preset</string>
|
<string name="settings_playback_presets">Preset</string>
|
||||||
|
|
@ -544,7 +544,7 @@
|
||||||
<string name="settings_playback_show_loading_overlay">Mostra overlay di caricamento</string>
|
<string name="settings_playback_show_loading_overlay">Mostra overlay di caricamento</string>
|
||||||
<string name="settings_playback_show_loading_overlay_description">Mostra una schermata di caricamento all'avvio della riproduzione di un flusso.</string>
|
<string name="settings_playback_show_loading_overlay_description">Mostra una schermata di caricamento all'avvio della riproduzione di un flusso.</string>
|
||||||
<string name="settings_playback_skip_intro_outro_recap">Salta Intro/Outro/Recap</string>
|
<string name="settings_playback_skip_intro_outro_recap">Salta Intro/Outro/Recap</string>
|
||||||
<string name="settings_playback_skip_intro_outro_recap_description">Mostra il pulsante \"salta\" durante i segmenti rilevati di introduzione, chiusura e riassunto.</string>
|
<string name="settings_playback_skip_intro_outro_recap_description">Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto.</string>
|
||||||
<string name="settings_playback_source_scope">Ambito sorgente</string>
|
<string name="settings_playback_source_scope">Ambito sorgente</string>
|
||||||
<string name="settings_playback_source_scope_all_addons">Tutti gli addon</string>
|
<string name="settings_playback_source_scope_all_addons">Tutti gli addon</string>
|
||||||
<string name="settings_playback_source_scope_all_addons_description">Considera i flussi da tutti gli addon installati.</string>
|
<string name="settings_playback_source_scope_all_addons_description">Considera i flussi da tutti gli addon installati.</string>
|
||||||
|
|
@ -819,7 +819,7 @@
|
||||||
<string name="profile_choose_avatar">Scegli un avatar</string>
|
<string name="profile_choose_avatar">Scegli un avatar</string>
|
||||||
<string name="profile_choose_avatar_below">Scegli un avatar qui sotto.</string>
|
<string name="profile_choose_avatar_below">Scegli un avatar qui sotto.</string>
|
||||||
<string name="profile_create_profile">Crea profilo</string>
|
<string name="profile_create_profile">Crea profilo</string>
|
||||||
<string name="profile_delete_confirm_message">Tutti i dati di \"%1$s\" verranno eliminati permanentemente.</string>
|
<string name="profile_delete_confirm_message">Tutti i dati di "%1$s" verranno eliminati permanentemente.</string>
|
||||||
<string name="profile_delete_title">Elimina profilo</string>
|
<string name="profile_delete_title">Elimina profilo</string>
|
||||||
<string name="profile_edit_add_title">Aggiungi profilo</string>
|
<string name="profile_edit_add_title">Aggiungi profilo</string>
|
||||||
<string name="profile_edit_edit_title">Modifica profilo</string>
|
<string name="profile_edit_edit_title">Modifica profilo</string>
|
||||||
|
|
@ -1040,4 +1040,134 @@
|
||||||
<string name="unit_bytes_kb">KB</string>
|
<string name="unit_bytes_kb">KB</string>
|
||||||
<string name="unit_bytes_mb">MB</string>
|
<string name="unit_bytes_mb">MB</string>
|
||||||
<string name="unit_bytes_gb">GB</string>
|
<string name="unit_bytes_gb">GB</string>
|
||||||
|
<string name="collections_editor_selected_count">%1$d selezionati</string>
|
||||||
|
<string name="collections_editor_catalog_count">%1$d cataloghi</string>
|
||||||
|
<string name="collections_editor_catalog_selected_count">%1$d selezionati</string>
|
||||||
|
<string name="collections_editor_tmdb_sources">Sorgenti TMDB</string>
|
||||||
|
<string name="collections_editor_tmdb_public_list_mode">Lista pubblica</string>
|
||||||
|
<string name="collections_editor_tmdb_production_mode">Produzione</string>
|
||||||
|
<string name="collections_editor_tmdb_network_mode">Network</string>
|
||||||
|
<string name="collections_editor_tmdb_collection_mode">Collezione</string>
|
||||||
|
<string name="collections_editor_tmdb_person_mode">Persona</string>
|
||||||
|
<string name="collections_editor_tmdb_director_mode">Regista</string>
|
||||||
|
<string name="collections_editor_tmdb_custom_mode">Personalizzato</string>
|
||||||
|
<string name="collections_editor_tmdb_help_presets">Scegli una sorgente pronta all\'uso. Puoi modificarla o rimuoverla dopo averla aggiunta.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_list">Incolla l\'URL di una lista pubblica TMDB o solo l\'ID numerico dall\'URL.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_production">Cerca per nome dello studio, oppure incolla l\'ID/URL di una casa di produzione TMDB per aggiungerla direttamente.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_network">Inserisci un ID network. I network più comuni sono disponibili nei Preset e nei filtri rapidi.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_collection">Cerca il nome di una collezione di film o incolla l\'ID collezione da TMDB.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_person">Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sul cast.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_director">Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sulla regia.</string>
|
||||||
|
<string name="collections_editor_tmdb_help_discover">Crea una riga dinamica TMDB usando filtri opzionali. Lascia i campi vuoti se non ti serve un filtro specifico.</string>
|
||||||
|
<string name="collections_editor_tmdb_public_list">Lista pubblica TMDB</string>
|
||||||
|
<string name="collections_editor_tmdb_network_id">ID Network</string>
|
||||||
|
<string name="collections_editor_tmdb_collection_id">ID Collezione</string>
|
||||||
|
<string name="collections_editor_tmdb_person_id">ID Persona</string>
|
||||||
|
<string name="collections_editor_tmdb_company_search">Nome, ID o URL casa di produzione</string>
|
||||||
|
<string name="collections_editor_tmdb_id_or_url">ID o URL TMDB</string>
|
||||||
|
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 o 8504994</string>
|
||||||
|
<string name="collections_editor_tmdb_network_placeholder">213 per Netflix, 49 per HBO, 2739 per Disney+</string>
|
||||||
|
<string name="collections_editor_tmdb_collection_placeholder">10 per Star Wars Collection</string>
|
||||||
|
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, o URL società</string>
|
||||||
|
<string name="collections_editor_tmdb_person_placeholder">31 per Tom Hanks, o URL persona</string>
|
||||||
|
<string name="collections_editor_tmdb_search_helper">Esempi: Marvel Studios, 420, o https://www.themoviedb.org/company/420.</string>
|
||||||
|
<string name="collections_editor_tmdb_collection_helper">Esempio: Star Wars Collection, Harry Potter Collection, o URL collezione.</string>
|
||||||
|
<string name="collections_editor_tmdb_network_helper">Esempi ID: Netflix 213, HBO 49, Disney+ 2739.</string>
|
||||||
|
<string name="collections_editor_tmdb_list_helper">Esempio: https://www.themoviedb.org/list/8504994 o 8504994.</string>
|
||||||
|
<string name="collections_editor_tmdb_person_helper">Esempio: https://www.themoviedb.org/person/31-tom-hanks o 31.</string>
|
||||||
|
<string name="collections_editor_tmdb_display_title">Titolo visualizzato</string>
|
||||||
|
<string name="collections_editor_tmdb_title_helper">Appare come nome della riga/scheda. Se vuoto, Nuvio ne creerà uno dalla sorgente.</string>
|
||||||
|
<string name="collections_editor_tmdb_title_placeholder">Film Marvel, Originali Netflix, Pixar</string>
|
||||||
|
<string name="collections_editor_tmdb_person_title_placeholder">Film con Tom Hanks, Attori preferiti</string>
|
||||||
|
<string name="collections_editor_tmdb_director_title_placeholder">Film di Christopher Nolan, Registi preferiti</string>
|
||||||
|
<string name="collections_editor_tmdb_discover_title_placeholder">Migliori film d\'azione, Drama coreani, Animazione 2024</string>
|
||||||
|
<string name="collections_editor_tmdb_search_results">Risultati della ricerca</string>
|
||||||
|
<string name="collections_editor_tmdb_collection">Collezione TMDB</string>
|
||||||
|
<string name="collections_editor_tmdb_company_fallback">Società TMDB %1$d</string>
|
||||||
|
<string name="collections_editor_tmdb_collection_fallback">Collezione TMDB %1$d</string>
|
||||||
|
<string name="collections_editor_tmdb_type">Tipo</string>
|
||||||
|
<string name="collections_editor_tmdb_movies">Film</string>
|
||||||
|
<string name="collections_editor_tmdb_series">Serie TV</string>
|
||||||
|
<string name="collections_editor_tmdb_both">Entrambi</string>
|
||||||
|
<string name="collections_editor_tmdb_sort">Ordina</string>
|
||||||
|
<string name="collections_editor_tmdb_filters">Filtri</string>
|
||||||
|
<string name="collections_editor_tmdb_filters_helper">Lascia i campi vuoti se non ti serve quel filtro.</string>
|
||||||
|
<string name="collections_editor_tmdb_quick_genres">Generi rapidi</string>
|
||||||
|
<string name="collections_editor_tmdb_quick_languages">Lingue rapide</string>
|
||||||
|
<string name="collections_editor_tmdb_quick_countries">Paesi rapidi</string>
|
||||||
|
<string name="collections_editor_tmdb_quick_keywords">Parole chiave rapide</string>
|
||||||
|
<string name="collections_editor_tmdb_quick_studios">Studi rapidi</string>
|
||||||
|
<string name="collections_editor_tmdb_quick_networks">Network rapidi</string>
|
||||||
|
<string name="collections_editor_tmdb_genres">ID Generi</string>
|
||||||
|
<string name="collections_editor_tmdb_genres_helper">Usa i numeri dei generi TMDB. Separa con la virgola per AND, o con la barra verticale (pipe) per OR.</string>
|
||||||
|
<string name="collections_editor_tmdb_date_from">Data uscita dal</string>
|
||||||
|
<string name="collections_editor_tmdb_date_to">Data uscita al</string>
|
||||||
|
<string name="collections_editor_tmdb_date_helper">Usa AAAA-MM-GG, ad esempio 2024-01-01.</string>
|
||||||
|
<string name="collections_editor_tmdb_rating_min">Voto minimo</string>
|
||||||
|
<string name="collections_editor_tmdb_rating_max">Voto massimo</string>
|
||||||
|
<string name="collections_editor_tmdb_rating_helper">Valutazione TMDB da 0 a 10. Esempio: 7.0.</string>
|
||||||
|
<string name="collections_editor_tmdb_votes_min">Voti minimi</string>
|
||||||
|
<string name="collections_editor_tmdb_votes_helper">Usa questo per evitare titoli poco noti con pochi voti. Esempio: 100.</string>
|
||||||
|
<string name="collections_editor_tmdb_language">Lingua originale</string>
|
||||||
|
<string name="collections_editor_tmdb_language_helper">Usa codici lingua a due lettere, ad esempio it, en, ko.</string>
|
||||||
|
<string name="collections_editor_tmdb_country">Paese d\'origine</string>
|
||||||
|
<string name="collections_editor_tmdb_country_helper">Usa codici paese a due lettere, ad esempio IT, US, KR.</string>
|
||||||
|
<string name="collections_editor_tmdb_keywords">ID Parole chiave</string>
|
||||||
|
<string name="collections_editor_tmdb_keywords_helper">Usa i numeri delle parole chiave TMDB. I suggerimenti rapidi contengono esempi comuni.</string>
|
||||||
|
<string name="collections_editor_tmdb_keywords_placeholder">9715 per supereroi</string>
|
||||||
|
<string name="collections_editor_tmdb_companies">ID Società</string>
|
||||||
|
<string name="collections_editor_tmdb_companies_helper">Usa gli ID degli studi/società. I suggerimenti rapidi contengono esempi comuni.</string>
|
||||||
|
<string name="collections_editor_tmdb_companies_placeholder">420 per Marvel Studios</string>
|
||||||
|
<string name="collections_editor_tmdb_networks">ID Network</string>
|
||||||
|
<string name="collections_editor_tmdb_networks_helper">Solo per le serie TV. Usa ID network come Netflix (213) o HBO (49).</string>
|
||||||
|
<string name="collections_editor_tmdb_networks_placeholder">213 per Netflix</string>
|
||||||
|
<string name="collections_editor_tmdb_year">Anno</string>
|
||||||
|
<string name="collections_editor_tmdb_year_helper">Usa l\'anno a quattro cifre, ad esempio 2024.</string>
|
||||||
|
<string name="collections_editor_tmdb_presets">Preset</string>
|
||||||
|
<string name="collections_editor_tmdb_search">Cerca</string>
|
||||||
|
<string name="collections_editor_add_source">Aggiungi sorgente</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_action">Azione</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_adventure">Avventura</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_animation">Animazione</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_comedy">Commedia</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_horror">Horror</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_scifi">Fantascienza</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_drama">Dramma</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_crime">Crime</string>
|
||||||
|
<string name="collections_editor_tmdb_genre_reality">Reality</string>
|
||||||
|
<string name="collections_editor_tmdb_language_english">Inglese</string>
|
||||||
|
<string name="collections_editor_tmdb_language_korean">Coreano</string>
|
||||||
|
<string name="collections_editor_tmdb_language_japanese">Giapponese</string>
|
||||||
|
<string name="collections_editor_tmdb_language_hindi">Hindi</string>
|
||||||
|
<string name="collections_editor_tmdb_language_spanish">Spagnolo</string>
|
||||||
|
<string name="collections_editor_tmdb_country_us">Stati Uniti</string>
|
||||||
|
<string name="collections_editor_tmdb_country_korea">Corea</string>
|
||||||
|
<string name="collections_editor_tmdb_country_japan">Giappone</string>
|
||||||
|
<string name="collections_editor_tmdb_country_india">India</string>
|
||||||
|
<string name="collections_editor_tmdb_country_uk">Regno Unito</string>
|
||||||
|
<string name="collections_editor_tmdb_keyword_superhero">Supereroi</string>
|
||||||
|
<string name="collections_editor_tmdb_keyword_based_on_novel">Basato su un romanzo</string>
|
||||||
|
<string name="collections_editor_tmdb_keyword_time_travel">Viaggio nel tempo</string>
|
||||||
|
<string name="collections_editor_tmdb_keyword_space">Spazio</string>
|
||||||
|
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
|
||||||
|
<string name="collections_editor_tmdb_studio_disney">Disney</string>
|
||||||
|
<string name="collections_editor_tmdb_studio_pixar">Pixar</string>
|
||||||
|
<string name="collections_editor_tmdb_studio_lucasfilm">Lucasfilm</string>
|
||||||
|
<string name="collections_editor_tmdb_studio_warner">Warner Bros.</string>
|
||||||
|
<string name="collections_editor_tmdb_network_netflix">Netflix</string>
|
||||||
|
<string name="collections_editor_tmdb_network_hbo">HBO</string>
|
||||||
|
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
|
||||||
|
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
|
||||||
|
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
|
||||||
|
<string name="collections_editor_tmdb_sort_original">Originale</string>
|
||||||
|
<string name="collections_editor_tmdb_sort_popular">Popolari</string>
|
||||||
|
<string name="collections_editor_tmdb_sort_top_rated">Più votati</string>
|
||||||
|
<string name="collections_editor_tmdb_sort_recent">Recenti</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_list">Lista TMDB</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_movie_collection">Collezione film TMDB</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_production">Produzione</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_network">Network</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_person">Persona</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_director">Regista</string>
|
||||||
|
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -188,6 +188,27 @@
|
||||||
<string name="collections_editor_tmdb_presets">Presets</string>
|
<string name="collections_editor_tmdb_presets">Presets</string>
|
||||||
<string name="collections_editor_tmdb_search">Search</string>
|
<string name="collections_editor_tmdb_search">Search</string>
|
||||||
<string name="collections_editor_add_source">Add Source</string>
|
<string name="collections_editor_add_source">Add Source</string>
|
||||||
|
<string name="collections_editor_add_trakt_source">Add Trakt List</string>
|
||||||
|
<string name="collections_editor_edit_trakt_source">Edit Trakt List</string>
|
||||||
|
<string name="collections_editor_trakt_sources">Trakt Lists</string>
|
||||||
|
<string name="collections_editor_trakt_list">Trakt list</string>
|
||||||
|
<string name="collections_editor_trakt_input_placeholder">Search title, Trakt URL, or list ID</string>
|
||||||
|
<string name="collections_editor_trakt_input_helper">Use a public Trakt list URL or numeric list ID, or search by name.</string>
|
||||||
|
<string name="collections_editor_trakt_title_placeholder">Weekend Watch, Award Winners</string>
|
||||||
|
<string name="collections_editor_trakt_search_results">Search Results</string>
|
||||||
|
<string name="collections_editor_trakt_trending">Trending Lists</string>
|
||||||
|
<string name="collections_editor_trakt_popular">Popular Lists</string>
|
||||||
|
<string name="collections_editor_trakt_direction">Direction</string>
|
||||||
|
<string name="collections_editor_trakt_ascending">Ascending</string>
|
||||||
|
<string name="collections_editor_trakt_descending">Descending</string>
|
||||||
|
<string name="collections_editor_trakt_sort_list_order">List Order</string>
|
||||||
|
<string name="collections_editor_trakt_sort_recently_added">Recently Added</string>
|
||||||
|
<string name="collections_editor_trakt_sort_title">Title</string>
|
||||||
|
<string name="collections_editor_trakt_sort_released">Released</string>
|
||||||
|
<string name="collections_editor_trakt_sort_runtime">Runtime</string>
|
||||||
|
<string name="collections_editor_trakt_sort_popular">Popular</string>
|
||||||
|
<string name="collections_editor_trakt_sort_percentage">Percentage</string>
|
||||||
|
<string name="collections_editor_trakt_sort_votes">Votes</string>
|
||||||
<string name="collections_editor_tmdb_genre_action">Action</string>
|
<string name="collections_editor_tmdb_genre_action">Action</string>
|
||||||
<string name="collections_editor_tmdb_genre_adventure">Adventure</string>
|
<string name="collections_editor_tmdb_genre_adventure">Adventure</string>
|
||||||
<string name="collections_editor_tmdb_genre_animation">Animation</string>
|
<string name="collections_editor_tmdb_genre_animation">Animation</string>
|
||||||
|
|
@ -1091,6 +1112,7 @@
|
||||||
<string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string>
|
<string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string>
|
||||||
<string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string>
|
<string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string>
|
||||||
<string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string>
|
<string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string>
|
||||||
|
<string name="collections_import_error_trakt_list_id">Source %1$d in folder '%2$s' is missing a Trakt list ID.</string>
|
||||||
<string name="collections_import_error_invalid_json">Invalid JSON: %1$s</string>
|
<string name="collections_import_error_invalid_json">Invalid JSON: %1$s</string>
|
||||||
<string name="collections_folder_addon_not_found">Addon not found: %1$s</string>
|
<string name="collections_folder_addon_not_found">Addon not found: %1$s</string>
|
||||||
<string name="date_month_january">January</string>
|
<string name="date_month_january">January</string>
|
||||||
|
|
|
||||||
|
|
@ -596,7 +596,9 @@ private fun MainAppContent(
|
||||||
NetworkCondition.ServersUnreachable,
|
NetworkCondition.ServersUnreachable,
|
||||||
-> {
|
-> {
|
||||||
offlineLaunchRouteHandled = true
|
offlineLaunchRouteHandled = true
|
||||||
val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable }
|
val hasPlayableDownload = downloadsUiState.completedItems.any {
|
||||||
|
DownloadsRepository.playableLocalFileUri(it) != null
|
||||||
|
}
|
||||||
if (hasPlayableDownload) {
|
if (hasPlayableDownload) {
|
||||||
selectedTab = AppScreenTab.Settings
|
selectedTab = AppScreenTab.Settings
|
||||||
navController.navigate(DownloadsSettingsRoute) {
|
navController.navigate(DownloadsSettingsRoute) {
|
||||||
|
|
@ -689,7 +691,7 @@ private fun MainAppContent(
|
||||||
episodeNumber = episodeNumber,
|
episodeNumber = episodeNumber,
|
||||||
videoId = videoId,
|
videoId = videoId,
|
||||||
)
|
)
|
||||||
val localSourceUrl = downloadedItem?.localFileUri
|
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
|
||||||
if (!localSourceUrl.isNullOrBlank()) {
|
if (!localSourceUrl.isNullOrBlank()) {
|
||||||
val launchId = PlayerLaunchStore.put(
|
val launchId = PlayerLaunchStore.put(
|
||||||
PlayerLaunch(
|
PlayerLaunch(
|
||||||
|
|
@ -1327,6 +1329,7 @@ private fun MainAppContent(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
StreamsRepository.consumeAutoPlay()
|
StreamsRepository.consumeAutoPlay()
|
||||||
|
StreamsRepository.cancelLoading()
|
||||||
navController.navigate(PlayerRoute(launchId = launchId)) {
|
navController.navigate(PlayerRoute(launchId = launchId)) {
|
||||||
popUpTo<StreamRoute> { inclusive = true }
|
popUpTo<StreamRoute> { inclusive = true }
|
||||||
}
|
}
|
||||||
|
|
@ -1405,6 +1408,7 @@ private fun MainAppContent(
|
||||||
initialProgressFraction = resolvedResumeProgressFraction,
|
initialProgressFraction = resolvedResumeProgressFraction,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
StreamsRepository.cancelLoading()
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
PlayerRoute(launchId = launchId)
|
PlayerRoute(launchId = launchId)
|
||||||
)
|
)
|
||||||
|
|
@ -1531,7 +1535,7 @@ private fun MainAppContent(
|
||||||
DownloadsScreen(
|
DownloadsScreen(
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onOpenDownload = { item ->
|
onOpenDownload = { item ->
|
||||||
val sourceUrl = item.localFileUri ?: return@DownloadsScreen
|
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
||||||
val resumeEntry = item.videoId
|
val resumeEntry = item.videoId
|
||||||
.takeIf { it.isNotBlank() }
|
.takeIf { it.isNotBlank() }
|
||||||
?.let(WatchProgressRepository::progressForVideo)
|
?.let(WatchProgressRepository::progressForVideo)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package com.nuvio.app.features.collection
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.home.PosterShape
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
@ -27,6 +29,8 @@ data class CollectionEditorUiState(
|
||||||
val showFolderEditor: Boolean = false,
|
val showFolderEditor: Boolean = false,
|
||||||
val showCatalogPicker: Boolean = false,
|
val showCatalogPicker: Boolean = false,
|
||||||
val showTmdbSourcePicker: Boolean = false,
|
val showTmdbSourcePicker: Boolean = false,
|
||||||
|
val showTraktSourcePicker: Boolean = false,
|
||||||
|
val editingTraktSourceIndex: Int? = null,
|
||||||
val genrePickerSourceIndex: Int? = null,
|
val genrePickerSourceIndex: Int? = null,
|
||||||
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
|
val tmdbBuilderMode: TmdbBuilderMode = TmdbBuilderMode.PRESETS,
|
||||||
val tmdbInput: String = "",
|
val tmdbInput: String = "",
|
||||||
|
|
@ -38,6 +42,16 @@ data class CollectionEditorUiState(
|
||||||
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
|
val tmdbCompanyResults: List<TmdbCompanySearchResult> = emptyList(),
|
||||||
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
|
val tmdbCollectionResults: List<TmdbCollectionSearchResult> = emptyList(),
|
||||||
val tmdbSearchError: String? = null,
|
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<TraktPublicListSearchResult> = emptyList(),
|
||||||
|
val traktTrendingResults: List<TraktPublicListSearchResult> = emptyList(),
|
||||||
|
val traktPopularResults: List<TraktPublicListSearchResult> = emptyList(),
|
||||||
|
val traktSearchError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class TmdbBuilderMode {
|
enum class TmdbBuilderMode {
|
||||||
|
|
@ -246,7 +260,7 @@ object CollectionEditorRepository {
|
||||||
fun updateCatalogSourceGenre(index: Int, genre: String?) {
|
fun updateCatalogSourceGenre(index: Int, genre: String?) {
|
||||||
val folder = _uiState.value.editingFolder ?: return
|
val folder = _uiState.value.editingFolder ?: return
|
||||||
val sources = folder.resolvedSources
|
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()
|
val updated = sources.toMutableList()
|
||||||
updated[index] = updated[index].copy(genre = genre)
|
updated[index] = updated[index].copy(genre = genre)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
|
|
@ -258,7 +272,11 @@ object CollectionEditorRepository {
|
||||||
val folder = _uiState.value.editingFolder ?: return
|
val folder = _uiState.value.editingFolder ?: return
|
||||||
val sources = folder.resolvedSources
|
val sources = folder.resolvedSources
|
||||||
val existingIndex = sources.indexOfFirst {
|
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) {
|
if (existingIndex >= 0) {
|
||||||
removeCatalogSource(existingIndex)
|
removeCatalogSource(existingIndex)
|
||||||
|
|
@ -271,6 +289,8 @@ object CollectionEditorRepository {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
showCatalogPicker = true,
|
showCatalogPicker = true,
|
||||||
showTmdbSourcePicker = false,
|
showTmdbSourcePicker = false,
|
||||||
|
showTraktSourcePicker = false,
|
||||||
|
editingTraktSourceIndex = null,
|
||||||
genrePickerSourceIndex = null,
|
genrePickerSourceIndex = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +303,8 @@ object CollectionEditorRepository {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
showTmdbSourcePicker = true,
|
showTmdbSourcePicker = true,
|
||||||
showCatalogPicker = false,
|
showCatalogPicker = false,
|
||||||
|
showTraktSourcePicker = false,
|
||||||
|
editingTraktSourceIndex = null,
|
||||||
genrePickerSourceIndex = null,
|
genrePickerSourceIndex = null,
|
||||||
tmdbSearchError = null,
|
tmdbSearchError = null,
|
||||||
)
|
)
|
||||||
|
|
@ -292,14 +314,139 @@ object CollectionEditorRepository {
|
||||||
_uiState.value = _uiState.value.copy(showTmdbSourcePicker = false, tmdbSearchError = null)
|
_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) {
|
fun showGenrePicker(index: Int) {
|
||||||
val folder = _uiState.value.editingFolder ?: return
|
val folder = _uiState.value.editingFolder ?: return
|
||||||
val sources = folder.resolvedSources
|
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(
|
_uiState.value = _uiState.value.copy(
|
||||||
genrePickerSourceIndex = index,
|
genrePickerSourceIndex = index,
|
||||||
showCatalogPicker = false,
|
showCatalogPicker = false,
|
||||||
showTmdbSourcePicker = false,
|
showTmdbSourcePicker = false,
|
||||||
|
showTraktSourcePicker = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,6 +469,8 @@ object CollectionEditorRepository {
|
||||||
showFolderEditor = false,
|
showFolderEditor = false,
|
||||||
showCatalogPicker = false,
|
showCatalogPicker = false,
|
||||||
showTmdbSourcePicker = false,
|
showTmdbSourcePicker = false,
|
||||||
|
showTraktSourcePicker = false,
|
||||||
|
editingTraktSourceIndex = null,
|
||||||
genrePickerSourceIndex = null,
|
genrePickerSourceIndex = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +481,8 @@ object CollectionEditorRepository {
|
||||||
showFolderEditor = false,
|
showFolderEditor = false,
|
||||||
showCatalogPicker = false,
|
showCatalogPicker = false,
|
||||||
showTmdbSourcePicker = false,
|
showTmdbSourcePicker = false,
|
||||||
|
showTraktSourcePicker = false,
|
||||||
|
editingTraktSourceIndex = null,
|
||||||
genrePickerSourceIndex = 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<CollectionSource>, 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 {
|
fun save(): Boolean {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.title.isBlank()) return false
|
if (state.title.isBlank()) return false
|
||||||
|
|
@ -593,11 +841,19 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun collectionSourceKey(source: CollectionSource): String =
|
private fun collectionSourceKey(source: CollectionSource): String =
|
||||||
if (source.isTmdb) {
|
when {
|
||||||
|
source.isTmdb -> {
|
||||||
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
|
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
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()}"
|
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun selectedMediaTypes(
|
private fun selectedMediaTypes(
|
||||||
state: CollectionEditorUiState,
|
state: CollectionEditorUiState,
|
||||||
|
|
@ -630,7 +886,22 @@ private fun titleForMedia(
|
||||||
return "$title $suffix"
|
return "$title $suffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selectedTraktMediaTypes(state: CollectionEditorUiState): List<TmdbCollectionMediaType> =
|
||||||
|
if (state.traktMediaBoth) {
|
||||||
|
listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV)
|
||||||
|
} else {
|
||||||
|
listOf(state.traktMediaType)
|
||||||
|
}
|
||||||
|
|
||||||
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
|
private fun CollectionSource.tmdbType(): TmdbCollectionSourceType =
|
||||||
tmdbSourceType
|
tmdbSourceType
|
||||||
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
|
?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() }
|
||||||
?: TmdbCollectionSourceType.DISCOVER
|
?: 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Edit
|
||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
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.nuvioSafeBottomPadding
|
||||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||||
import com.nuvio.app.features.home.PosterShape
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
import com.nuvio.app.features.trakt.TraktPublicListSearchResult
|
||||||
import nuvio.composeapp.generated.resources.*
|
import nuvio.composeapp.generated.resources.*
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||||
|
|
@ -107,6 +108,14 @@ fun CollectionEditorScreen(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.showTraktSourcePicker) {
|
||||||
|
TraktSourcePickerScreen(
|
||||||
|
state = state,
|
||||||
|
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val genrePickerIndex = state.genrePickerSourceIndex
|
val genrePickerIndex = state.genrePickerSourceIndex
|
||||||
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
|
val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) }
|
||||||
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
|
val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource()
|
||||||
|
|
@ -158,6 +167,14 @@ fun CollectionEditorScreen(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.showTraktSourcePicker) {
|
||||||
|
TraktSourcePickerScreen(
|
||||||
|
state = state,
|
||||||
|
onBack = { CollectionEditorRepository.hideTraktSourcePicker() },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
NuvioScreen(
|
NuvioScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
@ -704,7 +721,10 @@ private fun FolderEditorPage(
|
||||||
FolderEditorSection(
|
FolderEditorSection(
|
||||||
title = stringResource(Res.string.collections_editor_section_catalog_sources),
|
title = stringResource(Res.string.collections_editor_section_catalog_sources),
|
||||||
actions = {
|
actions = {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
|
TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Add,
|
imageVector = Icons.Rounded.Add,
|
||||||
|
|
@ -714,6 +734,15 @@ private fun FolderEditorPage(
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(stringResource(Res.string.source_tmdb))
|
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() }) {
|
TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Add,
|
imageVector = Icons.Rounded.Add,
|
||||||
|
|
@ -752,6 +781,12 @@ private fun FolderEditorPage(
|
||||||
source = source,
|
source = source,
|
||||||
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
|
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
|
||||||
)
|
)
|
||||||
|
} else if (source.isTrakt) {
|
||||||
|
FolderTraktSourceCard(
|
||||||
|
source = source,
|
||||||
|
onEdit = { CollectionEditorRepository.editTraktSource(index) },
|
||||||
|
onRemove = { CollectionEditorRepository.removeCatalogSource(index) },
|
||||||
|
)
|
||||||
} else if (addonSource != null) {
|
} else if (addonSource != null) {
|
||||||
FolderCatalogSourceCard(
|
FolderCatalogSourceCard(
|
||||||
source = addonSource,
|
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<TraktPublicListSearchResult>,
|
||||||
|
) {
|
||||||
|
if (results.isEmpty()) return
|
||||||
|
item {
|
||||||
|
PickerSectionLabel(title)
|
||||||
|
}
|
||||||
|
itemsIndexed(results) { _, result ->
|
||||||
|
PickerOptionRow(
|
||||||
|
title = result.title,
|
||||||
|
subtitle = result.subtitle,
|
||||||
|
selected = false,
|
||||||
|
onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerPanel(
|
private fun PickerPanel(
|
||||||
title: String,
|
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)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderCatalogSourceCard(
|
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)
|
TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun traktSortOptions(): List<Pair<String, String>> =
|
||||||
|
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
|
@Composable
|
||||||
private fun tmdbSourceSubtitle(source: CollectionSource): String {
|
private fun tmdbSourceSubtitle(source: CollectionSource): String {
|
||||||
val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {
|
val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) {
|
||||||
|
|
|
||||||
|
|
@ -144,17 +144,27 @@ internal object CollectionJsonPreserver {
|
||||||
private fun unifiedSourceKey(element: JsonElement): String? {
|
private fun unifiedSourceKey(element: JsonElement): String? {
|
||||||
val obj = element as? JsonObject ?: return null
|
val obj = element as? JsonObject ?: return null
|
||||||
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
|
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull ?: "addon"
|
||||||
return if (provider.equals("tmdb", ignoreCase = true)) {
|
return when {
|
||||||
|
provider.equals("tmdb", ignoreCase = true) -> {
|
||||||
val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
|
val sourceType = obj["tmdbSourceType"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
val tmdbId = obj["tmdbId"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
val mediaType = obj["mediaType"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
val sortBy = obj["sortBy"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||||
"$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
|
"$provider|$sourceType|$tmdbId|$mediaType|$sortBy"
|
||||||
} else {
|
}
|
||||||
|
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 addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
|
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
|
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
|
||||||
"$provider|$addonId|$type|$catalogId"
|
"$provider|$addonId|$type|$catalogId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,20 @@ data class CollectionSource(
|
||||||
val tmdbSourceType: String? = null,
|
val tmdbSourceType: String? = null,
|
||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val tmdbId: Int? = null,
|
val tmdbId: Int? = null,
|
||||||
|
val traktListId: Long? = null,
|
||||||
val mediaType: String? = null,
|
val mediaType: String? = null,
|
||||||
val sortBy: String? = null,
|
val sortBy: String? = null,
|
||||||
|
val sortHow: String? = null,
|
||||||
val filters: TmdbCollectionFilters? = null,
|
val filters: TmdbCollectionFilters? = null,
|
||||||
) {
|
) {
|
||||||
val isTmdb: Boolean
|
val isTmdb: Boolean
|
||||||
get() = provider.equals("tmdb", ignoreCase = true)
|
get() = provider.equals("tmdb", ignoreCase = true)
|
||||||
|
|
||||||
|
val isTrakt: Boolean
|
||||||
|
get() = provider.equals("trakt", ignoreCase = true)
|
||||||
|
|
||||||
fun addonCatalogSource(): CollectionCatalogSource? {
|
fun addonCatalogSource(): CollectionCatalogSource? {
|
||||||
if (isTmdb) return null
|
if (isTmdb || isTrakt) return null
|
||||||
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
|
val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null
|
||||||
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
|
val sourceType = type?.takeIf { it.isNotBlank() } ?: return null
|
||||||
val sourceCatalogId = catalogId?.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
|
@Serializable
|
||||||
enum class TmdbCollectionSourceType {
|
enum class TmdbCollectionSourceType {
|
||||||
LIST,
|
LIST,
|
||||||
|
|
@ -95,6 +103,36 @@ enum class TmdbCollectionSort(val value: String) {
|
||||||
FIRST_AIR_DATE_DESC("first_air_date.desc"),
|
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
|
@Immutable
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TmdbCollectionFilters(
|
data class TmdbCollectionFilters(
|
||||||
|
|
|
||||||
|
|
@ -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_folder_blank_title
|
||||||
import nuvio.composeapp.generated.resources.collections_import_error_invalid_json
|
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_source_blank_fields
|
||||||
|
import nuvio.composeapp.generated.resources.collections_import_error_trakt_list_id
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
@ -185,7 +186,20 @@ object CollectionRepository {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
f.resolvedSources.forEachIndexed { si, s ->
|
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())
|
(s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank())
|
||||||
val invalidTmdb = s.isTmdb &&
|
val invalidTmdb = s.isTmdb &&
|
||||||
s.tmdbSourceType.isNullOrBlank()
|
s.tmdbSourceType.isNullOrBlank()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||||
import com.nuvio.app.features.home.HomeCatalogSection
|
import com.nuvio.app.features.home.HomeCatalogSection
|
||||||
import com.nuvio.app.features.home.MetaPreview
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
import com.nuvio.app.features.home.stableKey
|
import com.nuvio.app.features.home.stableKey
|
||||||
|
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -148,6 +149,25 @@ object FolderDetailRepository {
|
||||||
isLoading = true,
|
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 {
|
} else {
|
||||||
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
||||||
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
|
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
|
||||||
|
|
@ -188,7 +208,7 @@ object FolderDetailRepository {
|
||||||
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
val tabIndex = if (showAll) sourceIndex + 1 else sourceIndex
|
||||||
val catalogSource = source.addonCatalogSource()
|
val catalogSource = source.addonCatalogSource()
|
||||||
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
|
val resolvedCatalog = catalogSource?.let { addons.findCollectionCatalog(it) }
|
||||||
if (!source.isTmdb && resolvedCatalog == null) {
|
if (!source.isTmdb && !source.isTrakt && resolvedCatalog == null) {
|
||||||
updateTab(tabIndex) {
|
updateTab(tabIndex) {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|
@ -254,7 +274,12 @@ object FolderDetailRepository {
|
||||||
private fun loadTabPage(index: Int, reset: Boolean) {
|
private fun loadTabPage(index: Int, reset: Boolean) {
|
||||||
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
|
val currentTab = _uiState.value.tabs.getOrNull(index) ?: return
|
||||||
val requestedSkip = if (reset) 0 else currentTab.nextSkip ?: 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 ->
|
updateTab(index) { tab ->
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
|
@ -277,13 +302,18 @@ object FolderDetailRepository {
|
||||||
val job = scope.launch {
|
val job = scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
val source = currentTab.source
|
val source = currentTab.source
|
||||||
if (source?.isTmdb == true) {
|
when {
|
||||||
TmdbCollectionSourceResolver.resolve(
|
source?.isTmdb == true -> TmdbCollectionSourceResolver.resolve(
|
||||||
source = source,
|
source = source,
|
||||||
page = if (reset) 1 else requestedSkip,
|
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),
|
manifestUrl = requireNotNull(currentTab.manifestUrl),
|
||||||
type = currentTab.type,
|
type = currentTab.type,
|
||||||
catalogId = currentTab.catalogId,
|
catalogId = currentTab.catalogId,
|
||||||
|
|
@ -399,3 +429,13 @@ private fun tmdbCatalogId(source: CollectionSource): String =
|
||||||
append("_")
|
append("_")
|
||||||
append(source.mediaType?.lowercase().orEmpty())
|
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("_")
|
||||||
|
|
|
||||||
|
|
@ -85,19 +85,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
|
||||||
seasonNumber = seasonNumber,
|
seasonNumber = seasonNumber,
|
||||||
episodeNumber = episodeNumber,
|
episodeNumber = episodeNumber,
|
||||||
)
|
)
|
||||||
val candidates = sortedEpisodes
|
var watchedIndex = sortedEpisodes.indexOfFirst { episode ->
|
||||||
.dropWhile { episode ->
|
|
||||||
buildPlaybackVideoId(
|
buildPlaybackVideoId(
|
||||||
content = WatchingContentRef(type = type, id = id),
|
content = WatchingContentRef(type = type, id = id),
|
||||||
seasonNumber = episode.season,
|
seasonNumber = episode.season,
|
||||||
episodeNumber = episode.episode,
|
episodeNumber = episode.episode,
|
||||||
fallbackVideoId = episode.id,
|
fallbackVideoId = episode.id,
|
||||||
) != watchedVideoId
|
) == watchedVideoId
|
||||||
}
|
}
|
||||||
.drop(1)
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watchedIndex < 0) return null
|
||||||
|
|
||||||
|
val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season
|
||||||
|
val candidates = sortedEpisodes
|
||||||
|
.drop(watchedIndex + 1)
|
||||||
.filter { episode ->
|
.filter { episode ->
|
||||||
shouldSurfaceNextEpisode(
|
shouldSurfaceNextEpisode(
|
||||||
watchedSeasonNumber = seasonNumber,
|
watchedSeasonNumber = watchedEpisodeSeason,
|
||||||
candidateSeasonNumber = episode.season,
|
candidateSeasonNumber = episode.season,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
releasedDate = episode.released,
|
releasedDate = episode.released,
|
||||||
|
|
|
||||||
|
|
@ -21,4 +21,6 @@ internal expect object DownloadsPlatformDownloader {
|
||||||
fun removeFile(localFileUri: String?): Boolean
|
fun removeFile(localFileUri: String?): Boolean
|
||||||
|
|
||||||
fun removePartialFile(destinationFileName: String): Boolean
|
fun removePartialFile(destinationFileName: String): Boolean
|
||||||
|
|
||||||
|
fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ object DownloadsRepository {
|
||||||
val normalizedVideoId = videoId?.trim().orEmpty()
|
val normalizedVideoId = videoId?.trim().orEmpty()
|
||||||
if (normalizedVideoId.isBlank()) return null
|
if (normalizedVideoId.isBlank()) return null
|
||||||
return _uiState.value.items.firstOrNull { item ->
|
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.parentMetaId == normalizedParentMetaId &&
|
||||||
item.seasonNumber == seasonNumber &&
|
item.seasonNumber == seasonNumber &&
|
||||||
item.episodeNumber == episodeNumber &&
|
item.episodeNumber == episodeNumber &&
|
||||||
item.isPlayable &&
|
item.hasPlayableLocalFile()
|
||||||
!item.localFileUri.isNullOrBlank()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items.firstOrNull { item ->
|
items.firstOrNull { item ->
|
||||||
item.parentMetaId == normalizedParentMetaId &&
|
item.parentMetaId == normalizedParentMetaId &&
|
||||||
item.seasonNumber == null &&
|
item.seasonNumber == null &&
|
||||||
item.episodeNumber == null &&
|
item.episodeNumber == null &&
|
||||||
item.isPlayable &&
|
item.hasPlayableLocalFile()
|
||||||
!item.localFileUri.isNullOrBlank()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
fun enqueueFromStream(
|
||||||
contentType: String,
|
contentType: String,
|
||||||
videoId: String,
|
videoId: String,
|
||||||
|
|
@ -117,7 +139,7 @@ object DownloadsRepository {
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
replacedExisting = true
|
replacedExisting = true
|
||||||
activeHandles.remove(existing.id)?.cancel()
|
activeHandles.remove(existing.id)?.cancel()
|
||||||
DownloadsPlatformDownloader.removeFile(existing.localFileUri)
|
DownloadsPlatformDownloader.removeFile(playableLocalFileUri(existing) ?: existing.localFileUri)
|
||||||
DownloadsPlatformDownloader.removePartialFile(existing.fileName)
|
DownloadsPlatformDownloader.removePartialFile(existing.fileName)
|
||||||
currentItems.removeAll { it.id == existing.id }
|
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) {
|
fun resumeDownload(downloadId: String) {
|
||||||
ensureLoaded()
|
ensureLoaded()
|
||||||
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
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
|
val item = _uiState.value.items.firstOrNull { it.id == downloadId } ?: return
|
||||||
|
|
||||||
activeHandles.remove(downloadId)?.cancel()
|
activeHandles.remove(downloadId)?.cancel()
|
||||||
DownloadsPlatformDownloader.removeFile(item.localFileUri)
|
DownloadsPlatformDownloader.removeFile(playableLocalFileUri(item) ?: item.localFileUri)
|
||||||
DownloadsPlatformDownloader.removePartialFile(item.fileName)
|
DownloadsPlatformDownloader.removePartialFile(item.fileName)
|
||||||
|
|
||||||
publish(_uiState.value.items.filterNot { it.id == downloadId })
|
publish(_uiState.value.items.filterNot { it.id == downloadId })
|
||||||
|
|
@ -233,9 +263,10 @@ object DownloadsRepository {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var shouldPersistNormalized = false
|
||||||
val normalized = DownloadsCodec.decodeItems(payload)
|
val normalized = DownloadsCodec.decodeItems(payload)
|
||||||
.map { item ->
|
.map { item ->
|
||||||
if (item.status == DownloadStatus.Downloading) {
|
val statusNormalized = if (item.status == DownloadStatus.Downloading) {
|
||||||
item.copy(
|
item.copy(
|
||||||
status = DownloadStatus.Paused,
|
status = DownloadStatus.Paused,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
|
@ -243,10 +274,19 @@ object DownloadsRepository {
|
||||||
} else {
|
} else {
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val localUriNormalized = normalizeCompletedLocalFileUri(statusNormalized)
|
||||||
|
if (localUriNormalized != item) {
|
||||||
|
shouldPersistNormalized = true
|
||||||
|
}
|
||||||
|
localUriNormalized
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.value = DownloadsUiState(normalized)
|
_uiState.value = DownloadsUiState(normalized)
|
||||||
notifyLiveStatusPlatform()
|
notifyLiveStatusPlatform()
|
||||||
|
if (shouldPersistNormalized) {
|
||||||
|
persist()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startDownload(item: DownloadItem) {
|
private fun startDownload(item: DownloadItem) {
|
||||||
|
|
@ -359,6 +399,26 @@ object DownloadsRepository {
|
||||||
append(nextDownloadOrdinal.toString(36))
|
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
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -868,7 +868,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
|
fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
|
||||||
val localFileUri = downloadItem.localFileUri ?: return
|
val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
|
||||||
showNextEpisodeCard = false
|
showNextEpisodeCard = false
|
||||||
showSourcesPanel = false
|
showSourcesPanel = false
|
||||||
showEpisodesPanel = false
|
showEpisodesPanel = false
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import nuvio.composeapp.generated.resources.Res
|
||||||
import nuvio.composeapp.generated.resources.lang_english
|
import nuvio.composeapp.generated.resources.lang_english
|
||||||
import nuvio.composeapp.generated.resources.lang_french
|
import nuvio.composeapp.generated.resources.lang_french
|
||||||
import nuvio.composeapp.generated.resources.lang_spanish
|
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_turkish
|
||||||
import nuvio.composeapp.generated.resources.lang_italian
|
import nuvio.composeapp.generated.resources.lang_italian
|
||||||
import nuvio.composeapp.generated.resources.lang_greek
|
import nuvio.composeapp.generated.resources.lang_greek
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
fun clear() {
|
||||||
activeJob?.cancel()
|
activeJob?.cancel()
|
||||||
|
activeJob = null
|
||||||
activeRequestKey = null
|
activeRequestKey = null
|
||||||
_uiState.value = StreamsUiState()
|
_uiState.value = StreamsUiState()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String, EpisodeMappingEntry>()
|
||||||
|
private val reverseMappingCache = mutableMapOf<String, EpisodeMappingEntry>()
|
||||||
|
private val addonEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
|
||||||
|
private val traktEpisodesCache = mutableMapOf<String, List<EpisodeMappingEntry>>()
|
||||||
|
// In-flight dedup: prevents multiple concurrent coroutines from fetching
|
||||||
|
// the same show's addon episodes simultaneously.
|
||||||
|
private val addonEpisodesInFlight = mutableMapOf<String, CompletableDeferred<List<EpisodeMappingEntry>>>()
|
||||||
|
|
||||||
|
// ── 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<EpisodeMappingEntry>,
|
||||||
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): 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<EpisodeMappingEntry>,
|
||||||
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): 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<EpisodeMappingEntry>,
|
||||||
|
traktEpisodes: List<EpisodeMappingEntry>,
|
||||||
|
): 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<EpisodeMappingEntry> {
|
||||||
|
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<List<EpisodeMappingEntry>>()
|
||||||
|
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<EpisodeMappingEntry> {
|
||||||
|
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<EpisodeMappingEntry> {
|
||||||
|
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<EpisodeMappingEntry> {
|
||||||
|
val seasons = runCatching {
|
||||||
|
json.decodeFromString<List<TraktSeasonDto>>(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<MetaVideo>.toEpisodeMappingEntries(): List<EpisodeMappingEntry> {
|
||||||
|
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<TraktSeasonEpisodeDto>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class TraktSeasonEpisodeDto(
|
||||||
|
@SerialName("number") val number: Int? = null,
|
||||||
|
@SerialName("season") val season: Int? = null,
|
||||||
|
@SerialName("title") val title: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -7,6 +7,7 @@ internal data class TraktExternalIds(
|
||||||
val trakt: Int? = null,
|
val trakt: Int? = null,
|
||||||
val imdb: String? = null,
|
val imdb: String? = null,
|
||||||
val tmdb: Int? = null,
|
val tmdb: Int? = null,
|
||||||
|
val slug: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun parseTraktContentIds(contentId: String?): TraktExternalIds {
|
internal fun parseTraktContentIds(contentId: String?): TraktExternalIds {
|
||||||
|
|
|
||||||
|
|
@ -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<String>? = null,
|
||||||
|
val poster: List<String>? = null,
|
||||||
|
val logo: List<String>? = null,
|
||||||
|
val clearart: List<String>? = null,
|
||||||
|
val banner: List<String>? = null,
|
||||||
|
val thumb: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun List<String>?.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()
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
package com.nuvio.app.features.trakt
|
package com.nuvio.app.features.trakt
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
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.httpGetTextWithHeaders
|
||||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
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.library.LibraryItem
|
||||||
import com.nuvio.app.features.tmdb.TmdbService
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -28,7 +23,6 @@ import org.jetbrains.compose.resources.getString
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.selects.select
|
import kotlinx.coroutines.selects.select
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
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 BASE_URL = "https://api.trakt.tv"
|
||||||
private const val WATCHLIST_KEY = "trakt:watchlist"
|
private const val WATCHLIST_KEY = "trakt:watchlist"
|
||||||
private const val PERSONAL_LIST_PREFIX = "trakt:list:"
|
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 LIST_FETCH_CONCURRENCY = 4
|
||||||
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
||||||
private const val LIST_TABS_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 var hasLoaded = false
|
||||||
private val refreshMutex = Mutex()
|
private val refreshMutex = Mutex()
|
||||||
private var hydrationJob: Job? = null
|
|
||||||
private var lastRefreshAtMs: Long = 0L
|
private var lastRefreshAtMs: Long = 0L
|
||||||
private var lastListTabsRefreshAtMs: Long = 0L
|
private var lastListTabsRefreshAtMs: Long = 0L
|
||||||
|
|
||||||
|
|
@ -91,8 +82,6 @@ object TraktLibraryRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onProfileChanged() {
|
fun onProfileChanged() {
|
||||||
hydrationJob?.cancel()
|
|
||||||
hydrationJob = null
|
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
lastRefreshAtMs = 0L
|
lastRefreshAtMs = 0L
|
||||||
lastListTabsRefreshAtMs = 0L
|
lastListTabsRefreshAtMs = 0L
|
||||||
|
|
@ -101,8 +90,6 @@ object TraktLibraryRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearLocalState() {
|
fun clearLocalState() {
|
||||||
hydrationJob?.cancel()
|
|
||||||
hydrationJob = null
|
|
||||||
hasLoaded = false
|
hasLoaded = false
|
||||||
lastRefreshAtMs = 0L
|
lastRefreshAtMs = 0L
|
||||||
lastListTabsRefreshAtMs = 0L
|
lastListTabsRefreshAtMs = 0L
|
||||||
|
|
@ -154,8 +141,6 @@ object TraktLibraryRepository {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
AddonRepository.initialize()
|
|
||||||
|
|
||||||
val headers = TraktAuthRepository.authorizedHeaders()
|
val headers = TraktAuthRepository.authorizedHeaders()
|
||||||
if (headers == null) {
|
if (headers == null) {
|
||||||
_uiState.value = TraktLibraryUiState()
|
_uiState.value = TraktLibraryUiState()
|
||||||
|
|
@ -173,7 +158,6 @@ object TraktLibraryRepository {
|
||||||
hasLoaded = true,
|
hasLoaded = true,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
hydrateMissingMetadataAsync(_uiState.value)
|
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
if (error is CancellationException) throw error
|
if (error is CancellationException) throw error
|
||||||
|
|
@ -195,7 +179,6 @@ object TraktLibraryRepository {
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
persistSnapshot(_uiState.value)
|
persistSnapshot(_uiState.value)
|
||||||
hydrateMissingMetadataAsync(_uiState.value)
|
|
||||||
lastRefreshAtMs = now
|
lastRefreshAtMs = now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +404,6 @@ object TraktLibraryRepository {
|
||||||
entriesByList = cached.entriesByList,
|
entriesByList = cached.entriesByList,
|
||||||
)
|
)
|
||||||
_uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true)
|
_uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true)
|
||||||
hydrateMissingMetadataAsync(_uiState.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistSnapshot(state: TraktLibraryUiState) {
|
private fun persistSnapshot(state: TraktLibraryUiState) {
|
||||||
|
|
@ -432,59 +414,6 @@ object TraktLibraryRepository {
|
||||||
TraktLibraryStorage.savePayload(json.encodeToString(payload))
|
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<String, List<LibraryItem>>,
|
|
||||||
hydratedEntriesByList: Map<String, List<LibraryItem>>,
|
|
||||||
): Map<String, List<LibraryItem>> {
|
|
||||||
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<String, String>): List<TraktListTab> {
|
private suspend fun fetchListTabs(headers: Map<String, String>): List<TraktListTab> {
|
||||||
val watchlistTabs = listOf(
|
val watchlistTabs = listOf(
|
||||||
TraktListTab(
|
TraktListTab(
|
||||||
|
|
@ -544,83 +473,6 @@ object TraktLibraryRepository {
|
||||||
entriesByList.toMap()
|
entriesByList.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun hydrateEntriesFromAddonMeta(
|
|
||||||
entriesByList: Map<String, List<LibraryItem>>,
|
|
||||||
): Map<String, List<LibraryItem>> = 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<String, String>): List<TraktListTab> {
|
private suspend fun fetchPersonalLists(headers: Map<String, String>): List<TraktListTab> {
|
||||||
val payload = httpGetTextWithHeaders(
|
val payload = httpGetTextWithHeaders(
|
||||||
url = "$BASE_URL/users/me/lists",
|
url = "$BASE_URL/users/me/lists",
|
||||||
|
|
@ -786,10 +638,9 @@ object TraktLibraryRepository {
|
||||||
?: ids?.trakt?.let { "trakt:$it" }
|
?: ids?.trakt?.let { "trakt:$it" }
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
val poster = media.images?.poster.firstNonBlankImageUrl()
|
val poster = media.images.traktBestPosterUrl()
|
||||||
?: media.images?.fanart.firstNonBlankImageUrl()
|
val banner = media.images.traktBestBackdropUrl()
|
||||||
val banner = media.images?.banner.firstNonBlankImageUrl()
|
val logo = media.images.traktBestLogoUrl()
|
||||||
val logo = media.images?.logo.firstNonBlankImageUrl()
|
|
||||||
|
|
||||||
val savedAt = item.listedAt
|
val savedAt = item.listedAt
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
@ -829,34 +680,6 @@ object TraktLibraryRepository {
|
||||||
return yearText.toIntOrNull()
|
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<String>?.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+")
|
private val imdbRegex = Regex("tt\\d+")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -866,11 +689,6 @@ private data class StoredTraktLibraryPayload(
|
||||||
val entriesByList: Map<String, List<LibraryItem>> = emptyMap(),
|
val entriesByList: Map<String, List<LibraryItem>> = 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
|
@Serializable
|
||||||
private data class TraktListSummaryDto(
|
private data class TraktListSummaryDto(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
|
|
@ -902,14 +720,6 @@ private data class TraktMediaDto(
|
||||||
val images: TraktImagesDto? = null,
|
val images: TraktImagesDto? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class TraktImagesDto(
|
|
||||||
val fanart: List<String>? = null,
|
|
||||||
val poster: List<String>? = null,
|
|
||||||
val logo: List<String>? = null,
|
|
||||||
val banner: List<String>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class TraktIdsDto(
|
private data class TraktIdsDto(
|
||||||
val trakt: Int? = null,
|
val trakt: Int? = null,
|
||||||
|
|
|
||||||
|
|
@ -434,9 +434,31 @@ object TraktProgressRepository {
|
||||||
|
|
||||||
entries.map { entry ->
|
entries.map { entry ->
|
||||||
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
|
val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry
|
||||||
val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) {
|
var resolvedSeason = entry.seasonNumber
|
||||||
meta.videos.firstOrNull { video ->
|
var resolvedEpisode = entry.episodeNumber
|
||||||
video.season == entry.seasonNumber && video.episode == 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 {
|
} else {
|
||||||
null
|
null
|
||||||
|
|
@ -447,6 +469,8 @@ object TraktProgressRepository {
|
||||||
logo = entry.logo ?: meta.logo,
|
logo = entry.logo ?: meta.logo,
|
||||||
poster = entry.poster ?: meta.poster,
|
poster = entry.poster ?: meta.poster,
|
||||||
background = entry.background ?: meta.background,
|
background = entry.background ?: meta.background,
|
||||||
|
seasonNumber = resolvedSeason ?: entry.seasonNumber,
|
||||||
|
episodeNumber = resolvedEpisode ?: entry.episodeNumber,
|
||||||
episodeTitle = entry.episodeTitle ?: episode?.title,
|
episodeTitle = entry.episodeTitle ?: episode?.title,
|
||||||
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
|
episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail,
|
||||||
pauseDescription = entry.pauseDescription
|
pauseDescription = entry.pauseDescription
|
||||||
|
|
|
||||||
|
|
@ -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<List<PublicTraktListItemDto>>(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<PublicTraktListSummaryDto>(
|
||||||
|
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<TraktPublicListSearchResult> = withContext(Dispatchers.Default) {
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isBlank()) return@withContext emptyList()
|
||||||
|
requestJson<List<PublicTraktSearchResultDto>>(
|
||||||
|
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<TraktPublicListSearchResult> =
|
||||||
|
loadProminentLists("lists/trending")
|
||||||
|
|
||||||
|
suspend fun popularPublicLists(): List<TraktPublicListSearchResult> =
|
||||||
|
loadProminentLists("lists/popular")
|
||||||
|
|
||||||
|
fun parseTraktListId(input: String): Long? =
|
||||||
|
parseTraktListPath(input)?.toLongOrNull()
|
||||||
|
|
||||||
|
private suspend fun loadProminentLists(endpoint: String): List<TraktPublicListSearchResult> =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
requestJson<List<PublicTraktProminentListDto>>(
|
||||||
|
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 <reified T> requestJson(
|
||||||
|
endpoint: String,
|
||||||
|
query: Map<String, String> = 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<T>(response.body) }
|
||||||
|
.onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
|
||||||
|
.getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun requestRaw(
|
||||||
|
endpoint: String,
|
||||||
|
query: Map<String, String> = 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, String>): 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<String>? = 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<String>? = 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<String>? = null,
|
||||||
|
val images: TraktImagesDto? = null,
|
||||||
|
)
|
||||||
|
|
@ -46,12 +46,30 @@ fun nextReleasedEpisodeAfter(
|
||||||
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
|
||||||
)
|
)
|
||||||
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
|
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
|
val candidates = sortedEpisodes
|
||||||
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
|
.drop(watchedIndex + 1)
|
||||||
.drop(1)
|
|
||||||
.filter { episode ->
|
.filter { episode ->
|
||||||
shouldSurfaceNextEpisode(
|
shouldSurfaceNextEpisode(
|
||||||
watchedSeasonNumber = seasonNumber,
|
watchedSeasonNumber = watchedEpisodeSeason,
|
||||||
candidateSeasonNumber = episode.seasonNumber,
|
candidateSeasonNumber = episode.seasonNumber,
|
||||||
todayIsoDate = todayIsoDate,
|
todayIsoDate = todayIsoDate,
|
||||||
releasedDate = episode.releasedDate,
|
releasedDate = episode.releasedDate,
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,11 @@ fun latestCompletedSeriesEpisode(
|
||||||
{ it.markedAtEpochMs },
|
{ it.markedAtEpochMs },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
compareBy<WatchingCompletedEpisode> { it.markedAtEpochMs }
|
compareBy<WatchingCompletedEpisode>(
|
||||||
|
{ it.markedAtEpochMs },
|
||||||
|
{ normalizeSeasonNumber(it.seasonNumber) },
|
||||||
|
{ it.episodeNumber },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val allMarkers = buildList {
|
val allMarkers = buildList {
|
||||||
progressRecords
|
progressRecords
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
|
||||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||||
|
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
|
||||||
import com.nuvio.app.features.watched.WatchedItem
|
import com.nuvio.app.features.watched.WatchedItem
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.async
|
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<WatchedItem>()
|
||||||
|
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) ───────────────────────────────────────────
|
// ── push (add to history) ───────────────────────────────────────────
|
||||||
|
|
@ -107,6 +131,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
val shows = mutableListOf<TraktHistoryShowRequestDto>()
|
val shows = mutableListOf<TraktHistoryShowRequestDto>()
|
||||||
|
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
|
if (!item.shouldSyncToTraktHistory()) return@forEach
|
||||||
|
|
||||||
val ids = parseIds(item.id) ?: return@forEach
|
val ids = parseIds(item.id) ?: return@forEach
|
||||||
val normalizedType = item.type.trim().lowercase()
|
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(
|
val body = json.encodeToString(
|
||||||
TraktHistoryAddRequestDto(
|
TraktHistoryAddRequestDto(
|
||||||
movies = movies.takeIf { it.isNotEmpty() },
|
movies = movies.takeIf { it.isNotEmpty() },
|
||||||
|
|
@ -178,7 +199,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
runCatching {
|
val responseText = runCatching {
|
||||||
httpPostJsonWithHeaders(
|
httpPostJsonWithHeaders(
|
||||||
url = "$BASE_URL/sync/history",
|
url = "$BASE_URL/sync/history",
|
||||||
body = body,
|
body = body,
|
||||||
|
|
@ -187,6 +208,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
log.w { "Failed to push watched items to Trakt: ${e.message}" }
|
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<String, String>,
|
||||||
|
items: Collection<WatchedItem>,
|
||||||
|
) {
|
||||||
|
val remappedShows = mutableListOf<TraktHistoryShowRequestDto>()
|
||||||
|
|
||||||
|
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<TraktHistoryShowRequestDto>()
|
val shows = mutableListOf<TraktHistoryShowRequestDto>()
|
||||||
|
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
|
if (!item.shouldSyncToTraktHistory()) return@forEach
|
||||||
|
|
||||||
val ids = parseIds(item.id) ?: return@forEach
|
val ids = parseIds(item.id) ?: return@forEach
|
||||||
val normalizedType = item.type.trim().lowercase()
|
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(
|
val body = json.encodeToString(
|
||||||
TraktHistoryRemoveRequestDto(
|
TraktHistoryRemoveRequestDto(
|
||||||
movies = movies.takeIf { it.isNotEmpty() },
|
movies = movies.takeIf { it.isNotEmpty() },
|
||||||
|
|
@ -251,6 +365,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
log.w { "Failed to remove watched items from Trakt: ${e.message}" }
|
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<String, String>,
|
||||||
|
items: Collection<WatchedItem>,
|
||||||
|
) {
|
||||||
|
val remappedShowDtos = mutableListOf<TraktHistoryShowRequestDto>()
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -348,6 +526,13 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
|
||||||
private fun Int.pad4(): String = "$this".padStart(4, '0')
|
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) ───────────────────────────────────
|
// ── DTOs for pull (GET /sync/watched) ───────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -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<List<Collection>>(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<List<Collection>>(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<List<Collection>>(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"""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String>().firstTraktImageUrl())
|
||||||
|
assertNull(TraktImagesDto().traktBestPosterUrl())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
package com.nuvio.app.features.downloads
|
package com.nuvio.app.features.downloads
|
||||||
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
import kotlinx.cinterop.addressOf
|
import kotlinx.cinterop.CPointer
|
||||||
import kotlinx.cinterop.convert
|
import kotlinx.cinterop.convert
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -13,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import platform.Foundation.NSError
|
import platform.Foundation.NSError
|
||||||
import platform.Foundation.NSDate
|
import platform.Foundation.NSDate
|
||||||
|
import platform.Foundation.NSData
|
||||||
import platform.Foundation.NSFileManager
|
import platform.Foundation.NSFileManager
|
||||||
import platform.Foundation.NSHTTPURLResponse
|
import platform.Foundation.NSHTTPURLResponse
|
||||||
import platform.Foundation.NSHomeDirectory
|
import platform.Foundation.NSHomeDirectory
|
||||||
|
|
@ -23,16 +23,17 @@ import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData
|
||||||
import platform.Foundation.NSURLResponse
|
import platform.Foundation.NSURLResponse
|
||||||
import platform.Foundation.NSURLSession
|
import platform.Foundation.NSURLSession
|
||||||
import platform.Foundation.NSURLSessionConfiguration
|
import platform.Foundation.NSURLSessionConfiguration
|
||||||
import platform.Foundation.NSURLSessionDownloadDelegateProtocol
|
import platform.Foundation.NSURLSessionDataDelegateProtocol
|
||||||
import platform.Foundation.NSURLSessionDownloadTask
|
import platform.Foundation.NSURLSessionDataTask
|
||||||
import platform.Foundation.NSURLSessionTask
|
import platform.Foundation.NSURLSessionTask
|
||||||
import platform.Foundation.setHTTPMethod
|
import platform.Foundation.setHTTPMethod
|
||||||
import platform.Foundation.setValue
|
import platform.Foundation.setValue
|
||||||
import platform.Foundation.timeIntervalSince1970
|
import platform.Foundation.timeIntervalSince1970
|
||||||
import platform.darwin.NSObject
|
import platform.darwin.NSObject
|
||||||
import platform.posix.fopen
|
import platform.posix.FILE
|
||||||
import platform.posix.fclose
|
import platform.posix.fclose
|
||||||
import platform.posix.fread
|
import platform.posix.fflush
|
||||||
|
import platform.posix.fopen
|
||||||
import platform.posix.fwrite
|
import platform.posix.fwrite
|
||||||
|
|
||||||
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
|
private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0
|
||||||
|
|
@ -49,6 +50,10 @@ fun handleDownloadsBackgroundEvents(
|
||||||
backgroundSessionCompletionHandlers[identifier] = completionHandler
|
backgroundSessionCompletionHandlers[identifier] = completionHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pauseDownloadsForAppBackground() {
|
||||||
|
DownloadsRepository.pauseActiveDownloads()
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
internal actual object DownloadsPlatformDownloader {
|
internal actual object DownloadsPlatformDownloader {
|
||||||
actual fun start(
|
actual fun start(
|
||||||
|
|
@ -132,22 +137,45 @@ internal actual object DownloadsPlatformDownloader {
|
||||||
actual fun removeFile(localFileUri: String?): Boolean {
|
actual fun removeFile(localFileUri: String?): Boolean {
|
||||||
if (localFileUri.isNullOrBlank()) return false
|
if (localFileUri.isNullOrBlank()) return false
|
||||||
val path = localFileUri.toLocalPath() ?: return false
|
val path = localFileUri.toLocalPath() ?: return false
|
||||||
|
if (NSFileManager.defaultManager.fileExistsAtPath(path)) {
|
||||||
return removePathIfExists(path)
|
return removePathIfExists(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fileName = path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return false
|
||||||
|
return removePathIfExists("${downloadsDirectoryPath()}/$fileName")
|
||||||
|
}
|
||||||
|
|
||||||
actual fun removePartialFile(destinationFileName: String): Boolean {
|
actual fun removePartialFile(destinationFileName: String): Boolean {
|
||||||
val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part"
|
val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part"
|
||||||
return removePathIfExists(tempPath)
|
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 class IosDownloadsTaskHandle(
|
||||||
private val job: Job,
|
private val job: Job,
|
||||||
) : DownloadsTaskHandle {
|
) : DownloadsTaskHandle {
|
||||||
private var task: NSURLSessionDownloadTask? = null
|
private var task: NSURLSessionTask? = null
|
||||||
private var session: NSURLSession? = null
|
private var session: NSURLSession? = null
|
||||||
|
|
||||||
fun attach(task: NSURLSessionDownloadTask, session: NSURLSession) {
|
fun attach(task: NSURLSessionTask, session: NSURLSession) {
|
||||||
this.task = task
|
this.task = task
|
||||||
this.session = session
|
this.session = session
|
||||||
}
|
}
|
||||||
|
|
@ -177,10 +205,14 @@ private class IosDownloadDelegate(
|
||||||
private val resumeFromBytes: Long,
|
private val resumeFromBytes: Long,
|
||||||
private val tempPath: String,
|
private val tempPath: String,
|
||||||
private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||||
) : NSObject(), NSURLSessionDownloadDelegateProtocol {
|
) : NSObject(), NSURLSessionDataDelegateProtocol {
|
||||||
private val completion = CompletableDeferred<IosDownloadResult>()
|
private val completion = CompletableDeferred<IosDownloadResult>()
|
||||||
private var result: IosDownloadResult? = null
|
private var result: IosDownloadResult? = null
|
||||||
private var fileError: Throwable? = null
|
private var fileError: Throwable? = null
|
||||||
|
private var outputFile: CPointer<FILE>? = null
|
||||||
|
private var startingBytesForResponse = 0L
|
||||||
|
private var bytesWrittenForResponse = 0L
|
||||||
|
private var totalBytesForResponse: Long? = null
|
||||||
private var lastProgressBytes = -1L
|
private var lastProgressBytes = -1L
|
||||||
private var lastProgressTimestampSeconds = 0.0
|
private var lastProgressTimestampSeconds = 0.0
|
||||||
|
|
||||||
|
|
@ -188,12 +220,13 @@ private class IosDownloadDelegate(
|
||||||
|
|
||||||
override fun URLSession(
|
override fun URLSession(
|
||||||
session: NSURLSession,
|
session: NSURLSession,
|
||||||
downloadTask: NSURLSessionDownloadTask,
|
dataTask: NSURLSessionDataTask,
|
||||||
didFinishDownloadingToURL: NSURL,
|
didReceiveResponse: NSURLResponse,
|
||||||
|
completionHandler: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val httpResponse = downloadTask.response as? NSHTTPURLResponse
|
val httpResponse = didReceiveResponse as? NSHTTPURLResponse
|
||||||
val statusCode = httpResponse?.statusCode?.toInt() ?: 200
|
val statusCode = httpResponse?.statusCode?.toInt() ?: 200
|
||||||
result = IosDownloadResult(
|
val nextResult = IosDownloadResult(
|
||||||
statusCode = statusCode,
|
statusCode = statusCode,
|
||||||
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
|
contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"),
|
||||||
contentLength = httpResponse
|
contentLength = httpResponse
|
||||||
|
|
@ -201,51 +234,59 @@ private class IosDownloadDelegate(
|
||||||
?.toLongOrNull()
|
?.toLongOrNull()
|
||||||
?.takeIf { it > 0L },
|
?.takeIf { it > 0L },
|
||||||
)
|
)
|
||||||
|
result = nextResult
|
||||||
|
|
||||||
if (statusCode !in 200..299) return
|
if (statusCode in 200..299) {
|
||||||
|
|
||||||
val sourcePath = didFinishDownloadingToURL.path
|
|
||||||
if (sourcePath.isNullOrBlank()) {
|
|
||||||
fileError = IllegalStateException("Downloaded file was not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
|
val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L
|
||||||
val stored = if (isPartialResume) {
|
startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L
|
||||||
appendFile(sourcePath, tempPath)
|
bytesWrittenForResponse = 0L
|
||||||
} else {
|
totalBytesForResponse = resolveTotalBytes(
|
||||||
removePathIfExists(tempPath) &&
|
startingBytes = startingBytesForResponse,
|
||||||
NSFileManager.defaultManager.moveItemAtPath(
|
isPartialResume = isPartialResume,
|
||||||
srcPath = sourcePath,
|
contentRangeHeader = nextResult.contentRange,
|
||||||
toPath = tempPath,
|
contentLength = nextResult.contentLength,
|
||||||
error = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
|
||||||
|
fileError = IllegalStateException("Failed to open partial download file")
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stored) {
|
reportProgress(startingBytesForResponse, totalBytesForResponse)
|
||||||
fileError = IllegalStateException("Failed to store download file")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
completionHandler(1L)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun URLSession(
|
override fun URLSession(
|
||||||
session: NSURLSession,
|
session: NSURLSession,
|
||||||
downloadTask: NSURLSessionDownloadTask,
|
dataTask: NSURLSessionDataTask,
|
||||||
didWriteData: Long,
|
didReceiveData: NSData,
|
||||||
totalBytesWritten: Long,
|
|
||||||
totalBytesExpectedToWrite: Long,
|
|
||||||
) {
|
) {
|
||||||
val statusCode = (downloadTask.response as? NSHTTPURLResponse)?.statusCode?.toInt()
|
if (fileError != null) return
|
||||||
val startingBytes = if (attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L) {
|
|
||||||
resumeFromBytes
|
val file = outputFile ?: run {
|
||||||
} else {
|
fileError = IllegalStateException("Partial download file is not open")
|
||||||
0L
|
return
|
||||||
}
|
}
|
||||||
val expectedTotal = totalBytesExpectedToWrite
|
|
||||||
.takeIf { it > 0L }
|
val bytesToWrite = didReceiveData.length.toLong()
|
||||||
?.let { startingBytes + it }
|
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(
|
reportProgress(
|
||||||
downloadedBytes = startingBytes + totalBytesWritten.coerceAtLeast(0L),
|
downloadedBytes = startingBytesForResponse + bytesWrittenForResponse,
|
||||||
totalBytes = expectedTotal,
|
totalBytes = totalBytesForResponse,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,6 +295,8 @@ private class IosDownloadDelegate(
|
||||||
task: NSURLSessionTask,
|
task: NSURLSessionTask,
|
||||||
didCompleteWithError: NSError?,
|
didCompleteWithError: NSError?,
|
||||||
) {
|
) {
|
||||||
|
closeOutputFile()
|
||||||
|
|
||||||
if (didCompleteWithError != null) {
|
if (didCompleteWithError != null) {
|
||||||
completion.completeExceptionally(
|
completion.completeExceptionally(
|
||||||
IllegalStateException(didCompleteWithError.localizedDescription),
|
IllegalStateException(didCompleteWithError.localizedDescription),
|
||||||
|
|
@ -275,6 +318,14 @@ private class IosDownloadDelegate(
|
||||||
backgroundSessionCompletionHandlers.remove(identifier)?.invoke()
|
backgroundSessionCompletionHandlers.remove(identifier)?.invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun closeOutputFile() {
|
||||||
|
outputFile?.let { file ->
|
||||||
|
fflush(file)
|
||||||
|
fclose(file)
|
||||||
|
}
|
||||||
|
outputFile = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun reportProgress(
|
private fun reportProgress(
|
||||||
downloadedBytes: Long,
|
downloadedBytes: Long,
|
||||||
totalBytes: Long?,
|
totalBytes: Long?,
|
||||||
|
|
@ -374,9 +425,11 @@ private suspend fun performDownloadRequest(
|
||||||
val session = NSURLSession.sessionWithConfiguration(
|
val session = NSURLSession.sessionWithConfiguration(
|
||||||
configuration = configuration,
|
configuration = configuration,
|
||||||
delegate = delegate,
|
delegate = delegate,
|
||||||
delegateQueue = NSOperationQueue(),
|
delegateQueue = NSOperationQueue().apply {
|
||||||
|
maxConcurrentOperationCount = 1
|
||||||
|
},
|
||||||
)
|
)
|
||||||
val task = session.downloadTaskWithRequest(nativeRequest)
|
val task = session.dataTaskWithRequest(nativeRequest)
|
||||||
|
|
||||||
handle.attach(task, session)
|
handle.attach(task, session)
|
||||||
onProgress(resumeFromBytes.coerceAtLeast(0L), null)
|
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)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
private fun fileSizeOrNull(path: String): Long? {
|
private fun fileSizeOrNull(path: String): Long? {
|
||||||
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null)
|
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null)
|
||||||
|
|
@ -439,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toLocalPath(): String? {
|
private fun String.toLocalPath(): String? {
|
||||||
if (startsWith("file://")) {
|
val value = trim()
|
||||||
return removePrefix("file://")
|
if (value.startsWith("file:")) {
|
||||||
|
return NSURL(string = value).path ?: value.removePrefix("file://")
|
||||||
}
|
}
|
||||||
return takeIf { it.isNotBlank() }
|
return value.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTotalBytes(
|
private fun resolveTotalBytes(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
CURRENT_PROJECT_VERSION=48
|
CURRENT_PROJECT_VERSION=49
|
||||||
MARKETING_VERSION=0.1.0
|
MARKETING_VERSION=0.1.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground()
|
||||||
|
}
|
||||||
|
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
|
|
|
||||||
|
|
@ -137,12 +137,22 @@ struct TrackInfo {
|
||||||
let selected: Bool
|
let selected: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PendingLoadRequest {
|
||||||
|
let urlString: String
|
||||||
|
let audioUrl: String?
|
||||||
|
let requestHeaders: [String: String]
|
||||||
|
let queuedAtUptime: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MPV Player View Controller
|
// MARK: - MPV Player View Controller
|
||||||
|
|
||||||
final class MPVPlayerViewController: UIViewController {
|
final class MPVPlayerViewController: UIViewController {
|
||||||
|
|
||||||
private let errorStateLock = NSLock()
|
private let errorStateLock = NSLock()
|
||||||
private var metalLayer = MetalLayer()
|
private var metalLayer = MetalLayer()
|
||||||
|
private var lastAppliedDrawableSize: CGSize = .zero
|
||||||
|
private var pendingLoadRequest: PendingLoadRequest?
|
||||||
|
private var pendingLoadRetryWorkItem: DispatchWorkItem?
|
||||||
private var mpv: OpaquePointer?
|
private var mpv: OpaquePointer?
|
||||||
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
|
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
|
||||||
private var recentPlaybackLogs: [String] = []
|
private var recentPlaybackLogs: [String] = []
|
||||||
|
|
@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
|
view.layer.masksToBounds = true
|
||||||
|
|
||||||
metalLayer.frame = view.bounds
|
metalLayer.contentsGravity = .resize
|
||||||
metalLayer.contentsScale = UIScreen.main.nativeScale
|
metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale
|
||||||
metalLayer.framebufferOnly = true
|
metalLayer.framebufferOnly = true
|
||||||
metalLayer.backgroundColor = UIColor.black.cgColor
|
metalLayer.backgroundColor = UIColor.black.cgColor
|
||||||
view.layer.addSublayer(metalLayer)
|
view.layer.addSublayer(metalLayer)
|
||||||
|
layoutMetalLayer()
|
||||||
|
|
||||||
setupMpv()
|
setupMpv()
|
||||||
setupNotifications()
|
setupNotifications()
|
||||||
|
|
@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
metalLayer.frame = view.bounds
|
layoutMetalLayer()
|
||||||
|
attemptStartPendingLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
refreshImmersiveSystemUI()
|
refreshImmersiveSystemUI()
|
||||||
|
attemptStartPendingLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewSafeAreaInsetsDidChange() {
|
override func viewSafeAreaInsetsDidChange() {
|
||||||
super.viewSafeAreaInsetsDidChange()
|
super.viewSafeAreaInsetsDidChange()
|
||||||
|
layoutMetalLayer()
|
||||||
refreshImmersiveSystemUI()
|
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
|
// MARK: - MPV Setup
|
||||||
|
|
@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
// MARK: - Playback API
|
// MARK: - Playback API
|
||||||
|
|
||||||
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
|
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 }
|
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()
|
clearPlaybackError()
|
||||||
let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders)
|
let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders)
|
||||||
activeRequestHeaders = sanitizedHeaders
|
activeRequestHeaders = sanitizedHeaders
|
||||||
applyRequestHeaders(sanitizedHeaders)
|
applyRequestHeaders(sanitizedHeaders)
|
||||||
isPlayerLoading = true
|
isPlayerLoading = true
|
||||||
isPlayerEnded = false
|
isPlayerEnded = false
|
||||||
command("loadfile", args: [urlString, "replace"])
|
command("loadfile", args: [request.urlString, "replace"])
|
||||||
if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||||
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
|
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() {
|
func playPlayback() {
|
||||||
guard mpv != nil else { return }
|
guard mpv != nil else { return }
|
||||||
setFlag("pause", false)
|
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, "panscan", "1.0"))
|
||||||
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
|
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
|
||||||
case 2: // Zoom
|
case 2: // Zoom
|
||||||
checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
|
checkError(mpv_set_option_string(mpv, "panscan", "1.0"))
|
||||||
checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big"))
|
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
|
||||||
default: // Fit
|
default: // Fit
|
||||||
checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
|
checkError(mpv_set_option_string(mpv, "panscan", "0.0"))
|
||||||
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
|
checkError(mpv_set_option_string(mpv, "video-unscaled", "no"))
|
||||||
|
|
@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController {
|
||||||
|
|
||||||
func destroyPlayer() {
|
func destroyPlayer() {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
pendingLoadRetryWorkItem?.cancel()
|
||||||
|
pendingLoadRetryWorkItem = nil
|
||||||
|
pendingLoadRequest = nil
|
||||||
clearPlaybackError()
|
clearPlaybackError()
|
||||||
guard let ctx = mpv else { return }
|
guard let ctx = mpv else { return }
|
||||||
mpv = nil // nil first so event loop stops reading
|
mpv = nil // nil first so event loop stops reading
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue