From 24794a67e9952c070bb30aad2a29493ac7809047 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:30:24 +0530 Subject: [PATCH 1/6] minor db deletion logic fix --- src/services/supabaseSyncService.ts | 116 ++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts index 6c4265bb..635baeb8 100644 --- a/src/services/supabaseSyncService.ts +++ b/src/services/supabaseSyncService.ts @@ -121,6 +121,8 @@ class SupabaseSyncService { private appStateSub: { remove: () => void } | null = null; private lastForegroundPullAt = 0; private readonly foregroundPullCooldownMs = 30000; + private pendingWatchProgressDeleteKeys = new Set(); + private watchProgressDeleteTimer: ReturnType | null = null; private pendingPushTimers: Record | null> = { plugins: null, @@ -544,7 +546,10 @@ class SupabaseSyncService { catalogService.onLibraryRemove(() => this.schedulePush('library')); storageService.subscribeToWatchProgressUpdates(() => this.schedulePush('watch_progress')); - storageService.onWatchProgressRemoved(() => this.schedulePush('watch_progress')); + storageService.onWatchProgressRemoved((id, type, episodeId) => { + this.schedulePush('watch_progress'); + this.scheduleWatchProgressDelete(id, type, episodeId); + }); watchedService.subscribeToWatchedUpdates(() => this.schedulePush('watched_items')); @@ -593,6 +598,91 @@ class SupabaseSyncService { }, DEFAULT_SYNC_DEBOUNCE_MS); } + private scheduleWatchProgressDelete(id: string, type: string, episodeId?: string): void { + if (!this.isConfigured() || this.suppressPushes) return; + + const keys = this.resolveWatchProgressDeleteKeys(id, type, episodeId); + if (keys.length === 0) return; + keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key)); + + if (this.watchProgressDeleteTimer) { + clearTimeout(this.watchProgressDeleteTimer); + } + this.watchProgressDeleteTimer = setTimeout(() => { + this.watchProgressDeleteTimer = null; + this.flushWatchProgressDeletes().catch((error) => { + logger.error('[SupabaseSyncService] watch progress delete flush failed:', error); + }); + }, DEFAULT_SYNC_DEBOUNCE_MS); + } + + private async flushWatchProgressDeletes(): Promise { + if (!this.isConfigured() || this.suppressPushes) return; + + const keys = Array.from(this.pendingWatchProgressDeleteKeys); + if (keys.length === 0) return; + this.pendingWatchProgressDeleteKeys.clear(); + + await this.initialize(); + if (!this.session) { + keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key)); + return; + } + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) return; + + try { + logger.log(`[SupabaseSyncService] flushWatchProgressDeletes: deleting ${keys.length} keys`); + await this.callRpc('sync_delete_watch_progress', { p_keys: keys }); + } catch (error) { + keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key)); + throw error; + } + } + + private resolveWatchProgressDeleteKeys(id: string, type: string, episodeId?: string): string[] { + const contentId = (id || '').trim(); + const contentType = (type || '').trim().toLowerCase(); + if (!contentId || !contentType) return []; + + const keys = new Set(); + + if (contentType === 'movie') { + keys.add(contentId); + return Array.from(keys); + } + + // Always delete the series mirror key when removing series progress. + keys.add(contentId); + + const normalizedEpisodeId = (episodeId || '').trim(); + if (normalizedEpisodeId) { + const parsed = this.parseSeasonEpisodeFromEpisodeId(normalizedEpisodeId); + if (parsed) { + keys.add(`${contentId}_s${parsed.season}e${parsed.episode}`); + } else { + // Fallback for any non-standard legacy progress_key format. + keys.add(`${contentId}_${normalizedEpisodeId}`); + } + } + + return Array.from(keys); + } + + private parseSeasonEpisodeFromEpisodeId( + episodeId: string + ): { season: number; episode: number } | null { + const match = episodeId.match(/(?:^|:)(\d+):(\d+)$/); + if (!match) return null; + + const season = Number(match[1]); + const episode = Number(match[2]); + if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; + + return { season, episode }; + } + private async executeScheduledPush(target: PushTarget): Promise { await this.initialize(); if (!this.session) return; @@ -1194,29 +1284,7 @@ class SupabaseSyncService { ); } - // Reconcile removals only when remote has at least one entry to avoid wiping local - // data if backend temporarily returns an empty set. - if (remoteSet.size > 0) { - const allLocal = await storageService.getAllWatchProgress(); - let removedCount = 0; - - for (const [key] of Object.entries(allLocal)) { - const parsed = this.parseWatchProgressKey(key); - if (!parsed) continue; - const localSig = `${parsed.contentType}:${parsed.contentId}:${parsed.season ?? ''}:${parsed.episode ?? ''}`; - if (remoteSet.has(localSig)) continue; - - const episodeId = parsed.contentType === 'series' && parsed.season != null && parsed.episode != null - ? `${parsed.contentId}:${parsed.season}:${parsed.episode}` - : undefined; - - await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId); - removedCount += 1; - } - logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: removedLocalExtras=${removedCount}`); - } else { - logger.log('[SupabaseSyncService] pullWatchProgressToLocal: remote set empty, skipped local prune'); - } + logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: merged ${(rows || []).length} remote entries (no local prune)`); } private async pushWatchProgressFromLocal(): Promise { From 29e5dee0014ecc8e740422c396182244e12356b2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:51:36 +0530 Subject: [PATCH 2/6] improve episode progress removal logic and normalize episode IDs --- .../home/ContinueWatchingSection.tsx | 10 ++-- src/services/storageService.ts | 51 +++++++++++++++++-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 62bdd020..586d66e1 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -1817,15 +1817,15 @@ const ContinueWatchingSection = React.forwardRef((props, re try { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - // For series episodes, only remove the specific episode's local progress - // Don't add a base tombstone which would block all episodes of the series + // For series episodes, remove only that episode's progress. + // Do not wipe all series entries. const isEpisode = selectedItem.type === 'series' && selectedItem.season && selectedItem.episode; if (isEpisode) { - // Only remove local progress for this specific episode (no base tombstone) - await storageService.removeAllWatchProgressForContent( + const episodeId = `${selectedItem.id}:${selectedItem.season}:${selectedItem.episode}`; + await storageService.removeWatchProgress( selectedItem.id, selectedItem.type, - { addBaseTombstone: false } + episodeId ); } else { // For movies or whole series, add the base tombstone diff --git a/src/services/storageService.ts b/src/services/storageService.ts index bd3b1484..74402527 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -80,6 +80,29 @@ class StorageService { return `${type}:${id}${episodeId ? `:${episodeId}` : ''}`; } + private normalizeContinueWatchingEpisodeRemoveId(id: string, episodeId?: string): string | undefined { + if (!episodeId) return undefined; + const normalizedId = id?.trim(); + const normalizedEpisodeId = episodeId.trim(); + if (!normalizedId || !normalizedEpisodeId) return undefined; + + const colonMatch = normalizedEpisodeId.match(/(?:^|:)(\d+):(\d+)$/); + if (colonMatch) { + return `${normalizedId}:${colonMatch[1]}:${colonMatch[2]}`; + } + + const sxeMatch = normalizedEpisodeId.match(/s(\d+)e(\d+)/i); + if (sxeMatch) { + return `${normalizedId}:${sxeMatch[1]}:${sxeMatch[2]}`; + } + + if (normalizedEpisodeId.startsWith(`${normalizedId}:`)) { + return normalizedEpisodeId; + } + + return `${normalizedId}:${normalizedEpisodeId}`; + } + public async addWatchProgressTombstone( id: string, type: string, @@ -274,12 +297,30 @@ class StorageService { try { const removedMap = await this.getContinueWatchingRemoved(); - const removedKey = this.buildWpKeyString(id, type); - const removedAt = removedMap[removedKey]; + const removeCandidates: Array<{ removeId: string; key: string }> = []; - if (removedAt != null && timestamp > removedAt) { - logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`); - await this.removeContinueWatchingRemoved(id, type); + const baseRemoveId = (id || '').trim(); + if (baseRemoveId) { + removeCandidates.push({ + removeId: baseRemoveId, + key: this.buildWpKeyString(baseRemoveId, type), + }); + } + + const episodeRemoveId = this.normalizeContinueWatchingEpisodeRemoveId(id, episodeId); + if (episodeRemoveId && episodeRemoveId !== baseRemoveId) { + removeCandidates.push({ + removeId: episodeRemoveId, + key: this.buildWpKeyString(episodeRemoveId, type), + }); + } + + for (const candidate of removeCandidates) { + const removedAt = removedMap[candidate.key]; + if (removedAt != null && timestamp > removedAt) { + logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${candidate.key}`); + await this.removeContinueWatchingRemoved(candidate.removeId, type); + } } } catch (e) { // Ignore error checks for restoration to prevent blocking save From a05a16f67b4ea25c0c8763674743be4dc64c30e5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:59:25 +0530 Subject: [PATCH 3/6] implement bounded concurrency for catalog loading and add loading screen timeout --- src/screens/HomeScreen.tsx | 49 ++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e5dd2eb1..4e710706 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -74,6 +74,8 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; +const MAX_CONCURRENT_CATALOG_REQUESTS = 4; +const HOME_LOADING_SCREEN_TIMEOUT_MS = 5000; // In-memory cache for catalog settings to avoid repeated MMKV reads let cachedCatalogSettings: Record | null = null; @@ -134,6 +136,7 @@ const HomeScreen = () => { const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); const [hasAddons, setHasAddons] = useState(null); const [hintVisible, setHintVisible] = useState(false); + const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false); const totalCatalogsRef = useRef(0); const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory const insets = useSafeAreaInsets(); @@ -185,6 +188,7 @@ const HomeScreen = () => { if (isFetchingRef.current) return; isFetchingRef.current = true; + setLoadingScreenTimedOut(false); setCatalogsLoading(true); setCatalogs([]); setLoadedCatalogCount(0); @@ -210,6 +214,7 @@ const HomeScreen = () => { catalogService.getAllAddons(), stremioService.getInstalledAddonsAsync() ]); + const manifestByAddonId = new Map(addonManifests.map((manifest: any) => [manifest.id, manifest])); // Set hasAddons state based on whether we have any addons - ensure on main thread InteractionManager.runAfterInteractions(() => { @@ -220,14 +225,20 @@ const HomeScreen = () => { let catalogIndex = 0; const catalogQueue: (() => Promise)[] = []; - // Launch all catalog loaders in parallel - const launchAllCatalogs = () => { - while (catalogQueue.length > 0) { - const catalogLoader = catalogQueue.shift(); - if (catalogLoader) { - catalogLoader(); + // Launch loaders with bounded concurrency to reduce startup pressure + const launchCatalogLoaders = () => { + const workerCount = Math.min(MAX_CONCURRENT_CATALOG_REQUESTS, catalogQueue.length); + const workers = Array.from({ length: workerCount }, async () => { + while (catalogQueue.length > 0) { + const catalogLoader = catalogQueue.shift(); + if (!catalogLoader) return; + await catalogLoader(); } - } + }); + + void Promise.all(workers).catch((error) => { + if (__DEV__) console.warn('[HomeScreen] Catalog loader worker failed:', error); + }); }; for (const addon of addons) { @@ -243,7 +254,7 @@ const HomeScreen = () => { const catalogLoader = async () => { try { - const manifest = addonManifests.find((a: any) => a.id === addon.id); + const manifest = manifestByAddonId.get(addon.id); if (!manifest) return; const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); @@ -345,8 +356,8 @@ const HomeScreen = () => { setCatalogs(new Array(catalogIndex).fill(null)); }); - // Start all catalog requests in parallel - launchAllCatalogs(); + // Start catalog requests with bounded concurrency + launchCatalogLoaders(); } catch (error) { if (__DEV__) console.error('[HomeScreen] Error in progressive catalog loading:', error); InteractionManager.runAfterInteractions(() => { @@ -356,14 +367,29 @@ const HomeScreen = () => { } }, []); + // Hard cap for initial home loading spinner. + // Keeps Home responsive even if one or more catalog addons are slow. + useEffect(() => { + if (!(catalogsLoading && loadedCatalogCount === 0)) { + return; + } + + const timer = setTimeout(() => { + setLoadingScreenTimedOut(true); + }, HOME_LOADING_SCREEN_TIMEOUT_MS); + + return () => clearTimeout(timer); + }, [catalogsLoading, loadedCatalogCount]); + // Only count feature section as loading if it's enabled in settings // For catalogs, we show them progressively, so loading should be false as soon as we have any content const isLoading = useMemo(() => { + if (loadingScreenTimedOut) return false; // Exit loading as soon as at least one catalog is ready, regardless of featured if (loadedCatalogCount > 0) return false; const heroLoading = showHeroSection ? featuredLoading : false; return heroLoading && (catalogsLoading && loadedCatalogCount === 0); - }, [showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount]); + }, [loadingScreenTimedOut, showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount]); // Update global loading state useEffect(() => { @@ -1482,4 +1508,3 @@ const HomeScreenWithFocusSync = (props: any) => { }; export default React.memo(HomeScreenWithFocusSync); - From 55afd3fd56be2db4895277ec580db75f182c789b Mon Sep 17 00:00:00 2001 From: sandra Date: Fri, 20 Feb 2026 17:50:20 +0100 Subject: [PATCH 4/6] Add Catalan language --- src/constants/locales.ts | 1 + src/i18n/locales/ca.json | 1433 +++++++++++++++++ src/i18n/locales/en.json | 1 + src/i18n/resources.ts | 2 + src/screens/TMDBSettingsScreen.tsx | 3 +- .../settings/PlaybackSettingsScreen.tsx | 3 +- 6 files changed, 1441 insertions(+), 2 deletions(-) create mode 100644 src/i18n/locales/ca.json diff --git a/src/constants/locales.ts b/src/constants/locales.ts index b0845091..7fe86307 100644 --- a/src/constants/locales.ts +++ b/src/constants/locales.ts @@ -23,4 +23,5 @@ export const LOCALES = [ { code: 'nl-NL', key: 'dutch_nl' }, { code: 'ro', key: 'romanian' }, { code: 'sq', key: 'albanian' }, + { code: 'ca', key: 'catalan' }, ]; diff --git a/src/i18n/locales/ca.json b/src/i18n/locales/ca.json new file mode 100644 index 00000000..bbfd8995 --- /dev/null +++ b/src/i18n/locales/ca.json @@ -0,0 +1,1433 @@ +{ + "common": { + "loading": "Carregant...", + "cancel": "Cancel·la", + "save": "Desa", + "delete": "Suprimeix", + "edit": "Edita", + "search": "Cerca", + "error": "Error", + "success": "Correcte", + "ok": "D'acord", + "unknown": "Desconegut", + "retry": "Reintenta", + "try_again": "Torna-ho a intentar", + "go_back": "Enrere", + "settings": "Configuració", + "close": "Tanca", + "enable": "Activa", + "disable": "Desactiva", + "show_more": "Mostra'n més", + "show_less": "Mostra'n menys", + "load_more": "Carrega'n més", + "unknown_date": "Data desconeguda", + "anonymous_user": "Usuari anònim", + "time": { + "now": "Ara mateix", + "minutes_ago": "Fa {{count}}m", + "hours_ago": "Fa {{count}}h", + "days_ago": "Fa {{count}}d" + }, + "days_short": { + "sun": "Dg", + "mon": "Dl", + "tue": "Dt", + "wed": "Dc", + "thu": "Dj", + "fri": "Dv", + "sat": "Ds" + }, + "email": "Correu electrònic", + "status": "Estat" + }, + "home": { + "categories": { + "movies": "Pel·lícules", + "series": "Sèries", + "channels": "Canals" + }, + "movies": "Pel·lícules", + "tv_shows": "Sèries de televisió", + "load_more_catalogs": "Carrega més catàlegs", + "no_content": "No hi ha contingut disponible", + "add_catalogs": "Afegeix catàlegs", + "sign_in_available": "Inici de sessió disponible", + "sign_in_desc": "Pots iniciar sessió en qualsevol moment des de Configuració → Compte", + "view_all": "Veure-ho tot", + "this_week": "Aquesta setmana", + "upcoming": "Pròximament", + "recently_released": "Publicat recentment", + "no_scheduled_episodes": "Sèries sense episodis programats", + "check_back_later": "Torna-hi més tard", + "continue_watching": "Continua veient", + "up_next": "A continuació", + "up_next_caps": "A CONTINUACIÓ", + "released": "Publicat", + "new": "Nou", + "tba": "Per confirmar", + "new_episodes": "{{count}} episodis nous", + "season_short": "T{{season}}", + "episode_short": "E{{episode}}", + "season": "Temporada {{season}}", + "episode": "Episodi {{episode}}", + "movie": "Pel·lícula", + "series": "Sèrie", + "tv_show": "Sèrie de televisió", + "percent_watched": "{{percent}}% vist", + "view_details": "Veure detalls", + "remove": "Elimina", + "play": "Reprodueix", + "play_now": "Reprodueix ara", + "resume": "Reprèn", + "info": "Informació", + "more_info": "Més informació", + "my_list": "La meva llista", + "save": "Desa", + "saved": "Desat", + "retry": "Reintenta", + "install_addons": "Instal·la complements", + "settings": "Configuració", + "no_featured_content": "No hi ha contingut destacat", + "couldnt_load_featured": "No s'ha pogut carregar el contingut destacat", + "no_featured_desc": "Instal·la complements amb catàlegs o canvia la font de contingut a la configuració.", + "load_error_desc": "S'ha produït un error en obtenir el contingut destacat. Comprova la connexió i torna-ho a intentar.", + "no_featured_available": "No hi ha contingut destacat disponible", + "no_description": "No hi ha descripció disponible" + }, + "navigation": { + "home": "Inici", + "library": "Biblioteca", + "search": "Cerca", + "downloads": "Baixades", + "settings": "Configuració" + }, + "search": { + "title": "Cerca", + "recent_searches": "Cerques recents", + "discover": "Descobreix", + "movies": "Pel·lícules", + "tv_shows": "Sèries de televisió", + "select_catalog": "Selecciona un catàleg", + "all_genres": "Tots els gèneres", + "discovering": "S'està descobrint contingut...", + "show_more": "Mostra'n més ({{count}})", + "no_content_found": "No s'ha trobat cap contingut", + "try_different": "Prova un gènere o catàleg diferent", + "select_catalog_desc": "Selecciona un catàleg per descobrir", + "tap_catalog_desc": "Toca el botó de catàleg de dalt per començar", + "placeholder": "Cerca pel·lícules, sèries...", + "keep_typing": "Continua escrivint...", + "type_characters": "Escriu almenys 2 caràcters per cercar", + "no_results": "No s'han trobat resultats", + "try_keywords": "Prova paraules clau diferents o comprova l'ortografia", + "select_type": "Selecciona el tipus", + "browse_movies": "Explora catàlegs de pel·lícules", + "browse_tv": "Explora catàlegs de sèries de televisió", + "select_genre": "Selecciona el gènere", + "show_all_content": "Mostra tot el contingut", + "genres_count": "{{count}} gèneres" + }, + "library": { + "title": "Biblioteca", + "watched": "Vistos", + "continue": "Continua", + "watchlist": "Llista per veure", + "collection": "Col·lecció", + "rated": "Valorats", + "items": "elements", + "trakt_collections": "Col·leccions de Trakt", + "trakt_collection": "Col·lecció de Trakt", + "no_trakt": "Cap col·lecció de Trakt", + "no_trakt_desc": "Les teves col·leccions de Trakt apareixeran aquí quan comencis a usar Trakt", + "load_collections": "Carrega col·leccions", + "empty_folder": "No hi ha contingut a {{folder}}", + "empty_folder_desc": "Aquesta col·lecció és buida", + "refresh": "Actualitza", + "no_movies": "Encara no hi ha pel·lícules", + "no_series": "Encara no hi ha sèries de televisió", + "no_content": "Encara no hi ha contingut", + "add_content_desc": "Afegeix contingut a la biblioteca per veure'l aquí", + "find_something": "Troba alguna cosa per veure", + "removed_from_library": "Eliminat de la biblioteca", + "item_removed": "Element eliminat de la biblioteca", + "failed_update_library": "No s'ha pogut actualitzar la biblioteca", + "unable_remove": "No s'ha pogut eliminar l'element de la biblioteca", + "marked_watched": "Marcat com a vist", + "marked_unwatched": "Marcat com a no vist", + "item_marked_watched": "Element marcat com a vist", + "item_marked_unwatched": "Element marcat com a no vist", + "failed_update_watched": "No s'ha pogut actualitzar l'estat de visualització", + "unable_update_watched": "No s'ha pogut actualitzar l'estat de visualització", + "added_to_library": "Afegit a la biblioteca", + "item_added": "Afegit a la biblioteca local", + "add_to_library": "Afegeix a la biblioteca", + "remove_from_library": "Elimina de la biblioteca", + "mark_watched": "Marca com a vist", + "mark_unwatched": "Marca com a no vist", + "share": "Comparteix", + "add_to_watchlist": "Afegeix a la llista de Trakt", + "remove_from_watchlist": "Elimina de la llista de Trakt", + "added_to_watchlist": "Afegit a la llista", + "added_to_watchlist_desc": "Afegit a la llista de Trakt", + "removed_from_watchlist": "Eliminat de la llista", + "removed_from_watchlist_desc": "Eliminat de la llista de Trakt", + "add_to_collection": "Afegeix a la col·lecció de Trakt", + "remove_from_collection": "Elimina de la col·lecció de Trakt", + "added_to_collection": "Afegit a la col·lecció", + "added_to_collection_desc": "Afegit a la col·lecció de Trakt", + "removed_from_collection": "Eliminat de la col·lecció", + "removed_from_collection_desc": "Eliminat de la col·lecció de Trakt" + }, + "metadata": { + "unable_to_load": "No es pot carregar el contingut", + "error_code": "Codi d'error: {{code}}", + "content_not_found": "Contingut no trobat", + "content_not_found_desc": "Aquest contingut no existeix o pot haver estat eliminat.", + "server_error": "Error del servidor", + "server_error_desc": "El servidor no està disponible temporalment. Torna-ho a intentar més tard.", + "bad_gateway": "Passarel·la incorrecta", + "bad_gateway_desc": "El servidor té problemes. Torna-ho a intentar més tard.", + "service_unavailable": "Servei no disponible", + "service_unavailable_desc": "El servei no està disponible per manteniment. Torna-ho a intentar més tard.", + "too_many_requests": "Massa sol·licituds", + "too_many_requests_desc": "Estàs fent massa sol·licituds. Espera un moment i torna-ho a intentar.", + "request_timeout": "La sol·licitud ha expirat", + "request_timeout_desc": "La sol·licitud ha tardat massa. Torna-ho a intentar.", + "network_error": "Error de xarxa", + "network_error_desc": "Comprova la connexió a Internet i torna-ho a intentar.", + "auth_error": "Error d'autenticació", + "auth_error_desc": "Comprova la configuració del compte i torna-ho a intentar.", + "access_denied": "Accés denegat", + "access_denied_desc": "No tens permís per accedir a aquest contingut.", + "connection_error": "Error de connexió", + "streams_unavailable": "Transmissions no disponibles", + "streams_unavailable_desc": "Les fonts de transmissió no estan disponibles ara mateix. Torna-ho a intentar més tard.", + "unknown_error": "Error desconegut", + "something_went_wrong": "Alguna cosa ha anat malament. Torna-ho a intentar.", + "cast": "Repartiment", + "more_like_this": "Més com aquest", + "collection": "Col·lecció", + "episodes": "Episodis", + "seasons": "Temporades", + "posters": "Cartells", + "banners": "Bàners", + "specials": "Especials", + "season_number": "Temporada {{number}}", + "episode_count": "{{count}} episodi", + "episode_count_plural": "{{count}} episodis", + "no_episodes": "No hi ha episodis disponibles", + "no_episodes_for_season": "No hi ha episodis disponibles per a la temporada {{season}}", + "episodes_not_released": "És possible que els episodis encara no s'hagin publicat", + "no_description": "No hi ha descripció disponible", + "episode_label": "EPISODI {{number}}", + "watch_again": "Torna a veure", + "completed": "Completat", + "play_episode": "Reprodueix T{{season}}E{{episode}}", + "play": "Reprodueix", + "watched": "Vist", + "watched_on_trakt": "Vist a Trakt", + "synced_with_trakt": "Sincronitzat amb Trakt", + "saved": "Desat", + "director": "Director", + "directors": "Directors", + "creator": "Creador", + "creators": "Creadors", + "production": "Producció", + "network": "Cadena", + "mark_watched": "Marca com a vist", + "mark_unwatched": "Marca com a no vist", + "marking": "Marcant...", + "removing": "Eliminant...", + "unmark_season": "Desmarca la temporada {{season}}", + "mark_season": "Marca la temporada {{season}}", + "resume": "Reprèn", + "spoiler_warning": "Avís de spoiler", + "spoiler_warning_desc": "Aquest comentari conté spoilers. Estàs segur que el vols revelar?", + "cancel": "Cancel·la", + "reveal_spoilers": "Revela els spoilers", + "movie_details": "Detalls de la pel·lícula", + "show_details": "Detalls de la sèrie", + "tagline": "Eslògan", + "status": "Estat", + "release_date": "Data de llançament", + "runtime": "Durada", + "budget": "Pressupost", + "revenue": "Recaptació", + "origin_country": "País d'origen", + "original_language": "Llengua original", + "first_air_date": "Data de primera emissió", + "last_air_date": "Data de darrera emissió", + "total_episodes": "Total d'episodis", + "episode_runtime": "Durada de l'episodi", + "created_by": "Creat per", + "backdrop_gallery": "Galeria de fons", + "loading_episodes": "Carregant episodis...", + "no_episodes_available": "No hi ha episodis disponibles", + "play_next": "Reprodueix T{{season}}E{{episode}}", + "play_next_episode": "Reprodueix el pròxim episodi", + "save": "Desa", + "percent_watched": "{{percent}}% vist", + "percent_watched_trakt": "{{percent}}% vist ({{traktPercent}}% a Trakt)", + "synced_with_trakt_progress": "Sincronitzat amb Trakt", + "using_trakt_progress": "S'utilitza el progrés de Trakt", + "added_to_collection_hero": "Afegit a la col·lecció", + "added_to_collection_desc_hero": "Afegit a la col·lecció de Trakt", + "removed_from_collection_hero": "Eliminat de la col·lecció", + "removed_from_collection_desc_hero": "Eliminat de la col·lecció de Trakt", + "mark_as_watched": "Marca com a vist", + "mark_as_unwatched": "Marca com a no vist" + }, + "cast": { + "biography": "Biografia", + "known_for": "Conegut per", + "personal_info": "Informació personal", + "born_in": "Nascut a {{place}}", + "filmography": "Filmografia", + "also_known_as": "També conegut com", + "no_info_available": "No hi ha informació addicional disponible", + "as_character": "com a {{character}}", + "loading_details": "Carregant detalls...", + "years_old": "{{age}} anys", + "view_filmography": "Veure la filmografia", + "filter": "Filtra", + "sort_by": "Ordena per", + "sort_popular": "Popular", + "sort_latest": "Més recent", + "sort_upcoming": "Pròximament", + "upcoming_badge": "PRÒXIMAMENT", + "coming_soon": "Pròximament", + "filmography_count": "Filmografia • {{count}} títols", + "loading_filmography": "Carregant filmografia...", + "load_more_remaining": "Carrega'n més ({{count}} restants)", + "alert_error_title": "Error", + "alert_error_message": "No es pot carregar «{{title}}». Torna-ho a intentar més tard.", + "alert_ok": "D'acord", + "no_upcoming": "No hi ha estrenes pròximes disponibles per a aquest actor", + "no_content": "No hi ha contingut disponible per a aquest actor", + "no_movies": "No hi ha pel·lícules disponibles per a aquest actor", + "no_tv": "No hi ha sèries de televisió disponibles per a aquest actor" + }, + "comments": { + "title": "Comentaris de Trakt", + "spoiler_warning": "⚠️ Aquest comentari conté spoilers. Toca per revelar-los.", + "spoiler": "Spoiler", + "contains_spoilers": "Conté spoilers", + "reveal": "Revela", + "vip": "VIP", + "unavailable": "Comentaris no disponibles", + "no_comments": "Encara no hi ha comentaris a Trakt", + "not_in_database": "És possible que aquest contingut encara no estigui a la base de dades de Trakt", + "check_trakt": "Vés a Trakt" + }, + "trailers": { + "title": "Tràilers", + "official_trailers": "Tràilers oficials", + "official_trailer": "Tràiler oficial", + "teasers": "Teasers", + "teaser": "Teaser", + "clips_scenes": "Clips i escenes", + "clip": "Clip", + "featurettes": "Featurettes", + "featurette": "Featurette", + "behind_the_scenes": "Darrere de les càmeres", + "no_trailers": "No hi ha tràilers disponibles", + "unavailable": "Tràiler no disponible", + "unavailable_desc": "No s'ha pogut carregar aquest tràiler. Torna-ho a intentar més tard.", + "unable_to_play": "No es pot reproduir el tràiler. Torna-ho a intentar.", + "watch_on_youtube": "Veure a YouTube" + }, + "catalog": { + "no_content_found": "No s'ha trobat cap contingut", + "no_content_filters": "No s'ha trobat contingut amb els filtres seleccionats", + "loading_content": "Carregant contingut...", + "back": "Enrere", + "in_theaters": "En cartellera", + "all": "Tot", + "failed_tmdb": "No s'ha pogut carregar el contingut de TMDB", + "movies": "Pel·lícules", + "tv_shows": "Sèries de televisió", + "channels": "Canals" + }, + "streams": { + "back_to_episodes": "Torna als episodis", + "back_to_info": "Torna a la informació", + "fetching_from": "Obtenint de:", + "no_sources_available": "No hi ha fonts de transmissió disponibles", + "add_sources_desc": "Afegeix fonts de transmissió a la configuració", + "add_sources": "Afegeix fonts", + "finding_streams": "Cercant transmissions disponibles...", + "finding_best_stream": "Cercant la millor transmissió per a la reproducció automàtica...", + "still_fetching": "Encara s'estan obtenint transmissions…", + "no_streams_available": "No hi ha transmissions disponibles", + "starting_best_stream": "Iniciant la millor transmissió...", + "loading_more_sources": "Carregant més fonts..." + }, + "player_ui": { + "via": "via {{name}}", + "audio_tracks": "Pistes d'àudio", + "no_audio_tracks": "No hi ha pistes d'àudio disponibles", + "playback_speed": "Velocitat de reproducció", + "on_hold": "En espera", + "playback_error": "Error de reproducció", + "unknown_error": "S'ha produït un error desconegut durant la reproducció.", + "copy_error": "Copia els detalls de l'error", + "copied_to_clipboard": "Copiat al porta-retalls", + "dismiss": "Descarta", + "continue_watching": "Continua veient", + "start_over": "Comença de nou", + "resume": "Reprèn", + "change_source": "Canvia la font", + "switching_source": "Canviant de font...", + "no_sources_found": "No s'han trobat fonts", + "sources": "Fonts", + "finding_sources": "Cercant fonts...", + "unknown_source": "Font desconeguda", + "sources_limited": "Les fonts poden ser limitades a causa d'errors del proveïdor.", + "episodes": "Episodis", + "specials": "Especials", + "season": "Temporada {{season}}", + "stream": "Transmissió {{number}}", + "subtitles": "Subtítols", + "built_in": "Integrat", + "addons": "Complements", + "style": "Estil", + "none": "Cap", + "search_online_subtitles": "Cerca subtítols en línia", + "preview": "Previsualització", + "quick_presets": "Configuracions ràpides", + "default": "Per defecte", + "yellow": "Groc", + "high_contrast": "Alt contrast", + "large": "Gran", + "core": "Bàsic", + "font_size": "Mida de la lletra", + "show_background": "Mostra el fons", + "advanced": "Avançat", + "position": "Posició", + "text_color": "Color del text", + "align": "Alineació", + "bottom_offset": "Desplaçament inferior", + "background_opacity": "Opacitat del fons", + "text_shadow": "Ombra del text", + "on": "Activat", + "off": "Desactivat", + "outline_color": "Color del contorn", + "outline": "Contorn", + "outline_width": "Amplada del contorn", + "letter_spacing": "Espaiat entre lletres", + "line_height": "Altura de línia", + "timing_offset": "Desplaçament de temps (s)", + "visual_sync": "Sincronització visual", + "timing_hint": "Ajusta els subtítols cap enrere (-) o cap endavant (+) per sincronitzar-los si cal.", + "reset_defaults": "Restableix els valors per defecte", + "mark_intro_start": "Marca l'inici de la introducció", + "mark_intro_end": "Marca el final de la introducció", + "intro_start_marked": "Inici de la introducció marcat", + "intro_submitted": "Introducció enviada correctament", + "intro_submit_failed": "No s'ha pogut enviar la introducció" + }, + "downloads": { + "title": "Baixades", + "no_downloads": "Encara no hi ha baixades", + "no_downloads_desc": "El contingut baixat apareixerà aquí per a la visualització sense connexió", + "explore": "Explora contingut", + "path_copied": "Ruta copiada", + "path_copied_desc": "Ruta del fitxer local copiada al porta-retalls", + "copied": "Copiat", + "incomplete": "Baixada incompleta", + "incomplete_desc": "La baixada encara no ha acabat", + "not_available": "No disponible", + "not_available_desc": "La ruta del fitxer local només estarà disponible quan la baixada hagi acabat.", + "status_downloading": "Baixant", + "status_completed": "Completada", + "status_paused": "En pausa", + "status_error": "Error", + "status_queued": "En cua", + "status_unknown": "Desconegut", + "provider": "Proveïdor", + "streaming_playlist_warning": "Pot no reproduir-se - llista de reproducció en streaming", + "remaining": "restant", + "not_ready": "Baixada no llesta", + "not_ready_desc": "Espera que la baixada s'acabi.", + "filter_all": "Tot", + "filter_active": "Actives", + "filter_done": "Acabades", + "filter_paused": "En pausa", + "no_filter_results": "No hi ha baixades {{filter}}", + "try_different_filter": "Prova de seleccionar un filtre diferent", + "limitations_title": "Limitacions de les baixades", + "limitations_msg": "• Els fitxers de menys d'1 MB solen ser llistes de reproducció M3U8 en streaming i no es poden baixar per a la visualització sense connexió. Aquests només funcionen amb el streaming en línia i contenen enllaços a segments de vídeo, no el contingut de vídeo real.", + "remove_title": "Elimina la baixada", + "remove_confirm": "Elimina «{{title}}»{{season_episode}}?", + "cancel": "Cancel·la", + "remove": "Elimina" + }, + "addons": { + "title": "Complements", + "reorder_mode": "Mode de reordenació", + "reorder_info": "Els complements de dalt tenen prioritat més alta quan es carrega el contingut", + "add_addon_placeholder": "URL del complement", + "add_button": "Afegeix un complement", + "my_addons": "Els meus complements", + "community_addons": "Complements de la comunitat", + "no_addons": "No hi ha complements instal·lats", + "uninstall_title": "Desinstal·la el complement", + "uninstall_message": "Estàs segur que vols desinstal·lar {{name}}?", + "uninstall_button": "Desinstal·la", + "install_success": "Complement instal·lat correctament", + "install_error": "No s'ha pogut instal·lar el complement", + "load_error": "No s'han pogut carregar els complements", + "fetch_error": "No s'han pogut obtenir els detalls del complement", + "invalid_url": "Introdueix una URL de complement", + "configure": "Configura", + "version": "Versió: {{version}}", + "installed_addons": "COMPLEMENTS INSTAL·LATS", + "reorder_drag_title": "ARROSSEGA ELS COMPLEMENTS PER REORDENAR-LOS", + "install": "Instal·la", + "config_unavailable_title": "Configuració no disponible", + "config_unavailable_msg": "No s'ha pogut determinar la URL de configuració d'aquest complement.", + "cannot_open_config_title": "No es pot obrir la configuració", + "cannot_open_config_msg": "La URL de configuració ({{url}}) no es pot obrir. És possible que el complement no tingui cap pàgina de configuració.", + "description": "Descripció", + "supported_types": "Tipus admesos", + "catalogs": "Catàlegs", + "no_description": "No hi ha descripció disponible", + "overview": "RESUM", + "no_categories": "Cap categoria", + "pre_installed": "PREINSTAL·LAT" + }, + "trakt": { + "title": "Configuració de Trakt", + "settings_title": "Configuració de Trakt", + "connect_title": "Connecta amb Trakt", + "connect_desc": "Sincronitza l'historial de visualitzacions, la llista per veure i la col·lecció amb Trakt.tv", + "sign_in": "Inicia sessió amb Trakt", + "sign_out": "Tanca la sessió", + "sign_out_confirm": "Estàs segur que vols tancar la sessió del compte de Trakt?", + "joined": "S'hi va unir el {{date}}", + "sync_settings_title": "Configuració de sincronització", + "sync_info": "Quan estàs connectat a Trakt, l'historial complet es sincronitza directament des de l'API i no s'escriu a l'emmagatzematge local. La llista de Continua veient reflecteix el progrés global de Trakt.", + "auto_sync_label": "Sincronització automàtica del progrés de reproducció", + "auto_sync_desc": "Sincronitza automàticament el progrés de visualització amb Trakt", + "import_history_label": "Importa l'historial de visualitzacions", + "import_history_desc": "Usa «Sincronitza ara» per importar l'historial de visualitzacions i el progrés de Trakt", + "sync_now_button": "Sincronitza ara", + "display_settings_title": "Configuració de visualització", + "show_comments_label": "Mostra els comentaris de Trakt", + "show_comments_desc": "Mostra els comentaris de Trakt a les pantalles de metadades quan estiguin disponibles", + "maintenance_title": "En manteniment", + "maintenance_unavailable": "Trakt no disponible", + "maintenance_desc": "La integració amb Trakt està temporalment en pausa per manteniment. Tota la sincronització i l'autenticació estan desactivades fins que acabi el manteniment.", + "maintenance_button": "Servei en manteniment", + "auth_success_title": "Connexió establerta", + "auth_success_msg": "El teu compte de Trakt s'ha connectat correctament.", + "auth_error_title": "Error d'autenticació", + "auth_error_msg": "No s'ha pogut completar l'autenticació amb Trakt.", + "auth_error_generic": "S'ha produït un error durant l'autenticació.", + "sign_out_error": "No s'ha pogut tancar la sessió de Trakt.", + "sync_complete_title": "Sincronització completada", + "sync_success_msg": "S'ha sincronitzat correctament el progrés de visualització amb Trakt.", + "sync_error_msg": "La sincronització ha fallat. Torna-ho a intentar." + }, + "simkl": { + "title": "Configuració de Simkl", + "settings_title": "Configuració de Simkl", + "connect_title": "Connecta amb Simkl", + "connect_desc": "Sincronitza l'historial de visualitzacions i fes el seguiment del que estàs veient", + "sign_in": "Inicia sessió amb Simkl", + "sign_out": "Desconnecta", + "sign_out_confirm": "Estàs segur que vols desconnectar-te de Simkl?", + "syncing_desc": "Els elements vistos s'estan sincronitzant amb Simkl.", + "auth_success_title": "Connexió establerta", + "auth_success_msg": "El teu compte de Simkl s'ha connectat correctament.", + "auth_error_title": "Error d'autenticació", + "auth_error_msg": "No s'ha pogut completar l'autenticació amb Simkl.", + "auth_error_generic": "S'ha produït un error durant l'autenticació.", + "sign_out_error": "No s'ha pogut desconnectar de Simkl.", + "config_error_title": "Error de configuració", + "config_error_msg": "L'ID de client de Simkl no es troba a les variables d'entorn.", + "conflict_title": "Conflicte", + "conflict_msg": "No pots connectar-te a Simkl mentre Trakt estigui connectat. Primer desconnecta Trakt.", + "disclaimer": "Nuvio no està afiliat amb Simkl." + }, + "tmdb_settings": { + "title": "Configuració de TMDb", + "metadata_enrichment": "Enriquiment de metadades", + "metadata_enrichment_desc": "Millora les metadades del contingut amb dades de TMDb per obtenir millors detalls i informació.", + "enable_enrichment": "Activa l'enriquiment", + "enable_enrichment_desc": "Amplia les metadades del complement amb TMDb per obtenir repartiment, certificació, logotips/cartells i informació de producció.", + "localized_text": "Text localitzat", + "localized_text_desc": "Obté títols i descripcions en el teu idioma preferit de TMDb.", + "language": "Idioma", + "change": "Canvia", + "logo_preview": "Previsualització del logotip", + "logo_preview_desc": "La previsualització mostra com apareixeran els logotips localitzats en l'idioma seleccionat.", + "example": "Exemple:", + "no_logo": "No hi ha logotip disponible", + "enrichment_options": "Opcions d'enriquiment", + "enrichment_options_desc": "Controla quines dades s'obtenen de TMDb. Les opcions desactivades usaran les dades del complement si estan disponibles.", + "cast_crew": "Repartiment i equip", + "cast_crew_desc": "Actors, directors i guionistes amb fotos de perfil", + "title_description": "Títol i descripció", + "title_description_desc": "Usa el títol localitzat de TMDb i el text del resum", + "title_logos": "Logotips de títol", + "title_logos_desc": "Imatges d'alta qualitat del títol", + "banners_backdrops": "Bàners i fons", + "banners_backdrops_desc": "Imatges de fons d'alta resolució", + "certification": "Certificació de contingut", + "certification_desc": "Classificacions d'edat (PG-13, R, TV-MA, etc.)", + "recommendations": "Recomanacions", + "recommendations_desc": "Suggeriments de contingut similar", + "episode_data": "Dades dels episodis", + "episode_data_desc": "Miniatures, informació i alternatives per a sèries de televisió", + "season_posters": "Cartells de temporada", + "season_posters_desc": "Imatges de cartell específiques de cada temporada", + "production_info": "Informació de producció", + "production_info_desc": "Cadenes i productores amb logotips", + "movie_details": "Detalls de la pel·lícula", + "movie_details_desc": "Pressupost, recaptació, durada, eslògan", + "tv_details": "Detalls de la sèrie de televisió", + "tv_details_desc": "Estat, nombre de temporades, cadenes, creadors", + "movie_collections": "Col·leccions de pel·lícules", + "movie_collections_desc": "Pel·lícules de franquícies (Marvel, Star Wars, etc.)", + "api_configuration": "Configuració de l'API", + "api_configuration_desc": "Configura l'accés a l'API de TMDb per a una millor funcionalitat.", + "custom_api_key": "Clau de l'API personalitzada", + "custom_api_key_desc": "Usa la teva pròpia clau de l'API de TMDb per a un millor rendiment i límits de taxa dedicats.", + "custom_key_active": "Clau de l'API personalitzada activa", + "api_key_required": "Cal la clau de l'API", + "api_key_placeholder": "Enganxa la teva clau de l'API de TMDb (v3)", + "how_to_get_key": "Com obtenir una clau de l'API de TMDb?", + "built_in_key_msg": "S'està usant la clau de l'API integrada. Considera usar la teva pròpia clau per a un millor rendiment.", + "cache_size": "Mida de la memòria cau", + "clear_cache": "Esborra la memòria cau", + "cache_days": "Les respostes de TMDB es desen a la memòria cau durant 7 dies per millorar el rendiment", + "choose_language": "Tria l'idioma", + "choose_language_desc": "Selecciona el teu idioma preferit per al contingut de TMDb", + "popular": "Popular", + "all_languages": "Tots els idiomes", + "search_results": "Resultats de la cerca", + "no_languages_found": "No s'han trobat idiomes per a «{{query}}»", + "clear_search": "Esborra la cerca", + "clear_cache_title": "Esborra la memòria cau de TMDB", + "clear_cache_msg": "S'esborraran totes les dades de TMDB emmagatzemades a la memòria cau ({{size}}). Això pot alentir temporalment la càrrega fins que es torni a construir la memòria cau.", + "clear_cache_success": "La memòria cau de TMDB s'ha esborrat correctament.", + "clear_cache_error": "No s'ha pogut esborrar la memòria cau.", + "clear_api_key_title": "Esborra la clau de l'API", + "clear_api_key_msg": "Estàs segur que vols eliminar la teva clau de l'API personalitzada i tornar a la predeterminada?", + "clear_api_key_success": "La clau de l'API s'ha esborrat correctament", + "clear_api_key_error": "No s'ha pogut esborrar la clau de l'API", + "empty_api_key": "La clau de l'API no pot estar buida.", + "invalid_api_key": "La clau de l'API no és vàlida. Comprova-la i torna-ho a intentar.", + "save_error": "S'ha produït un error en desar. Torna-ho a intentar.", + "using_builtin_key": "Ara s'usa la clau de l'API de TMDb integrada.", + "using_custom_key": "Ara s'usa la teva clau de l'API de TMDb personalitzada.", + "enter_custom_key": "Introdueix i desa la teva clau de l'API de TMDb personalitzada.", + "key_verified": "La clau de l'API s'ha verificat i desat correctament." + }, + "settings": { + "language": "Idioma", + "select_language": "Selecciona l'idioma", + "english": "Anglès", + "portuguese": "Portuguès", + "portuguese_br": "Portuguès (Brasil)", + "portuguese_pt": "Portuguès (Portugal)", + "german": "Alemany", + "arabic": "Àrab", + "spanish": "Castellà", + "french": "Francès", + "italian": "Italià", + "croatian": "Croat", + "chinese": "Xinès (simplificat)", + "hindi": "Hindi", + "serbian": "Serbi", + "hebrew": "Hebreu", + "bulgarian": "Búlgar", + "polish": "Polonès", + "czech": "Txec", + "turkish": "Turc", + "slovenian": "Eslovè", + "macedonian": "Macedoni", + "russian": "Rus", + "filipino": "Filipino", + "dutch_nl": "Neerlandès (Països Baixos)", + "romanian": "Romanès", + "albanian": "Albanès", + "catalan": "Català", + "account": "Compte", + "content_discovery": "Contingut i descoberta", + "appearance": "Aparença", + "integrations": "Integracions", + "playback": "Reproducció", + "backup_restore": "Còpia de seguretat i restauració", + "updates": "Actualitzacions", + "about": "Quant a", + "developer": "Desenvolupador", + "cache": "Memòria cau", + "title": "Configuració", + "settings_title": "Configuració", + "sign_in_sync": "Inicia sessió per sincronitzar", + "add_catalogs_sources": "Complements, catàlegs i fonts", + "player_trailers_downloads": "Reproductor, tràilers i baixades", + "mdblist_tmdb_ai": "MDBList, TMDB, IA", + "check_updates": "Comprova si hi ha actualitzacions", + "clear_mdblist_cache": "Esborra la memòria cau de MDBList", + "cache_management": "GESTIÓ DE LA MEMÒRIA CAU", + "downloads_counter": "baixades i comptant", + "made_with_love": "Fet amb ❤️ per Tapframe i amics", + "sections": { + "information": "INFORMACIÓ", + "account": "COMPTE", + "theme": "TEMA", + "layout": "DISSENY", + "sources": "FONTS", + "catalogs": "CATÀLEGS", + "discovery": "DESCOBERTA", + "metadata": "METADADES", + "ai_assistant": "ASSISTENT D'IA", + "video_player": "REPRODUCTOR DE VÍDEO", + "audio_subtitles": "ÀUDIO I SUBTÍTOLS", + "media": "MULTIMÈDIA", + "notifications": "NOTIFICACIONS", + "testing": "PROVES", + "danger_zone": "ZONA DE PERILL" + }, + "items": { + "legal": "Avís legal i exempció de responsabilitat", + "privacy_policy": "Política de privadesa", + "report_issue": "Informa d'un problema", + "version": "Versió", + "contributors": "Col·laboradors", + "view_contributors": "Veure tots els col·laboradors", + "theme": "Tema", + "episode_layout": "Disseny dels episodis", + "streams_backdrop": "Fons de les transmissions", + "streams_backdrop_desc": "Mostra el fons desenfocat a les transmissions mòbils", + "addons": "Complements", + "installed": "instal·lats", + "debrid_integration": "Integració Debrid", + "debrid_desc": "Connecta Torbox", + "plugins": "Connectors", + "plugins_desc": "Gestiona connectors i repositoris", + "catalogs": "Catàlegs", + "active": "actius", + "home_screen": "Pantalla d'inici", + "home_screen_desc": "Disseny i contingut", + "continue_watching": "Continua veient", + "continue_watching_desc": "Memòria cau i comportament de reproducció", + "show_discover": "Mostra la secció de descoberta", + "show_discover_desc": "Mostra el contingut de descoberta a la cerca", + "mdblist": "MDBList", + "mdblist_connected": "Connectat", + "mdblist_desc": "Activa per afegir valoracions i ressenyes", + "simkl": "Simkl", + "simkl_connected": "Connectat", + "simkl_desc": "Fes el seguiment del que veus", + "tmdb": "TMDB", + "tmdb_desc": "Proveïdor de metadades i logotips", + "openrouter": "API d'OpenRouter", + "openrouter_connected": "Connectat", + "openrouter_desc": "Afegeix la teva clau de l'API per activar el xat d'IA", + "video_player": "Reproductor de vídeo", + "built_in": "Integrat", + "external": "Extern", + "preferred_audio": "Idioma d'àudio preferit", + "preferred_subtitle": "Idioma de subtítols preferit", + "subtitle_source": "Prioritat de la font de subtítols", + "auto_select_subs": "Selecció automàtica de subtítols", + "auto_select_subs_desc": "Selecciona automàticament els subtítols que coincideixin amb les preferències", + "show_trailers": "Mostra els tràilers", + "show_trailers_desc": "Mostra els tràilers a la secció destacada", + "enable_downloads": "Activa les baixades", + "enable_downloads_desc": "Mostra la pestanya de baixades i permet desar transmissions", + "notifications": "Notificacions", + "notifications_desc": "Recordatoris d'episodis", + "developer_tools": "Eines de desenvolupador", + "developer_tools_desc": "Opcions de prova i depuració", + "test_onboarding": "Prova l'incorporació", + "reset_onboarding": "Restableix l'incorporació", + "test_announcement": "Prova l'anunci", + "test_announcement_desc": "Mostra la superposició de novetats", + "reset_campaigns": "Restableix les campanyes", + "reset_campaigns_desc": "Esborra les impressions de les campanyes", + "clear_all_data": "Esborra totes les dades", + "clear_all_data_desc": "Restableix tota la configuració i les dades en memòria cau" + }, + "options": { + "horizontal": "Horitzontal", + "vertical": "Vertical", + "internal_first": "Primer l'intern", + "internal_first_desc": "Prefereix els subtítols incrustats, després els externs", + "external_first": "Primer l'extern", + "external_first_desc": "Prefereix els subtítols del complement, després els incrustats", + "any_available": "Qualsevol disponible", + "any_available_desc": "Usa la primera pista de subtítols disponible" + }, + "clear_data_desc": "Això restablirà tota la configuració i esborrarà totes les dades en memòria cau. Estàs segur?", + "app_updates": "Actualitzacions de l'aplicació", + "about_nuvio": "Quant a Nuvio" + }, + "privacy": { + "title": "Privadesa i dades", + "settings_desc": "Controla la telemetria i la recopilació de dades", + "info_title": "La teva privadesa importa", + "info_description": "Controla quines dades es recullen i es comparteixen. L'anàlisi està desactivada per defecte i els informes de fallades són anònims per defecte.", + "analytics_enabled_title": "Anàlisi activada", + "analytics_enabled_message": "Es recolliran dades d'ús per ajudar a millorar l'aplicació. Pots desactivar-ho en qualsevol moment.", + "disable_error_reporting_title": "Desactivar els informes d'error?", + "disable_error_reporting_message": "Desactivar els informes d'error significa que no rebrem notificacions de fallades o problemes que experimentis. Això pot afectar la nostra capacitat per corregir errors.", + "enable_session_replay_title": "Activar la reproducció de sessió?", + "enable_session_replay_message": "La reproducció de sessió registra la pantalla quan es produeixen errors per ajudar-nos a entendre el que ha passat. Això pot capturar el contingut visible a la pantalla.", + "enable_pii_title": "Activar la recopilació d'informació d'identificació personal?", + "enable_pii_message": "Això permet la recopilació d'informació d'identificació personal com l'adreça IP i les dades del dispositiu. Aquestes dades ajuden a diagnosticar problemes però augmenten l'exposició a la privadesa.", + "disable_all_title": "Desactivar tota la telemetria?", + "disable_all_message": "Això desactivarà tota l'anàlisi, els informes d'error i la reproducció de sessió. No rebrem cap dada sobre l'ús de l'aplicació ni les fallades.", + "disable_all_button": "Desactiva-ho tot", + "all_disabled_title": "Tota la telemetria desactivada", + "all_disabled_message": "S'ha desactivat tota la recopilació de dades. Els canvis tindran efecte en el proper reinici de l'aplicació.", + "reset_title": "Restableix als valors recomanats", + "reset_message": "La configuració de privadesa s'ha restablert als valors recomanats per defecte (informes d'error activats, anàlisi desactivada).", + "section_analytics": "ANÀLISI", + "analytics_title": "Anàlisi d'ús", + "analytics_description": "Recull patrons d'ús i visualitzacions de pantalla anònims", + "section_error_reporting": "INFORMES D'ERROR", + "error_reporting_title": "Informes de fallades", + "error_reporting_description": "Envia informes de fallades anònims per millorar l'estabilitat", + "session_replay_title": "Reproducció de sessió", + "session_replay_description": "Registra la pantalla quan es produeixen errors", + "pii_title": "Inclou informació del dispositiu", + "pii_description": "Envia l'adreça IP i les dades del dispositiu amb els informes", + "section_quick_actions": "ACCIONS RÀPIDES", + "disable_all": "Desactiva tota la telemetria", + "disable_all_desc": "Desactiva tota la recopilació de dades", + "reset_recommended": "Restableix als valors recomanats", + "reset_recommended_desc": "Valors per defecte amb prioritat a la privadesa i informes d'error", + "section_learn_more": "MÉS INFORMACIÓ", + "privacy_policy": "Política de privadesa", + "current_settings": "Resum de la configuració actual", + "summary_analytics": "Anàlisi", + "summary_errors": "Informes d'error", + "summary_replay": "Reproducció de sessió", + "summary_pii": "Informació del dispositiu", + "restart_note_detailed": "* Els canvis en l'anàlisi i els informes d'error surten efecte immediatament. La configuració de reproducció de sessió i d'informació d'identificació personal requereix reiniciar l'aplicació." + }, + "ai_settings": { + "title": "Assistent d'IA", + "info_title": "Xat amb IA", + "info_desc": "Fes preguntes sobre qualsevol pel·lícula o episodi de sèrie usant IA avançada. Obtén informació sobre la trama, els personatges, els temes, les curiositats i molt més, tot basat en dades completes de TMDB.", + "feature_1": "Context i anàlisi específics de l'episodi", + "feature_2": "Explicacions de la trama i informació sobre els personatges", + "feature_3": "Curiositats i fets darrere de les càmeres", + "feature_4": "La teva pròpia clau de l'API gratuïta d'OpenRouter", + "api_key_section": "CLAU DE L'API D'OPENROUTER", + "api_key_label": "Clau de l'API", + "api_key_desc": "Introdueix la teva clau de l'API d'OpenRouter per activar les funcions de xat d'IA", + "save_api_key": "Desa la clau de l'API", + "saving": "Desant...", + "update": "Actualitza", + "remove": "Elimina", + "get_free_key": "Obtén la clau de l'API gratuïta d'OpenRouter", + "enable_chat": "Activa el xat d'IA", + "enable_chat_desc": "Quan estigui activat, el botó Pregunta a la IA apareixerà a les pàgines de contingut.", + "chat_enabled": "Xat d'IA activat", + "chat_enabled_desc": "Ara pots fer preguntes sobre pel·lícules i sèries de televisió. Busca el botó «Pregunta a la IA» a les pàgines de contingut!", + "how_it_works": "Com funciona", + "how_it_works_desc": "• OpenRouter proporciona accés a múltiples models d'IA\n• La teva clau de l'API es manté privada i segura\n• El nivell gratuït inclou límits d'ús generosos\n• Xateja amb context sobre episodis/pel·lícules específics\n• Obtén anàlisis i explicacions detallades", + "error_invalid_key": "Introdueix una clau de l'API vàlida", + "error_key_format": "Les claus de l'API d'OpenRouter haurien de començar amb «sk-or-»", + "success_saved": "La clau de l'API d'OpenRouter s'ha desat correctament!", + "error_save": "No s'ha pogut desar la clau de l'API", + "confirm_remove_title": "Elimina la clau de l'API", + "confirm_remove_msg": "Estàs segur que vols eliminar la clau de l'API d'OpenRouter? Això desactivarà les funcions de xat d'IA.", + "success_removed": "La clau de l'API s'ha eliminat correctament", + "error_remove": "No s'ha pogut eliminar la clau de l'API" + }, + "catalog_settings": { + "title": "Catàlegs", + "layout_phone": "DISSENY DE LA PANTALLA DE CATÀLEG (TELÈFON)", + "posters_per_row": "Cartells per fila", + "auto": "Automàtic", + "show_titles": "Mostra els títols dels cartells", + "show_titles_desc": "Mostra el text del títol sota cada cartell", + "phone_only_hint": "Només s'aplica als telèfons. Les tauletes mantenen el disseny adaptatiu.", + "catalogs_group": "Catàlegs", + "enabled_count": "{{enabled}} de {{total}} activats", + "rename_hint": "Mantén premut un catàleg per canviar-li el nom", + "rename_modal_title": "Canvia el nom del catàleg", + "rename_placeholder": "Introdueix el nou nom del catàleg", + "error_save_name": "No s'ha pogut desar el nom personalitzat." + }, + "continue_watching_settings": { + "title": "Continua veient", + "playback_behavior": "COMPORTAMENT DE REPRODUCCIÓ", + "use_cached": "Usa les transmissions en memòria cau", + "use_cached_desc": "Quan estigui activat, fer clic als elements de Continua veient obrirà el reproductor directament amb les transmissions reproduïdes anteriorment. Quan estigui desactivat, obre una pantalla de contingut.", + "open_metadata": "Obre la pantalla de metadades", + "open_metadata_desc": "Quan les transmissions en memòria cau estan desactivades, obre la pantalla de metadades en lloc de la pantalla de transmissions. Mostra els detalls del contingut i permet la selecció manual de transmissions.", + "card_appearance": "APARENÇA DE LA TARGETA", + "card_style": "Estil de targeta", + "card_style_desc": "Tria com apareixen els elements de Continua veient a la pantalla d'inici", + "wide": "Ample", + "poster": "Cartell", + "cache_settings": "CONFIGURACIÓ DE LA MEMÒRIA CAU", + "cache_duration": "Durada de la memòria cau de transmissions", + "cache_duration_desc": "Quant de temps es conserven els enllaços de transmissió en memòria cau abans que caduquin", + "important_note": "Nota important", + "important_note_text": "No tots els enllaços de transmissió poden romandre actius durant tota la durada de la memòria cau. Un temps de memòria cau més llarg pot resultar en enllaços caducats. Si un enllaç en memòria cau falla, l'aplicació tornarà a obtenir transmissions noves.", + "how_it_works": "Com funciona", + "how_it_works_cached": "• Les transmissions es desen a la memòria cau per la durada seleccionada un cop reproduïdes\n• Les transmissions en memòria cau es validen abans d'usar-les\n• Si la memòria cau no és vàlida o ha caducat, torna a la pantalla de contingut\n• «Usa les transmissions en memòria cau» controla la navegació directa al reproductor o a la pantalla\n• «Obre la pantalla de metadades» apareix només quan les transmissions en memòria cau estan desactivades", + "how_it_works_uncached": "• Quan les transmissions en memòria cau estan desactivades, fer clic als elements de Continua veient obre pantalles de contingut\n• L'opció «Obre la pantalla de metadades» controla quina pantalla s'obre\n• La pantalla de metadades mostra els detalls del contingut i permet la selecció manual de transmissions\n• La pantalla de transmissions mostra les transmissions disponibles per a la reproducció immediata", + "changes_saved": "Canvis desats", + "min": "min", + "hour": "hora", + "hours": "hores" + }, + "contributors": { + "title": "Col·laboradors", + "special_mentions": "Mencions especials", + "tab_contributors": "Col·laboradors", + "tab_special": "Mencions especials", + "tab_donors": "Donants", + "manager_role": "Gestor de la comunitat", + "manager_desc": "Gestiona les comunitats de Discord i Reddit de Nuvio", + "sponsor_role": "Patrocinador del servidor", + "sponsor_desc": "Ha patrocinat la infraestructura del servidor de Nuvio", + "mod_role": "Moderador de Discord", + "mod_desc": "Ajuda a moderar la comunitat de Discord de Nuvio", + "loading": "Carregant...", + "discord_user": "Usuari de Discord", + "contributions": "contribucions", + "gratitude_title": "Agraïm cada contribució", + "gratitude_desc": "Cada línia de codi, informe d'error i suggeriment ajuda a fer Nuvio millor per a tothom", + "special_thanks_title": "Agraïments especials", + "special_thanks_desc": "Aquestes persones fantàstiques ajuden a mantenir la comunitat de Nuvio activa i els servidors en línia", + "donors_desc": "Gràcies per creure en el que estem construint. El teu suport manté Nuvio gratuït i en constant millora.", + "latest_donations": "Darrers", + "leaderboard": "Classificació", + "loading_donors": "Carregant donants…", + "no_donors": "Encara no hi ha donants", + "error_rate_limit": "S'ha superat el límit de velocitat de l'API de GitHub. Torna-ho a intentar més tard o estira per actualitzar.", + "error_failed": "No s'han pogut carregar els col·laboradors. Comprova la connexió a Internet.", + "retry": "Torna-ho a intentar", + "no_contributors": "No s'han trobat col·laboradors", + "loading_contributors": "Carregant col·laboradors..." + }, + "debrid": { + "title": "Integració Debrid", + "description_torbox": "Connecta Torbox per usar les preferències de font basades en el compte. Introdueix la teva clau de l'API a continuació per configurar la integració.", + "description_torrentio": "Configura Torrentio com a integració de font externa. Pot ser necessari un compte debrid compatible depenent de la configuració.", + "tab_torbox": "TorBox", + "tab_torrentio": "Torrentio", + "status_connected": "Connectat", + "status_disconnected": "Desconnectat", + "enable_addon": "Activa el complement", + "disconnect_button": "Desconnecta i elimina", + "disconnect_loading": "Desconnectant...", + "account_info": "Informació del compte", + "plan": "Pla", + "plan_free": "Gratuït", + "plan_essential": "Essential (3 $/mes)", + "plan_pro": "Pro (10 $/mes)", + "plan_standard": "Standard (5 $/mes)", + "plan_unknown": "Desconegut", + "expires": "Caduca", + "downloaded": "Baixat", + "status_active": "Actiu", + "connected_title": "✓ Connectat a TorBox", + "connected_desc": "El complement TorBox és actiu i proporciona transmissions premium.", + "configure_title": "Configura el complement", + "configure_desc": "Personalitza la teva experiència de streaming. Ordena per qualitat, filtra les mides dels fitxers i gestiona altres configuracions d'integració.", + "open_settings": "Obre la configuració", + "what_is_debrid": "Què és un servei Debrid?", + "enter_api_key": "Introdueix la teva clau de l'API", + "connect_button": "Connecta i instal·la", + "connecting": "Connectant...", + "unlock_speeds_title": "Subscripció opcional a Torbox", + "unlock_speeds_desc": "Torbox ofereix nivells de compte amb funcions de rendiment i disponibilitat millorades.", + "get_subscription": "Obtén la subscripció", + "powered_by": "Impulsat per", + "disclaimer_torbox": "Nuvio no està afiliat amb Torbox de cap manera.", + "disclaimer_torrentio": "Nuvio no està afiliat amb Torrentio de cap manera.", + "installed_badge": "✓ INSTAL·LAT", + "promo_title": "⚡ Necessites un servei Debrid?", + "promo_desc": "Usa TorBox si vols funcions de rendiment gestionades per compte per a les integracions compatibles.", + "promo_button": "Obtén la subscripció a TorBox", + "service_label": "Servei Debrid *", + "api_key_label": "Clau de l'API *", + "sorting_label": "Ordenació", + "exclude_qualities": "Exclou qualitats", + "priority_languages": "Idiomes prioritaris", + "max_results": "Resultats màxims", + "additional_options": "Opcions addicionals", + "no_download_links": "No mostris els enllaços de baixada", + "no_debrid_catalog": "No mostris el catàleg debrid", + "install_button": "Instal·la Torrentio", + "installing": "Instal·lant...", + "update_button": "Actualitza la configuració", + "updating": "Actualitzant...", + "remove_button": "Elimina Torrentio", + "error_api_required": "Cal la clau de l'API", + "error_api_required_desc": "Introdueix la clau de l'API del servei debrid per instal·lar Torrentio.", + "success_installed": "El complement Torrentio s'ha instal·lat correctament!", + "success_removed": "El complement Torrentio s'ha eliminat correctament", + "alert_disconnect_title": "Desconnecta Torbox", + "alert_disconnect_msg": "Estàs segur que vols desconnectar Torbox? Això eliminarà el complement i esborrarà la clau de l'API desada." + }, + "home_screen": { + "title": "Configuració de la pantalla d'inici", + "changes_applied": "Canvis aplicats", + "display_options": "OPCIONS DE VISUALITZACIÓ", + "show_hero": "Mostra la secció destacada", + "show_hero_desc": "Contingut destacat a la part superior", + "show_this_week": "Mostra la secció d'aquesta setmana", + "show_this_week_desc": "Nous episodis de la setmana actual", + "select_catalogs": "Selecciona catàlegs", + "all_catalogs": "Tots els catàlegs", + "selected": "seleccionats", + "prefer_external_meta": "Prefereix el complement de metadades extern", + "prefer_external_meta_desc": "Usa metadades externes a la pàgina de detalls", + "hero_layout": "Disseny de la secció destacada", + "layout_legacy": "Llegat", + "layout_carousel": "Carrusel", + "layout_appletv": "Apple TV", + "layout_desc": "Bàner de pantalla completa, targetes lliscables o estil Apple TV", + "featured_source": "Font destacada", + "using_catalogs": "Usant catàlegs", + "manage_selected_catalogs": "Gestiona els catàlegs seleccionats", + "dynamic_bg": "Fons dinàmic de la secció destacada", + "dynamic_bg_desc": "Bàner desenfocat darrere del carrusel", + "performance_note": "Pot afectar el rendiment en dispositius de gamma baixa.", + "posters": "Cartells", + "show_titles": "Mostra els títols", + "poster_size": "Mida del cartell", + "poster_corners": "Cantonades del cartell", + "size_small": "Petit", + "size_medium": "Mitjà", + "size_large": "Gran", + "corners_square": "Quadrat", + "corners_rounded": "Arrodonit", + "corners_pill": "Píndola", + "about_these_settings": "SOBRE AQUESTA CONFIGURACIÓ", + "about_desc": "Aquesta configuració controla com es mostra el contingut a la pantalla d'inici. Els canvis s'apliquen immediatament sense necessitat de reiniciar l'aplicació.", + "hero_catalogs": { + "title": "Catàlegs de la secció destacada", + "select_all": "Selecciona-ho tot", + "clear_all": "Esborra-ho tot", + "info": "Selecciona quins catàlegs es mostren a la secció destacada. Si no se'n selecciona cap, s'usaran tots els catàlegs. No oblidis prémer Desa quan hagis acabat.", + "settings_saved": "Configuració desada", + "error_load": "No s'han pogut carregar els catàlegs", + "movies": "Pel·lícules", + "tv_shows": "Sèries de televisió" + } + }, + "calendar": { + "title": "Calendari", + "loading": "Carregant el calendari...", + "no_scheduled_episodes": "No hi ha episodis programats", + "check_back_later": "Torna-hi més tard", + "showing_episodes_for": "Mostrant episodis per al {{date}}", + "show_all_episodes": "Mostra tots els episodis", + "no_episodes_for": "No hi ha episodis per al {{date}}", + "no_upcoming_found": "No s'han trobat episodis propers", + "add_series_desc": "Afegeix sèries a la biblioteca per veure els seus episodis propers aquí" + }, + "mdblist": { + "title": "Fonts de valoració", + "status_disabled": "MDBList desactivat", + "status_active": "Clau de l'API activa", + "status_required": "Cal la clau de l'API", + "status_disabled_desc": "La funcionalitat de MDBList està desactivada.", + "status_active_desc": "Les valoracions de MDBList estan activades.", + "status_required_desc": "Afegeix la teva clau a continuació per activar les valoracions.", + "enable_toggle": "Activa MDBList", + "enable_toggle_desc": "Activa o desactiva tota la funcionalitat de MDBList", + "api_section": "Clau de l'API", + "placeholder": "Enganxa la teva clau de l'API de MDBList", + "save": "Desa", + "clear": "Esborra la clau", + "rating_providers": "Proveïdors de valoració", + "rating_providers_desc": "Tria quines valoracions es mostren a l'aplicació", + "how_to": "Com obtenir una clau de l'API", + "step_1": "Inicia sessió a", + "step_1_link": "el lloc web de MDBList", + "step_2": "Ves a", + "step_2_settings": "Configuració", + "step_2_api": "API", + "step_2_end": "secció.", + "step_3": "Genera una nova clau i copia-la.", + "go_to_website": "Ves a MDBList", + "alert_clear_title": "Esborra la clau de l'API", + "alert_clear_msg": "Estàs segur que vols eliminar la clau de l'API desada?", + "success_saved": "La clau de l'API s'ha desat correctament.", + "error_empty": "La clau de l'API no pot estar buida.", + "error_save": "S'ha produït un error en desar. Torna-ho a intentar.", + "api_key_empty_error": "La clau de l'API no pot estar buida.", + "success_cleared": "La clau de l'API s'ha esborrat correctament", + "error_clear": "No s'ha pogut esborrar la clau de l'API" + }, + "notification": { + "title": "Configuració de notificacions", + "section_general": "General", + "enable_notifications": "Activa les notificacions", + "section_types": "Tipus de notificació", + "new_episodes": "Nous episodis", + "upcoming_shows": "Sèries pròximes", + "reminders": "Recordatoris", + "section_timing": "Moment de la notificació", + "timing_desc": "Quan vols rebre una notificació abans que s'emeti un episodi?", + "hours_1": "1 hora", + "hours_suffix": "hores", + "section_status": "Estat de les notificacions", + "stats_upcoming": "Pròxims", + "stats_this_week": "Aquesta setmana", + "stats_total": "Total", + "sync_button": "Sincronitza la biblioteca i Trakt", + "syncing": "Sincronitzant...", + "sync_desc": "Sincronitza automàticament les notificacions de totes les sèries de la biblioteca i la llista per veure/col·lecció de Trakt.", + "section_advanced": "Avançat", + "reset_button": "Restableix totes les notificacions", + "test_button": "Prova la notificació (5 s)", + "test_notification_in": "Notificació en {{seconds}}s...", + "test_notification_text": "La notificació apareixerà en {{seconds}} segons", + "alert_reset_title": "Restableix les notificacions", + "alert_reset_msg": "Això cancel·larà totes les notificacions programades, però no eliminarà res de la biblioteca desada. Estàs segur?", + "alert_reset_success": "Totes les notificacions s'han restablert", + "alert_sync_complete": "Sincronització completada", + "alert_sync_msg": "S'han sincronitzat correctament les notificacions de la biblioteca i els elements de Trakt.\n\nProgramats: {{upcoming}} episodis propers\nAquesta setmana: {{thisWeek}} episodis", + "alert_test_scheduled": "La notificació de prova s'ha programat per llançar-se immediatament" + }, + "backup": { + "title": "Còpia de seguretat i restauració", + "options_title": "Opcions de còpia de seguretat", + "options_desc": "Tria què incloure a les còpies de seguretat", + "section_core": "Dades principals", + "section_addons": "Complements i integracions", + "section_settings": "Configuració i preferències", + "library_label": "Biblioteca", + "library_desc": "Les teves pel·lícules i sèries de televisió desades", + "watch_progress_label": "Progrés de visualització", + "watch_progress_desc": "Posicions de Continua veient", + "addons_label": "Complements", + "addons_desc": "Complements de Stremio instal·lats", + "plugins_label": "Connectors", + "plugins_desc": "Configuracions personalitzades del raspador", + "trakt_label": "Integració de Trakt", + "trakt_desc": "Dades de sincronització i tokens d'autenticació", + "app_settings_label": "Configuració de l'aplicació", + "app_settings_desc": "Tema, preferències i configuracions", + "user_prefs_label": "Preferències de l'usuari", + "user_prefs_desc": "Ordre dels complements i configuració de la interfície", + "catalog_settings_label": "Configuració dels catàlegs", + "catalog_settings_desc": "Filtres i preferències dels catàlegs", + "api_keys_label": "Claus de l'API", + "api_keys_desc": "Claus de MDBList i OpenRouter", + "action_create": "Crea una còpia de seguretat", + "action_restore": "Restaura des de la còpia de seguretat", + "section_info": "Sobre les còpies de seguretat", + "info_text": "• Personalitza el contingut de la còpia de seguretat amb els interruptors de dalt\n• Els fitxers de còpia de seguretat s'emmagatzemen localment al dispositiu\n• Comparteix la còpia de seguretat per transferir dades entre dispositius\n• La restauració sobreescriurà les dades actuals", + "alert_create_title": "Crea una còpia de seguretat", + "alert_no_content": "No s'ha seleccionat cap contingut per a la còpia de seguretat.\n\nActiva almenys una opció a la secció d'opcions de còpia de seguretat de dalt.", + "alert_backup_created_title": "Còpia de seguretat creada", + "alert_backup_created_msg": "La còpia de seguretat s'ha creat i és a punt per compartir.", + "alert_backup_failed_title": "Error en la còpia de seguretat", + "alert_restore_confirm_title": "Confirma la restauració", + "alert_restore_confirm_msg": "Això restaurarà les dades d'una còpia de seguretat creada el {{date}}.\n\nAquesta acció sobreescriurà les dades actuals. Estàs segur que vols continuar?", + "alert_restore_complete_title": "Restauració completada", + "alert_restore_complete_msg": "Les dades s'han restaurat correctament. Reinicia l'aplicació per veure tots els canvis.", + "alert_restore_failed_title": "Error en la restauració", + "restart_app": "Reinicia l'aplicació", + "alert_restart_failed_title": "Error en reiniciar", + "alert_restart_failed_msg": "No s'ha pogut reiniciar l'aplicació. Tanca-la manualment i torna-la a obrir per veure les dades restaurades." + }, + "updates": { + "title": "Actualitzacions de l'aplicació", + "status_checking": "Comprovant si hi ha actualitzacions...", + "status_available": "Actualització disponible!", + "status_downloading": "Baixant l'actualització...", + "status_installing": "Instal·lant l'actualització...", + "status_success": "Actualització instal·lada correctament!", + "status_error": "Error en l'actualització", + "status_ready": "Llest per comprovar si hi ha actualitzacions", + "action_check": "Comprova si hi ha actualitzacions", + "action_install": "Instal·la l'actualització", + "release_notes": "Notes de llançament:", + "version": "Versió:", + "last_checked": "Darrera comprovació:", + "current_version": "Versió actual:", + "current_release_notes": "Notes de llançament actuals:", + "github_release": "LLANÇAMENT DE GITHUB", + "current": "Actual:", + "latest": "Més recent:", + "notes": "Notes:", + "view_release": "Veure el llançament", + "notification_settings": "CONFIGURACIÓ DE NOTIFICACIONS", + "ota_alerts_label": "Alertes d'actualització OTA", + "ota_alerts_desc": "Mostra notificacions per a les actualitzacions en l'aire", + "major_alerts_label": "Alertes d'actualitzacions principals", + "major_alerts_desc": "Mostra notificacions per a noves versions de l'aplicació a GitHub", + "alert_disable_ota_title": "Desactivar les alertes d'actualització OTA?", + "alert_disable_ota_msg": "Ja no rebràs notificacions automàtiques d'actualitzacions OTA.\n\n⚠️ Advertiment: Mantenir-se a la versió més recent és important per a:\n• Correccions d'errors i millores d'estabilitat\n• Noves funcions i millores\n• Proporcionar comentaris precisos i informes de fallades\n\nEncara pots comprovar les actualitzacions manualment en aquesta pantalla.", + "alert_disable_major_title": "Desactivar les alertes d'actualitzacions principals?", + "alert_disable_major_msg": "Ja no rebràs notificacions d'actualitzacions principals de l'aplicació que requereixin reinstal·lació.\n\n⚠️ Advertiment: Les actualitzacions principals sovint inclouen:\n• Pedaços de seguretat crítics\n• Canvis que requereixen la reinstal·lació de l'aplicació\n• Correccions importants de compatibilitat\n\nEncara pots comprovar les actualitzacions manualment.", + "warning_note": "Mantenir les alertes activades garanteix que rebs correccions d'errors i pots proporcionar informes de fallades precisos.", + "disable": "Desactiva", + "alert_no_update_to_install": "No hi ha cap actualització disponible per instal·lar", + "alert_install_failed": "No s'ha pogut instal·lar l'actualització", + "alert_no_update_title": "Cap actualització", + "alert_update_applied_msg": "L'actualització s'aplicarà en el proper reinici de l'aplicació" + }, + "player": { + "title": "Reproductor de vídeo", + "section_selection": "SELECCIÓ DEL REPRODUCTOR", + "internal_title": "Reproductor integrat", + "internal_desc": "Usa el reproductor de vídeo predeterminat de l'aplicació", + "vlc_title": "VLC", + "vlc_desc": "Obre les transmissions al reproductor multimèdia VLC", + "infuse_title": "Infuse", + "infuse_desc": "Obre les transmissions al reproductor Infuse", + "outplayer_title": "OutPlayer", + "outplayer_desc": "Obre les transmissions a OutPlayer", + "vidhub_title": "VidHub", + "vidhub_desc": "Obre les transmissions al reproductor VidHub", + "infuse_live_title": "Infuse Livecontainer", + "infuse_live_desc": "Obre les transmissions al reproductor Infuse LiveContainer", + "external_title": "Reproductor extern", + "external_desc": "Obre les transmissions al teu reproductor de vídeo preferit", + "section_playback": "OPCIONS DE REPRODUCCIÓ", + "skip_intro_settings_title": "Salta la introducció", + "powered_by_introdb": "Impulsat per IntroDB", + "autoplay_title": "Reproducció automàtica de la primera transmissió", + "autoplay_desc": "Inicia automàticament la primera transmissió de la llista.", + "resume_title": "Reprèn sempre", + "resume_desc": "Salta el missatge de represa i continua automàticament on ho vas deixar (si has vist menys del 85%).", + "engine_title": "Motor del reproductor de vídeo", + "engine_desc": "Auto usa ExoPlayer amb MPV de reserva. Alguns formats com Dolby Vision i HDR poden no ser compatibles amb MPV, de manera que Auto és el recomanat per a la millor compatibilitat.", + "decoder_title": "Mode de descodificació", + "decoder_desc": "Com es descodifica el vídeo. Auto és el recomanat per al millor equilibri.", + "gpu_title": "Renderització per GPU", + "gpu_desc": "GPU-Next ofereix una millor gestió de HDR i color.", + "external_downloads_title": "Reproductor extern per a baixades", + "external_downloads_desc": "Reprodueix el contingut baixat al teu reproductor extern preferit.", + "restart_required": "Cal reiniciar", + "restart_msg_decoder": "Reinicia l'aplicació perquè el canvi de descodificació tingui efecte.", + "restart_msg_gpu": "Reinicia l'aplicació perquè el canvi de mode GPU tingui efecte.", + "option_auto": "Auto", + "option_auto_desc_engine": "ExoPlayer + MPV de reserva", + "option_mpv": "MPV", + "option_mpv_desc": "Només MPV", + "option_auto_desc_decoder": "Millor equilibri", + "option_sw": "SW", + "option_sw_desc": "Programari", + "option_hw": "HW", + "option_hw_desc": "Maquinari", + "option_hw_plus": "HW+", + "option_hw_plus_desc": "HW complet", + "option_gpu_desc": "Estàndard", + "option_gpu_next_desc": "Avançat" + }, + "plugins": { + "title": "Connectors", + "enable_title": "Activa els connectors", + "enable_desc": "Activa el motor de connectors per resoldre fonts de contingut multimèdia externes", + "repo_config_title": "Configuració del repositori", + "repo_config_desc": "Gestiona els repositoris de connectors externs. Activa o desactiva cada repositori a continuació.", + "your_repos": "Repositoris", + "your_repos_desc": "Configura fonts externes per als connectors.", + "add_repo_button": "Afegeix un repositori", + "refresh": "Actualitza", + "remove": "Elimina", + "enabled": "Activat", + "disabled": "Desactivat", + "updating": "Actualitzant...", + "success": "Correcte", + "error": "Error", + "alert_repo_added": "S'ha afegit el repositori i s'han carregat els connectors correctament", + "alert_repo_saved": "La URL del repositori s'ha desat correctament", + "alert_repo_refreshed": "El repositori s'ha actualitzat correctament", + "alert_invalid_url": "Format d'URL no vàlid", + "alert_plugins_cleared": "S'han eliminat tots els connectors", + "alert_cache_cleared": "La memòria cau del repositori s'ha esborrat correctament", + "unknown": "Desconegut", + "active": "Actiu", + "available": "Disponible", + "platform_disabled": "Plataforma desactivada", + "limited": "Limitat", + "clear_all": "Esborra tots els connectors", + "clear_all_desc": "Estàs segur que vols eliminar tots els connectors instal·lats? Aquesta acció no es pot desfer.", + "clear_cache": "Esborra la memòria cau del repositori", + "clear_cache_desc": "Això eliminarà la URL del repositori desada i esborrarà totes les dades de connectors en memòria cau. Hauràs de tornar a introduir la URL del repositori.", + "add_new_repo": "Afegeix un nou repositori", + "available_plugins": "Connectors disponibles ({{count}})", + "placeholder": "Cerca connectors...", + "all": "Tot", + "filter_all": "Tots els tipus", + "filter_movies": "Pel·lícules", + "filter_tv": "Sèries de televisió", + "enable_all": "Activa-ho tot", + "disable_all": "Desactiva-ho tot", + "no_plugins_found": "No s'han trobat connectors", + "no_plugins_available": "No hi ha connectors disponibles", + "no_match_desc": "Cap connector coincideix amb «{{query}}». Prova un terme de cerca diferent.", + "configure_repo_desc": "Configura un repositori de dalt per veure els connectors disponibles.", + "clear_search": "Esborra la cerca", + "no_external_player": "Cap reproductor extern", + "showbox_token": "Token d'interfície de ShowBox", + "showbox_placeholder": "Enganxa el token d'interfície de ShowBox", + "save": "Desa", + "clear": "Esborra", + "additional_settings": "Configuració addicional", + "enable_url_validation": "Activa la validació d'URL", + "url_validation_desc": "Valida les URL multimèdia abans de retornar-les (pot alentir els resultats però millora la fiabilitat)", + "group_streams": "Agrupa les fonts de connectors", + "group_streams_desc": "Quan estigui activat, les fonts s'agrupen per repositori. Quan estigui desactivat, cada connector es mostra com a proveïdor separat.", + "sort_quality": "Ordena primer per qualitat", + "sort_quality_desc": "Quan estigui activat, les fonts s'ordenen primer per qualitat. Només disponible quan l'agrupació estigui activada.", + "show_logos": "Mostra els logotips dels connectors", + "show_logos_desc": "Mostra els logotips dels connectors al costat dels enllaços multimèdia a la pantalla de fonts.", + "quality_filtering": "Filtratge per qualitat", + "quality_filtering_desc": "Exclou resolucions de vídeo específiques dels resultats de cerca. Toca una qualitat per excloure-la dels resultats dels connectors.", + "excluded_qualities": "Qualitats excloses:", + "language_filtering": "Filtratge per idioma", + "language_filtering_desc": "Exclou idiomes específics dels resultats de cerca. Toca un idioma per excloure'l dels resultats dels connectors.", + "note": "Nota:", + "language_filtering_note": "Aquest filtre només s'aplica als proveïdors que inclouen informació d'idioma. No afecta els altres proveïdors.", + "excluded_languages": "Idiomes exclosos:", + "about_title": "Sobre els connectors", + "about_desc_1": "Els connectors són components modulars que adapten contingut de diversos protocols externs. S'executen localment al dispositiu i es poden instal·lar des de repositoris de confiança.", + "about_desc_2": "Els connectors marcats com a «Limitat» poden requerir configuracions externes específiques.", + "help_title": "Configuració dels connectors", + "help_step_1": "1. **Activa els connectors** - Activa l'interruptor principal", + "help_step_2": "2. **Afegeix un repositori** - Afegeix una URL de repositori vàlida", + "help_step_3": "3. **Actualitza el repositori** - Obté els connectors disponibles", + "help_step_4": "4. **Activa** - Activa els connectors que vols usar", + "got_it": "Entès!", + "repo_format_hint": "Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch", + "cancel": "Cancel·la", + "add": "Afegeix" + }, + "theme": { + "title": "Temes de l'aplicació", + "select_theme": "SELECCIONA EL TEMA", + "create_custom": "Crea un tema personalitzat", + "options": "OPCIONS", + "use_dominant_color": "Usa el color dominant de les imatges", + "categories": { + "all": "Tots els temes", + "dark": "Temes foscos", + "colorful": "De colors", + "custom": "Els meus temes" + }, + "editor": { + "theme_name_placeholder": "Nom del tema", + "save": "Desa", + "primary": "Principal", + "secondary": "Secundari", + "background": "Fons", + "invalid_name_title": "Nom no vàlid", + "invalid_name_msg": "Introdueix un nom de tema vàlid" + }, + "alerts": { + "delete_title": "Suprimeix el tema", + "delete_msg": "Estàs segur que vols suprimir «{{name}}»?", + "ok": "D'acord", + "delete": "Suprimeix", + "cancel": "Cancel·la", + "back": "Configuració" + } + }, + "legal": { + "title": "Avís legal i exempció de responsabilitat", + "intro_title": "Naturalesa de l'aplicació", + "intro_text": "Nuvio és una aplicació de reproducció multimèdia i gestió de metadades. Actua únicament com a interfície del costat del client per navegar per metadades disponibles públicament (pel·lícules, sèries de televisió, etc.) i reproduir fitxers multimèdia proporcionats per l'usuari o extensions de tercers. Nuvio no allotja, emmagatzema, distribueix ni indexa cap contingut multimèdia.", + "extensions_title": "Connectors de tercers", + "extensions_text": "Nuvio usa una arquitectura extensible que permet als usuaris instal·lar connectors de tercers. Aquests connectors estan desenvolupats i mantinguts per desenvolupadors independents no afiliats amb Nuvio. No tenim cap control sobre el contingut, la legalitat ni la funcionalitat de cap connector de tercers, ni assumim cap responsabilitat al respecte.", + "user_resp_title": "Responsabilitat de l'usuari", + "user_resp_text": "Els usuaris són els únics responsables dels connectors que instal·len i del contingut al qual accedeixen. En usar aquesta aplicació, acceptes assegurar-te que tens el dret legal d'accedir a qualsevol contingut que visualitzis amb Nuvio. Els desenvolupadors de Nuvio no avalen ni fomenten la violació dels drets d'autor.", + "dmca_title": "Drets d'autor i DMCA", + "dmca_text": "Respectem els drets de propietat intel·lectual d'altri. Nuvio no allotja contingut multimèdia. Si creus que el codi, els recursos o la interfície d'aquest projecte infringeixen els teus drets, envia un avís a través dels canals de contacte oficials del projecte que figuren al lloc web i al repositori.", + "warranty_title": "Sense garantia", + "warranty_text": "Aquest programari es proporciona «tal com és», sense garantia de cap mena, explícita ni implícita. En cap cas els autors o titulars dels drets d'autor seran responsables de cap reclamació, dany o altra responsabilitat derivada de l'ús d'aquest programari." + }, + "plugin_tester": { + "title": "Provador de connectors", + "subtitle": "Executa raspadors i inspecciona els registres en temps real", + "tabs": { + "individual": "Individual", + "repo": "Prova del repositori", + "code": "Codi", + "logs": "Registres", + "results": "Resultats" + }, + "common": { + "error": "Error", + "success": "Correcte", + "movie": "Pel·lícula", + "tv": "TV", + "tmdb_id": "ID de TMDB", + "season": "Temporada", + "episode": "Episodi", + "running": "Executant…", + "run_test": "Executa la prova", + "play": "Reprodueix", + "done": "Fet", + "test": "Prova", + "testing": "Provant…" + }, + "individual": { + "load_from_url": "Carrega des de l'URL", + "load_from_url_desc": "Enganxa una URL bruta de GitHub o una IP local i toca baixar.", + "enter_url_error": "Introdueix una URL", + "code_loaded": "Codi carregat des de l'URL", + "fetch_error": "No s'ha pogut obtenir: {{message}}", + "no_code_error": "No hi ha cap codi per executar", + "plugin_code": "Codi del connector", + "focus_editor": "Posa el focus a l'editor de codi", + "code_placeholder": "// Enganxa el codi del connector aquí...", + "test_parameters": "Paràmetres de prova", + "no_logs": "Encara no hi ha registres. Executa una prova per veure la sortida.", + "no_streams": "Encara no s'han trobat transmissions.", + "streams_found": "{{count}} transmissió trobada", + "streams_found_plural": "{{count}} transmissions trobades", + "tap_play_hint": "Toca Reprodueix per provar una transmissió al reproductor natiu.", + "unnamed_stream": "Transmissió sense nom", + "quality": "Qualitat: {{quality}}", + "size": "Mida: {{size}}", + "url_label": "URL: {{url}}", + "headers_info": "Capçaleres: {{count}} capçalera(es) personalitzada(es)", + "find_placeholder": "Cerca al codi…", + "edit_code_title": "Edita el codi", + "no_url_stream_error": "No s'ha trobat cap URL per a aquesta transmissió" + }, + "repo": { + "title": "Prova del repositori", + "description": "Obté un repositori (URL local o GitHub brut) i prova cada proveïdor.", + "enter_repo_url_error": "Introdueix una URL de repositori", + "invalid_url_title": "URL no vàlida", + "invalid_url_msg": "Usa una URL bruta de GitHub o una URL local http(s).\n\nExemple:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/main", + "manifest_build_error": "No s'ha pogut construir una URL de manifest a partir de l'entrada", + "manifest_fetch_error": "No s'ha pogut obtenir el manifest", + "repo_manifest_fetch_error": "No s'ha pogut obtenir el manifest del repositori", + "missing_filename": "Falta el nom del fitxer al manifest", + "scraper_build_error": "No s'ha pogut construir una URL del raspador", + "download_scraper_error": "No s'ha pogut baixar el raspador", + "test_failed": "La prova ha fallat", + "test_parameters": "Paràmetres de prova del repositori", + "test_parameters_desc": "Aquests paràmetres s'usen només per a la prova del repositori.", + "using_info": "Usant: {{mediaType}} • TMDB {{tmdbId}}", + "using_info_tv": "Usant: {{mediaType}} • TMDB {{tmdbId}} • T{{season}}E{{episode}}", + "providers_title": "Proveïdors", + "repository_default": "Repositori", + "providers_count": "{{count}} proveïdors", + "fetch_hint": "Obté un repositori per llistar els proveïdors.", + "test_all": "Prova-ho tot", + "status_running": "EXECUTANT", + "status_ok": "OK ({{count}})", + "status_ok_empty": "OK (0)", + "status_failed": "FALLAT", + "status_idle": "INACTIU", + "tried_url": "S'ha provat: {{url}}", + "provider_logs": "Registres del proveïdor", + "no_logs_captured": "No s'han capturat registres." + } + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3be593ee..4b413c6f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -653,6 +653,7 @@ "dutch_nl": "Dutch (Netherlands)", "romanian": "Romanian", "albanian": "Albanian", + "catalan": "Catalan", "account": "Account", "content_discovery": "Content & Discovery", "appearance": "Appearance", diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 3be6d915..52c1a6f1 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -23,6 +23,7 @@ import fil from './locales/fil.json'; import nlNL from './locales/nl-NL.json'; import ro from './locales/ro.json'; import sq from './locales/sq.json'; +import ca from './locales/ca.json'; export const resources = { en: { translation: en }, @@ -49,4 +50,5 @@ export const resources = { 'nl-NL': { translation: nlNL }, ro: { translation: ro }, sq: { translation: sq }, + ca: { translation: ca }, }; diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index 57f0dd4a..01f3b11d 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -1181,7 +1181,8 @@ const TMDBSettingsScreen = () => { { code: 'sl', label: 'Slovenščina', native: 'Slovenian' }, { code: 'mk', label: 'Македонски', native: 'Macedonian' }, { code: 'fil', label: 'Filipino', native: 'Filipino' }, - { code: 'sq', label: 'Shqipe', native: 'Albanian' }, + { code: 'sq', label: 'Shqipe', native: 'Albanian' }, + { code: 'ca', label: 'Català', native: 'Catalan' }, ]; const filteredLanguages = languages.filter(({ label, code, native }) => diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index 2c7e888f..ed0abd6b 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -61,7 +61,8 @@ const AVAILABLE_LANGUAGES = [ { code: 'mk', name: 'Macedonian' }, { code: 'fil', name: 'Filipino' }, { code: 'ro', name: 'Romanian' }, - { code: 'sq', name: 'Albanian' }, + { code: 'sq', name: 'Albanian' }, + { code: 'ca', name: 'Catalan' }, ]; const SUBTITLE_SOURCE_OPTIONS = [ From a7fcf6779c0be5b97f1d4ef0439a82c93244270d Mon Sep 17 00:00:00 2001 From: John Neerdael Date: Fri, 20 Feb 2026 23:15:41 +0100 Subject: [PATCH 5/6] Fix OpenRouter model routing and add configurable model setting --- src/screens/AIChatScreen.tsx | 8 +++- src/screens/AISettingsScreen.tsx | 67 +++++++++++++++++++++++++++++++- src/services/aiService.ts | 62 +++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx index 6f49d0bc..a669bb5c 100644 --- a/src/screens/AIChatScreen.tsx +++ b/src/screens/AIChatScreen.tsx @@ -696,8 +696,14 @@ const AIChatScreen: React.FC = () => { if (error instanceof Error) { if (error.message.includes('not configured')) { errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.'; + } else if (/401|unauthorized|invalid api key|authentication/i.test(error.message)) { + errorMessage = 'OpenRouter rejected your API key. Please verify the key in Settings > AI Assistant.'; + } else if (/insufficient|credit|quota|429/i.test(error.message)) { + errorMessage = 'OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.'; + } else if (/model|provider|endpoint|unsupported|unavailable|not found/i.test(error.message)) { + errorMessage = 'The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.'; } else if (error.message.includes('API request failed')) { - errorMessage = 'Failed to connect to AI service. Please check your internet connection and API key.'; + errorMessage = 'Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability.'; } } diff --git a/src/screens/AISettingsScreen.tsx b/src/screens/AISettingsScreen.tsx index 251134af..3d88b2a4 100644 --- a/src/screens/AISettingsScreen.tsx +++ b/src/screens/AISettingsScreen.tsx @@ -25,6 +25,7 @@ import { useTranslation } from 'react-i18next'; const { width } = Dimensions.get('window'); const isTablet = width >= 768; +const DEFAULT_OPENROUTER_MODEL = 'openrouter/free'; const AISettingsScreen: React.FC = () => { const { t } = useTranslation(); @@ -75,6 +76,8 @@ const AISettingsScreen: React.FC = () => { const [apiKey, setApiKey] = useState(''); const [loading, setLoading] = useState(false); const [isKeySet, setIsKeySet] = useState(false); + const [useDefaultModel, setUseDefaultModel] = useState(true); + const [customModel, setCustomModel] = useState(''); useEffect(() => { loadApiKey(); @@ -82,11 +85,21 @@ const AISettingsScreen: React.FC = () => { const loadApiKey = async () => { try { - const savedKey = await mmkvStorage.getItem('openrouter_api_key'); + const [savedKey, savedModel] = await Promise.all([ + mmkvStorage.getItem('openrouter_api_key'), + mmkvStorage.getItem('openrouter_model'), + ]); if (savedKey) { setApiKey(savedKey); setIsKeySet(true); } + if (savedModel && savedModel.trim()) { + setUseDefaultModel(false); + setCustomModel(savedModel.trim()); + } else { + setUseDefaultModel(true); + setCustomModel(''); + } } catch (error) { if (__DEV__) console.error('Error loading OpenRouter API key:', error); } @@ -106,6 +119,11 @@ const AISettingsScreen: React.FC = () => { setLoading(true); try { await mmkvStorage.setItem('openrouter_api_key', apiKey.trim()); + if (useDefaultModel || !customModel.trim()) { + await mmkvStorage.removeItem('openrouter_model'); + } else { + await mmkvStorage.setItem('openrouter_model', customModel.trim()); + } setIsKeySet(true); openAlert(t('common.success'), t('ai_settings.success_saved')); } catch (error) { @@ -253,6 +271,44 @@ const AISettingsScreen: React.FC = () => { autoCorrect={false} /> + + + + Model + + + + + {useDefaultModel + ? `Using ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).` + : 'Use a custom OpenRouter model ID (useful for paid plans).'} + + {!useDefaultModel && ( + + )} + + {!isKeySet ? ( { - if (!this.apiKey) { - await this.initialize(); - } + // Always refresh from storage so key changes in settings are picked up immediately. + await this.initialize(); return !!this.apiKey; } + private async getPreferredModels(): Promise { + const configuredModel = (await mmkvStorage.getItem('openrouter_model'))?.trim(); + if (!configuredModel) { + return [this.defaultModel]; + } + return [configuredModel]; + } + + private async parseErrorResponse(response: Response): Promise<{ + statusLine: string; + message: string; + raw: string; + }> { + const raw = await response.text(); + let message = ''; + + try { + const parsed = JSON.parse(raw) as OpenRouterErrorResponse; + message = parsed.error?.message || ''; + } catch { + message = raw; + } + + return { + statusLine: `${response.status} ${response.statusText}`, + message: (message || '').trim(), + raw, + }; + } + private createSystemPrompt(context: ContentContext): string { const isSeries = 'episodesBySeason' in (context as any); const isEpisode = !isSeries && 'showTitle' in (context as any); @@ -349,6 +386,9 @@ Answer questions about this movie using only the verified database information a }); } + const model = (await this.getPreferredModels())[0]; + if (__DEV__) console.log('[AIService] Using model:', model); + const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', headers: { @@ -358,7 +398,7 @@ Answer questions about this movie using only the verified database information a 'X-Title': 'Nuvio - AI Chat', }, body: JSON.stringify({ - model: 'xiaomi/mimo-v2-flash:free', + model, messages, max_tokens: 1000, temperature: 0.7, @@ -369,9 +409,17 @@ Answer questions about this movie using only the verified database information a }); if (!response.ok) { - const errorText = await response.text(); - if (__DEV__) console.error('[AIService] API Error:', response.status, errorText); - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + const parsedError = await this.parseErrorResponse(response); + + if (__DEV__) { + console.error('[AIService] API Error:', { + model, + status: parsedError.statusLine, + message: parsedError.message || parsedError.raw, + }); + } + + throw new Error(`API request failed: ${parsedError.statusLine} - ${parsedError.message || parsedError.raw || 'Request failed'}`); } const data: OpenRouterResponse = await response.json(); From 37ad5647f8cfe5f573e21ed15b1837b64790a4e6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:05:12 +0530 Subject: [PATCH 6/6] ref: Simkl authentication to use PKCE and update exchangeCodeForToken method --- src/screens/SimklSettingsScreen.tsx | 15 +++++++++------ src/services/simklService.ts | 7 ++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index 5bcb8087..3d1529fb 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -27,7 +27,6 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Simkl configuration const SIMKL_CLIENT_ID = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID as string; -const SIMKL_REDIRECT_URI = process.env.EXPO_PUBLIC_SIMKL_REDIRECT_URI || 'nuvio://auth/simkl'; const discovery = { authorizationEndpoint: 'https://simkl.com/oauth/authorize', @@ -76,9 +75,10 @@ const SimklSettingsScreen: React.FC = () => { { clientId: SIMKL_CLIENT_ID, scopes: [], // Simkl doesn't strictly use scopes for basic access - redirectUri: SIMKL_REDIRECT_URI, // Must match what is set in Simkl Dashboard + redirectUri: redirectUri, responseType: ResponseType.Code, - // codeChallengeMethod: CodeChallengeMethod.S256, // Simkl might not verify PKCE, but standard compliant + usePKCE: true, + codeChallengeMethod: CodeChallengeMethod.S256, }, discovery ); @@ -90,12 +90,12 @@ const SimklSettingsScreen: React.FC = () => { // Handle the response from the auth request useEffect(() => { if (response) { - if (response.type === 'success') { + if (response.type === 'success' && request?.codeVerifier) { const { code } = response.params; setIsExchangingCode(true); logger.log('[SimklSettingsScreen] Auth code received, exchanging...'); - simklService.exchangeCodeForToken(code) + simklService.exchangeCodeForToken(code, request.codeVerifier) .then(success => { if (success) { refreshAuthStatus(); @@ -109,11 +109,14 @@ const SimklSettingsScreen: React.FC = () => { openAlert(t('common.error'), t('simkl.auth_error_generic')); }) .finally(() => setIsExchangingCode(false)); + } else if (response.type === 'success') { + logger.error('[SimklSettingsScreen] Missing PKCE code verifier on successful auth response'); + openAlert(t('common.error'), t('simkl.auth_error_msg')); } else if (response.type === 'error') { openAlert(t('simkl.auth_error_title'), t('simkl.auth_error_generic') + ' ' + (response.error?.message || t('common.unknown'))); } } - }, [response, refreshAuthStatus]); + }, [response, refreshAuthStatus, request?.codeVerifier, t]); const handleSignIn = () => { if (!SIMKL_CLIENT_ID) { diff --git a/src/services/simklService.ts b/src/services/simklService.ts index ef027ffe..ef0d4db7 100644 --- a/src/services/simklService.ts +++ b/src/services/simklService.ts @@ -301,7 +301,7 @@ export class SimklService { * Exchange code for access token * Simkl tokens do not expire */ - public async exchangeCodeForToken(code: string): Promise { + public async exchangeCodeForToken(code: string, codeVerifier: string): Promise { await this.ensureInitialized(); try { @@ -315,7 +315,8 @@ export class SimklService { client_id: SIMKL_CLIENT_ID, client_secret: SIMKL_CLIENT_SECRET, redirect_uri: SIMKL_REDIRECT_URI, - grant_type: 'authorization_code' + grant_type: 'authorization_code', + code_verifier: codeVerifier }) }); @@ -937,4 +938,4 @@ export class SimklService { return null; } } -} \ No newline at end of file +}