diff --git a/assets/translations/ar.json b/assets/translations/ar.json index e538b4d8..2375969c 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -26,7 +26,15 @@ "off": "معطّل", "on": "مُفعّل", "new_badge": "جديد", - "reset": "إعادة الضبط" + "reset": "إعادة الضبط", + "clear": "مسح", + "copy": "نسخ", + "share": "مشاركة", + "install": "تثبيت", + "remove": "إزالة", + "show": "إظهار", + "hide": "إخفاء", + "installed": "مثبّت" }, "auth": { "login": "تسجيل الدخول", @@ -78,7 +86,8 @@ "strong": "قوية", "code_resent": "أُعيد إرسال الرمز. تحقّق من بريدك.", "verify": "تحقّق", - "avatar_bad_format": "صيغة الصورة غير مدعومة (jpg، png، webp)" + "avatar_bad_format": "صيغة الصورة غير مدعومة (jpg، png، webp)", + "verification_code": "رمز التحقق" }, "onboarding": { "skip": "تخطّي", @@ -348,7 +357,10 @@ "fit": "ملاءمة", "pip": "صورة داخل صورة", "layout_pinned_badge": "مثبّت", - "info_fields_title": "صفوف لوحة المعلومات" + "info_fields_title": "صفوف لوحة المعلومات", + "logs_title": "سجلات المشغّل ({})", + "logs_copied": "تم نسخ السجلات", + "logs_empty": "لا توجد سجلات بعد" }, "profile": { "title": "حسابي", @@ -369,7 +381,7 @@ "guest_subtitle": "سجّل الدخول لمزامنة المفضّلة وسجل المشاهدة.", "auth_provider": "مزوّد تسجيل الدخول", "joined": "تاريخ الانضمام", - "providers": "المزوّدون", + "providers": "المصادر", "providers_error": "تعذّر تحميل المزوّدين.", "offline_title": "تعذّر الوصول إلى الخادم", "offline_local_available": "المصادر المثبَّتة على هذا الجهاز والبالغ عددها {} ما زالت تعمل. المصادر السحابية غير متاحة حتى يعود الخادم.", @@ -382,7 +394,7 @@ "connections": "الاتصالات", "coming_soon": "قريباً", "favorites": "المفضّلة", - "all_providers": "كل المزوّدين", + "all_providers": "كل المصادر", "add_favorite": "إضافة إلى المفضّلة", "cloudstream_sources": "مصادر CloudStream", "aniyomi_sources": "مصادر Aniyomi", @@ -391,12 +403,12 @@ "sign_in": "تسجيل الدخول", "sign_out": "تسجيل الخروج", "sign_out_confirm": "هل أنت متأكد من تسجيل الخروج؟", - "section_providers": "المزوّدون", + "section_providers": "المصادر", "section_content": "المحتوى", "sources_row": "مصادر الإضافات", "sources_row_subtitle": "CloudStream وAniyomi وMangayomi", - "provider": "المزوّد", - "choose_provider": "اختيار المزوّد", + "provider": "المصدر", + "choose_provider": "تغيير المصدر", "search_providers_hint": "ابحث عن مزوّدين…", "count_of_total_shown": "معروض {} من أصل {}", "no_providers_in_category": "لا يوجد مزوّدون في هذا التصنيف", @@ -513,7 +525,9 @@ "activity_subtitle": "السجل والتنزيلات والمتابعة والإحصاءات", "connections_subtitle": "المتتبعات، ديسكورد، التلفاز، سطح المكتب", "trackers": "المتتبعات", - "stat_days": "أيام مشاهدة" + "stat_days": "أيام مشاهدة", + "mangayomi_sources": "مصادر Mangayomi", + "view_only": "للمشاهدة فقط" }, "appearance": { "title": "المظهر", @@ -632,7 +646,8 @@ "voice_listening": "جارٍ الاستماع…", "voice_done": "توقّف الاستماع", "voice_hint": "قل اسم فيلم أو مسلسل", - "try_torrents": "البحث في التورنت" + "try_torrents": "البحث في التورنت", + "capped_note": "تم البحث في أكثر {} مصدرًا موثوقية من أصل {} — ضيّق النطاق للوصول إلى مصدر بعينه." }, "movie": { "rating": "التقييم", @@ -1698,5 +1713,96 @@ "uhd": "‏4K — أثقل في فك الترميز والتنزيل", "large_file": "ملف كبير — بطيء البدء على بيانات الجوال", "unreachable": "لم يستجب هذا الرابط عند فحصه" + }, + "update": { + "available_title": "يتوفر إصدار جديد (v{})", + "available_body": "يتوفر إصدار جديد من التطبيق.", + "later": "لاحقًا", + "now": "تحديث", + "open_store": "فتح في App Store", + "required_title": "التحديث مطلوب", + "required_body": "ثبّت الإصدار الجديد (v{}) لمتابعة استخدام التطبيق.", + "exit_app": "إغلاق التطبيق", + "downloading": "جارٍ تنزيل التحديث…" + }, + "ext": { + "cloudstream_title": "مصادر CloudStream", + "aniyomi_title": "مصادر Aniyomi", + "mangayomi_title": "مصادر Mangayomi", + "android_only": "هذه الميزة متاحة على Android فقط", + "needs_webview": "تحتاج إضافات JavaScript إلى WebView، وهو غير متوفر على هذه المنصة.", + "check_updates": "التحقق من التحديثات", + "check_extension_updates": "التحقق من تحديثات الإضافات", + "installed_sources": "المصادر المثبّتة", + "installed_repos": "المستودعات المثبّتة", + "sources_heading": "المصادر", + "recommended": "موصى بها", + "repo_url": "رابط المستودع", + "index_url": "رابط الفهرس", + "browse_plugins": "تصفح الإضافات", + "no_sources_yet": "لا توجد مصادر بعد", + "no_repos_yet": "لا توجد مستودعات بعد", + "add_source": "إضافة مصدر", + "add_repo": "إضافة مستودع", + "installing": "جارٍ التثبيت…", + "installing_extensions": "جارٍ تثبيت الإضافات…", + "installing_plugins": "جارٍ تثبيت الإضافات…", + "installing_progress": "جارٍ التثبيت {} / {}", + "installing_plugins_progress": "جارٍ تثبيت {} / {} إضافة…", + "installing_extensions_progress": "جارٍ تثبيت {} / {} إضافة…", + "installing_long": "جارٍ تثبيت الإضافات… قد يستغرق ذلك بعض الوقت في المستودعات الكبيرة.", + "checking_updates": "جارٍ التحقق من التحديثات…", + "checking_extension_updates": "جارٍ التحقق من تحديثات الإضافات…", + "all_up_to_date": "جميع الإضافات محدّثة.", + "updated_count": "تم تحديث {} إضافة.", + "updated_list": "تم تحديث {}: {}", + "update_check_failed": "فشل التحقق من التحديثات: {}", + "source_removed": "تمت إزالة المصدر.", + "repo_removed": "تمت إزالة المستودع.", + "none_found": "لم يتم العثور على إضافات في هذا الرابط.", + "none_found_js": "لم يتم العثور على إضافات JavaScript في هذا الرابط.", + "added_sources": "تمت إضافة {} مصدر.", + "added_sources_providers": "تمت إضافة {} مصدر · {} مزوّد.", + "installed_from_file": "تم تثبيت {} مصدر.", + "could_not_install": "تعذّر التثبيت: {}", + "could_not_install_plugin": "تعذّر تثبيت الإضافة", + "could_not_remove_plugin": "تعذّرت إزالة الإضافة", + "error": "خطأ: {}", + "cloudstream_desc": "أضف مستودع CloudStream ثم ثبّت الإضافات التي تريدها فقط — وحدها تظهر كمزوّدات. تعمل على جهازك (Android فقط).", + "aniyomi_desc": "أضف مستودعات إضافات Aniyomi لتثبيت مزوّدات أنمي إضافية. تعمل على جهازك (Android فقط).", + "mangayomi_desc": "إضافات Mangayomi مكتوبة بلغة JavaScript وليست تطبيقات Android — لذلك، بخلاف مصادر CloudStream وAniyomi وManga، تعمل أيضًا على iOS وmacOS وWindows.", + "plugins_summary": "{} إضافة · {} مثبّتة", + "install_all": "تثبيت الكل", + "repo_load_failed": "تعذّر تحميل هذا المستودع.", + "no_plugins": "لا توجد إضافات في هذا المستودع", + "search_plugins": "بحث في الإضافات…", + "no_matches": "لا توجد نتائج", + "import_title": "هل تريد تثبيت مستودع الإضافات؟", + "import_pick_library": "تستخدم مستودعات الأنمي والمانغا الصيغة نفسها، لذا اختر المكتبة التي ينتمي إليها هذا المستودع.", + "import_js_only": "على هذه المنصة يمكن تثبيت إضافات Mangayomi (JavaScript) فقط — أما مستودعات CloudStream وAniyomi وMihon فتوفّر حزم تطبيقات Android.", + "import_none_found": "لم يتم العثور على إضافات في هذا الملف. قد يكون لتطبيق آخر أو أن النوع المحدد غير صحيح.", + "mangayomi_url_only": "تُثبَّت مستودعات Mangayomi من رابط — افتح رابط الفهرس بدلًا من نسخة مُنزَّلة.", + "skipped_dart": "تم تخطي {} مصدرًا بلغة Dart فقط — فهي مدمجة في Mangayomi ولا تعمل هنا.", + "type_manga": "مانغا", + "type_anime": "أنمي", + "type_novel": "رواية" + }, + "ux": { + "quick_search": "بحث سريع ({})", + "quick_search_note": "يشمل البحث السريع {} من {} مصدرًا. اختر المصادر للبحث في مصدر محدد.", + "torrents": "تورنت", + "filters": "عوامل التصفية", + "change_source": "تغيير المصدر", + "manage_sources": "إدارة المصادر", + "no_source_match": "لا توجد مصادر تطابق بحثك.", + "download_options": "خيارات التنزيل", + "download_options_note": "اختر مصدرًا وجودة متاحين. يعتمد الصوت على المصدر المحدد.", + "source_number": "المصدر {}", + "checking_quality": "جارٍ التحقق من الجودة المتاحة…", + "quality_unavailable": "تعذر التحقق من الجودة. أعد المحاولة أو نزّل البث الأصلي دون ضمان الجودة.", + "quality_unknown": "الجودة غير معروفة", + "size_unknown": "لم يحدد المصدر حجم الملف", + "interface_language_note": "تتغير واجهة التطبيق فقط. تبقى عناوين الأفلام والأوصاف ومحتويات المصادر الأخرى بلغتها الأصلية.", + "continue": "متابعة" } } diff --git a/assets/translations/de.json b/assets/translations/de.json new file mode 100644 index 00000000..9922feaf --- /dev/null +++ b/assets/translations/de.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Wird geladen …", + "error": "Etwas ist schiefgelaufen", + "retry": "Wiederholen", + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "ok": "OK", + "yes": "Ja", + "no": "Nein", + "back": "Zurück", + "next": "Weiter", + "done": "Fertig", + "save": "Speichern", + "delete": "Löschen", + "edit": "Bearbeiten", + "search": "Suchen", + "close": "Schließen", + "settings": "Einstellungen", + "logout": "Abmelden", + "no_internet": "Keine Internetverbindung", + "try_again": "Erneut versuchen", + "see_all": "Alle ansehen", + "unknown": "Unbekannt", + "off": "Aus", + "on": "An", + "new_badge": "NEU", + "reset": "Zurücksetzen", + "clear": "Leeren", + "copy": "Kopieren", + "share": "Teilen", + "install": "Installieren", + "remove": "Entfernen", + "show": "Anzeigen", + "hide": "Ausblenden", + "installed": "Installiert" + }, + "auth": { + "login": "Anmelden", + "register": "Registrieren", + "email": "E-Mail", + "password": "Passwort", + "username": "Benutzername", + "email_hint": "Gib deine E-Mail-Adresse ein", + "password_hint": "Gib dein Passwort ein", + "username_hint": "Gib deinen Benutzernamen ein", + "forgot_password": "Passwort vergessen?", + "dont_have_account": "Noch kein Konto?", + "already_have_account": "Schon ein Konto?", + "sign_up": "Registrieren", + "sign_in": "Anmelden", + "login_success": "Anmeldung erfolgreich", + "register_success": "Registrierung erfolgreich", + "logout_success": "Erfolgreich abgemeldet", + "invalid_email": "Ungültige E-Mail-Adresse", + "invalid_password": "Das Passwort muss mindestens 6 Zeichen haben", + "invalid_username": "Der Benutzername muss mindestens 3 Zeichen haben", + "confirm_password": "Passwort bestätigen", + "passwords_not_match": "Die Passwörter stimmen nicht überein", + "terms_agree": "Ich stimme den Nutzungsbedingungen und der Datenschutzerklärung zu", + "or_continue_with": "Oder weiter mit", + "welcome_back": "Willkommen zurück", + "create_account": "Konto erstellen", + "login_subtitle": "Melde dich an, um dein Profil und deine gespeicherten Daten zu nutzen.", + "register_subtitle": "Erstelle ein Konto mit E-Mail, Benutzername und Passwort.", + "forgot_password_title": "Passwort zurücksetzen", + "forgot_password_subtitle": "Gib die E-Mail-Adresse deines Kontos ein, wir senden dir einen 6-stelligen Code.", + "reset_password_subtitle": "Wir haben einen Code an {email} gesendet. Gib ihn zusammen mit deinem neuen Passwort ein.", + "send_reset_code": "Code senden", + "reset_code_sent": "Falls diese E-Mail registriert ist, ist ein Code unterwegs", + "reset_code_hint": "6-stelliger Code", + "invalid_reset_code": "Gib den 6-stelligen Code ein", + "new_password_hint": "Neues Passwort", + "confirm_password_hint": "Neues Passwort wiederholen", + "reset_password": "Passwort zurücksetzen", + "resend_code": "Neuen Code senden", + "resend_in": "Erneut senden in {seconds}s", + "identifier_hint": "E-Mail oder Benutzername", + "identifier_required": "E-Mail oder Benutzername erforderlich", + "continue_with_google": "Weiter mit Google", + "google_unavailable": "Die Google-Anmeldung ist auf diesem Gerät nicht verfügbar", + "google_failed": "Anmeldung mit Google fehlgeschlagen. Bitte versuche es erneut", + "weak": "Schwach", + "medium": "Mittel", + "strong": "Stark", + "code_resent": "Code erneut gesendet. Sieh in deinem Postfach nach.", + "verify": "Bestätigen", + "avatar_bad_format": "Bildformat nicht unterstützt (jpg, png, webp)", + "verification_code": "Bestätigungscode" + }, + "onboarding": { + "skip": "Überspringen", + "continue_with_email": "Mit E-Mail fortfahren", + "slide_1_title": "Tausende Filme und Serien", + "slide_1_body": "Neuerscheinungen, Weltkino und Animation — alles in einer App.", + "slide_2_title": "Anime, eine ganze Bibliothek", + "slide_2_body": "Seasonal-Titel, Klassiker und Untertitel. Verknüpfe AniList und deine Liste pflegt sich selbst.", + "slide_3_title": "Auch auf dem großen Bildschirm", + "slide_3_body": "Live-TV-Sender, Handy und Fernseher koppeln und gemeinsam schauen — Sozo läuft auch auf dem TV." + }, + "home": { + "title": "Start", + "continue_watching": "Weiterschauen", + "trending": "Gerade im Trend", + "new_releases": "Neuerscheinungen", + "top_rated": "Bestbewertet", + "for_you": "Für dich", + "all_categories": "Alle Kategorien", + "genres": "Genres", + "get_all": "Alle holen", + "popular": "Beliebt", + "watch_now": "Jetzt ansehen", + "more_info": "Mehr Infos", + "add_to_list": "Zur Liste hinzufügen", + "my_list": "Meine Liste", + "featured": "Empfohlen", + "action": "Action", + "comedy": "Komödie", + "drama": "Drama", + "thriller": "Thriller", + "horror": "Horror", + "romance": "Romantik", + "sci_fi": "Sci-Fi", + "documentary": "Dokumentation", + "animation": "Animation", + "telegram_promo_title": "Sozo auf Telegram beitreten", + "telegram_promo_subtitle": "Erhalte Neuigkeiten, neue Funktionen und Hinweise zu neuen Inhalten.", + "telegram_join_channel": "Kanal beitreten", + "dont_show_again": "Nicht mehr anzeigen", + "downloaded": "Heruntergeladen", + "view_all": "Alle ansehen", + "remove_from_continue": "Aus „Weiterschauen“ entfernen", + "ep_number": "FL. {}", + "untitled": "Ohne Titel", + "empty_title": "Hier ist noch nichts", + "empty_subtitle": "Wähle eine andere Quelle oder zieh zum Aktualisieren.", + "list_empty_title": "Diese Liste ist leer", + "list_empty_subtitle": "Für diesen Bereich gibt es gerade nichts zu zeigen." + }, + "player": { + "needs_media_kit": "Benötigt die media_kit-Engine", + "auto_quality_desc": "Der Player wählt das Beste, was deine Verbindung schafft", + "on": "An", + "info_title": "Player-Info", + "info_resolution": "Auflösung", + "info_engine": "Engine", + "info_provider": "Quelle", + "info_server": "Server", + "info_quality": "Qualität", + "info_codec": "Codec", + "info_hdr": "HDR", + "info_audio": "Ton", + "info_container": "Container", + "info_size": "Dateigröße", + "info_host": "Host", + "info_position": "Position", + "info_buffer": "Vorgepuffert", + "info_speed": "Tempo", + "info_state": "Status", + "switching_to": "Wechsel zu {} …", + "fault": { + "decoder": "Dieses Gerät konnte das Video nicht dekodieren. Probiere eine andere Qualität oder versuche es erneut.", + "unsupported_format": "Dieses Videoformat wird auf deinem Gerät nicht unterstützt. Du kannst es im Browser abspielen.", + "source_blocked": "Die Videoquelle ließ sich nicht öffnen — der Server hat sie womöglich blockiert. Probiere eine andere Qualität.", + "network": "Netzwerkfehler — prüfe deine Verbindung.", + "no_source": "Keine Quelle für diese Folge.", + "empty_url": "Diese Quelle hat keine Videoadresse geliefert.", + "engine_unavailable": "Der Player ist nicht gestartet. Bitte starte die App vollständig neu.", + "unknown": "Video konnte nicht geladen werden." + }, + "play": "Abspielen", + "pause": "Pause", + "resume": "Fortsetzen", + "forward": "10 s vor", + "backward": "10 s zurück", + "fullscreen": "Vollbild", + "exit_fullscreen": "Vollbild beenden", + "quality": "Qualität", + "subtitles": "Untertitel", + "audio": "Ton", + "speed": "Wiedergabetempo", + "auto": "Auto", + "off": "Aus", + "episodes": "Folgen", + "next_episode": "Nächste Folge", + "season": "Staffel {n}", + "fit_original": "Original", + "fit_fill": "Füllen", + "fit_stretch": "Strecken", + "play_in_browser": "Im Browser abspielen", + "view_logs": "Protokolle ansehen", + "speed_2x": "2-fache Geschwindigkeit", + "tap_to_unlock": "Tippen zum Entsperren", + "previous": "Zurück", + "live": "LIVE", + "go_live": "Zu Live springen", + "lang_sub": "Untertitelt (SUB)", + "lang_dub": "Synchronisiert (DUB)", + "lang_softsub": "Weiche Untertitel", + "audio_language": "Tonsprache", + "speed_short": "Tempo", + "aspect": "Format", + "aspect_ratio": "Seitenverhältnis", + "subtitle_style": "Untertitelstil", + "audio_track": "Tonspur", + "diagnostics_logs": "Diagnose / Protokolle", + "extracting_media": "Medien werden extrahiert …", + "loading_video": "Video wird geladen …", + "fetching_link": "Wiedergabelink von der Quelle holen", + "preparing_stream": "Videostream wird vorbereitet", + "episode_n": "Folge {}", + "search_online": "Online suchen", + "subtitle_sync": "Untertitel-Sync", + "subtitle_sync_desc": "Timing verschieben, damit es zum Ton passt", + "subtitle_sync_help": "Untertitel zu früh? Erhöhen (+). Zu spät? Verringern (−).", + "search_subtitles": "Untertitel suchen", + "subtitle_search_hint": "Titel / Filmname", + "searching_subtitles": "Untertitel werden gesucht …", + "no_subtitles_found": "Keine Untertitel gefunden", + "search_failed": "Suche fehlgeschlagen: {}", + "online_subtitles_count": "Online-Untertitel · {}", + "loading_subtitle": "Untertitel wird geladen …", + "subtitle_loaded": "Untertitel geladen", + "subtitle_failed_download": "Dieser Untertitel ließ sich nicht laden", + "subtitle_failed_empty": "Diese Untertiteldatei enthält keinen Text", + "subtitle_failed_archive": "Archivierte Untertitel (.zip / .rar) werden nicht unterstützt", + "subtitle_failed_html": "Der Server hat eine Webseite geliefert, keinen Untertitel", + "subtitle_failed_format": "Untertitelformat nicht unterstützt", + "subtitle_rate": "Bildratenumrechnung", + "subtitle_rate_help": "Läuft es mit der Zeit immer weiter auseinander? Der Untertitel wurde für eine andere Bildrate erstellt — wähle die Umrechnung.", + "subtitle_rate_off": "Aus", + "reset": "Zurücksetzen", + "font_size": "Schriftgröße", + "text_color": "Textfarbe", + "background": "Hintergrund", + "none": "Keiner", + "solid": "Deckend", + "edge": "Kante", + "shadow": "Schatten", + "outline": "Kontur", + "position": "Position", + "lower": "Tiefer", + "default": "Standard", + "higher": "Höher", + "bold": "Fett", + "audio_track_engine_only": "nur media_kit", + "audio_track_single": "Nur eine Spur", + "external_player": "In externem Player abspielen", + "external_player_gated": "Diese Quelle braucht besondere Anfrage-Header, die VLC und MX Player nicht senden können. Sie wird höchstwahrscheinlich nicht laden. Trotzdem versuchen?", + "external_player_try": "Trotzdem versuchen", + "external_player_none": "Kein externer Videoplayer gefunden", + "audio_track_numbered": "Ton {}", + "engine_sheet_title": "Wie soll das abgespielt werden?", + "engine_sheet_subtitle": "Wähle die Engine für dieses Video. Du kannst sie jederzeit unter Einstellungen → Player ändern.", + "engine_sheet_dont_ask": "Nicht mehr fragen", + "engine_sheet_play": "Abspielen", + "subtitle_preview_text": "Franz jagt im komplett verwahrlosten Taxi quer durch Bayern", + "server": "Server", + "stream_not_found": "Diese Quelle versteckt ihren Stream hinter einem geschützten Player. Probiere eine andere Quelle oder öffne sie im Browser.", + "subtitle_translation": "Untertitelübersetzung", + "auto_translate": "Automatisch übersetzen", + "auto_translate_desc": "Hat die Quelle keinen Untertitel in deiner Sprache, übersetzt die KI ihn", + "translate_to": "Übersetzen nach", + "translate_note": "Die Übersetzung ist maschinell und kann falsch sein. Es gilt ein Tageslimit.", + "translate_ai": "KI-Übersetzung", + "translating_subtitle": "Wird übersetzt …", + "translate_failed": "Übersetzung fehlgeschlagen", + "ai_translate_title": "Mit KI nach {} übersetzen", + "ai_translate_hint": "Wähle einen Untertitel aus der Liste und tippe auf KI", + "ai_translate_remaining": "Heute noch {}/{} Übersetzungen", + "ai_translate_used_up": "Du hast das heutige Übersetzungslimit aufgebraucht", + "ai_translate_menu": "KI-Übersetzung", + "ai_translate_menu_desc": "Übersetzt einen Untertitel nach {}", + "ready_translations": "Fertige Übersetzungen", + "lines": "Zeilen", + "sleep_timer": "Sleeptimer", + "sleep_minutes": "{} Minuten", + "sleep_hours": "{} Stunden", + "sleep_minutes_short": "{} Min.", + "sleep_episode_end": "Ende der Folge", + "sleep_fired": "Sleeptimer — Wiedergabe pausiert", + "skip_opening": "Intro überspringen", + "skip_ending": "Abspann überspringen", + "alt_sources": "Andere Quelle versuchen", + "alt_searching": "Andere Quellen werden durchsucht …", + "alt_none": "Keine andere Quelle hat diesen Titel", + "alt_failed": "Diese Quelle ließ sich nicht öffnen", + "alt_no_episode": "Diese Quelle hat Folge {} nicht", + "alt_search_all": "Alle Quellen durchsuchen", + "guide": "Programm", + "change_source": "Quelle wechseln", + "cast": "Auf TV streamen", + "cast_to": "Streamen auf", + "cast_searching": "Suche nach Geräten in diesem WLAN …", + "cast_none": "Keine Geräte gefunden. Der Fernseher muss im selben WLAN sein wie dieses Handy.", + "cast_failed": "Verbindung zu {} fehlgeschlagen", + "cast_unsupported": "Dieser Stream lässt sich nicht streamen", + "cast_active": "Läuft auf {}", + "cast_stop": "Streaming beenden", + "color_profile": "Bild", + "color_natural": "Natürlich", + "color_cinema": "Kino", + "color_night": "Nacht", + "color_anime": "Anime", + "color_restore": "Zurücksetzen", + "shaders": "Verbessern (Anime4K)", + "shaders_note": "GPU-Hochskalierung für Animation. Die Shader-Dateien werden beim ersten Mal einmalig geladen.", + "shader_off": "Aus", + "shader_off_desc": "Keine Verbesserung", + "shader_sharpen": "Schärfen", + "shader_sharpen_desc": "Details zurückholen — am besten für saubere Quellen", + "shader_deblur": "Entschärfen", + "shader_deblur_desc": "Sanftere Aufbereitung — für unscharfe Quellen", + "shader_denoise": "Rauschen entfernen", + "shader_denoise_desc": "Körnung glätten — für stark komprimierte Quellen", + "shader_tier": "GPU-BUDGET", + "shader_tier_mid": "Ausgewogen", + "shader_tier_mid_desc": "Läuft auf den meisten Handys flüssig", + "shader_tier_high": "Maximum", + "shader_tier_high_desc": "Am schärfsten, braucht eine starke GPU", + "font_family": "Schrift", + "font_system": "Standard", + "font_sans": "Serifenlos", + "font_serif": "Serif", + "font_condensed": "Schmal", + "font_mono": "Monospace", + "second_track": "Zweite Spur", + "second_track_on": "{} wird als zweite Spur angezeigt", + "second_track_same": "Diese Spur ist bereits die erste", + "second_track_hint": "Halte eine Spur gedrückt, um sie neben der ersten anzuzeigen", + "share_frame": "Dieses Bild teilen", + "share_frame_failed": "Das Bild ließ sich nicht aufnehmen", + "save_frame": "Bild speichern", + "save_frame_done": "In der Galerie gespeichert", + "save_frame_failed": "Das Bild ließ sich nicht speichern", + "save_frame_denied": "Zum Speichern wird Zugriff auf die Galerie benötigt", + "switching_server": "Server wird gewechselt …", + "recommended": "Empfohlen", + "episode_page_failed": "Der nächste Teil der Serie ließ sich nicht laden", + "info_fields_desc": "Wähle, was über dem Video erscheint. Es wird zusammen mit den Steuerelementen eingeblendet.", + "layout_title": "Player-Steuerung", + "layout_desc": "Ziehen zum Umsortieren, oder ein Element in einen anderen Bereich der Leiste verschieben.", + "layout_top_bar": "Obere Leiste", + "layout_bottom_left": "Unten links", + "layout_bottom_right": "Unten rechts", + "layout_hidden": "Ausgeblendet", + "layout_hidden_desc": "Weiterhin über die Einstellungen im Player erreichbar.", + "layout_pinned": "Einstellungen bleibt, wo es ist — es ist der Weg zurück zu allem anderen.", + "layout_top_bar_full": "Die obere Leiste ist voll. Nimm zuerst etwas heraus.", + "layout_unknown_control": "Dieses Element ist nicht mehr verfügbar.", + "layout_move_to": "Verschieben", + "layout_empty": "Hier ist nichts.", + "rotate": "Drehen", + "lock": "Sperren", + "enhance": "Verbessern", + "fit": "Anpassen", + "pip": "Bild im Bild", + "layout_pinned_badge": "Angeheftet", + "info_fields_title": "Zeilen der Info-Einblendung", + "logs_title": "Player-Logs ({})", + "logs_copied": "Logs kopiert", + "logs_empty": "Noch keine Logs" + }, + "profile": { + "title": "Profil", + "my_account": "Mein Konto", + "notifications": "Benachrichtigungen", + "language": "Sprache", + "theme": "Design", + "privacy": "Datenschutzerklärung", + "help": "Hilfe & Support", + "about": "Über", + "version": "Version", + "subscription": "Abo", + "downloads": "Downloads", + "watch_history": "Wiedergabeverlauf", + "manage_profiles": "Profile verwalten", + "parental_controls": "Kindersicherung", + "guest_title": "Beim Profil anmelden", + "guest_subtitle": "Melde dich an, damit Favoriten und Verlauf synchron bleiben.", + "auth_provider": "Anmeldedienst", + "joined": "Dabei seit", + "providers": "Quellen", + "providers_error": "Quellen konnten nicht geladen werden.", + "offline_title": "Server nicht erreichbar", + "offline_local_available": "{} auf diesem Gerät installierte Quelle(n) funktionieren weiterhin. Cloud-Quellen sind nicht verfügbar, bis der Server zurück ist.", + "offline_no_local": "Der Server antwortet nicht und auf diesem Gerät ist keine Quelle installiert. Installiere ein CloudStream-Plugin, um offline weiterzuschauen.", + "offline_cached_at": "Cloud-Liste von {}", + "offline_install_plugins": "Plugins installieren", + "offline_badge": "OFFLINE", + "provider_needs_server": "Diese Quelle braucht den Server, der gerade nicht erreichbar ist. Wähle eine Quelle, die auf diesem Gerät installiert ist.", + "logout_confirm": "Möchtest du dich von deinem Konto abmelden?", + "connections": "Verbindungen", + "coming_soon": "Demnächst", + "favorites": "Favoriten", + "all_providers": "Alle Quellen", + "add_favorite": "Zu Favoriten hinzufügen", + "cloudstream_sources": "CloudStream-Quellen", + "aniyomi_sources": "Aniyomi-Quellen", + "signin_account_title": "Bei deinem Konto anmelden", + "signin_account_subtitle": "Melde dich an, um Favoriten und Verlauf zu speichern.", + "sign_in": "Anmelden", + "sign_out": "Abmelden", + "sign_out_confirm": "Möchtest du dich wirklich abmelden?", + "section_providers": "Quellen", + "section_content": "INHALTE", + "sources_row": "Erweiterungsquellen", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Quelle", + "choose_provider": "Quelle wechseln", + "search_providers_hint": "Quellen durchsuchen …", + "count_of_total_shown": "{} von {} angezeigt", + "no_providers_in_category": "Keine Quellen in dieser Kategorie", + "try_select_all": "Wähle „Alle“, um jede Quelle zu sehen", + "section_activity": "AKTIVITÄT", + "share_sources_desktop": "Quellen mit dem Desktop teilen", + "desktop_sources": "Desktop-Quellen", + "developer_role": "Mobile-Entwickler", + "section_about": "ÜBER", + "developer": "Entwickler", + "website": "Website", + "server": "Server", + "expired": "Abgelaufen", + "support_title": "Halte Sozo am Laufen", + "server_expired": "Server abgelaufen", + "days": "Tage", + "hours": "Std.", + "min": "Min.", + "sec": "Sek.", + "support_body_expired": "Der Server ist abgelaufen und Inhalte lassen sich nicht mehr laden. Mit deiner Unterstützung kommt er zurück.", + "support_body": "Sozo läuft auf einem Server, der regelmäßig verlängert werden muss. Wenn dir die App gefällt, hilft deine Unterstützung, dass alles rundläuft.", + "support_developer": "Entwickler unterstützen", + "section_appearance": "DARSTELLUNG", + "native_window_bar": "Native Fensterleiste", + "native_window_bar_subtitle": "Die Standard-Titelleiste von Windows statt der eigenen der App verwenden.", + "liquid_glass_nav": "Liquid-Glass-Navigation", + "liquid_glass_nav_subtitle": "Schwebende Tab-Leiste aus Milchglas (iOS 26). Ausschalten für eine schlichte, deckende Leiste.", + "nav_style": "Navigationsleiste", + "nav_style_glass": "Liquid Glass", + "nav_style_solid": "Deckend", + "nav_style_classic": "Klassisch", + "section_player": "Player", + "player_engine_native": "Systemplayer", + "player_engine_native_desc": "Der Player der Plattform. Beste Akkulaufzeit und Hardware-Dekodierung. Kann keine Tonspuren wechseln.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Spielt mehr Formate ab und lässt dich Ton- und Untertitelspuren wechseln. Braucht etwas mehr Akku.", + "player_engine_external": "Externer Player", + "player_engine_external_desc": "Das Video an VLC oder MX Player schicken, statt es hier abzuspielen.", + "player_engine_external_note": "Manche Quellen funktionieren nur in Sozo, weil sie besondere Anfrage-Header brauchen. Die laufen in einer externen App nicht.", + "player_engine_badge_default": "STANDARD", + "player_engine_section": "WIEDERGABE-ENGINE", + "ask_engine_on_play": "Jedes Mal fragen", + "ask_engine_on_play_desc": "Die Engine bei jedem Videostart wählen", + "playback_section": "WIEDERGABE", + "default_speed": "Standardtempo", + "default_speed_desc": "Tempo, mit dem jedes Video startet", + "default_fit": "Standard-Füllmodus", + "default_fit_desc": "Wie das Video den Bildschirm füllt", + "auto_next_episode": "Nächste Folge automatisch", + "auto_next_episode_desc": "Die nächste Folge starten, wenn eine endet", + "keep_screen_on": "Bildschirm anlassen", + "keep_screen_on_desc": "Verhindert, dass sich der Bildschirm beim Schauen ausschaltet", + "gestures_section": "GESTEN", + "seek_step": "Sprung bei Doppeltippen", + "seek_step_desc": "Wie weit ein Doppeltippen springt", + "long_press_boost": "Tempo beim Gedrückthalten", + "long_press_boost_desc": "Tempo, solange du auf das Video drückst", + "brightness_gesture": "Helligkeit wischen", + "brightness_gesture_desc": "Auf der linken Seite hoch und runter wischen", + "volume_gesture": "Lautstärke wischen", + "volume_gesture_desc": "Auf der rechten Seite hoch und runter wischen", + "seconds_short": "{} s", + "subtitle_style_note": "Das gilt für jedes Video. Das Untertitel-Timing bleibt pro Folge und wird im Player eingestellt.", + "searching_all_sources": "alle Quellen werden durchsucht", + "section_connections": "VERBINDUNGEN", + "section_extensions": "QUELLEN", + "section_settings": "EINSTELLUNGEN", + "remove_favorite": "Aus Favoriten entfernen", + "edit_profile": "Profil bearbeiten", + "display_name_hint": "Dein Name", + "display_name_too_long": "Der Name darf höchstens 60 Zeichen haben", + "pick_from_gallery": "Aus der Galerie wählen", + "take_photo": "Foto aufnehmen", + "remove_photo": "Foto entfernen", + "photo_pick_failed": "Es konnte kein Foto gewählt werden", + "incognito": "Inkognito", + "incognito_desc": "Schauen, ohne den Verlauf oder Tracker zu füllen", + "incognito_off": "Inkognito ausgeschaltet", + "auto_skip_intro": "Openings automatisch überspringen", + "auto_skip_intro_desc": "Anime-Openings und -Endings ohne Nachfrage überspringen", + "incognito_active": "Inkognito ist an. Nichts, was du schaust, landet im Verlauf oder geht an AniList und MAL.", + "incognito_turn_off": "Ausschalten", + "all_languages": "Alle Sprachen", + "section_library": "DEINE BIBLIOTHEK", + "section_watch": "SCHAUEN", + "hero_trailer": "Trailer im Kopfbereich abspielen", + "hero_trailer_desc": "Kurz nachdem ein Titel geöffnet ist, weicht das Poster dem Trailer — ohne Ton", + "section_overview": "ÜBERBLICK", + "section_app": "APP", + "library": "Bibliothek", + "library_subtitle": "Favoriten, Später schauen, private Liste", + "sources_title": "Quellen", + "section_active_source": "AKTIVE QUELLE", + "active_source_footnote": "Die Cloud-Quelle, in der Sozo sucht und aus der es abspielt. Die Erweiterungen darunter fügen Quellen hinzu, die auf diesem Gerät laufen.", + "settings_title": "Einstellungen", + "settings_subtitle": "Sprache, Darstellung, Player, Sicherheit", + "section_general": "ALLGEMEIN", + "section_data": "DATEN", + "section_lists": "LISTEN", + "section_accounts": "KONTEN", + "section_devices": "GERÄTE", + "language_desc": "Sprache der Oberfläche", + "not_connected": "Nicht verbunden", + "joined_on": "Dabei seit {}", + "watch_later": "Später schauen", + "watched": "Gesehen", + "stat_hours": "Stunden gesehen", + "stat_completed": "abgeschlossen", + "stat_streak": "Tage-Streak", + "guest_benefit_sync": "Favoriten und Verlauf auf allen deinen Geräten synchron", + "guest_benefit_streak": "Streak, Statistik und Trivia-Rang", + "guest_benefit_trackers": "AniList, MyAnimeList und Watch Party", + "activity": "Aktivität", + "activity_subtitle": "Verlauf, Downloads, Verfolgen, Statistik", + "connections_subtitle": "Tracker, Discord, TV, Desktop", + "trackers": "Tracker", + "stat_days": "Tage gesehen", + "mangayomi_sources": "Mangayomi-Quellen", + "view_only": "NUR ANSEHEN" + }, + "appearance": { + "title": "Darstellung", + "entry_subtitle": "Akzentfarbe, reines Schwarz", + "section_preview": "VORSCHAU", + "section_accent": "AKZENTFARBE", + "accent_footnote": "Der Akzent färbt Schaltflächen, Hervorhebungen, Fortschrittsbalken und den aktiven Tab — überall in der App.", + "accent_custom": "Eigene", + "accent_red": "Sozo-Rot", + "accent_ember": "Glut", + "accent_amber": "Bernstein", + "accent_emerald": "Smaragd", + "accent_teal": "Petrol", + "accent_sky": "Himmel", + "accent_ocean": "Ozean", + "accent_indigo": "Indigo", + "accent_violet": "Violett", + "accent_magenta": "Magenta", + "accent_rose": "Rosé", + "accent_slate": "Schiefer", + "section_darkness": "HINTERGRUND", + "darkness_dark": "Dunkel", + "darkness_dark_desc": "Die gewohnten dunklen Grautöne", + "darkness_black": "Reines Schwarz", + "darkness_black_desc": "Echt schwarze Hintergründe", + "amoled_footnote": "Auf einem OLED-Display ist ein schwarzes Pixel ausgeschaltet — reines Schwarz gibt also tieferen Kontrast und spart Akku.", + "reset": "Auf Standard zurücksetzen", + "reset_tooltip": "Zurück zum Standard-Look", + "custom_title": "Eigene Farbe", + "custom_hue": "FARBTON", + "custom_saturation": "SÄTTIGUNG", + "custom_lightness": "HELLIGKEIT", + "custom_apply": "Diese Farbe verwenden", + "custom_adjusted": "Etwas abgedunkelt, damit weißer Text darauf lesbar bleibt.", + "section_library": "AUS DEINER BIBLIOTHEK", + "library_footnote": "Farben aus den Postern dessen, was du zuletzt geschaut hast.", + "section_extras": "MEHR", + "tint_nav": "Tab-Leiste einfärben", + "tint_nav_desc": "Färbt den aktiven Tab in deinem Akzent statt in Weiß", + "shuffle": "Nächste Farbe probieren", + "shuffle_desc": "Geh die Palette Akzent für Akzent durch" + }, + "search": { + "title": "Suche", + "hint": "Filme, Serien suchen …", + "no_results": "Keine Ergebnisse gefunden", + "no_results_for": "Keine Ergebnisse für „{query}“", + "recent": "Letzte Suchen", + "popular_searches": "Beliebte Suchen", + "categories": "Genres", + "all": "Alle", + "movies": "Filme", + "series": "Serien", + "filter": "Filter", + "sort": "Sortieren nach", + "sort_newest": "Neueste", + "sort_oldest": "Älteste", + "sort_rating": "Bewertung", + "type": "Typ", + "apply": "Anwenden", + "clear_filter": "Löschen", + "sources": "Quellen", + "sources_n": "{} Quellen", + "all_sources": "Alle auswählen", + "this_server": "Dieser Server", + "local_group": "Lokal", + "search_in": "Suchen in", + "sources_hint": "Mehrere Quellen auf einmal durchsuchen. Leer lassen, um deine aktuelle Quelle zu nutzen.", + "searching": "Wird gesucht …", + "all_source_search": "Suche über alle Quellen", + "cross_hint": "In deinen Quellen suchen …", + "pick_sources": "Wähle, welche Quellen durchsucht werden.", + "choose_sources": "Quellen wählen", + "no_sources_selected": "Keine Quellen ausgewählt — tippen zum Wählen", + "sources_selected": "{} ausgewählt — tippen zum Ändern", + "type_to_search_n": "Tippe, um {} Quellen gleichzeitig zu durchsuchen.", + "found_in": "In {} von {} Quellen gefunden", + "results_n": "{} Ergebnisse", + "waiting_for": "{} wird durchsucht …", + "no_results_any": "Keine Ergebnisse in einer der gewählten Quellen.", + "offline_note": "Offline — es lassen sich nur Quellen auf dem Gerät durchsuchen.", + "group_by_source": "Nach Quelle gruppieren", + "merge_titles": "Titel zusammenführen", + "open_from": "Öffnen aus", + "load_more": "Mehr laden", + "search_failed": "Suche fehlgeschlagen", + "source_failed": "Diese Quelle ist gerade nicht verfügbar", + "try_all_sources": "Alle Quellen probieren", + "weak_results": "Nichts hier sieht nach „{query}“ aus — diese Quelle hat auf etwas anderes gepasst. Andere Quellen haben es vielleicht.", + "did_you_mean": "Meintest du", + "search_sources": "Quellen durchsuchen", + "filter_providers": "Quellen filtern …", + "selected_n": "{} ausgewählt", + "apply_n": "Anwenden ({})", + "many_sources_warning": "{} Quellen zu durchsuchen kann dauern. Die App bleibt bedienbar, aber weniger ist schneller.", + "all_sources_n": "Alle Quellen ({})", + "more_sources": "Mehr …", + "select_all_sources": "Alle auswählen", + "search_all_sources": "Alle Quellen durchsuchen", + "apply_all": "Alle Quellen durchsuchen", + "selected_n_of_m": "{} von {} Quellen", + "loading_sources": "Deine Quellen werden geladen …", + "no_sources_available": "Keine Quellen verfügbar. Füge eine Quelle hinzu oder aktiviere eine, um zu suchen.", + "min_query_n": "Gib mindestens {} Zeichen ein.", + "retry_failed": "Fehlgeschlagene erneut versuchen", + "status_searching": "Wird gesucht …", + "status_searching_n": "{} werden durchsucht", + "status_with_results_n": "{} mit Ergebnissen", + "status_no_match_n": "{} ohne Treffer", + "status_timed_out_n": "{} zeitüberschritten", + "status_failed_n": "{} fehlgeschlagen", + "all_sources_failed": "Alle {} Quellen haben nicht geantwortet. Das ist kein leeres Ergebnis — versuche es noch einmal.", + "no_results_with_failures": "Keine Treffer bei den Quellen, die geantwortet haben ({}). {} waren nicht erreichbar.", + "all_sources_failed_one": "Die durchsuchte Quelle hat nicht geantwortet. Das ist kein leeres Ergebnis — versuche es noch einmal.", + "voice": "Per Sprache suchen", + "voice_listening": "Ich höre zu …", + "voice_done": "Zuhören beendet", + "voice_hint": "Sag den Namen eines Films oder einer Serie", + "try_torrents": "Torrents durchsuchen", + "capped_note": "Die {} verlässlichsten von {} Quellen wurden durchsucht — grenze die Auswahl ein, um eine bestimmte zu erreichen." + }, + "movie": { + "rating": "Bewertung", + "year": "Jahr", + "quality": "Qualität", + "duration": "Länge", + "genre": "Genre", + "director": "Regie", + "cast": "Besetzung", + "description": "Beschreibung", + "similar": "Ähnliche Titel", + "trailer": "Trailer", + "share": "Teilen", + "download": "Herunterladen", + "rate": "Bewerten", + "episodes": "Folgen", + "seasons": "Staffeln" + }, + "errors": { + "network": "Netzwerkfehler. Prüfe deine Verbindung.", + "server": "Serverfehler. Bitte versuche es erneut.", + "unauthorized": "Bitte melde dich an, um fortzufahren.", + "not_found": "Inhalt nicht gefunden.", + "unknown": "Ein unbekannter Fehler ist aufgetreten.", + "timeout": "Zeitüberschreitung. Bitte versuche es erneut.", + "empty": "Keine Inhalte verfügbar.", + "source_http": "Der Server der Quelle hat mit einem Fehler geantwortet ({})." + }, + "navigation": { + "home": "Start", + "search": "Suche", + "shorts": "Shorts", + "downloads": "Downloads", + "profile": "Profil", + "my_list": "Meine Liste", + "history": "Verlauf", + "following": "Abos", + "premiere": "Premiere", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "Live-TV" + }, + "premiere": { + "tab_title": "Premieren", + "live_now": "Jetzt live", + "upcoming": "Demnächst", + "live": "LIVE", + "catching_up": "Wird zum Live-Punkt aufgeholt", + "starts_in": "Beginnt in", + "starts_at": "Beginnt {when}", + "days": "TAGE", + "hours": "STD.", + "minutes": "MIN.", + "seconds": "SEK.", + "waiting": "{count} warten", + "watching": "{count} schauen", + "join": "Premiere beitreten", + "join_now": "Jetzt beitreten", + "set_reminder": "Erinnerung setzen", + "reminder_set": "Erinnerung gesetzt", + "notify_me": "Benachrichtige mich zum Start", + "guest_watch_hint": "Du kannst als Gast zuschauen.", + "login_to_chat_react": "Melde dich an, um zu chatten und zu reagieren", + "login_to_chat": "Melde dich an, um zu chatten", + "chat_hint": "Sag Hallo — der Chat ist offen", + "chat_empty": "Sag Hallo 👋 Der Chat startet mit der Premiere", + "chat_placeholder": "Nachricht …", + "slow_mode": "Langsamer Modus: {sec} s", + "react": "Reagieren", + "paused_by_host": "Vom Host pausiert", + "buffering": "Wird gepuffert …", + "reconnecting": "Verbindung wird wiederhergestellt …", + "connecting": "Verbindung zur Premiere …", + "ended_title": "Premiere beendet", + "ended_watch_vod": "Auf Abruf ansehen", + "canceled": "Diese Premiere wurde abgesagt", + "empty_title": "Noch keine Premieren", + "empty_subtitle": "Live-Events erscheinen hier", + "error_generic": "Etwas ist schiefgelaufen", + "error_full": "Diese Premiere ist voll", + "error_not_found": "Premiere nicht gefunden", + "retry": "Wiederholen", + "share": "Teilen", + "back": "Zurück", + "resolve_failed": "Die Wiedergabe ließ sich auf diesem Gerät nicht starten" + }, + "nav_customize": { + "entry_title": "Tabs anpassen", + "entry_subtitle": "Wähle, welche Tabs erscheinen und in welcher Reihenfolge", + "title": "Untere Leiste", + "shown": "Sichtbar", + "available": "Verfügbar", + "min_hint": "Behalte mindestens {count} Tabs", + "max_hint": "Bis zu {count} Tabs", + "pick_range": "Wähle zwischen {min} und {max} Tabs", + "reset": "Zurücksetzen", + "cancel": "Abbrechen", + "save": "Speichern" + }, + "app_lock": { + "section_label": "SICHERHEIT", + "app_lock": "App-Sperre", + "settings_title": "App-Sperre", + "setup_title": "PIN einrichten", + "enable": "App-Sperre aktivieren", + "state_on": "An", + "state_off": "Aus", + "choose_length_title": "PIN-Länge", + "choose_length_subtitle": "Wähle, aus wie vielen Ziffern deine PIN besteht.", + "length_n_digits": "{}-stellige PIN", + "create_pin_title": "Neue PIN erstellen", + "create_pin_subtitle": "Wähle etwas, das du mit niemandem teilst.", + "confirm_pin_title": "PIN bestätigen", + "confirm_pin_subtitle": "Gib deine neue PIN noch einmal ein.", + "verify_title": "App gesperrt", + "verify_subtitle": "Gib deine PIN ein, um fortzufahren.", + "verify_current_pin": "Gib deine aktuelle PIN ein", + "change_pin": "PIN ändern", + "biometric": "Mit Biometrie entsperren", + "biometric_unavailable": "Biometrie ist auf diesem Gerät nicht verfügbar", + "biometric_enable_reason": "Authentifiziere dich, um die biometrische Entsperrung zu aktivieren", + "biometric_unlock_reason": "Authentifiziere dich, um die App zu öffnen", + "pin_mismatch": "Die PINs stimmen nicht überein", + "pin_wrong": "Falsche PIN", + "disable_title": "App-Sperre ausschalten?", + "disable_subtitle": "Deine PIN und die Biometrie-Einstellung werden entfernt.", + "enabled_hint": "Beim Start der App wirst du nach deiner PIN gefragt.", + "disabled_hint": "Die App-Sperre ist aus. Schalte sie ein, um zu schützen, was du schaust.", + "private_list": "Private Liste", + "move_to_private": "Nach privat verschieben", + "moved_to_private": "In die private Liste verschoben", + "removed_from_private": "Aus privat entfernt", + "move_to_my_list": "Zurück zu Meine Liste", + "private_empty": "Noch nichts Privates", + "private_setup_required": "Richte zuerst eine PIN ein, um die private Liste zu nutzen", + "private_long_press_hint": "Halte gedrückt, um in deine private Liste zu verschieben — hinter deiner PIN, und ohne dass der Verlauf gespeichert wird.", + "private_always_ask": "Für die private Liste immer nach der PIN fragen", + "private_always_ask_hint": "Gib deine PIN jedes Mal ein, nicht nur einmal pro Sitzung", + "reset_title": "App-Sperre zurücksetzen?", + "reset_body": "Das entfernt die PIN und entsperrt Sozo auf diesem Computer. Deine Bibliothek und dein Verlauf bleiben. Eine neue PIN kannst du später unter Profil → App-Sperre setzen.", + "reset": "Zurücksetzen", + "forgot_pin": "PIN vergessen?" + }, + "my_list": { + "saved_account": "Im Konto gespeichert", + "saved_local": "Auf diesem Gerät", + "untitled": "Ohne Titel", + "empty_title": "Deine Liste ist leer", + "empty_subtitle": "Tippe bei einem Titel auf +, um ihn hier zu speichern.", + "unauth_title": "Melde dich an, um deine Liste zu sehen", + "unauth_subtitle": "Deine gespeicherten Titel erscheinen hier, sobald du angemeldet bist.", + "error_title": "Liste konnte nicht geladen werden" + }, + "deeplink": { + "opt_in_title": "Sozo-Links in dieser App öffnen?", + "opt_in_body_android": "Geteilte sozo.azamov.me-Links öffnen sich direkt in Sozo. Aktiviere „Unterstützte Links öffnen“ in deinen Systemeinstellungen.", + "opt_in_body_ios": "Sozo-Links öffnen sich automatisch in der App. Öffnet sich ein Link in Safari, halte ihn gedrückt und wähle „In Sozo öffnen“.", + "opt_in_enable": "Aktivieren", + "opt_in_later": "Später" + }, + "streak": { + "section_label": "STREAK", + "empty_title": "Starte einen Streak", + "empty_subtitle": "Schau heute eine Folge, um deinen täglichen Streak zu beginnen.", + "current_n_days": "{} Tage", + "locked_today": "Heute ist gesichert. Komm morgen wieder!", + "keep_going": "Halte deinen Streak am Leben — vergiss nicht, heute zu schauen.", + "risk_subtitle": "Dein Streak wird um Mitternacht zurückgesetzt. Schau jetzt etwas, um ihn zu retten.", + "record_label": "Rekord", + "next_milestone": "Nächstes Ziel", + "weekday_mon": "Mo", + "weekday_tue": "Di", + "weekday_wed": "Mi", + "weekday_thu": "Do", + "weekday_fri": "Fr", + "weekday_sat": "Sa", + "weekday_sun": "So", + "milestone_title": "{}-Tage-Streak!", + "milestone_subtitle": "Unglaublich beständig. Bleib dran und stell einen neuen persönlichen Rekord auf.", + "share_text": "{}-Tage-Streak auf Sozo geschafft! 🔥", + "cta_continue": "Weiter", + "cta_share": "Teilen", + "freeze_label": "Freezes", + "freeze_count": "{} / {}", + "total_days_label": "Tage insgesamt", + "this_week_label": "Diese Woche", + "calendar_label": "Letzte 35 Tage", + "days_to_milestone": "Noch {} Tage bis zum nächsten Meilenstein", + "freeze_saved_title": "Streak gerettet!", + "freeze_saved_subtitle": "Ein Freeze hat deinen verpassten Tag abgedeckt — dein Streak lebt weiter.", + "freeze_awarded_title": "Du hast einen Freeze verdient!", + "freeze_awarded_subtitle": "Freezes schützen deinen Streak, wenn du einen einzelnen Tag verpasst.", + "freeze_explainer": "Ein Freeze rettet deinen Streak automatisch an einem Tag, den du verpasst. Neue verdienst du dir, indem du regelmäßig schaust." + }, + "watch_party": { + "title": "Watch Party", + "entry_subtitle": "Synchron mit Freunden schauen — einer spielt, alle folgen.", + "entry_create": "Party erstellen", + "entry_create_hint": "Starte einen Raum und lade Freunde mit einem Code ein", + "entry_join": "Mit Code beitreten", + "entry_join_hint": "Gib einen Code ein, den dir jemand geschickt hat", + "not_available": "Gemeinsames Schauen gibt es für diesen Titel noch nicht", + "create": "Party erstellen", + "join": "Party beitreten", + "join_hint": "Gib den Party-Code ein", + "invalid_code": "Ungültiger Code", + "code_label": "Party-Code", + "copy_code": "Code kopieren", + "copied": "Kopiert", + "share_invite": "Einladung teilen", + "invite": "Einladen", + "lobby_waiting_host": "Warten, bis der Host startet …", + "host_label": "Host", + "members": "Mitglieder", + "member_count": "{} / {}", + "you": "Du", + "start_watching": "Losschauen", + "close_party": "Party schließen", + "leave_party": "Party verlassen", + "leave_confirm": "Diese Watch Party verlassen?", + "only_host_controls": "Nur der Host kann die Wiedergabe steuern", + "host_changed": "Der Host hat gewechselt", + "room_closed": "Die Party wurde geschlossen", + "room_closed_empty": "Die Party wurde geschlossen, weil alle gegangen sind", + "content_none": "Noch kein Titel gewählt", + "resolving": "Das Video wird vorbereitet …", + "resolve_failed": "Dieser Titel ließ sich auf deinem Gerät nicht laden", + "plugin_required_title": "Plugin erforderlich", + "plugin_required_body": "Installiere das nötige Quellen-Plugin, um das gemeinsam zu schauen.", + "install_plugin": "Plugin installieren", + "chat_hint": "Sag etwas …", + "chat_empty": "Noch keine Nachrichten", + "reactions": "Reaktionen", + "error_not_found": "Party nicht gefunden oder bereits geschlossen", + "error_full": "Diese Party ist voll", + "error_forbidden": "Du hast keinen Zugang zu dieser Party", + "error_rate": "Etwas langsamer", + "error_unauthorized": "Melde dich an, um einer Watch Party beizutreten", + "error_generic": "Etwas ist schiefgelaufen", + "reconnecting": "Verbindung wird wiederhergestellt …", + "connecting": "Verbindung wird aufgebaut …", + "connect_failed_title": "Verbindung fehlgeschlagen", + "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut." + }, + "notifications": { + "title": "Benachrichtigungen", + "mark_all_read": "Alle gelesen", + "empty_title": "Noch keine Benachrichtigungen", + "empty_subtitle": "Sobald etwas Neues eintrifft, siehst du es hier.", + "time_now": "jetzt", + "time_minutes": "{} Min.", + "time_hours": "{} Std.", + "time_days": "{} T." + }, + "manga": { + "sources_title": "Manga-Quellen", + "sources_desc": "Füge Manga-Erweiterungs-Repos hinzu, um weitere Manga-, Manhwa- und Webtoon-Anbieter zu installieren. Sie laufen auf deinem Gerät (nur Android).", + "repo_url": "Repo-URL", + "add_source": "Quelle hinzufügen", + "installing": "Wird installiert …", + "installing_long": "Erweiterungen werden installiert … bei großen Repos kann das einen Moment dauern.", + "installing_progress": "{} / {} Erweiterungen werden installiert …", + "no_extensions": "Unter dieser URL wurden keine Erweiterungen gefunden.", + "added_sources": "{} Quelle(n) · {} Anbieter hinzugefügt.", + "source_removed": "Quelle entfernt.", + "recommended": "EMPFOHLEN", + "installed_sources": "INSTALLIERTE QUELLEN", + "source_settings": "QUELLENEINSTELLUNGEN", + "no_sources": "Noch keine Quellen", + "show_nsfw": "Quellen für Erwachsene", + "show_nsfw_hint": "Zeigt als 18+ markierte Quellen in dieser Liste und in der Quellenauswahl. Standardmäßig aus.", + "show": "Anzeigen", + "hide": "Ausblenden", + "installed": "Installiert", + "android_only": "Diese Funktion gibt es nur unter Android", + "error_prefix": "Fehler: {}", + "no_preferences": "Diese Quelle hat keine einstellbaren Optionen", + "not_selected": "Nicht ausgewählt", + "n_selected": "{} ausgewählt", + "reading_mode": "Lesemodus", + "mode_continuous": "Fortlaufend", + "mode_paged": "Seitenweise", + "direction": "Richtung", + "dir_ltr": "Links nach rechts (LTR)", + "dir_rtl": "Rechts nach links (RTL)", + "background": "Hintergrund", + "bg_black": "Schwarz", + "bg_gray": "Grau", + "bg_white": "Weiß", + "chapters": "Kapitel", + "chapter_ended": "Kapitel zu Ende", + "next_chapter": "Nächstes Kapitel", + "first_chapter": "Erstes Kapitel", + "last_chapter": "Letztes Kapitel", + "no_pages": "Keine Seiten gefunden", + "tap_to_reload": "Zum Neuladen tippen", + "download_started": "Download gestartet", + "downloaded_pages": "{} / {} Seiten", + "check_updates": "Nach Updates suchen", + "checking_updates": "Es wird nach Erweiterungs-Updates gesucht …", + "up_to_date": "Alle Erweiterungen sind aktuell.", + "updated_n": "{} Erweiterung(en) aktualisiert.", + "spread": "Doppelseite", + "spread_desc": "Nebeneinander im Querformat, so wie das Buch gezeichnet wurde" + }, + "cloudflare": { + "title": "Bestätige, dass du ein Mensch bist", + "hint": "Schließe die Prüfung unten ab — danach schließt sich das Fenster automatisch.", + "solve": "Cloudflare lösen", + "done": "Fertig" + }, + "desktop": { + "saved_refresh_on_pc": "Gespeichert — tippe am PC auf „Quellen aktualisieren“, um es zu übernehmen.", + "sources_turned_off": "Desktop-Quellen ausgeschaltet.", + "saved_loading_sources": "Gespeichert — Quellen werden vom Handy geladen …", + "refreshing_sources": "Quellen werden vom Handy aktualisiert …", + "share_title": "Quellen mit dem Desktop teilen", + "sources_title": "Desktop-Quellen", + "share_with_desktop": "Quellen mit dem Desktop teilen", + "let_pc_use_sources": "Erlaube der Sozo-PC-App, die Quellen dieses Handys zu nutzen.", + "your_link": "Dein Link", + "link_copied": "Link kopiert", + "no_wifi_address": "Eingeschaltet, aber es wurde keine WLAN-Adresse gefunden. Verbinde dieses Handy mit WLAN (nicht mobile Daten) und öffne diesen Bildschirm erneut.", + "step_host_same_wifi": "Halte dieses Handy und deinen PC im selben WLAN.", + "step_host_turn_on": "Schalte den Schalter oben ein.", + "step_host_pick_sources": "Wähle, welche Quellen geteilt werden (oder lass „Alle teilen“ an).", + "step_host_paste_on_pc": "Öffne am PC Sozo → Profil → Desktop-Quellen und füge den Link ein.", + "step_host_keep_open": "Lass Sozo auf diesem Handy offen, während du am PC stöberst.", + "sources_to_share": "Zu teilende Quellen", + "reload_from_phone": "Vom Handy neu laden", + "share_all_sources": "Alle Quellen teilen", + "every_source_shared": "Jede Quelle auf diesem Handy wird geteilt", + "sharing_count": "{} von {} werden geteilt", + "save_send_to_desktop": "Speichern & an den Desktop senden", + "n_selected": "{} ausgewählt", + "select_all": "Alle auswählen", + "clear": "Leeren", + "no_sources_yet": "Auf diesem Handy sind noch keine Quellen. Füge welche unter Profil → CloudStream / Aniyomi / Manga-Quellen hinzu und lade hier neu.", + "phone_link": "Handy-Link", + "refresh_sources": "Quellen aktualisieren", + "step_client_same_wifi": "Halte diesen PC und dein Handy im selben WLAN.", + "step_client_open_on_phone": "Öffne am Handy Sozo → Profil → Quellen mit dem Desktop teilen und schalte es ein.", + "step_client_choose_sources": "Wähle am Handy, welche Quellen geteilt werden (oder lass „Alle teilen“ an).", + "step_client_copy_link": "Kopiere den dort angezeigten Link, füge ihn oben ein und tippe auf Speichern.", + "step_client_refresh": "Die geteilten Quellen am Handy geändert? Tippe hier auf „Quellen aktualisieren“.", + "step_client_keep_open": "Lass Sozo auf dem Handy offen, während du hier stöberst.", + "step_client_emulator": "Du nutzt einen Android-Emulator? Seine IP ist nicht direkt erreichbar — führe auf diesem PC „adb forward tcp:8765 tcp:8765“ aus und füge oben http://127.0.0.1:8765 ein (den Schalter im Emulator trotzdem einschalten).", + "how_it_works": "So funktioniert es", + "how_it_works_desc": "Nutze deine CloudStream-, Aniyomi- und Manga-Quellen in der Sozo-Desktop-App. Das Handy führt die Quellen aus, der PC zeigt sie nur an.", + "internet_still_unavailable": "Es besteht weiterhin keine Internetverbindung", + "offline_watch_downloads": "Heruntergeladene Videos kannst du auch offline weiterschauen.", + "refresh": "Aktualisieren" + }, + "detail": { + "download_will_resolve": "Wird aufgelöst …", + "download_needs_playback": "Diese Quelle versteckt ihren Stream hinter einem geschützten Player. Spiel die Folge einmal ab und lade sie dann aus dem Player herunter.", + "download_choose_server": "Download · Server wählen", + "download_playing_now": "Läuft gerade", + "similar": "Ähnlich", + "comments": "Kommentare", + "screenshots": "Screenshots", + "play": "Abspielen", + "no_episodes": "Keine Folgen verfügbar", + "no_playable_source": "Keine abspielbare Quelle", + "added_to_my_list": "Zu Meine Liste hinzugefügt", + "removed_from_my_list": "Aus Meine Liste entfernt", + "failed_resolve_pages": "Seiten konnten nicht aufgelöst werden", + "episodes_count": "{} Folgen", + "episodes_count_of": "{} von {} Folgen", + "episode_n": "Folge {}", + "no_films": "Keine Filme gefunden", + "filmography": "Filmografie", + "actor": "Schauspieler:in", + "continue_watching": "Weiter", + "continue_ep": "Weiter · Folge {}", + "no_cast": "Keine Besetzung verfügbar", + "no_recommendations": "Keine Empfehlungen verfügbar", + "untitled": "Ohne Titel", + "read": "Lesen", + "watch_later": "Später schauen", + "watched": "Gesehen", + "follow_series": "Serie folgen", + "find_other_sources": "Andere Quellen finden", + "anilist_track": "Auf AniList verfolgen", + "anilist_tracked": "Auf AniList verfolgt", + "following_on": "Wird verfolgt", + "following_off": "Wird nicht verfolgt", + "no_screenshots": "Keine Screenshots", + "show_more": "Mehr anzeigen", + "show_less": "Weniger anzeigen", + "time_left": "noch {}", + "liked": "{}% gefällt es", + "watched_pct": "{}% gesehen", + "copy_link": "Link kopieren", + "link_copied": "Link kopiert", + "report_problem": "Problem melden", + "selected_n": "{} ausgewählt", + "select_all": "Alle auswählen", + "select_none": "Auswahl aufheben", + "download_n": "{} herunterladen", + "download_queueing": "Wird eingereiht …", + "download_action": "Herunterladen", + "download_already": "Bereits heruntergeladen", + "download_in_progress": "Download läuft", + "download_started": "Download gestartet", + "download_needs_permission": "Für Downloads wird die Benachrichtigungsberechtigung gebraucht", + "download_resolve_failed": "Für diese Folge ließ sich kein Download-Link finden", + "download_queued_n": "{} zu den Downloads hinzugefügt", + "download_none_queued": "Nichts hinzugefügt — vermutlich schon heruntergeladen", + "download_batch_title": "Mehrere Folgen herunterladen?", + "download_batch_body": "Die Folgen {range} werden eingereiht — {count} insgesamt. Über mobile Daten können das mehrere Gigabyte sein.", + "relations": "Verwandte Titel", + "no_relations": "Zu diesem Titel wurden keine verwandten Titel gefunden.", + "relation_sequel": "FORTSETZUNG", + "relation_prequel": "VORGESCHICHTE", + "relation_parent": "HAUPTGESCHICHTE", + "relation_side_story": "NEBENGESCHICHTE", + "relation_alternative": "ALTERNATIVE FASSUNG", + "relation_spin_off": "ABLEGER", + "relation_summary": "ZUSAMMENFASSUNG", + "relation_adaptation": "ADAPTION", + "relation_source": "VORLAGE", + "relation_character": "GLEICHE FIGUREN", + "relation_other": "SONSTIGES", + "trailer_action": "Trailer", + "more_options": "Weitere Optionen", + "in_private_list": "In deiner privaten Liste", + "add_to_my_list_action": "Zu Meine Liste hinzufügen", + "remove_from_my_list_action": "Aus Meine Liste entfernen", + "trailer_mute": "Trailer stummschalten", + "trailer_unmute": "Trailer-Ton an" + }, + "episodes": { + "filter_hint": "Folgennummer oder Titel", + "filter_none": "Keine Folge passt zu „{query}“ in diesem Block", + "jump_to": "Zu Folge {n}", + "jump_loads": "Lädt {range}", + "jump_missing": "Folge {n} ist nicht auf dieser Seite", + "range_of": "Folgen {from}–{to} von {total}" + }, + "shorts": { + "refresh": "Aktualisieren", + "opening": "Wird geöffnet …", + "content_unavailable": "Inhalt nicht verfügbar", + "loading": "Shorts werden geladen …", + "empty_title": "Noch keine Shorts", + "empty_subtitle": "Kurze Clips erscheinen hier, sobald sie verfügbar sind.", + "error_title": "Shorts konnten nicht geladen werden", + "video_unavailable": "Video nicht verfügbar", + "speed_2x": "2-fache Geschwindigkeit", + "report_submitted": "Meldung abgeschickt, danke", + "watch_full_movie": "Ganzen Film ansehen", + "browse_movies": "Filme durchstöbern", + "sign_in_to_like": "Melde dich an, um zu liken" + }, + "comments": { + "please_sign_in": "Bitte melde dich zuerst an", + "report_submitted": "Meldung abgeschickt, danke", + "edited": "· bearbeitet", + "like": "Gefällt mir", + "reply": "Antworten", + "hide": "Ausblenden", + "replies_one": "1 Antwort", + "replies_other": "{} Antworten", + "report": "Melden", + "edit_hint": "Kommentar bearbeiten …", + "reply_hint": "Antwort schreiben …", + "comment_hint": "Kommentar schreiben …", + "editing": "Wird bearbeitet", + "replying_to": "Antwort an {}", + "sign_in_to_comment": "Zum Kommentieren anmelden", + "empty_title": "Noch keine Kommentare", + "empty_subtitle": "Sag als Erste:r, was du denkst", + "count_one": "1 Kommentar", + "count_other": "{} Kommentare", + "show_more": "Mehr anzeigen", + "delete_confirm_title": "Diesen Kommentar löschen?", + "delete_confirm_body": "Das lässt sich nicht rückgängig machen.", + "sign_in_prompt": "Melde dich an, um zu kommentieren" + }, + "downloads": { + "delete_all_title": "Alle Downloads löschen?", + "file_not_found": "Datei nicht gefunden", + "clear_all": "Alle löschen", + "segments_progress": "{} / {} Segmente", + "downloaded_amount": "{} geladen", + "pages_count": "{} Seiten", + "downloaded": "Heruntergeladen", + "failed": "Fehlgeschlagen", + "pending": "Ausstehend", + "remove": "Entfernen", + "empty_title": "Noch keine Downloads", + "empty_subtitle": "Heruntergeladene Videos erscheinen hier", + "paused": "Pausiert", + "wifi_only": "Nur über WLAN herunterladen", + "wifi_only_desc": "Eingereihte Downloads warten, bis du im WLAN bist", + "waiting_for_wifi": "Warten auf WLAN — Downloads laufen automatisch weiter", + "export": "In „Downloads“ speichern", + "export_hint": "Macht die Datei für andere Apps sichtbar", + "export_running": "Wird gespeichert …", + "export_done": "In {} gespeichert", + "export_failed": "Speichern in „Downloads“ fehlgeschlagen", + "downloading": "Wird geladen", + "missing": "Datei fehlt — zum erneuten Laden tippen", + "redownload_started": "Wird erneut geladen …", + "file_missing_retry": "Diese Datei ist weg. Sie wird erneut geladen.", + "download_again": "Erneut herunterladen", + "pause": "Pausieren", + "resume": "Fortsetzen", + "retry_all": "Alles Fehlgeschlagene erneut versuchen", + "sort_by": "SORTIEREN NACH", + "sort_newest": "Neueste zuerst", + "sort_title": "Titel", + "sort_size": "Größte zuerst", + "filter_all": "Alle", + "filter_active": "Laufend", + "filter_done": "Fertig", + "filter_problems": "Braucht Aufmerksamkeit", + "filter_empty_title": "Nichts passt zu diesem Filter", + "filter_empty_subtitle": "Deine Downloads sind noch da — nur diese Ansicht ist eingeschränkt.", + "filter_clear": "Alle Downloads anzeigen", + "storage_used": "{} von Downloads belegt", + "storage_free": "{} frei", + "orphans_found": "{} in Resten abgebrochener Downloads", + "orphans_clean": "Aufräumen", + "group_summary": "{} von {} geladen", + "group_problems": "Braucht Aufmerksamkeit", + "error": { + "network": "Die Verbindung ist abgebrochen", + "refused": "Die Quelle hat den Download abgelehnt — spiel es einmal ab und versuche es dann erneut", + "gone": "Die Datei liegt nicht mehr bei der Quelle", + "no_space": "Nicht genug Speicher auf diesem Gerät", + "not_media": "Die Quelle hat eine Webseite statt des Videos geschickt", + "incomplete": "Der Download endete unvollständig", + "unknown": "Der Download ist fehlgeschlagen" + }, + "location": "Wo Downloads liegen", + "location_hint": "Nur Orte, in die die App ohne Berechtigung schreiben darf. Downloads ziehen mit der Einstellung um; nichts wird gelöscht, bevor jede Datei angekommen ist.", + "location_internal": "Telefonspeicher (App)", + "location_external": "Telefonspeicher", + "location_sdcard": "SD-Karte", + "location_moved": "Downloads verschoben", + "location_unchanged": "Schon dort", + "location_failed": "Die Downloads ließen sich nicht verschieben — sie liegen noch, wo sie waren" + }, + "history": { + "clear_title": "Verlauf löschen", + "clear_confirm": "Möchtest du deinen Wiedergabeverlauf wirklich löschen?", + "clear": "Löschen", + "clear_all": "Alle löschen", + "remove": "Entfernen", + "empty_title": "Noch kein Wiedergabeverlauf", + "empty_subtitle": "Fang an zu schauen, dann erscheint dein Verlauf hier" + }, + "main": { + "shorts_refresh_title": "Shorts aktualisieren", + "shorts_refresh_body": "Tippe erneut auf den Shorts-Tab. Der Feed lädt neu und neue Videos erscheinen.", + "double_tap": "Doppelt tippen", + "tap_again": "Nochmal tippen" + }, + "reports": { + "title": "Melden", + "reason_spam": "Spam", + "reason_abuse": "Belästigung", + "reason_sexual": "Sexuelle Inhalte", + "reason_violence": "Gewalt", + "reason_copyright": "Urheberrecht", + "reason_other": "Sonstiges", + "note_hint": "Zusätzliche Anmerkung (optional)", + "submit": "Absenden" + }, + "ios": { + "sources_title": "Erweiterungsquellen", + "how_it_works_desc": "Nutze deine CloudStream-, Aniyomi- und Manga-Quellen in Sozo für iOS. Dein Android-Handy führt die Quellen aus, dieses Gerät zeigt sie nur an.", + "step_client_same_wifi": "Halte dieses Gerät und dein Android-Handy im selben WLAN." + }, + "trivia": { + "fan_test_title": "Fan-Test", + "fan_test_subtitle": "Beweise, dass du der größte Fan bist", + "hub_tagline": "Wie gut kennst du das Kino wirklich?", + "view_leaderboards": "Bestenlisten ansehen", + "my_daily_rank": "Mein Tagesrang", + "rank_value": "#{}", + "unranked": "Ohne Platzierung", + "points_value": "{} Pkt.", + "popular_now": "Gerade beliebt", + "results": "Ergebnisse", + "searching": "Wird gesucht …", + "search_cast_hint": "Schauspieler:innen suchen", + "no_results": "Keine Ergebnisse", + "no_results_hint": "Versuche einen anderen Namen", + "something_wrong": "Etwas ist schiefgelaufen", + "filmography": "Filmografie", + "n_titles": "{count} Titel", + "start_fan_test": "Fan-Test starten", + "challenge_friend": "Freund:in herausfordern", + "top_fans": "Top-Fans", + "no_fans_yet": "Noch keine Fans", + "be_first_fan": "Sei der erste Fan", + "fans_meta": "{rounds} Runden · {fandom}% Fandom im Schnitt", + "you": "Du", + "you_rank": "Du · #{rank}", + "best_score": "Bestwert {score}", + "fandom": "Fandom", + "challenge_share_text": "Kennst du {name} besser als ich? Mach den Fan-Test auf Sozo!", + "scope_daily": "Täglich", + "scope_weekly": "Wöchentlich", + "scope_all_time": "Insgesamt", + "scope_friends": "Freunde", + "leaderboard": "Bestenliste", + "no_friends_board": "Noch keine Freunde auf der Liste", + "no_scores_yet": "Noch keine Punkte", + "correct_of": "{count} richtig", + "forfeit_title": "Spiel verlassen?", + "forfeit_body": "Dein Fortschritt in dieser Runde geht verloren.", + "keep_playing": "Weiterspielen", + "forfeit_confirm": "Verlassen", + "correct": "Richtig", + "the_answer": "Die Antwort", + "watch_full": "Ganzen Film ansehen", + "building_round": "Deine Runde wird zusammengestellt …", + "round_failed": "Die Runde ließ sich nicht starten", + "go_back": "Zurück", + "fan_test_done": "Fan-Test abgeschlossen", + "round_done": "Runde abgeschlossen", + "score": "Punkte", + "rank": "Rang", + "play_again": "Nochmal spielen", + "share_result": "Ergebnis teilen", + "share_text": "Ich habe gerade Buff auf Sozo gespielt!", + "challenge_text": "Du meinst, du kennst Filme besser? Fordere mich bei Buff in Sozo heraus!", + "share_tagline": "Spiel Buff auf Sozo", + "challenge_from": "Herausforderung von {name}", + "same_clips_head_to_head": "Dieselben Clips, Kopf an Kopf", + "score_to_beat": "Zu schlagende Punktzahl", + "play_challenge": "Herausforderung spielen", + "challenge_not_found": "Herausforderung nicht gefunden", + "no_actors_yet": "Noch keine Schauspieler:innen", + "no_actors_yet_body": "Buff wird gerade bestückt. Clips werden einzeln geprüft — Schauspieler:innen erscheinen hier, sobald ihre Clips freigegeben sind.", + "actor_not_ready": "Diese Person ist noch nicht bereit", + "actor_not_ready_body": "Es gibt noch nicht genug freigegebene Clips für eine Runde. Probiere jemand anderen.", + "round_short_note": "Diese Runde hatte {count} Clips — alle bisher freigegebenen Clips zu dieser Person.", + "todays_top": "Heute an der Spitze", + "how_it_works": "So funktioniert Buff", + "how_step_1": "Wähle eine Person", + "how_step_2": "Sieh dir einen kurzen Clip an", + "how_step_3": "Nenne den Film" + }, + "outage": { + "body_with_local": "Die Sozo-Server sind nicht erreichbar. Deine {} Quellen auf dem Gerät funktionieren weiterhin.", + "body_no_local": "Die Sozo-Server sind nicht erreichbar. Füge eine CloudStream-, Aniyomi- oder Manga-Quelle hinzu, um unabhängig von unserer API weiterzustöbern.", + "pick_source": "Quelle wählen" + }, + "link_tv": { + "title": "Fernseher verbinden", + "tile_subtitle": "Scanne den QR-Code auf dem TV oder tippe seinen Code ein", + "subtitle": "Scanne den QR-Code auf deinem Fernseher oder tippe den 8-stelligen Code darunter ein.", + "scan_button": "QR-Code scannen", + "scan_hint": "Richte die Kamera auf den QR-Code am TV-Bildschirm", + "manual_title": "Oder gib den Code ein", + "confirm": "Bestätigen", + "approved": "TV verbunden", + "approved_named": "„{}“ verbunden", + "approved_hint": "Der Fernseher meldet sich in wenigen Sekunden bei deinem Konto an.", + "linked_devices": "Verbundene Geräte", + "no_devices": "Noch keine verbundenen Geräte", + "unnamed_device": "Unbenanntes Gerät", + "last_seen": "Zuletzt gesehen: {}", + "unlink_title": "Gerät trennen", + "unlink_message": "Dieses Gerät wird abgemeldet und braucht zum erneuten Verbinden einen neuen QR-Code.", + "unlink_confirm": "Trennen", + "camera_denied": "Kamerazugriff verweigert. Erlaube ihn in den Einstellungen oder gib den Code ein.", + "camera_error": "Die Kamera ließ sich nicht starten.", + "error_bad_code": "Der Code muss 8 Zeichen haben.", + "error_not_found": "Dieser Code wurde nicht gefunden. Hol dir am TV einen neuen.", + "error_expired": "Der Code ist abgelaufen. Drücke am TV auf Aktualisieren.", + "error_already_used": "Dieser Code wurde bereits benutzt.", + "error_unauthorized": "Bitte melde dich zuerst an.", + "error_rate_limited": "Zu viele Versuche. Probiere es gleich noch einmal.", + "error_network": "Keine Internetverbindung.", + "error_generic": "Etwas ist schiefgelaufen." + }, + "anilist": { + "connections_title": "Verbindungen", + "connect": "AniList verbinden", + "disconnect": "Trennen", + "disconnect_confirm": "Dein AniList-Konto wird getrennt und verknüpfte Titel werden vergessen. Deine Liste bleibt auf AniList.", + "not_connected": "Nicht verbunden", + "connect_explainer": "Verbinde AniList, dann werden die Folgen, die du schaust, automatisch markiert und deine Liste hier angezeigt.", + "connected_explainer": "Gesehene Folgen werden in deine AniList-Liste geschrieben. Die Verbindung liegt auf deinem Konto, dein Fernseher und andere Geräte fragen also nicht erneut nach einer Anmeldung.", + "connect_prompt": "Verbinde dein Konto, um deine AniList-Bibliothek hier zu sehen.", + "upcoming_connect_prompt": "Verbinde AniList, um zu sehen, wann die nächsten Folgen deiner Serien laufen.", + "sign_in_first": "Melde dich zuerst bei deinem Sozo-Konto an", + "browser_failed": "Der Browser ließ sich nicht öffnen", + "open_library": "Meine AniList-Bibliothek", + "linked_titles": "Verknüpfte Titel", + "linked_titles_count": "{} Titel verknüpft", + "linked_titles_none": "Noch nichts verknüpft", + "unlink": "Verknüpfung lösen", + "unlinked": "{} nicht mehr verknüpft", + "auto_matched": "Automatisch", + "privacy_note": "Dein AniList-Token liegt auf deinem Sozo-Konto. Die App schreibt ausschließlich die Nummer der Folge, die du gesehen hast.", + "status_label": "STATUS", + "status_current": "Schaue ich", + "status_planning": "Geplant", + "status_completed": "Abgeschlossen", + "status_paused": "Pausiert", + "status_dropped": "Abgebrochen", + "status_repeating": "Nochmal", + "episodes_watched": "Folgen gesehen", + "empty_status": "Deine Liste „{}“ ist leer", + "behind_n": "{} neu", + "mark_episode_watched": "Folge {} als gesehen markieren", + "find_in_sources": "In Quellen finden", + "find_in_sources_hint": "Jede Quelle nach diesem Titel durchsuchen", + "open_on_anilist": "Auf AniList öffnen", + "refresh": "Aktualisieren", + "retry": "Erneut versuchen", + "upcoming_title": "Demnächst", + "upcoming_empty": "Für keine deiner verfolgten Serien ist eine nächste Folge angekündigt.", + "today": "Heute", + "tomorrow": "Morgen", + "episode_n": "Folge {}", + "aired": "Ausgestrahlt", + "in_days": "in {} Tagen", + "in_hours": "in {} Stunden", + "in_minutes": "in {} Min.", + "link_title": "Mit AniList verknüpfen", + "link_hint": "Wähle, welcher AniList-Anime dieser Titel ist.", + "link_search_hint": "Anime-Name", + "link_no_results": "Nichts gefunden", + "linked_to": "Verknüpft mit {}", + "tracking_on": "Auf AniList verfolgt", + "track_on_anilist": "Auf AniList verfolgen", + "n_episodes_short": "{} Fl.", + "open_anilist": "AniList", + "connect_tagline": "Verfolge automatisch, was du schaust", + "connect_short": "Verbinden", + "calendar_title": "Ausstrahlungskalender", + "calendar_all": "Alle", + "calendar_mine": "Meine Liste", + "calendar_episode": "Folge {episode}", + "calendar_empty": "An diesem Tag läuft nichts.", + "calendar_empty_mine": "An diesem Tag läuft nichts aus deiner Liste.", + "calendar_open": "Ausstrahlungskalender", + "calendar_error": "Der Ausstrahlungsplan konnte nicht geladen werden.", + "calendar_rate_limited": "AniList ist gerade ausgelastet. Versuche es gleich noch einmal.", + "calendar_list_error": "Deine AniList-Liste konnte nicht geladen werden.", + "calendar_now": "jetzt", + "calendar_count": "{count} Ausstrahlungen", + "calendar_count_mine": "{mine} von {total} sind deine", + "calendar_episode_range": "Folgen {from}-{to}", + "calendar_add_planning": "Zu „Geplant“ hinzufügen", + "calendar_added_planning": "Zu deiner Liste „Geplant“ hinzugefügt", + "calendar_add_failed": "Das ließ sich nicht zu deiner Liste hinzufügen", + "calendar_already_on_list": "Steht schon auf deiner Liste", + "browse_title": "Entdecken", + "browse_trending": "Im Trend", + "browse_season": "Diese Saison", + "browse_popular": "Allzeit beliebt", + "browse_upcoming": "Bald verfügbar", + "browse_empty": "Hier gibt es nichts zu zeigen.", + "browse_failed": "AniList war nicht erreichbar.", + "episodes_count": "{count} Fl.", + "reminder_body": "Folge {episode} läuft in 10 Minuten", + "reminders_on": "Erinnerungen an — vorher und wenn eine Folge läuft", + "reminders_off": "Folgen-Erinnerungen sind aus", + "connect_first": "Verbinde zuerst AniList", + "save_failed": "Speichern auf AniList fehlgeschlagen", + "search_in_source": "IN EINER QUELLE SUCHEN", + "no_sources": "Noch keine Quellen installiert", + "released_body": "Folge {episode} ist da", + "remove_entry": "Aus meiner Liste entfernen", + "remove_confirm": "„{}“ von AniList entfernen? Dein Fortschritt dazu wird ebenfalls gelöscht.", + "removed": "Von AniList entfernt" + }, + "mal": { + "connect": "MyAnimeList verbinden", + "disconnect": "MyAnimeList trennen", + "disconnect_confirm": "Dein MyAnimeList-Konto wird getrennt und zugeordnete Titel werden vergessen. Deine Liste bleibt auf MyAnimeList.", + "connect_explainer": "Verbinde MyAnimeList und die Folgen, die du schaust, werden automatisch markiert.", + "connected_explainer": "Gesehene Folgen werden in deine MyAnimeList geschrieben. Die Verbindung liegt auf deinem Konto, dein Fernseher und andere Geräte fragen also nicht erneut nach einer Anmeldung.", + "not_configured": "MyAnimeList ist auf dem Server noch nicht eingerichtet", + "matching_title": "Wie Titel zugeordnet werden", + "matching_note": "Die Zuordnung läuft über AniList, das die MyAnimeList-ID desselben Eintrags mitführt. Verknüpfe einen Titel einmal und beide Tracker folgen ihm.", + "privacy_note": "Deine AniList- und MyAnimeList-Tokens liegen auf deinem Sozo-Konto. Die App schreibt ausschließlich die Nummer der Folge, die du gesehen hast.", + "tracking_on": "Auf MyAnimeList verfolgt", + "save_failed": "Speichern auf MyAnimeList fehlgeschlagen", + "open_library": "Meine MyAnimeList", + "linked_titles": "Verknüpfte Titel", + "connect_prompt": "Verbinde dein Konto, um deine MyAnimeList hier zu sehen.", + "connect_first": "Verbinde zuerst MyAnimeList", + "search_failed": "MyAnimeList war nicht erreichbar.", + "link_title": "Mit MyAnimeList verknüpfen", + "link_hint": "Wähle, welcher MyAnimeList-Anime dieser Titel ist.", + "linked_to": "Verknüpft mit {}", + "track": "Auf MyAnimeList verfolgen", + "tracked": "Auf MyAnimeList verfolgt", + "score_label": "BEWERTUNG", + "remove_entry": "Aus meiner Liste entfernen", + "remove_confirm": "„{}“ von deiner MyAnimeList entfernen? Dein Fortschritt dazu wird ebenfalls gelöscht.", + "removed": "Von MyAnimeList entfernt", + "rewatching_note": "Du schaust das gerade erneut. Sozo lässt den Status unangetastet, damit der Rewatch nicht beendet wird." + }, + "tracker": { + "title": "Verfolgen", + "tab_following": "Abos", + "tab_upcoming": "Demnächst", + "following_empty": "Folge einer Serie, um bei neuen Folgen benachrichtigt zu werden.", + "new_episodes_found": "{} Titel haben neue Folgen", + "no_new_episodes": "Keine neuen Folgen", + "n_episodes": "{} Folgen", + "not_checked": "Noch nicht geprüft", + "unfollow": "Nicht mehr folgen", + "unfollowed": "{} nicht mehr abonniert", + "undo": "Rückgängig" + }, + "remote": { + "title": "TV-Fernbedienung", + "no_devices": "Noch keine verbundenen Fernseher. Koppel zuerst einen in der TV-App.", + "load_failed": "Deine Fernseher ließen sich nicht laden.", + "tv_offline": "TV ist offline", + "idle": "Es läuft nichts", + "command_failed": "Das kam nicht beim TV an", + "search_on_tv": "AUF DEM TV SUCHEN", + "search_hint": "Tippe hier, gesucht wird auf dem TV", + "back": "Zurück", + "home": "Start", + "open_on_tv": "Auf dem TV abspielen", + "sent_to_tv": "An {device} gesendet", + "connected_to": "Verbunden mit {device}", + "not_connected_to": "{device} ist nicht verbunden", + "turn_tv_on": "Öffne Sozo auf dem TV, um ihn zu steuern", + "retry": "Erneut versuchen", + "navigation": "NAVIGATION", + "playback": "WIEDERGABE", + "pick_device": "Welcher Fernseher?", + "device_online": "Online", + "device_offline": "Offline — öffne Sozo darauf" + }, + "live_tv": { + "title": "Live-TV", + "favourites": "Favoriten", + "recent": "Zuletzt gesehen", + "categories": "Kategorien", + "countries": "Länder", + "results": "Suchergebnisse", + "search_hint": "Sendername …", + "search_in": "In {folder} suchen …", + "no_match": "Kein Sender passt dazu.", + "empty": "Noch keine Sender.", + "load_failed": "Die Sender ließen sich nicht laden.", + "retry": "Erneut versuchen", + "now": "Jetzt", + "next": "Danach", + "guide": "TV-Programm", + "no_guide": "Für diesen Sender gibt es kein Programm.", + "watch": "Ansehen", + "favourite_add": "Zu Favoriten hinzufügen", + "favourite_remove": "Aus Favoriten entfernen", + "hint_long_press": "Halte einen Sender gedrückt für Programm und Favoriten.", + "channel_count": { + "one": "{} Sender", + "other": "{} Sender" + }, + "up_next": "ALS NÄCHSTES", + "minutes_left": "noch {} Min." + }, + "time": { + "now": "gerade eben", + "minutes": "vor {} Min.", + "hours": "vor {} Std.", + "days": "vor {} T." + }, + "user_lists": { + "title": "Meine Listen", + "remove": "Entfernen", + "empty_title": "Noch nichts gespeichert", + "empty_subtitle": "Titel, die du speicherst, erscheinen hier." + }, + "torrent": { + "title": "Torrents", + "search_hint": "Torrents suchen …", + "empty_hint": "Durchsuche Nyaa, Tokyo Toshokan und nekoBT auf einmal.", + "searching": "Tracker werden durchsucht …", + "no_results": "Auf den aktivierten Trackern nichts gefunden", + "no_results_hint": "Probiere einen kürzeren Titel oder lockere die Filter.", + "results_n": "{} Ergebnisse", + "seeders": "Seeder", + "leechers": "Leecher", + "unknown_swarm": "Schwarmgröße unbekannt", + "dead": "Keine Seeder", + "dead_warning": "Dieser Torrent hat keine Seeder und lässt sich nicht streamen.", + "trusted": "Vertrauenswürdig", + "remake": "Neu hochgeladen", + "filters": "Filter", + "category": "Kategorie", + "cat_all": "Alle", + "cat_anime": "Anime", + "cat_english": "Englische Untertitel", + "cat_non_english": "Andere Sprachen", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Realverfilmung", + "sort": "Sortieren nach", + "sort_seeders": "Seeder", + "sort_date": "Neueste", + "sort_size": "Größe", + "sort_downloads": "Downloads", + "min_seeders": "Mindestzahl Seeder", + "quality": "Qualität", + "any_quality": "Beliebig", + "trusted_only": "Nur vertrauenswürdige Uploader", + "exclude_hardsubs": "Hardsub-Releases ausblenden", + "exclude_mini": "Mini-Encodes ausblenden", + "exclude_remakes": "Neu-Uploads ausblenden", + "require_dual_audio": "Nur Dual Audio", + "exclude_batches": "Staffelpakete ausblenden", + "trackers": "Tracker", + "reset_filters": "Zurücksetzen", + "consent_title": "Über BitTorrent streamen?", + "consent_body": "Torrents sind Peer-to-Peer. Während einer läuft, sehen andere im Schwarm deine IP-Adresse und was du lädst, und dein Gerät lädt auch zu ihnen hoch.\n\nNutze ein VPN, falls das bei dir eine Rolle spielt.", + "consent_accept": "Verstanden", + "consent_remember": "Nicht mehr fragen", + "preparing": "Torrent-Engine wird gestartet …", + "fetching_metadata": "Dateiliste wird aus dem Schwarm geholt …", + "metadata_timeout": "Keine Antwort aus dem Schwarm. Der Torrent hat womöglich keine Seeder mehr.", + "engine_failed": "Die Torrent-Engine ließ sich nicht starten.", + "unsupported": "Torrent-Streaming gibt es nur unter Android.", + "pick_file": "Datei wählen", + "pick_file_hint": "Dieser Torrent enthält {} Videos.", + "play": "Abspielen", + "copy_magnet": "Magnet-Link kopieren", + "magnet_copied": "Magnet-Link kopiert", + "open_external": "In Torrent-App öffnen", + "open_page": "Auf dem Tracker öffnen", + "stats_peers": "{} Peers", + "buffering": "Puffern {} %", + "still_searching": "{} Tracker antworten noch", + "buffering_title": "Wird gepuffert …", + "no_video_in_torrent": "Dieser Torrent enthält kein abspielbares Video.", + "tracker_failed": "{} konnte nicht durchsucht werden — die Ergebnisse sind möglicherweise unvollständig." + }, + "backup": { + "title": "Sichern & wiederherstellen", + "subtitle": "Wiedergabeverlauf, Listen, Quellen und Einstellungen. Anmeldungen sind nicht enthalten.", + "export": "Sicherung erstellen", + "export_desc": "Speichere eine Datei, die du behalten oder dir selbst schicken kannst", + "restore": "Wiederherstellen", + "restore_desc": "Eine Sicherungsdatei zurück in die App einlesen", + "restore_warning": "Werte aus der Sicherung überschreiben die auf diesem Gerät. Was nicht in der Datei steht, bleibt unangetastet.", + "restored": "{} Werte wiederhergestellt", + "not_a_backup": "Diese Datei ist keine Sozo-Sicherung", + "too_new": "Diese Sicherung stammt aus einer neueren Version der App", + "export_failed": "Die Sicherung konnte nicht erstellt werden", + "restore_failed": "Diese Sicherung konnte nicht gelesen werden", + "file_subject": "Sozo-Sicherung", + "last_export": "Letzte Sicherung: {}", + "never_exported": "Auf diesem Gerät wurde noch keine Sicherung erstellt." + }, + "provider_test": { + "title": "Quellen testen", + "intro": "Führt eine echte Suche aus, öffnet einen Titel und fordert die ersten Bytes vom CDN an. Die Ergebnisse gelten für dieses Gerät und dieses Netz, jetzt gerade.", + "summary": "{} von {} abgespielt", + "run_all": "Alle testen", + "run_one": "Testen", + "stop": "Stopp", + "stage_catalogue": "Katalog", + "stage_detail": "Titel", + "stage_stream": "Link", + "stage_playback": "Wiedergabe", + "entry": "Quellen testen" + }, + "catalog": { + "title": "Quellen nach Sprache", + "subtitle": "Durchstöbere alle Erweiterungsquellen aus allen vier Ökosystemen", + "type_all": "Alle", + "type_anime": "Anime", + "type_video": "Filme & Serien", + "type_manga": "Manga", + "type_novel": "Romane", + "add_repo": "Repo hinzufügen", + "installed": "{} Quelle(n) aus {} hinzugefügt", + "install_empty": "Dieses Repo hat nichts Installierbares geliefert.", + "install_failed": "Installation fehlgeschlagen: {}", + "needs_js": "Dart-Quelle — hier nicht ausführbar", + "android_only": "Nur Android", + "empty": "Hier ist noch nichts. Probiere eine andere Sprache oder setze die Filter zurück.", + "unreachable": "Der Quellenkatalog war nicht erreichbar." + }, + "mode": { + "video": "Schauen", + "manga": "Manga", + "novel": "Romane", + "none_installed": "Noch keine {}-Quellen installiert." + }, + "home_rails": { + "title": "Startseiten-Layout", + "entry_title": "Startseite anpassen", + "entry_subtitle": "Wähle, welche Reihen erscheinen und in welcher Reihenfolge", + "hint": "Ziehen zum Umsortieren. Tippe auf das Auge, um eine Reihe auszublenden — sie behält ihren Platz und kehrt genau dorthin zurück.", + "hero": "Hervorgehobenes Banner", + "resume": "Weiterschauen", + "genres": "Genres", + "live_tv": "Live-TV", + "catalogue": "Katalogreihen" + }, + "stats": { + "title": "Statistik", + "entry_subtitle": "Sehdauer, Streak, wohin sie ging", + "watched": "Gesehen", + "hours_short": "Std.", + "minutes_short": "Min.", + "since": "Gezählt seit {}", + "completed": "Abgeschlossen", + "streak": "Tage-Streak", + "last_days": "Letzte {} Tage", + "by_source": "Nach Quelle", + "empty_title": "Noch nichts gezählt", + "empty_subtitle": "Schau etwas und die Summen beginnen hier. Was im Inkognito-Modus läuft, wird nicht gezählt." + }, + "discord": { + "sign_in": "Mit Discord anmelden", + "sign_in_desc": "Melde dich auf Discords eigener Anmeldeseite an. Das Token wird automatisch übernommen und nur auf diesem Gerät gespeichert.", + "or_paste": "oder füge selbst ein Token ein", + "web_login_title": "Discord verbinden", + "web_login_paste": "Token einfügen", + "web_login_paste_title": "Discord-Token einfügen", + "web_login_paste_hint": "Dein Discord-Benutzertoken", + "title": "Discord", + "what_it_does": "Zeigt auf deinem Discord-Profil, was du gerade schaust — den Titel, die Folge und wie weit du bist. Deine Freunde sehen es in ihrer Freundesliste.", + "enable": "Auf Discord anzeigen", + "enable_desktop_desc": "Erfordert, dass die Discord-App auf diesem Computer läuft", + "enable_mobile_desc": "Erfordert dein Discord-Token — lies zuerst die Warnung unten", + "connected": "Verbunden", + "not_connected": "Nicht verbunden", + "risk_title": "Lies das, bevor du fortfährst", + "risk_body": "Auf dem Handy gibt es keine Discord-App, mit der sich verbinden ließe, also muss Sozo sich mit deinem eigenen Token als du anmelden. Discord erlaubt das nicht: Dein Konto kann dafür gesperrt werden. Das Token gibt vollen Zugriff auf dein Discord-Konto, und Sozo speichert es ausschließlich auf diesem Gerät — nie auf einem Server. Mit „Vergessen“ kannst du es jederzeit entfernen.", + "token_label": "Discord-Token", + "token_hint": "Füge dein Token ein", + "token_saved": "Ein Token ist gespeichert — füge ein neues ein, um es zu ersetzen", + "token_save": "Speichern", + "token_forget": "Vergessen", + "brand_title": "Rich Presence", + "brand_subtitle": "Deine Freunde sehen live auf deinem Profil, was du schaust", + "preview_heading": "SCHAUT GERADE", + "preview_title": "Hier erscheint der Titel", + "preview_subtitle": "Staffel und Folge", + "preview_elapsed": "{} vergangen", + "preview_elapsed_placeholder": "Verstrichene Zeit", + "row_off": "Aus", + "row_live": "Wird auf deinem Profil angezeigt", + "row_waiting": "An — warte auf Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — das Bild kann auf diesem Gerät grün oder violett wirken", + "atmos": "Atmos-Ton — dieses Gerät gibt ihn möglicherweise stumm wieder", + "indirect_host": "Dieser Mirror öffnet eine Seite statt des Videos", + "uhd": "4K — aufwendiger zu dekodieren und zu laden", + "large_file": "Große Datei — startet über mobile Daten langsam", + "unreachable": "Dieser Link hat bei der Prüfung nicht geantwortet" + }, + "update": { + "available_title": "Neue Version verfügbar (v{})", + "available_body": "Eine neue Version der App ist verfügbar.", + "later": "Später", + "now": "Aktualisieren", + "open_store": "Im App Store öffnen", + "required_title": "Update erforderlich", + "required_body": "Installiere die neue Version (v{}), um die App weiter zu nutzen.", + "exit_app": "App beenden", + "downloading": "Update wird geladen …" + }, + "ext": { + "cloudstream_title": "CloudStream-Quellen", + "aniyomi_title": "Aniyomi-Quellen", + "mangayomi_title": "Mangayomi-Quellen", + "android_only": "Diese Funktion gibt es nur unter Android", + "needs_webview": "JavaScript-Erweiterungen brauchen eine WebView, die es auf dieser Plattform nicht gibt.", + "check_updates": "Nach Updates suchen", + "check_extension_updates": "Nach Erweiterungs-Updates suchen", + "installed_sources": "INSTALLIERTE QUELLEN", + "installed_repos": "INSTALLIERTE REPOS", + "sources_heading": "QUELLEN", + "recommended": "EMPFOHLEN", + "repo_url": "Repo-URL", + "index_url": "Index-URL", + "browse_plugins": "Plugins durchsuchen", + "no_sources_yet": "Noch keine Quellen", + "no_repos_yet": "Noch keine Repos", + "add_source": "Quelle hinzufügen", + "add_repo": "Repo hinzufügen", + "installing": "Wird installiert …", + "installing_extensions": "Erweiterungen werden installiert …", + "installing_plugins": "Plugins werden installiert …", + "installing_progress": "Installiere {} / {}", + "installing_plugins_progress": "Installiere {} / {} Plugins …", + "installing_extensions_progress": "Installiere {} / {} Erweiterungen …", + "installing_long": "Erweiterungen werden installiert … bei großen Repos dauert das einen Moment.", + "checking_updates": "Suche nach Updates …", + "checking_extension_updates": "Suche nach Erweiterungs-Updates …", + "all_up_to_date": "Alle Erweiterungen sind aktuell.", + "updated_count": "{} Erweiterung(en) aktualisiert.", + "updated_list": "{} aktualisiert: {}", + "update_check_failed": "Update-Prüfung fehlgeschlagen: {}", + "source_removed": "Quelle entfernt.", + "repo_removed": "Repo entfernt.", + "none_found": "Unter dieser URL wurden keine Erweiterungen gefunden.", + "none_found_js": "Unter dieser URL wurden keine JavaScript-Erweiterungen gefunden.", + "added_sources": "{} Quelle(n) hinzugefügt.", + "added_sources_providers": "{} Quelle(n) · {} Anbieter hinzugefügt.", + "installed_from_file": "{} Quelle(n) installiert.", + "could_not_install": "Installation fehlgeschlagen: {}", + "could_not_install_plugin": "Plugin konnte nicht installiert werden", + "could_not_remove_plugin": "Plugin konnte nicht entfernt werden", + "error": "Fehler: {}", + "cloudstream_desc": "Füge ein CloudStream-Repo hinzu und installiere nur die Plugins, die du willst — nur die erscheinen als Anbieter. Sie laufen auf deinem Gerät (nur Android).", + "aniyomi_desc": "Füge Aniyomi-Erweiterungs-Repos hinzu, um weitere Anime-Anbieter zu installieren. Sie laufen auf deinem Gerät (nur Android).", + "mangayomi_desc": "Mangayomi-Erweiterungen sind JavaScript, keine Android-Apps — anders als CloudStream-, Aniyomi- und Manga-Quellen laufen sie deshalb auch unter iOS, macOS und Windows.", + "plugins_summary": "{} Plugins · {} installiert", + "install_all": "Alle installieren", + "repo_load_failed": "Dieses Repo konnte nicht geladen werden.", + "no_plugins": "Keine Plugins in diesem Repo gefunden", + "search_plugins": "Plugins suchen …", + "no_matches": "Keine Treffer", + "import_title": "Erweiterungs-Repo installieren?", + "import_pick_library": "Anime- und Manga-Repos nutzen dasselbe Dateiformat — wähle also, zu welcher Bibliothek dieses gehört.", + "import_js_only": "Auf dieser Plattform lassen sich nur Mangayomi-Erweiterungen (JavaScript) installieren — CloudStream-, Aniyomi- und Mihon-Repos liefern Android-App-Pakete.", + "import_none_found": "In dieser Datei wurden keine Erweiterungen gefunden. Sie gehört vielleicht zu einer anderen App, oder der Typ war falsch gewählt.", + "mangayomi_url_only": "Mangayomi-Repos werden über eine URL installiert — öffne den Index-Link statt einer heruntergeladenen Kopie.", + "skipped_dart": "{} reine Dart-Quelle(n) übersprungen — die sind fest in Mangayomi eingebaut und laufen hier nicht.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Novel" + }, + "ux": { + "quick_search": "Schnellsuche ({})", + "quick_search_note": "Die Schnellsuche durchsucht {} von {} Quellen. Wähle Quellen für eine gezielte Suche.", + "torrents": "Torrents", + "filters": "Filter", + "change_source": "Quelle wechseln", + "manage_sources": "Quellen verwalten", + "no_source_match": "Keine Quellen entsprechen deiner Suche.", + "download_options": "Download-Optionen", + "download_options_note": "Wähle eine verfügbare Quelle und Qualität. Die Audiospur hängt von der gewählten Quelle ab.", + "source_number": "Quelle {}", + "checking_quality": "Verfügbare Qualität wird geprüft…", + "quality_unavailable": "Die Qualität konnte nicht geprüft werden. Versuche es erneut oder lade den ursprünglichen Stream ohne Qualitätsgarantie herunter.", + "quality_unknown": "Qualität unbekannt", + "size_unknown": "Die Quelle gibt keine Dateigröße an", + "interface_language_note": "Nur die App-Oberfläche ändert sich. Filmtitel, Beschreibungen und andere Quelleninhalte bleiben in ihrer Originalsprache.", + "continue": "Weiter" + } +} diff --git a/assets/translations/en.json b/assets/translations/en.json index 403bf228..3afc5955 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -26,7 +26,15 @@ "off": "Off", "on": "On", "new_badge": "NEW", - "reset": "Reset" + "reset": "Reset", + "clear": "Clear", + "copy": "Copy", + "share": "Share", + "install": "Install", + "remove": "Remove", + "show": "Show", + "hide": "Hide", + "installed": "Installed" }, "auth": { "login": "Login", @@ -78,7 +86,8 @@ "strong": "Strong", "code_resent": "Code resent. Check your email.", "verify": "Verify", - "avatar_bad_format": "Image format not supported (jpg, png, webp)" + "avatar_bad_format": "Image format not supported (jpg, png, webp)", + "verification_code": "Verification code" }, "onboarding": { "skip": "Skip", @@ -348,7 +357,10 @@ "fit": "Fit", "pip": "Picture in picture", "layout_pinned_badge": "Pinned", - "info_fields_title": "Info overlay rows" + "info_fields_title": "Info overlay rows", + "logs_title": "Player logs ({})", + "logs_copied": "Logs copied", + "logs_empty": "No logs yet" }, "profile": { "title": "Profile", @@ -369,7 +381,7 @@ "guest_subtitle": "Login to keep favorites and watch history synced.", "auth_provider": "Auth provider", "joined": "Joined", - "providers": "Providers", + "providers": "Sources", "providers_error": "Could not load providers.", "offline_title": "Server unreachable", "offline_local_available": "{} source(s) installed on this device still work. Cloud sources are unavailable until the server is back.", @@ -382,7 +394,7 @@ "connections": "Connections", "coming_soon": "Coming soon", "favorites": "Favorites", - "all_providers": "All providers", + "all_providers": "All sources", "add_favorite": "Add to favorites", "cloudstream_sources": "CloudStream Sources", "aniyomi_sources": "Aniyomi Sources", @@ -391,12 +403,12 @@ "sign_in": "Sign In", "sign_out": "Sign Out", "sign_out_confirm": "Are you sure you want to sign out?", - "section_providers": "PROVIDERS", + "section_providers": "Sources", "section_content": "CONTENT", "sources_row": "Extension sources", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", - "provider": "Provider", - "choose_provider": "Choose Provider", + "provider": "Source", + "choose_provider": "Change source", "search_providers_hint": "Search providers…", "count_of_total_shown": "{} of {} shown", "no_providers_in_category": "No providers in this category", @@ -513,7 +525,9 @@ "activity_subtitle": "History, downloads, tracking, stats", "connections_subtitle": "Trackers, Discord, TV, desktop", "trackers": "Trackers", - "stat_days": "days watched" + "stat_days": "days watched", + "mangayomi_sources": "Mangayomi Sources", + "view_only": "VIEW ONLY" }, "appearance": { "title": "Appearance", @@ -632,7 +646,8 @@ "voice_listening": "Listening…", "voice_done": "Stopped listening", "voice_hint": "Say the name of a film or series", - "try_torrents": "Search torrents" + "try_torrents": "Search torrents", + "capped_note": "Searched the {} most reliable of {} sources — narrow the scope to reach a specific one." }, "movie": { "rating": "Rating", @@ -1694,5 +1709,96 @@ "uhd": "4K — heavier to decode and to download", "large_file": "Large file — slow to start on mobile data", "unreachable": "This link did not respond when checked" + }, + "update": { + "available_title": "New version available (v{})", + "available_body": "A new version of the app is available.", + "later": "Later", + "now": "Update", + "open_store": "Open in App Store", + "required_title": "Update required", + "required_body": "Please install the new version (v{}) to continue using the app.", + "exit_app": "Exit app", + "downloading": "Downloading update…" + }, + "ext": { + "cloudstream_title": "CloudStream Sources", + "aniyomi_title": "Aniyomi Sources", + "mangayomi_title": "Mangayomi Sources", + "android_only": "This feature is only available on Android", + "needs_webview": "JavaScript extensions need a WebView, which is unavailable on this platform.", + "check_updates": "Check for updates", + "check_extension_updates": "Check for extension updates", + "installed_sources": "INSTALLED SOURCES", + "installed_repos": "INSTALLED REPOS", + "sources_heading": "SOURCES", + "recommended": "RECOMMENDED", + "repo_url": "Repo URL", + "index_url": "Index URL", + "browse_plugins": "Browse plugins", + "no_sources_yet": "No sources yet", + "no_repos_yet": "No repos yet", + "add_source": "Add source", + "add_repo": "Add repo", + "installing": "Installing…", + "installing_extensions": "Installing extensions…", + "installing_plugins": "Installing plugins…", + "installing_progress": "Installing {} / {}", + "installing_plugins_progress": "Installing {} / {} plugins…", + "installing_extensions_progress": "Installing {} / {} extensions…", + "installing_long": "Installing extensions… this can take a moment for large repos.", + "checking_updates": "Checking for updates…", + "checking_extension_updates": "Checking for extension updates…", + "all_up_to_date": "All extensions are up to date.", + "updated_count": "Updated {} extension(s).", + "updated_list": "Updated {}: {}", + "update_check_failed": "Update check failed: {}", + "source_removed": "Source removed.", + "repo_removed": "Repo removed.", + "none_found": "No extensions found at that URL.", + "none_found_js": "No JavaScript extensions found at that URL.", + "added_sources": "Added {} source(s).", + "added_sources_providers": "Added {} source(s) · {} provider(s).", + "installed_from_file": "Installed {} source(s).", + "could_not_install": "Could not install: {}", + "could_not_install_plugin": "Could not install plugin", + "could_not_remove_plugin": "Could not remove plugin", + "error": "Error: {}", + "cloudstream_desc": "Add a CloudStream repo, then install only the plugins you want — only those show up as providers. They run on your device (Android only).", + "aniyomi_desc": "Add Aniyomi extension repositories to install extra anime providers. They run on your device (Android only).", + "mangayomi_desc": "Mangayomi extensions are JavaScript, not Android apps — so unlike CloudStream, Aniyomi and Manga sources, these also work on iOS, macOS and Windows.", + "plugins_summary": "{} plugins · {} installed", + "install_all": "Install all", + "repo_load_failed": "Could not load this repo.", + "no_plugins": "No plugins found in this repo", + "search_plugins": "Search plugins…", + "no_matches": "No matches", + "import_title": "Install extension repo?", + "import_pick_library": "Anime and manga repos use the same file format, so pick which library this one belongs to.", + "import_js_only": "On this platform only Mangayomi (JavaScript) extensions can be installed — CloudStream, Aniyomi and Mihon repos ship Android app packages.", + "import_none_found": "No extensions found in this file. It may be for a different app, or the wrong type was selected.", + "mangayomi_url_only": "Mangayomi repos install from a URL — open the index link instead of a downloaded copy.", + "skipped_dart": "{} Dart-only source(s) skipped — those are built into Mangayomi and cannot run here.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Novel" + }, + "ux": { + "quick_search": "Quick search ({})", + "quick_search_note": "Quick search covers {} of {} sources. Choose sources to search a specific one.", + "torrents": "Torrents", + "filters": "Filters", + "change_source": "Change source", + "manage_sources": "Manage sources", + "no_source_match": "No sources match your search.", + "download_options": "Download options", + "download_options_note": "Choose an available source and quality. Audio follows the selected source.", + "source_number": "Source {}", + "checking_quality": "Checking available quality…", + "quality_unavailable": "Quality could not be checked. Retry, or download the original stream without a quality guarantee.", + "quality_unknown": "Quality unknown", + "size_unknown": "Size not provided by the source", + "interface_language_note": "Only the app interface changes. Film titles, descriptions and other source content stay in their original language.", + "continue": "Continue" } } diff --git a/assets/translations/es.json b/assets/translations/es.json new file mode 100644 index 00000000..2f9c3fdc --- /dev/null +++ b/assets/translations/es.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Cargando…", + "error": "Algo salió mal", + "retry": "Reintentar", + "cancel": "Cancelar", + "confirm": "Confirmar", + "ok": "Aceptar", + "yes": "Sí", + "no": "No", + "back": "Atrás", + "next": "Siguiente", + "done": "Listo", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "search": "Buscar", + "close": "Cerrar", + "settings": "Ajustes", + "logout": "Cerrar sesión", + "no_internet": "Sin conexión a internet", + "try_again": "Inténtalo de nuevo", + "see_all": "Ver todo", + "unknown": "Desconocido", + "off": "No", + "on": "Sí", + "new_badge": "NUEVO", + "reset": "Restablecer", + "clear": "Borrar", + "copy": "Copiar", + "share": "Compartir", + "install": "Instalar", + "remove": "Quitar", + "show": "Mostrar", + "hide": "Ocultar", + "installed": "Instalado" + }, + "auth": { + "login": "Iniciar sesión", + "register": "Registrarse", + "email": "Correo", + "password": "Contraseña", + "username": "Nombre de usuario", + "email_hint": "Introduce tu correo", + "password_hint": "Introduce tu contraseña", + "username_hint": "Introduce tu nombre de usuario", + "forgot_password": "¿Olvidaste tu contraseña?", + "dont_have_account": "¿No tienes cuenta?", + "already_have_account": "¿Ya tienes cuenta?", + "sign_up": "Crear cuenta", + "sign_in": "Entrar", + "login_success": "Sesión iniciada", + "register_success": "Registro completado", + "logout_success": "Sesión cerrada", + "invalid_email": "Correo no válido", + "invalid_password": "La contraseña debe tener al menos 6 caracteres", + "invalid_username": "El nombre de usuario debe tener al menos 3 caracteres", + "confirm_password": "Confirmar contraseña", + "passwords_not_match": "Las contraseñas no coinciden", + "terms_agree": "Acepto los Términos y la Política de privacidad", + "or_continue_with": "O continúa con", + "welcome_back": "Hola de nuevo", + "create_account": "Crear cuenta", + "login_subtitle": "Inicia sesión para usar tu perfil y tus datos guardados.", + "register_subtitle": "Crea una cuenta con correo, nombre de usuario y contraseña.", + "forgot_password_title": "Restablece tu contraseña", + "forgot_password_subtitle": "Introduce el correo de tu cuenta y te enviaremos un código de 6 dígitos.", + "reset_password_subtitle": "Enviamos un código a {email}. Introdúcelo junto con tu nueva contraseña.", + "send_reset_code": "Enviar código", + "reset_code_sent": "Si ese correo está registrado, el código va en camino", + "reset_code_hint": "Código de 6 dígitos", + "invalid_reset_code": "Introduce el código de 6 dígitos", + "new_password_hint": "Nueva contraseña", + "confirm_password_hint": "Repite la nueva contraseña", + "reset_password": "Restablecer contraseña", + "resend_code": "Enviar otro código", + "resend_in": "Volver a enviar en {seconds}s", + "identifier_hint": "Correo o nombre de usuario", + "identifier_required": "Se necesita el correo o el nombre de usuario", + "continue_with_google": "Continuar con Google", + "google_unavailable": "El inicio de sesión con Google no está disponible en este dispositivo", + "google_failed": "No se pudo iniciar sesión con Google. Inténtalo de nuevo", + "weak": "Débil", + "medium": "Media", + "strong": "Fuerte", + "code_resent": "Código reenviado. Revisa tu correo.", + "verify": "Verificar", + "avatar_bad_format": "Formato de imagen no admitido (jpg, png, webp)", + "verification_code": "Código de verificación" + }, + "onboarding": { + "skip": "Omitir", + "continue_with_email": "Continuar con correo", + "slide_1_title": "Miles de películas y series", + "slide_1_body": "Estrenos, cine mundial y animación: todo en una sola app.", + "slide_2_title": "Anime, una biblioteca entera", + "slide_2_body": "Estrenos de temporada, clásicos y subtítulos. Vincula AniList y tu lista se actualiza sola.", + "slide_3_title": "También en la pantalla grande", + "slide_3_body": "Canales de TV en vivo, vincular el móvil con el televisor y ver juntos: Sozo también funciona en la TV." + }, + "home": { + "title": "Inicio", + "continue_watching": "Seguir viendo", + "trending": "En tendencia", + "new_releases": "Novedades", + "top_rated": "Mejor valorados", + "for_you": "Para ti", + "all_categories": "Todas las categorías", + "genres": "Géneros", + "get_all": "Ver todo", + "popular": "Popular", + "watch_now": "Ver ahora", + "more_info": "Más información", + "add_to_list": "Añadir a la lista", + "my_list": "Mi lista", + "featured": "Destacado", + "action": "Acción", + "comedy": "Comedia", + "drama": "Drama", + "thriller": "Suspense", + "horror": "Terror", + "romance": "Romance", + "sci_fi": "Ciencia ficción", + "documentary": "Documental", + "animation": "Animación", + "telegram_promo_title": "Únete a Sozo en Telegram", + "telegram_promo_subtitle": "Recibe novedades, nuevas funciones y avisos de contenido.", + "telegram_join_channel": "Unirse al canal", + "dont_show_again": "No volver a mostrar", + "downloaded": "Descargado", + "view_all": "Ver todo", + "remove_from_continue": "Quitar de Seguir viendo", + "ep_number": "EP {}", + "untitled": "Sin título", + "empty_title": "Aquí no hay nada todavía", + "empty_subtitle": "Elige otra fuente, o desliza hacia abajo para actualizar.", + "list_empty_title": "Esta lista está vacía", + "list_empty_subtitle": "Ahora mismo no hay nada que mostrar en esta sección." + }, + "player": { + "needs_media_kit": "Necesita el motor media_kit", + "auto_quality_desc": "El reproductor elige lo mejor que soporte tu conexión", + "on": "Sí", + "info_title": "Información del reproductor", + "info_resolution": "Resolución", + "info_engine": "Motor", + "info_provider": "Fuente", + "info_server": "Servidor", + "info_quality": "Calidad", + "info_codec": "Códec", + "info_hdr": "HDR", + "info_audio": "Audio", + "info_container": "Contenedor", + "info_size": "Tamaño", + "info_host": "Host", + "info_position": "Posición", + "info_buffer": "Precargado", + "info_speed": "Velocidad", + "info_state": "Estado", + "switching_to": "Cambiando a {}…", + "fault": { + "decoder": "Este dispositivo no pudo decodificar el vídeo. Prueba otra calidad o inténtalo de nuevo.", + "unsupported_format": "Este formato de vídeo no es compatible con tu dispositivo. Puedes reproducirlo en el navegador.", + "source_blocked": "No se pudo abrir la fuente del vídeo: puede que el servidor la haya bloqueado. Prueba otra calidad.", + "network": "Error de red: comprueba tu conexión.", + "no_source": "No hay fuente para este episodio.", + "empty_url": "Esta fuente no devolvió ninguna dirección de vídeo.", + "engine_unavailable": "El reproductor no arrancó. Reinicia la app por completo.", + "unknown": "No se pudo cargar el vídeo." + }, + "play": "Reproducir", + "pause": "Pausar", + "resume": "Reanudar", + "forward": "Adelantar 10 s", + "backward": "Retroceder 10 s", + "fullscreen": "Pantalla completa", + "exit_fullscreen": "Salir de pantalla completa", + "quality": "Calidad", + "subtitles": "Subtítulos", + "audio": "Audio", + "speed": "Velocidad de reproducción", + "auto": "Automática", + "off": "Desactivado", + "episodes": "Episodios", + "next_episode": "Siguiente episodio", + "season": "Temporada {n}", + "fit_original": "Original", + "fit_fill": "Rellenar", + "fit_stretch": "Estirar", + "play_in_browser": "Reproducir en el navegador", + "view_logs": "Ver registros", + "speed_2x": "Velocidad 2x", + "tap_to_unlock": "Toca para desbloquear", + "previous": "Anterior", + "live": "EN VIVO", + "go_live": "Ir al directo", + "lang_sub": "Subtitulado (SUB)", + "lang_dub": "Doblado (DUB)", + "lang_softsub": "Subtítulos incrustados blandos", + "audio_language": "Idioma del audio", + "speed_short": "Velocidad", + "aspect": "Formato", + "aspect_ratio": "Relación de aspecto", + "subtitle_style": "Estilo de subtítulos", + "audio_track": "Pista de audio", + "diagnostics_logs": "Diagnóstico / registros", + "extracting_media": "Extrayendo el medio…", + "loading_video": "Cargando vídeo…", + "fetching_link": "Obteniendo el enlace de reproducción de la fuente", + "preparing_stream": "Preparando el flujo de vídeo", + "episode_n": "Episodio {}", + "search_online": "Buscar en línea", + "subtitle_sync": "Sincronización de subtítulos", + "subtitle_sync_desc": "Desplaza el tiempo para que encaje con el audio", + "subtitle_sync_help": "¿Los subtítulos van adelantados? Aumenta (+). ¿Atrasados? Disminuye (−).", + "search_subtitles": "Buscar subtítulos", + "subtitle_search_hint": "Título / nombre de la película", + "searching_subtitles": "Buscando subtítulos…", + "no_subtitles_found": "No se encontraron subtítulos", + "search_failed": "La búsqueda falló: {}", + "online_subtitles_count": "Subtítulos en línea · {}", + "loading_subtitle": "Cargando subtítulo…", + "subtitle_loaded": "Subtítulo cargado", + "subtitle_failed_download": "No se pudo descargar este subtítulo", + "subtitle_failed_empty": "Este archivo de subtítulos no tiene texto", + "subtitle_failed_archive": "Los subtítulos comprimidos (.zip / .rar) no son compatibles", + "subtitle_failed_html": "El servidor devolvió una página web, no un subtítulo", + "subtitle_failed_format": "Formato de subtítulos no compatible", + "subtitle_rate": "Conversión de fotogramas", + "subtitle_rate_help": "¿Se desajusta cada vez más con el tiempo? El subtítulo se sincronizó para otra tasa de fotogramas: elige la conversión.", + "subtitle_rate_off": "Desactivada", + "reset": "Restablecer", + "font_size": "Tamaño de letra", + "text_color": "Color del texto", + "background": "Fondo", + "none": "Ninguno", + "solid": "Sólido", + "edge": "Borde", + "shadow": "Sombra", + "outline": "Contorno", + "position": "Posición", + "lower": "Más abajo", + "default": "Por defecto", + "higher": "Más arriba", + "bold": "Negrita", + "audio_track_engine_only": "solo media_kit", + "audio_track_single": "Solo una pista", + "external_player": "Reproducir en un reproductor externo", + "external_player_gated": "Esta fuente necesita cabeceras especiales que VLC y MX Player no pueden enviar. Lo más probable es que no cargue. ¿Intentarlo igualmente?", + "external_player_try": "Intentar igualmente", + "external_player_none": "No se encontró ningún reproductor de vídeo externo", + "audio_track_numbered": "Audio {}", + "engine_sheet_title": "¿Cómo debería reproducirse?", + "engine_sheet_subtitle": "Elige el motor para este vídeo. Puedes cambiarlo cuando quieras en Ajustes → Reproductor.", + "engine_sheet_dont_ask": "No volver a preguntar", + "engine_sheet_play": "Reproducir", + "subtitle_preview_text": "El veloz murciélago hindú comía feliz cardillo y kiwi", + "server": "Servidor", + "stream_not_found": "Esta fuente esconde su flujo detrás de un reproductor protegido. Prueba otra fuente o ábrela en el navegador.", + "subtitle_translation": "Traducción de subtítulos", + "auto_translate": "Traducir automáticamente", + "auto_translate_desc": "Cuando la fuente no tiene subtítulo en tu idioma, la IA lo traduce", + "translate_to": "Traducir a", + "translate_note": "La traducción es automática y puede fallar. Se aplica un límite diario.", + "translate_ai": "Traducir con IA", + "translating_subtitle": "Traduciendo…", + "translate_failed": "No se pudo traducir", + "ai_translate_title": "Traducir al {} con IA", + "ai_translate_hint": "Elige un subtítulo de la lista y toca IA", + "ai_translate_remaining": "Te quedan {}/{} traducciones hoy", + "ai_translate_used_up": "Has agotado el límite de traducciones de hoy", + "ai_translate_menu": "Traducir con IA", + "ai_translate_menu_desc": "Traduce un subtítulo al {}", + "ready_translations": "Traducciones listas", + "lines": "líneas", + "sleep_timer": "Temporizador de apagado", + "sleep_minutes": "{} minutos", + "sleep_hours": "{} horas", + "sleep_minutes_short": "{} min", + "sleep_episode_end": "Fin del episodio", + "sleep_fired": "Temporizador: reproducción en pausa", + "skip_opening": "Saltar intro", + "skip_ending": "Saltar créditos", + "alt_sources": "Probar otra fuente", + "alt_searching": "Buscando en otras fuentes…", + "alt_none": "Ninguna otra fuente tiene este título", + "alt_failed": "No se pudo abrir esta fuente", + "alt_no_episode": "Esta fuente no tiene el episodio {}", + "alt_search_all": "Buscar en todas las fuentes", + "guide": "Guía", + "change_source": "Cambiar de fuente", + "cast": "Enviar a la TV", + "cast_to": "Enviar a", + "cast_searching": "Buscando dispositivos en esta wifi…", + "cast_none": "No se encontraron dispositivos. El televisor debe estar en la misma wifi que este móvil.", + "cast_failed": "No se pudo conectar con {}", + "cast_unsupported": "Este flujo no se puede enviar", + "cast_active": "Reproduciendo en {}", + "cast_stop": "Dejar de enviar", + "color_profile": "Imagen", + "color_natural": "Natural", + "color_cinema": "Cine", + "color_night": "Noche", + "color_anime": "Anime", + "color_restore": "Restaurar", + "shaders": "Mejorar (Anime4K)", + "shaders_note": "Escalado por GPU para animación. Los archivos de shader se descargan una sola vez la primera vez.", + "shader_off": "Desactivado", + "shader_off_desc": "Sin mejora", + "shader_sharpen": "Enfocar", + "shader_sharpen_desc": "Recupera detalle: ideal para fuentes limpias", + "shader_deblur": "Quitar desenfoque", + "shader_deblur_desc": "Recuperación más suave: para fuentes borrosas", + "shader_denoise": "Quitar ruido", + "shader_denoise_desc": "Limpia el grano: para fuentes muy comprimidas", + "shader_tier": "PRESUPUESTO DE GPU", + "shader_tier_mid": "Equilibrado", + "shader_tier_mid_desc": "Fluido en la mayoría de móviles", + "shader_tier_high": "Máximo", + "shader_tier_high_desc": "El más nítido, necesita una GPU potente", + "font_family": "Fuente", + "font_system": "Por defecto", + "font_sans": "Sin serifa", + "font_serif": "Con serifa", + "font_condensed": "Condensada", + "font_mono": "Monoespaciada", + "second_track": "Segunda pista", + "second_track_on": "Mostrando {} como segunda pista", + "second_track_same": "Esa pista ya es la principal", + "second_track_hint": "Mantén pulsada una pista para mostrarla junto a la primera", + "share_frame": "Compartir este fotograma", + "share_frame_failed": "No se pudo capturar el fotograma", + "save_frame": "Guardar fotograma", + "save_frame_done": "Guardado en la galería", + "save_frame_failed": "No se pudo guardar el fotograma", + "save_frame_denied": "Se necesita acceso a la galería para guardar el fotograma", + "switching_server": "Cambiando de servidor…", + "recommended": "Recomendado", + "episode_page_failed": "No se pudo cargar la siguiente parte de la serie", + "info_fields_desc": "Elige qué aparece sobre el vídeo. Se muestra junto con los controles.", + "layout_title": "Controles del reproductor", + "layout_desc": "Arrastra para reordenar, o mueve un control a otra parte de la barra.", + "layout_top_bar": "Barra superior", + "layout_bottom_left": "Abajo a la izquierda", + "layout_bottom_right": "Abajo a la derecha", + "layout_hidden": "Ocultos", + "layout_hidden_desc": "Siguen accesibles desde Ajustes dentro del reproductor.", + "layout_pinned": "Ajustes se queda donde está: es la vía de vuelta a todo lo demás.", + "layout_top_bar_full": "La barra superior está llena. Quita algo primero.", + "layout_unknown_control": "Ese control ya no está disponible.", + "layout_move_to": "Mover", + "layout_empty": "Aquí no hay nada.", + "rotate": "Girar", + "lock": "Bloquear", + "enhance": "Mejorar", + "fit": "Ajustar", + "pip": "Imagen en imagen", + "layout_pinned_badge": "Fijado", + "info_fields_title": "Filas del panel de información", + "logs_title": "Registros del reproductor ({})", + "logs_copied": "Registros copiados", + "logs_empty": "Aún no hay registros" + }, + "profile": { + "title": "Perfil", + "my_account": "Mi cuenta", + "notifications": "Notificaciones", + "language": "Idioma", + "theme": "Tema", + "privacy": "Política de privacidad", + "help": "Ayuda y soporte", + "about": "Acerca de", + "version": "Versión", + "subscription": "Suscripción", + "downloads": "Descargas", + "watch_history": "Historial", + "manage_profiles": "Gestionar perfiles", + "parental_controls": "Control parental", + "guest_title": "Inicia sesión en tu perfil", + "guest_subtitle": "Inicia sesión para mantener sincronizados favoritos e historial.", + "auth_provider": "Método de acceso", + "joined": "Se unió", + "providers": "Fuentes", + "providers_error": "No se pudieron cargar las fuentes.", + "offline_title": "Servidor inaccesible", + "offline_local_available": "{} fuente(s) instaladas en este dispositivo siguen funcionando. Las fuentes en la nube no estarán disponibles hasta que vuelva el servidor.", + "offline_no_local": "El servidor no responde y no hay fuentes instaladas en este dispositivo. Instala un complemento de CloudStream para seguir viendo sin conexión.", + "offline_cached_at": "Lista de la nube del {}", + "offline_install_plugins": "Instalar complementos", + "offline_badge": "SIN CONEXIÓN", + "provider_needs_server": "Esta fuente necesita el servidor, que ahora no está accesible. Elige una fuente instalada en este dispositivo.", + "logout_confirm": "¿Quieres cerrar la sesión de tu cuenta?", + "connections": "Conexiones", + "coming_soon": "Próximamente", + "favorites": "Favoritos", + "all_providers": "Todas las fuentes", + "add_favorite": "Añadir a favoritos", + "cloudstream_sources": "Fuentes de CloudStream", + "aniyomi_sources": "Fuentes de Aniyomi", + "signin_account_title": "Inicia sesión en tu cuenta", + "signin_account_subtitle": "Inicia sesión para guardar tus favoritos y tu historial.", + "sign_in": "Iniciar sesión", + "sign_out": "Cerrar sesión", + "sign_out_confirm": "¿Seguro que quieres cerrar sesión?", + "section_providers": "Fuentes", + "section_content": "CONTENIDO", + "sources_row": "Fuentes de extensión", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Fuente", + "choose_provider": "Cambiar fuente", + "search_providers_hint": "Buscar fuentes…", + "count_of_total_shown": "{} de {} mostradas", + "no_providers_in_category": "No hay fuentes en esta categoría", + "try_select_all": "Selecciona «Todas» para ver todas las fuentes", + "section_activity": "ACTIVIDAD", + "share_sources_desktop": "Compartir fuentes con el escritorio", + "desktop_sources": "Fuentes del escritorio", + "developer_role": "Desarrollador móvil", + "section_about": "ACERCA DE", + "developer": "Desarrollador", + "website": "Sitio web", + "server": "Servidor", + "expired": "Caducado", + "support_title": "Mantén Sozo en marcha", + "server_expired": "Servidor caducado", + "days": "Días", + "hours": "Horas", + "min": "Min", + "sec": "Seg", + "support_body_expired": "El servidor ha caducado y ya no se puede cargar contenido. Tu apoyo puede ayudar a devolverlo en línea.", + "support_body": "Sozo depende de un servidor que hay que renovar cada cierto tiempo. Si te gusta la app, tu apoyo ayuda a que todo siga funcionando.", + "support_developer": "Apoyar al desarrollador", + "section_appearance": "APARIENCIA", + "native_window_bar": "Barra de ventana nativa", + "native_window_bar_subtitle": "Usar la barra de título estándar de Windows en lugar de la propia de la app.", + "liquid_glass_nav": "Navegación liquid glass", + "liquid_glass_nav_subtitle": "Barra de pestañas flotante de cristal esmerilado (iOS 26). Desactívala para una barra sólida.", + "nav_style": "Barra de navegación", + "nav_style_glass": "Liquid glass", + "nav_style_solid": "Sólida", + "nav_style_classic": "Clásica", + "section_player": "Reproductor", + "player_engine_native": "Reproductor del sistema", + "player_engine_native_desc": "El reproductor de la plataforma. Mejor batería y decodificación por hardware. No permite cambiar de pista de audio.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Reproduce más formatos y permite cambiar de pista de audio y subtítulos. Gasta algo más de batería.", + "player_engine_external": "Reproductor externo", + "player_engine_external_desc": "Enviar el vídeo a VLC o MX Player en lugar de reproducirlo aquí.", + "player_engine_external_note": "Algunas fuentes solo funcionan dentro de Sozo porque necesitan cabeceras especiales. Esas no se reproducirán en una app externa.", + "player_engine_badge_default": "POR DEFECTO", + "player_engine_section": "MOTOR DE REPRODUCCIÓN", + "ask_engine_on_play": "Preguntar siempre", + "ask_engine_on_play_desc": "Elige el motor cada vez que empieza un vídeo", + "playback_section": "REPRODUCCIÓN", + "default_speed": "Velocidad por defecto", + "default_speed_desc": "Velocidad con la que empieza cada vídeo", + "default_fit": "Modo de ajuste por defecto", + "default_fit_desc": "Cómo encaja el vídeo en la pantalla", + "auto_next_episode": "Reproducir el siguiente episodio", + "auto_next_episode_desc": "Empieza el siguiente episodio cuando termina uno", + "keep_screen_on": "Mantener la pantalla encendida", + "keep_screen_on_desc": "Evita que la pantalla se apague mientras ves algo", + "gestures_section": "GESTOS", + "seek_step": "Salto al tocar dos veces", + "seek_step_desc": "Cuánto salta un toque doble", + "long_press_boost": "Velocidad al mantener pulsado", + "long_press_boost_desc": "Velocidad mientras mantienes pulsado el vídeo", + "brightness_gesture": "Deslizar para el brillo", + "brightness_gesture_desc": "Desliza arriba y abajo en el lado izquierdo", + "volume_gesture": "Deslizar para el volumen", + "volume_gesture_desc": "Desliza arriba y abajo en el lado derecho", + "seconds_short": "{} s", + "subtitle_style_note": "Esto vale para todos los vídeos. La sincronía de subtítulos se guarda por episodio y se ajusta dentro del reproductor.", + "searching_all_sources": "buscando en todas las fuentes", + "section_connections": "CONEXIONES", + "section_extensions": "FUENTES", + "section_settings": "AJUSTES", + "remove_favorite": "Quitar de favoritos", + "edit_profile": "Editar perfil", + "display_name_hint": "Tu nombre", + "display_name_too_long": "El nombre no puede pasar de 60 caracteres", + "pick_from_gallery": "Elegir de la galería", + "take_photo": "Hacer una foto", + "remove_photo": "Quitar la foto", + "photo_pick_failed": "No se pudo elegir una foto", + "incognito": "Incógnito", + "incognito_desc": "Ver sin añadir al historial ni a los trackers", + "incognito_off": "Modo incógnito desactivado", + "auto_skip_intro": "Saltar intros automáticamente", + "auto_skip_intro_desc": "Salta las intros y los créditos de anime sin preguntar", + "incognito_active": "El modo incógnito está activo. Nada de lo que veas se guarda en el historial ni se envía a AniList y MAL.", + "incognito_turn_off": "Desactivar", + "all_languages": "Todos los idiomas", + "section_library": "TU BIBLIOTECA", + "section_watch": "VER", + "hero_trailer": "Reproducir el tráiler en la cabecera", + "hero_trailer_desc": "Poco después de abrir un título, el póster da paso al tráiler, sin sonido", + "section_overview": "RESUMEN", + "section_app": "APP", + "library": "Biblioteca", + "library_subtitle": "Favoritos, ver más tarde, lista privada", + "sources_title": "Fuentes", + "section_active_source": "FUENTE ACTIVA", + "active_source_footnote": "La fuente en la nube en la que Sozo busca y desde la que reproduce. Las extensiones de abajo añaden fuentes que se ejecutan en este dispositivo.", + "settings_title": "Ajustes", + "settings_subtitle": "Idioma, apariencia, reproductor, seguridad", + "section_general": "GENERAL", + "section_data": "DATOS", + "section_lists": "LISTAS", + "section_accounts": "CUENTAS", + "section_devices": "DISPOSITIVOS", + "language_desc": "Idioma de la interfaz", + "not_connected": "Sin conectar", + "joined_on": "Se unió el {}", + "watch_later": "Ver más tarde", + "watched": "Visto", + "stat_hours": "horas vistas", + "stat_completed": "completados", + "stat_streak": "racha de días", + "guest_benefit_sync": "Favoritos e historial sincronizados en todos tus dispositivos", + "guest_benefit_streak": "Racha, estadísticas y rango de trivia", + "guest_benefit_trackers": "AniList, MyAnimeList y Watch Party", + "activity": "Actividad", + "activity_subtitle": "Historial, descargas, seguimiento, estadísticas", + "connections_subtitle": "Trackers, Discord, TV, escritorio", + "trackers": "Trackers", + "stat_days": "días vistos", + "mangayomi_sources": "Fuentes de Mangayomi", + "view_only": "SOLO VER" + }, + "appearance": { + "title": "Apariencia", + "entry_subtitle": "Color de acento, negro puro", + "section_preview": "VISTA PREVIA", + "section_accent": "COLOR DE ACENTO", + "accent_footnote": "El acento colorea botones, resaltados, barras de progreso y la pestaña activa: en toda la app.", + "accent_custom": "Personalizado", + "accent_red": "Rojo Sozo", + "accent_ember": "Brasa", + "accent_amber": "Ámbar", + "accent_emerald": "Esmeralda", + "accent_teal": "Verde azulado", + "accent_sky": "Cielo", + "accent_ocean": "Océano", + "accent_indigo": "Índigo", + "accent_violet": "Violeta", + "accent_magenta": "Magenta", + "accent_rose": "Rosa", + "accent_slate": "Pizarra", + "section_darkness": "FONDO", + "darkness_dark": "Oscuro", + "darkness_dark_desc": "Los grises oscuros de siempre", + "darkness_black": "Negro puro", + "darkness_black_desc": "Fondos negros de verdad", + "amoled_footnote": "En una pantalla OLED un píxel negro está apagado, así que el negro puro da más contraste y gasta menos batería.", + "reset": "Volver a los valores por defecto", + "reset_tooltip": "Volver al aspecto por defecto", + "custom_title": "Color personalizado", + "custom_hue": "TONO", + "custom_saturation": "SATURACIÓN", + "custom_lightness": "LUMINOSIDAD", + "custom_apply": "Usar este color", + "custom_adjusted": "Se oscureció un poco para que el texto blanco siga siendo legible encima.", + "section_library": "DE TU BIBLIOTECA", + "library_footnote": "Colores sacados de los pósteres de lo que has estado viendo.", + "section_extras": "MÁS", + "tint_nav": "Colorear la barra de pestañas", + "tint_nav_desc": "Pinta la pestaña activa con tu acento en lugar de blanco", + "shuffle": "Probar el siguiente color", + "shuffle_desc": "Recorre la paleta acento a acento" + }, + "search": { + "title": "Buscar", + "hint": "Busca películas, series…", + "no_results": "No se encontraron resultados", + "no_results_for": "Sin resultados para «{query}»", + "recent": "Búsquedas recientes", + "popular_searches": "Búsquedas populares", + "categories": "Géneros", + "all": "Todo", + "movies": "Películas", + "series": "Series", + "filter": "Filtrar", + "sort": "Ordenar por", + "sort_newest": "Más recientes", + "sort_oldest": "Más antiguas", + "sort_rating": "Valoración", + "type": "Tipo", + "apply": "Aplicar", + "clear_filter": "Borrar", + "sources": "Fuentes", + "sources_n": "{} fuentes", + "all_sources": "Seleccionar todo", + "this_server": "Este servidor", + "local_group": "Local", + "search_in": "Buscar en", + "sources_hint": "Busca en varias fuentes a la vez. Déjalo vacío para usar tu fuente actual.", + "searching": "Buscando…", + "all_source_search": "Búsqueda en todas las fuentes", + "cross_hint": "Busca en tus fuentes…", + "pick_sources": "Elige en qué fuentes buscar.", + "choose_sources": "Elegir fuentes", + "no_sources_selected": "Ninguna fuente seleccionada: toca para elegir", + "sources_selected": "{} seleccionadas: toca para cambiar", + "type_to_search_n": "Escribe para buscar en {} fuentes a la vez.", + "found_in": "Encontrado en {} de {} fuentes", + "results_n": "{} resultados", + "waiting_for": "Buscando en {}…", + "no_results_any": "Sin resultados en ninguna de las fuentes elegidas.", + "offline_note": "Sin conexión: solo se pueden buscar las fuentes del dispositivo.", + "group_by_source": "Agrupar por fuente", + "merge_titles": "Unificar títulos", + "open_from": "Abrir desde", + "load_more": "Cargar más", + "search_failed": "La búsqueda falló", + "source_failed": "Esta fuente no está disponible ahora mismo", + "try_all_sources": "Probar todas las fuentes", + "weak_results": "Nada de esto se parece a «{query}»: esta fuente encontró otra cosa. Puede que otras fuentes sí lo tengan.", + "did_you_mean": "Quizás quisiste decir", + "search_sources": "Buscar fuentes", + "filter_providers": "Filtrar fuentes…", + "selected_n": "{} seleccionadas", + "apply_n": "Aplicar ({})", + "many_sources_warning": "Buscar en {} fuentes puede ser lento. La app sigue respondiendo, pero con menos va más ágil.", + "all_sources_n": "Todas las fuentes ({})", + "more_sources": "Más…", + "select_all_sources": "Seleccionar todo", + "search_all_sources": "Buscar en todas las fuentes", + "apply_all": "Buscar en todas las fuentes", + "selected_n_of_m": "{} de {} fuentes", + "loading_sources": "Cargando tus fuentes…", + "no_sources_available": "No hay fuentes disponibles. Añade o activa una fuente para buscar.", + "min_query_n": "Escribe al menos {} caracteres.", + "retry_failed": "Reintentar las fallidas", + "status_searching": "Buscando…", + "status_searching_n": "{} buscando", + "status_with_results_n": "{} con resultados", + "status_no_match_n": "{} sin coincidencias", + "status_timed_out_n": "{} agotaron el tiempo", + "status_failed_n": "{} fallaron", + "all_sources_failed": "Las {} fuentes no respondieron. Esto no es un resultado vacío: inténtalo de nuevo.", + "no_results_with_failures": "Sin coincidencias en las fuentes que respondieron ({}). No se pudo contactar con {}.", + "all_sources_failed_one": "La fuente en la que buscaste no respondió. Esto no es un resultado vacío: inténtalo de nuevo.", + "voice": "Buscar por voz", + "voice_listening": "Escuchando…", + "voice_done": "Dejé de escuchar", + "voice_hint": "Di el nombre de una película o serie", + "try_torrents": "Buscar torrents", + "capped_note": "Se buscó en las {} fuentes más fiables de {} — acota la selección para llegar a una concreta." + }, + "movie": { + "rating": "Valoración", + "year": "Año", + "quality": "Calidad", + "duration": "Duración", + "genre": "Género", + "director": "Dirección", + "cast": "Reparto", + "description": "Descripción", + "similar": "Títulos parecidos", + "trailer": "Tráiler", + "share": "Compartir", + "download": "Descargar", + "rate": "Valorar", + "episodes": "Episodios", + "seasons": "Temporadas" + }, + "errors": { + "network": "Error de red. Comprueba tu conexión.", + "server": "Error del servidor. Inténtalo de nuevo.", + "unauthorized": "Inicia sesión para continuar.", + "not_found": "Contenido no encontrado.", + "unknown": "Se produjo un error desconocido.", + "timeout": "Se agotó el tiempo de espera. Inténtalo de nuevo.", + "empty": "No hay contenido disponible.", + "source_http": "El servidor de la fuente respondió con un error ({})." + }, + "navigation": { + "home": "Inicio", + "search": "Buscar", + "shorts": "Shorts", + "downloads": "Descargas", + "profile": "Perfil", + "my_list": "Mi lista", + "history": "Historial", + "following": "Siguiendo", + "premiere": "Estreno", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "TV en vivo" + }, + "premiere": { + "tab_title": "Estrenos", + "live_now": "En directo ahora", + "upcoming": "Próximamente", + "live": "EN DIRECTO", + "catching_up": "Alcanzando el directo", + "starts_in": "Empieza en", + "starts_at": "Empieza {when}", + "days": "DÍAS", + "hours": "H", + "minutes": "MIN", + "seconds": "SEG", + "waiting": "{count} esperando", + "watching": "{count} viendo", + "join": "Unirse al estreno", + "join_now": "Unirse ahora", + "set_reminder": "Poner recordatorio", + "reminder_set": "Recordatorio puesto", + "notify_me": "Avísame cuando empiece", + "guest_watch_hint": "Puedes verlo como invitado.", + "login_to_chat_react": "Inicia sesión para chatear y reaccionar", + "login_to_chat": "Inicia sesión para chatear", + "chat_hint": "Saluda: el chat está abierto", + "chat_empty": "Saluda 👋 El chat empieza con el estreno", + "chat_placeholder": "Mensaje…", + "slow_mode": "Modo lento: {sec} s", + "react": "Reaccionar", + "paused_by_host": "Pausado por el anfitrión", + "buffering": "Cargando…", + "reconnecting": "Reconectando…", + "connecting": "Conectando al estreno…", + "ended_title": "El estreno terminó", + "ended_watch_vod": "Ver bajo demanda", + "canceled": "Este estreno se canceló", + "empty_title": "Aún no hay estrenos", + "empty_subtitle": "Los eventos en directo aparecerán aquí", + "error_generic": "Algo salió mal", + "error_full": "Este estreno está lleno", + "error_not_found": "Estreno no encontrado", + "retry": "Reintentar", + "share": "Compartir", + "back": "Atrás", + "resolve_failed": "No se pudo iniciar la reproducción en este dispositivo" + }, + "nav_customize": { + "entry_title": "Personalizar pestañas", + "entry_subtitle": "Elige qué pestañas aparecen y en qué orden", + "title": "Barra inferior", + "shown": "Visibles", + "available": "Disponibles", + "min_hint": "Mantén al menos {count} pestañas", + "max_hint": "Hasta {count} pestañas", + "pick_range": "Elige entre {min} y {max} pestañas", + "reset": "Restablecer", + "cancel": "Cancelar", + "save": "Guardar" + }, + "app_lock": { + "section_label": "SEGURIDAD", + "app_lock": "Bloqueo de la app", + "settings_title": "Bloqueo de la app", + "setup_title": "Configurar PIN", + "enable": "Activar el bloqueo", + "state_on": "Activado", + "state_off": "Desactivado", + "choose_length_title": "Longitud del PIN", + "choose_length_subtitle": "Elige de cuántos dígitos será tu PIN.", + "length_n_digits": "PIN de {} dígitos", + "create_pin_title": "Crea un PIN nuevo", + "create_pin_subtitle": "Elige algo que no vayas a compartir con nadie.", + "confirm_pin_title": "Confirma tu PIN", + "confirm_pin_subtitle": "Introduce tu nuevo PIN una vez más.", + "verify_title": "App bloqueada", + "verify_subtitle": "Introduce tu PIN para continuar.", + "verify_current_pin": "Introduce tu PIN actual", + "change_pin": "Cambiar el PIN", + "biometric": "Desbloquear con biometría", + "biometric_unavailable": "La biometría no está disponible en este dispositivo", + "biometric_enable_reason": "Autentícate para activar el desbloqueo biométrico", + "biometric_unlock_reason": "Autentícate para abrir la app", + "pin_mismatch": "Los PIN no coinciden", + "pin_wrong": "PIN incorrecto", + "disable_title": "¿Desactivar el bloqueo de la app?", + "disable_subtitle": "Se eliminarán tu PIN y tu preferencia de biometría.", + "enabled_hint": "Se te pedirá el PIN al abrir la app.", + "disabled_hint": "El bloqueo está desactivado. Actívalo para proteger lo que ves.", + "private_list": "Lista privada", + "move_to_private": "Mover a privada", + "moved_to_private": "Movido a la lista privada", + "removed_from_private": "Quitado de privada", + "move_to_my_list": "Devolver a Mi lista", + "private_empty": "Aún no hay nada privado", + "private_setup_required": "Configura primero un PIN para usar la lista privada", + "private_long_press_hint": "Mantén pulsado para mover a tu lista privada: queda tras el PIN y su historial no se guarda.", + "private_always_ask": "Pedir siempre el PIN para la lista privada", + "private_always_ask_hint": "Introduce tu PIN cada vez, no solo una vez por sesión", + "reset_title": "¿Restablecer el bloqueo de la app?", + "reset_body": "Esto elimina el PIN y desbloquea Sozo en este ordenador. Tu biblioteca y tu historial se quedan. Podrás poner un PIN nuevo más tarde en Perfil → Bloqueo de la app.", + "reset": "Restablecer", + "forgot_pin": "¿Olvidaste el PIN?" + }, + "my_list": { + "saved_account": "Guardado en la cuenta", + "saved_local": "En este dispositivo", + "untitled": "Sin título", + "empty_title": "Tu lista está vacía", + "empty_subtitle": "Toca el botón + en cualquier título para guardarlo aquí.", + "unauth_title": "Inicia sesión para ver tu lista", + "unauth_subtitle": "Tus títulos guardados aparecerán aquí en cuanto inicies sesión.", + "error_title": "No se pudo cargar la lista" + }, + "deeplink": { + "opt_in_title": "¿Abrir los enlaces de Sozo en esta app?", + "opt_in_body_android": "Los enlaces compartidos de sozo.azamov.me se abrirán directamente en Sozo. Activa «Abrir enlaces compatibles» en los ajustes del sistema.", + "opt_in_body_ios": "Los enlaces de Sozo se abren automáticamente en la app. Si uno se abre en Safari, mantenlo pulsado y elige «Abrir en Sozo».", + "opt_in_enable": "Activar", + "opt_in_later": "Más tarde" + }, + "streak": { + "section_label": "RACHA", + "empty_title": "Empieza una racha", + "empty_subtitle": "Mira un episodio hoy para empezar tu racha diaria.", + "current_n_days": "{} días", + "locked_today": "Hoy ya está asegurado. ¡Vuelve mañana!", + "keep_going": "Mantén viva tu racha: no olvides ver algo hoy.", + "risk_subtitle": "Tu racha se reinicia a medianoche. Mira algo ahora para salvarla.", + "record_label": "Récord", + "next_milestone": "Siguiente meta", + "weekday_mon": "Lun", + "weekday_tue": "Mar", + "weekday_wed": "Mié", + "weekday_thu": "Jue", + "weekday_fri": "Vie", + "weekday_sat": "Sáb", + "weekday_sun": "Dom", + "milestone_title": "¡Racha de {} días!", + "milestone_subtitle": "Una constancia increíble. Sigue apareciendo y bate tu récord personal.", + "share_text": "¡Llegué a una racha de {} días en Sozo! 🔥", + "cta_continue": "Continuar", + "cta_share": "Compartir", + "freeze_label": "Congelaciones", + "freeze_count": "{} / {}", + "total_days_label": "Días en total", + "this_week_label": "Esta semana", + "calendar_label": "Últimos 35 días", + "days_to_milestone": "{} días para el siguiente hito", + "freeze_saved_title": "¡Racha salvada!", + "freeze_saved_subtitle": "Una congelación cubrió el día que te saltaste: tu racha sigue viva.", + "freeze_awarded_title": "¡Has ganado una congelación!", + "freeze_awarded_subtitle": "Las congelaciones protegen tu racha cuando te saltas un solo día.", + "freeze_explainer": "Una congelación salva tu racha automáticamente el día que te la saltas. Ganas nuevas viendo con regularidad." + }, + "watch_party": { + "title": "Watch Party", + "entry_subtitle": "Ved a la vez con amigos: uno reproduce y los demás siguen.", + "entry_create": "Crear una sala", + "entry_create_hint": "Abre una sala e invita a tus amigos con un código", + "entry_join": "Unirse con código", + "entry_join_hint": "Introduce el código que te ha pasado un amigo", + "not_available": "Ver juntos todavía no está disponible para este título", + "create": "Crear sala", + "join": "Unirse a la sala", + "join_hint": "Introduce el código de la sala", + "invalid_code": "Código no válido", + "code_label": "Código de la sala", + "copy_code": "Copiar el código", + "copied": "Copiado", + "share_invite": "Compartir la invitación", + "invite": "Invitar", + "lobby_waiting_host": "Esperando a que el anfitrión empiece…", + "host_label": "Anfitrión", + "members": "Miembros", + "member_count": "{} / {}", + "you": "Tú", + "start_watching": "Empezar a ver", + "close_party": "Cerrar la sala", + "leave_party": "Salir de la sala", + "leave_confirm": "¿Salir de esta Watch Party?", + "only_host_controls": "Solo el anfitrión controla la reproducción", + "host_changed": "El anfitrión ha cambiado", + "room_closed": "La sala se cerró", + "room_closed_empty": "La sala se cerró porque se fueron todos", + "content_none": "Todavía no hay ningún título elegido", + "resolving": "Preparando el vídeo…", + "resolve_failed": "No se pudo cargar este título en tu dispositivo", + "plugin_required_title": "Hace falta un complemento", + "plugin_required_body": "Instala el complemento de la fuente para verlo juntos.", + "install_plugin": "Instalar el complemento", + "chat_hint": "Di algo…", + "chat_empty": "Aún no hay mensajes", + "reactions": "Reacciones", + "error_not_found": "Sala no encontrada o ya cerrada", + "error_full": "Esta sala está llena", + "error_forbidden": "No tienes acceso a esta sala", + "error_rate": "Ve un poco más despacio", + "error_unauthorized": "Inicia sesión para unirte a una Watch Party", + "error_generic": "Algo salió mal", + "reconnecting": "Reconectando…", + "connecting": "Conectando…", + "connect_failed_title": "No se pudo conectar", + "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo." + }, + "notifications": { + "title": "Notificaciones", + "mark_all_read": "Marcar todo leído", + "empty_title": "Aún no hay notificaciones", + "empty_subtitle": "Cuando llegue algo nuevo, lo verás aquí.", + "time_now": "ahora", + "time_minutes": "{} min", + "time_hours": "{} h", + "time_days": "{} d" + }, + "manga": { + "sources_title": "Fuentes de manga", + "sources_desc": "Añade repos de extensiones de manga para instalar más proveedores de manga, manhwa y webtoon. Se ejecutan en tu dispositivo (solo Android).", + "repo_url": "URL del repo", + "add_source": "Añadir fuente", + "installing": "Instalando…", + "installing_long": "Instalando extensiones… con repos grandes esto puede tardar un poco.", + "installing_progress": "Instalando {} / {} extensiones…", + "no_extensions": "No se encontraron extensiones en esa URL.", + "added_sources": "Se añadieron {} fuente(s) · {} proveedor(es).", + "source_removed": "Fuente eliminada.", + "recommended": "RECOMENDADO", + "installed_sources": "FUENTES INSTALADAS", + "source_settings": "AJUSTES DE LA FUENTE", + "no_sources": "Todavía no hay fuentes", + "show_nsfw": "Fuentes para adultos", + "show_nsfw_hint": "Muestra las fuentes marcadas como +18 en esta lista y en el selector de fuentes. Desactivado por defecto.", + "show": "Mostrar", + "hide": "Ocultar", + "installed": "Instalada", + "android_only": "Esta función solo está disponible en Android", + "error_prefix": "Error: {}", + "no_preferences": "Esta fuente no tiene opciones configurables", + "not_selected": "Sin seleccionar", + "n_selected": "{} seleccionadas", + "reading_mode": "Modo de lectura", + "mode_continuous": "Continuo", + "mode_paged": "Por páginas", + "direction": "Dirección", + "dir_ltr": "De izquierda a derecha (LTR)", + "dir_rtl": "De derecha a izquierda (RTL)", + "background": "Fondo", + "bg_black": "Negro", + "bg_gray": "Gris", + "bg_white": "Blanco", + "chapters": "Capítulos", + "chapter_ended": "Fin del capítulo", + "next_chapter": "Capítulo siguiente", + "first_chapter": "Primer capítulo", + "last_chapter": "Último capítulo", + "no_pages": "No se encontraron páginas", + "tap_to_reload": "Toca para recargar", + "download_started": "Descarga iniciada", + "downloaded_pages": "{} / {} páginas", + "check_updates": "Buscar actualizaciones", + "checking_updates": "Buscando actualizaciones de extensiones…", + "up_to_date": "Todas las extensiones están al día.", + "updated_n": "Se actualizaron {} extensión(es).", + "spread": "Dos páginas", + "spread_desc": "Una al lado de la otra en horizontal, tal como se dibujó el libro" + }, + "cloudflare": { + "title": "Verifica que eres humano", + "hint": "Completa la comprobación de abajo: esto se cierra solo al terminar.", + "solve": "Resolver Cloudflare", + "done": "Listo" + }, + "desktop": { + "saved_refresh_on_pc": "Guardado: pulsa «Actualizar fuentes» en el PC para aplicarlo.", + "sources_turned_off": "Fuentes del escritorio desactivadas.", + "saved_loading_sources": "Guardado: cargando las fuentes desde tu móvil…", + "refreshing_sources": "Actualizando las fuentes desde tu móvil…", + "share_title": "Compartir fuentes con el escritorio", + "sources_title": "Fuentes del escritorio", + "share_with_desktop": "Compartir fuentes con el escritorio", + "let_pc_use_sources": "Deja que la app de Sozo para PC use las fuentes de este móvil.", + "your_link": "Tu enlace", + "link_copied": "Enlace copiado", + "no_wifi_address": "Activado, pero no se encontró ninguna dirección wifi. Conecta este móvil al wifi (no a los datos móviles) y vuelve a abrir esta pantalla.", + "step_host_same_wifi": "Mantén este móvil y tu PC en la misma red wifi.", + "step_host_turn_on": "Activa el interruptor de arriba.", + "step_host_pick_sources": "Elige qué fuentes compartir (o deja «Compartir todas» activado).", + "step_host_paste_on_pc": "En el PC, abre Sozo → Perfil → Fuentes del escritorio y pega el enlace.", + "step_host_keep_open": "Deja Sozo abierto en este móvil mientras navegas en el PC.", + "sources_to_share": "Fuentes a compartir", + "reload_from_phone": "Recargar desde el móvil", + "share_all_sources": "Compartir todas las fuentes", + "every_source_shared": "Se comparte todas las fuentes de este móvil", + "sharing_count": "Compartiendo {} de {}", + "save_send_to_desktop": "Guardar y enviar al escritorio", + "n_selected": "{} seleccionadas", + "select_all": "Seleccionar todo", + "clear": "Limpiar", + "no_sources_yet": "Todavía no hay fuentes en este móvil. Añade alguna en Perfil → CloudStream / Aniyomi / Fuentes de manga y recarga aquí.", + "phone_link": "Enlace del móvil", + "refresh_sources": "Actualizar fuentes", + "step_client_same_wifi": "Mantén este PC y tu móvil en la misma red wifi.", + "step_client_open_on_phone": "En el móvil, abre Sozo → Perfil → Compartir fuentes con el escritorio y actívalo.", + "step_client_choose_sources": "En el móvil, elige qué fuentes compartir (o deja «Compartir todas» activado).", + "step_client_copy_link": "Copia el enlace que aparece ahí, pégalo arriba y pulsa Guardar.", + "step_client_refresh": "¿Cambiaste las fuentes compartidas en el móvil? Pulsa «Actualizar fuentes» aquí.", + "step_client_keep_open": "Deja Sozo abierto en el móvil mientras navegas aquí.", + "step_client_emulator": "¿Usas un emulador de Android? Su IP no es accesible directamente: ejecuta «adb forward tcp:8765 tcp:8765» en este PC y pega arriba http://127.0.0.1:8765 (activa igualmente el interruptor en el emulador).", + "how_it_works": "Cómo funciona", + "how_it_works_desc": "Usa tus fuentes de CloudStream, Aniyomi y manga en la app de escritorio de Sozo. El móvil ejecuta las fuentes; el PC solo las ve.", + "internet_still_unavailable": "Internet sigue sin estar disponible", + "offline_watch_downloads": "Puedes seguir viendo los vídeos descargados mientras no haya conexión.", + "refresh": "Actualizar" + }, + "detail": { + "download_will_resolve": "Resolviendo…", + "download_needs_playback": "Esta fuente esconde su flujo detrás de un reproductor protegido. Reproduce el episodio una vez y luego descárgalo desde el reproductor.", + "download_choose_server": "Descarga · elegir servidor", + "download_playing_now": "Reproduciendo ahora", + "similar": "Similares", + "comments": "Comentarios", + "screenshots": "Capturas", + "play": "Reproducir", + "no_episodes": "No hay episodios disponibles", + "no_playable_source": "No hay ninguna fuente reproducible", + "added_to_my_list": "Añadido a Mi lista", + "removed_from_my_list": "Quitado de Mi lista", + "failed_resolve_pages": "No se pudieron resolver las páginas", + "episodes_count": "{} episodios", + "episodes_count_of": "{} de {} episodios", + "episode_n": "Episodio {}", + "no_films": "No se encontraron películas", + "filmography": "Filmografía", + "actor": "Actor", + "continue_watching": "Continuar", + "continue_ep": "Continuar · ep. {}", + "no_cast": "No hay reparto disponible", + "no_recommendations": "No hay recomendaciones disponibles", + "untitled": "Sin título", + "read": "Leer", + "watch_later": "Ver más tarde", + "watched": "Visto", + "follow_series": "Seguir la serie", + "find_other_sources": "Buscar otras fuentes", + "anilist_track": "Seguir en AniList", + "anilist_tracked": "Seguido en AniList", + "following_on": "Siguiendo", + "following_off": "Sin seguir", + "no_screenshots": "Sin capturas", + "show_more": "Ver más", + "show_less": "Ver menos", + "time_left": "quedan {}", + "liked": "Le gustó al {}%", + "watched_pct": "{}% visto", + "copy_link": "Copiar enlace", + "link_copied": "Enlace copiado", + "report_problem": "Informar de un problema", + "selected_n": "{} seleccionados", + "select_all": "Seleccionar todo", + "select_none": "Quitar selección", + "download_n": "Descargar {}", + "download_queueing": "Poniendo en cola…", + "download_action": "Descargar", + "download_already": "Ya descargado", + "download_in_progress": "Descarga en curso", + "download_started": "Descarga iniciada", + "download_needs_permission": "Se necesita el permiso de notificaciones para descargar", + "download_resolve_failed": "No se pudo obtener un enlace de descarga para este episodio", + "download_queued_n": "{} añadidos a las descargas", + "download_none_queued": "No se añadió nada: puede que ya estén descargados", + "download_batch_title": "¿Descargar varios episodios?", + "download_batch_body": "Los episodios {range} entrarán en la cola: {count} en total. Con datos móviles esto pueden ser varios gigabytes.", + "relations": "Relacionados", + "no_relations": "No se han encontrado títulos relacionados con este.", + "relation_sequel": "SECUELA", + "relation_prequel": "PRECUELA", + "relation_parent": "HISTORIA PRINCIPAL", + "relation_side_story": "HISTORIA PARALELA", + "relation_alternative": "VERSIÓN ALTERNATIVA", + "relation_spin_off": "SPIN-OFF", + "relation_summary": "RESUMEN", + "relation_adaptation": "ADAPTACIÓN", + "relation_source": "OBRA ORIGINAL", + "relation_character": "MISMOS PERSONAJES", + "relation_other": "OTROS", + "trailer_action": "Tráiler", + "more_options": "Más opciones", + "in_private_list": "En tu lista privada", + "add_to_my_list_action": "Añadir a Mi lista", + "remove_from_my_list_action": "Quitar de Mi lista", + "trailer_mute": "Silenciar el tráiler", + "trailer_unmute": "Activar el sonido del tráiler" + }, + "episodes": { + "filter_hint": "Número o título del episodio", + "filter_none": "Ningún episodio coincide con «{query}» en este bloque", + "jump_to": "Ir al episodio {n}", + "jump_loads": "Carga {range}", + "jump_missing": "El episodio {n} no está en esta página", + "range_of": "Episodios {from}–{to} de {total}" + }, + "shorts": { + "refresh": "Actualizar", + "opening": "Abriendo…", + "content_unavailable": "Contenido no disponible", + "loading": "Cargando shorts…", + "empty_title": "Aún no hay shorts", + "empty_subtitle": "Los clips cortos aparecerán aquí cuando estén disponibles.", + "error_title": "No se pudieron cargar los shorts", + "video_unavailable": "Vídeo no disponible", + "speed_2x": "Velocidad 2x", + "report_submitted": "Denuncia enviada, gracias", + "watch_full_movie": "Ver la película completa", + "browse_movies": "Explorar películas", + "sign_in_to_like": "Inicia sesión para dar me gusta" + }, + "comments": { + "please_sign_in": "Inicia sesión primero", + "report_submitted": "Denuncia enviada, gracias", + "edited": "· editado", + "like": "Me gusta", + "reply": "Responder", + "hide": "Ocultar", + "replies_one": "1 respuesta", + "replies_other": "{} respuestas", + "report": "Denunciar", + "edit_hint": "Edita tu comentario…", + "reply_hint": "Escribe una respuesta…", + "comment_hint": "Escribe un comentario…", + "editing": "Editando", + "replying_to": "Respondiendo a {}", + "sign_in_to_comment": "Inicia sesión para comentar", + "empty_title": "Aún no hay comentarios", + "empty_subtitle": "Sé el primero en decir lo que piensas", + "count_one": "1 comentario", + "count_other": "{} comentarios", + "show_more": "Ver más", + "delete_confirm_title": "¿Eliminar este comentario?", + "delete_confirm_body": "Esta acción no se puede deshacer.", + "sign_in_prompt": "Inicia sesión para dejar un comentario" + }, + "downloads": { + "delete_all_title": "¿Eliminar todas las descargas?", + "file_not_found": "Archivo no encontrado", + "clear_all": "Borrar todo", + "segments_progress": "{} / {} segmentos", + "downloaded_amount": "{} descargados", + "pages_count": "{} páginas", + "downloaded": "Descargado", + "failed": "Falló", + "pending": "Pendiente", + "remove": "Quitar", + "empty_title": "Aún no hay descargas", + "empty_subtitle": "Los vídeos descargados aparecerán aquí", + "paused": "En pausa", + "wifi_only": "Descargar solo con wifi", + "wifi_only_desc": "Las descargas en cola esperan a que estés en wifi", + "waiting_for_wifi": "Esperando wifi: las descargas siguen solas", + "export": "Guardar en Descargas", + "export_hint": "Hace visible el archivo para otras apps", + "export_running": "Guardando…", + "export_done": "Guardado en {}", + "export_failed": "No se pudo guardar en Descargas", + "downloading": "Descargando", + "missing": "Falta el archivo: toca para descargarlo otra vez", + "redownload_started": "Descargando de nuevo…", + "file_missing_retry": "Ese archivo ya no está. Se descargará otra vez.", + "download_again": "Descargar de nuevo", + "pause": "Pausar", + "resume": "Reanudar", + "retry_all": "Reintentar todo lo que falló", + "sort_by": "ORDENAR POR", + "sort_newest": "Más recientes primero", + "sort_title": "Título", + "sort_size": "Más grandes primero", + "filter_all": "Todas", + "filter_active": "En curso", + "filter_done": "Terminadas", + "filter_problems": "Requiere atención", + "filter_empty_title": "Nada coincide con este filtro", + "filter_empty_subtitle": "Tus descargas siguen ahí: solo esta vista está acotada.", + "filter_clear": "Mostrar todas las descargas", + "storage_used": "{} ocupados por las descargas", + "storage_free": "{} libres", + "orphans_found": "{} en restos de descargas canceladas", + "orphans_clean": "Limpiar", + "group_summary": "{} de {} descargados", + "group_problems": "Requiere atención", + "error": { + "network": "Se cortó la conexión", + "refused": "La fuente rechazó la descarga: reprodúcelo una vez y vuelve a intentarlo", + "gone": "El archivo ya no está en la fuente", + "no_space": "No hay espacio suficiente en este dispositivo", + "not_media": "La fuente envió una página web en lugar del vídeo", + "incomplete": "La descarga terminó incompleta", + "unknown": "La descarga falló" + }, + "location": "Dónde se guardan las descargas", + "location_hint": "Solo sitios donde la app puede escribir sin permisos. Las descargas se mueven con el ajuste; no se borra nada hasta que ha llegado cada archivo.", + "location_internal": "Almacenamiento del móvil (app)", + "location_external": "Almacenamiento del móvil", + "location_sdcard": "Tarjeta SD", + "location_moved": "Descargas movidas", + "location_unchanged": "Ya estaban ahí", + "location_failed": "No se pudieron mover las descargas: siguen donde estaban" + }, + "history": { + "clear_title": "Borrar historial", + "clear_confirm": "¿Seguro que quieres borrar tu historial de reproducción?", + "clear": "Borrar", + "clear_all": "Borrar todo", + "remove": "Quitar", + "empty_title": "Aún no hay historial", + "empty_subtitle": "Empieza a ver algo y tu historial aparecerá aquí" + }, + "main": { + "shorts_refresh_title": "Actualizar Shorts", + "shorts_refresh_body": "Toca de nuevo la pestaña Shorts. El feed se actualiza y se cargan vídeos nuevos.", + "double_tap": "Toca dos veces", + "tap_again": "Toca otra vez" + }, + "reports": { + "title": "Denunciar", + "reason_spam": "Spam", + "reason_abuse": "Acoso", + "reason_sexual": "Contenido sexual", + "reason_violence": "Violencia", + "reason_copyright": "Derechos de autor", + "reason_other": "Otro", + "note_hint": "Nota adicional (opcional)", + "submit": "Enviar" + }, + "ios": { + "sources_title": "Fuentes de extensión", + "how_it_works_desc": "Usa tus fuentes de CloudStream, Aniyomi y manga en Sozo para iOS. Tu móvil Android ejecuta las fuentes; este dispositivo solo las ve.", + "step_client_same_wifi": "Mantén este dispositivo y tu móvil Android en la misma red wifi." + }, + "trivia": { + "fan_test_title": "Test de fan", + "fan_test_subtitle": "Demuestra que eres el mayor fan", + "hub_tagline": "¿Cuánto sabes de verdad de cine?", + "view_leaderboards": "Ver clasificaciones", + "my_daily_rank": "Mi puesto de hoy", + "rank_value": "#{}", + "unranked": "Sin clasificar", + "points_value": "{} pts", + "popular_now": "Popular ahora", + "results": "Resultados", + "searching": "Buscando…", + "search_cast_hint": "Buscar actores", + "no_results": "Sin resultados", + "no_results_hint": "Prueba con otro nombre", + "something_wrong": "Algo salió mal", + "filmography": "Filmografía", + "n_titles": "{count} títulos", + "start_fan_test": "Empezar el test", + "challenge_friend": "Retar a un amigo", + "top_fans": "Mejores fans", + "no_fans_yet": "Aún no hay fans", + "be_first_fan": "Sé el primer fan", + "fans_meta": "{rounds} rondas · {fandom}% de fandom medio", + "you": "Tú", + "you_rank": "Tú · #{rank}", + "best_score": "Mejor {score}", + "fandom": "Fandom", + "challenge_share_text": "¿Sabes más de {name} que yo? ¡Haz el test de fan en Sozo!", + "scope_daily": "Diario", + "scope_weekly": "Semanal", + "scope_all_time": "Histórico", + "scope_friends": "Amigos", + "leaderboard": "Clasificación", + "no_friends_board": "Aún no hay amigos en la tabla", + "no_scores_yet": "Aún no hay puntuaciones", + "correct_of": "{count} aciertos", + "forfeit_title": "¿Salir de la partida?", + "forfeit_body": "Perderás lo que llevas de esta ronda.", + "keep_playing": "Seguir jugando", + "forfeit_confirm": "Salir", + "correct": "Correcto", + "the_answer": "La respuesta", + "watch_full": "Ver la película completa", + "building_round": "Preparando tu ronda…", + "round_failed": "No se pudo empezar la ronda", + "go_back": "Volver", + "fan_test_done": "Test de fan completado", + "round_done": "Ronda completada", + "score": "Puntuación", + "rank": "Puesto", + "play_again": "Jugar otra vez", + "share_result": "Compartir el resultado", + "share_text": "¡Acabo de jugar a Buff en Sozo!", + "challenge_text": "¿Crees que sabes más de cine? ¡Rétame en Buff dentro de Sozo!", + "share_tagline": "Juega a Buff en Sozo", + "challenge_from": "Reto de {name}", + "same_clips_head_to_head": "Los mismos clips, cara a cara", + "score_to_beat": "Puntuación a batir", + "play_challenge": "Jugar el reto", + "challenge_not_found": "Reto no encontrado", + "no_actors_yet": "Aún no hay actores", + "no_actors_yet_body": "Buff se está llenando. Los clips se revisan uno a uno: los actores aparecen aquí en cuanto se aprueban sus clips.", + "actor_not_ready": "Este actor todavía no está listo", + "actor_not_ready_body": "Aún no hay clips aprobados suficientes para una ronda. Prueba con otro actor.", + "round_short_note": "Esta ronda tuvo {count} clips: todos los clips aprobados de este actor hasta ahora.", + "todays_top": "Los mejores de hoy", + "how_it_works": "Cómo funciona Buff", + "how_step_1": "Elige un actor", + "how_step_2": "Mira un clip corto", + "how_step_3": "Di de qué película es" + }, + "outage": { + "body_with_local": "No se puede acceder a los servidores de Sozo. Tus {} fuentes en el dispositivo siguen funcionando.", + "body_no_local": "No se puede acceder a los servidores de Sozo. Añade una fuente de CloudStream, Aniyomi o manga para seguir navegando sin nuestra API.", + "pick_source": "Elegir fuente" + }, + "link_tv": { + "title": "Vincular una TV", + "tile_subtitle": "Escanea el QR de tu TV, o escribe su código", + "subtitle": "Escanea el código QR de tu televisor, o escribe el código de 8 caracteres que aparece debajo.", + "scan_button": "Escanear el código QR", + "scan_hint": "Apunta la cámara al código QR de la pantalla de la TV", + "manual_title": "O introduce el código", + "confirm": "Confirmar", + "approved": "TV vinculada", + "approved_named": "«{}» vinculada", + "approved_hint": "La TV entrará en tu cuenta en unos segundos.", + "linked_devices": "Dispositivos vinculados", + "no_devices": "Aún no hay dispositivos vinculados", + "unnamed_device": "Dispositivo sin nombre", + "last_seen": "Visto por última vez: {}", + "unlink_title": "Desvincular el dispositivo", + "unlink_message": "Se cerrará la sesión en este dispositivo y hará falta un código QR nuevo para volver a vincularlo.", + "unlink_confirm": "Desvincular", + "camera_denied": "Acceso a la cámara denegado. Permítelo en Ajustes, o escribe el código.", + "camera_error": "No se pudo iniciar la cámara.", + "error_bad_code": "El código debe tener 8 caracteres.", + "error_not_found": "Ese código no se encontró. Consigue uno nuevo en la TV.", + "error_expired": "El código ha caducado. Pulsa Actualizar en la TV.", + "error_already_used": "Ese código ya se ha usado.", + "error_unauthorized": "Inicia sesión primero.", + "error_rate_limited": "Demasiados intentos. Prueba dentro de un momento.", + "error_network": "Sin conexión a internet.", + "error_generic": "Algo salió mal." + }, + "anilist": { + "connections_title": "Conexiones", + "connect": "Conectar AniList", + "disconnect": "Desconectar", + "disconnect_confirm": "Se desconectará tu cuenta de AniList y se olvidarán los títulos vinculados. Tu lista se queda en AniList.", + "not_connected": "Sin conectar", + "connect_explainer": "Conecta AniList y los episodios que veas se marcarán solos, con tu lista aquí a la vista.", + "connected_explainer": "Los episodios vistos se escriben en tu lista de AniList. La conexión se guarda en tu cuenta, así que tu TV y tus otros dispositivos no volverán a pedirte iniciar sesión.", + "connect_prompt": "Conecta tu cuenta para ver aquí tu biblioteca de AniList.", + "upcoming_connect_prompt": "Conecta AniList para saber cuándo se emiten los próximos episodios de las series que sigues.", + "sign_in_first": "Inicia sesión primero en tu cuenta de Sozo", + "browser_failed": "No se pudo abrir el navegador", + "open_library": "Mi biblioteca de AniList", + "linked_titles": "Títulos vinculados", + "linked_titles_count": "{} títulos vinculados", + "linked_titles_none": "Nada vinculado todavía", + "unlink": "Desvincular", + "unlinked": "{} desvinculado", + "auto_matched": "Automático", + "privacy_note": "Tu token de AniList se guarda en tu cuenta de Sozo. La app solo escribe el número del episodio que has visto.", + "status_label": "ESTADO", + "status_current": "Viendo", + "status_planning": "Pendiente", + "status_completed": "Completado", + "status_paused": "En pausa", + "status_dropped": "Abandonado", + "status_repeating": "Reviendo", + "episodes_watched": "episodios vistos", + "empty_status": "Tu lista «{}» está vacía", + "behind_n": "{} nuevos", + "mark_episode_watched": "Marcar el episodio {} como visto", + "find_in_sources": "Buscar en las fuentes", + "find_in_sources_hint": "Buscar este título en todas las fuentes", + "open_on_anilist": "Abrir en AniList", + "refresh": "Actualizar", + "retry": "Inténtalo de nuevo", + "upcoming_title": "Próximamente", + "upcoming_empty": "Ninguna de las series que sigues tiene un próximo episodio anunciado.", + "today": "Hoy", + "tomorrow": "Mañana", + "episode_n": "Episodio {}", + "aired": "Emitido", + "in_days": "en {} días", + "in_hours": "en {} horas", + "in_minutes": "en {} min", + "link_title": "Vincular con AniList", + "link_hint": "Elige con qué anime de AniList se corresponde este título.", + "link_search_hint": "Nombre del anime", + "link_no_results": "No se encontró nada", + "linked_to": "Vinculado con {}", + "tracking_on": "Seguido en AniList", + "track_on_anilist": "Seguir en AniList", + "n_episodes_short": "{} ep.", + "open_anilist": "AniList", + "connect_tagline": "Registra automáticamente lo que ves", + "connect_short": "Conectar", + "calendar_title": "Calendario de emisión", + "calendar_all": "Todo", + "calendar_mine": "Mi lista", + "calendar_episode": "Episodio {episode}", + "calendar_empty": "Este día no se emite nada.", + "calendar_empty_mine": "Este día no se emite nada de tu lista.", + "calendar_open": "Calendario de emisión", + "calendar_error": "No se pudo cargar el calendario de emisión.", + "calendar_rate_limited": "AniList está saturado ahora mismo. Inténtalo en un momento.", + "calendar_list_error": "No se pudo cargar tu lista de AniList.", + "calendar_now": "ahora", + "calendar_count": "{count} emisiones", + "calendar_count_mine": "{mine} de {total} son tuyas", + "calendar_episode_range": "Episodios {from}-{to}", + "calendar_add_planning": "Añadir a Pendientes", + "calendar_added_planning": "Añadido a tu lista de Pendientes", + "calendar_add_failed": "No se pudo añadir esto a tu lista", + "calendar_already_on_list": "Ya está en tu lista", + "browse_title": "Descubrir", + "browse_trending": "En tendencia", + "browse_season": "Esta temporada", + "browse_popular": "Populares de siempre", + "browse_upcoming": "Próximamente", + "browse_empty": "Aquí no hay nada que mostrar.", + "browse_failed": "No se pudo contactar con AniList.", + "episodes_count": "{count} ep.", + "reminder_body": "El episodio {episode} se emite en 10 minutos", + "reminders_on": "Avisos activados: antes y al emitirse un episodio", + "reminders_off": "Los avisos de episodios están desactivados", + "connect_first": "Conecta antes AniList", + "save_failed": "No se pudo guardar en AniList", + "search_in_source": "BUSCAR EN UNA FUENTE", + "no_sources": "Todavía no hay fuentes instaladas", + "released_body": "Ya está el episodio {episode}", + "remove_entry": "Quitar de mi lista", + "remove_confirm": "¿Quitar «{}» de tu AniList? También se borra tu progreso.", + "removed": "Quitado de AniList" + }, + "mal": { + "connect": "Conectar MyAnimeList", + "disconnect": "Desconectar MyAnimeList", + "disconnect_confirm": "Se desconectará tu cuenta de MyAnimeList y se olvidarán los títulos emparejados. Tu lista se queda en MyAnimeList.", + "connect_explainer": "Conecta MyAnimeList y los episodios que veas se marcarán solos.", + "connected_explainer": "Los episodios vistos se escriben en tu MyAnimeList. La conexión se guarda en tu cuenta, así que tu TV y tus otros dispositivos no volverán a pedirte iniciar sesión.", + "not_configured": "MyAnimeList todavía no está configurado en el servidor", + "matching_title": "Cómo se emparejan los títulos", + "matching_note": "El emparejamiento pasa por AniList, que lleva el id de MyAnimeList de la misma entrada. Vincula un título una vez y los dos trackers lo siguen.", + "privacy_note": "Tus tokens de AniList y MyAnimeList se guardan en tu cuenta de Sozo. La app solo escribe el número del episodio que has visto.", + "tracking_on": "Seguido en MyAnimeList", + "save_failed": "No se pudo guardar en MyAnimeList", + "open_library": "Mi MyAnimeList", + "linked_titles": "Títulos vinculados", + "connect_prompt": "Conecta tu cuenta para ver aquí tu MyAnimeList.", + "connect_first": "Conecta antes MyAnimeList", + "search_failed": "No se pudo contactar con MyAnimeList.", + "link_title": "Vincular con MyAnimeList", + "link_hint": "Elige con qué anime de MyAnimeList se corresponde este título.", + "linked_to": "Vinculado con {}", + "track": "Seguir en MyAnimeList", + "tracked": "Seguido en MyAnimeList", + "score_label": "NOTA", + "remove_entry": "Quitar de mi lista", + "remove_confirm": "¿Quitar «{}» de tu MyAnimeList? También se borra tu progreso.", + "removed": "Quitado de MyAnimeList", + "rewatching_note": "Lo estás reviendo. Sozo no toca el estado para no dar por terminado el reviaje." + }, + "tracker": { + "title": "Seguimiento", + "tab_following": "Siguiendo", + "tab_upcoming": "Próximamente", + "following_empty": "Sigue una serie para recibir avisos cuando salgan episodios nuevos.", + "new_episodes_found": "{} títulos tienen episodios nuevos", + "no_new_episodes": "No hay episodios nuevos", + "n_episodes": "{} episodios", + "not_checked": "Aún sin comprobar", + "unfollow": "Dejar de seguir", + "unfollowed": "Dejaste de seguir {}", + "undo": "Deshacer" + }, + "remote": { + "title": "Mando de la TV", + "no_devices": "Aún no hay TVs vinculadas. Empareja una desde la app de TV primero.", + "load_failed": "No se pudieron cargar tus TVs.", + "tv_offline": "La TV está desconectada", + "idle": "No hay nada en reproducción", + "command_failed": "Eso no llegó a la TV", + "search_on_tv": "BUSCAR EN LA TV", + "search_hint": "Escribe aquí; la búsqueda se hace en la TV", + "back": "Atrás", + "home": "Inicio", + "open_on_tv": "Reproducir en la TV", + "sent_to_tv": "Enviado a {device}", + "connected_to": "Conectado a {device}", + "not_connected_to": "{device} no está conectada", + "turn_tv_on": "Abre Sozo en la TV para controlarla", + "retry": "Inténtalo de nuevo", + "navigation": "NAVEGACIÓN", + "playback": "REPRODUCCIÓN", + "pick_device": "¿Qué TV?", + "device_online": "Conectada", + "device_offline": "Desconectada: abre Sozo en ella" + }, + "live_tv": { + "title": "TV en vivo", + "favourites": "Favoritos", + "recent": "Vistos hace poco", + "categories": "Categorías", + "countries": "Países", + "results": "Resultados de la búsqueda", + "search_hint": "Nombre del canal…", + "search_in": "Buscar en {folder}…", + "no_match": "Ningún canal coincide con eso.", + "empty": "Todavía no hay canales.", + "load_failed": "No se pudieron cargar los canales.", + "retry": "Inténtalo de nuevo", + "now": "Ahora", + "next": "A continuación", + "guide": "Guía de TV", + "no_guide": "No hay guía para este canal.", + "watch": "Ver", + "favourite_add": "Añadir a favoritos", + "favourite_remove": "Quitar de favoritos", + "hint_long_press": "Mantén pulsado un canal para ver la guía y los favoritos.", + "channel_count": { + "one": "{} canal", + "other": "{} canales" + }, + "up_next": "A CONTINUACIÓN", + "minutes_left": "quedan {} min" + }, + "time": { + "now": "ahora mismo", + "minutes": "hace {} min", + "hours": "hace {} h", + "days": "hace {} d" + }, + "user_lists": { + "title": "Mis listas", + "remove": "Quitar", + "empty_title": "Nada guardado todavía", + "empty_subtitle": "Los títulos que guardes aparecerán aquí." + }, + "torrent": { + "title": "Torrents", + "search_hint": "Buscar torrents…", + "empty_hint": "Busca en Nyaa, Tokyo Toshokan y nekoBT a la vez.", + "searching": "Buscando en los trackers…", + "no_results": "No se encontró nada en los trackers activos", + "no_results_hint": "Prueba con un título más corto o relaja los filtros.", + "results_n": "{} resultados", + "seeders": "semillas", + "leechers": "pares", + "unknown_swarm": "Tamaño del enjambre desconocido", + "dead": "Sin semillas", + "dead_warning": "Este torrent no tiene semillas y no se puede reproducir.", + "trusted": "De confianza", + "remake": "Resubida", + "filters": "Filtros", + "category": "Categoría", + "cat_all": "Todas", + "cat_anime": "Anime", + "cat_english": "Subtítulos en inglés", + "cat_non_english": "Otros idiomas", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Imagen real", + "sort": "Ordenar por", + "sort_seeders": "Semillas", + "sort_date": "Más recientes", + "sort_size": "Tamaño", + "sort_downloads": "Descargas", + "min_seeders": "Semillas mínimas", + "quality": "Calidad", + "any_quality": "Cualquiera", + "trusted_only": "Solo subidas de confianza", + "exclude_hardsubs": "Ocultar versiones con subtítulos incrustados", + "exclude_mini": "Ocultar mini-encodes", + "exclude_remakes": "Ocultar resubidas", + "require_dual_audio": "Solo audio dual", + "exclude_batches": "Ocultar packs de temporada", + "trackers": "Trackers", + "reset_filters": "Restablecer", + "consent_title": "¿Reproducir por BitTorrent?", + "consent_body": "Los torrents son entre pares. Mientras uno se reproduce, otras personas del enjambre pueden ver tu dirección IP y qué estás descargando, y tu dispositivo también les sube datos.\n\nUsa una VPN si eso importa donde estás.", + "consent_accept": "Entendido", + "consent_remember": "No volver a preguntar", + "preparing": "Arrancando el motor de torrents…", + "fetching_metadata": "Obteniendo la lista de archivos del enjambre…", + "metadata_timeout": "El enjambre no responde. Puede que el torrent ya no tenga semillas.", + "engine_failed": "No se pudo arrancar el motor de torrents.", + "unsupported": "La reproducción por torrent solo está disponible en Android.", + "pick_file": "Elige un archivo", + "pick_file_hint": "Este torrent contiene {} vídeos.", + "play": "Reproducir", + "copy_magnet": "Copiar el enlace magnet", + "magnet_copied": "Enlace magnet copiado", + "open_external": "Abrir en una app de torrents", + "open_page": "Abrir en el tracker", + "stats_peers": "{} pares", + "buffering": "Cargando {}%", + "still_searching": "{} trackers aún respondiendo", + "buffering_title": "Cargando…", + "no_video_in_torrent": "Este torrent no tiene ningún vídeo reproducible.", + "tracker_failed": "No se pudo buscar en {}: los resultados pueden estar incompletos." + }, + "backup": { + "title": "Copia de seguridad y restauración", + "subtitle": "Historial, listas, fuentes y ajustes. Las sesiones iniciadas no se incluyen.", + "export": "Crear copia", + "export_desc": "Guarda un archivo que puedas conservar o enviarte a ti mismo", + "restore": "Restaurar", + "restore_desc": "Volver a leer un archivo de copia en la app", + "restore_warning": "Los valores de la copia sobrescribirán los de este dispositivo. Lo que no esté en el archivo se queda como está.", + "restored": "{} valores restaurados", + "not_a_backup": "Ese archivo no es una copia de Sozo", + "too_new": "Esa copia se hizo con una versión más nueva de la app", + "export_failed": "No se pudo crear la copia", + "restore_failed": "No se pudo leer esa copia", + "file_subject": "Copia de Sozo", + "last_export": "Última copia: {}", + "never_exported": "Todavía no se ha hecho ninguna copia en este dispositivo." + }, + "provider_test": { + "title": "Probar fuentes", + "intro": "Hace una búsqueda real, abre un título y pide los primeros bytes al CDN. Los resultados valen para este dispositivo y esta red, ahora mismo.", + "summary": "{} de {} reprodujeron", + "run_all": "Probar todas", + "run_one": "Probar", + "stop": "Detener", + "stage_catalogue": "Catálogo", + "stage_detail": "Título", + "stage_stream": "Enlace", + "stage_playback": "Reproducción", + "entry": "Probar fuentes" + }, + "catalog": { + "title": "Fuentes por idioma", + "subtitle": "Explora todas las fuentes de extensión de los cuatro ecosistemas", + "type_all": "Todas", + "type_anime": "Anime", + "type_video": "Películas y series", + "type_manga": "Manga", + "type_novel": "Novelas", + "add_repo": "Añadir repo", + "installed": "Se añadieron {} fuente(s) de {}", + "install_empty": "Ese repo no devolvió nada instalable.", + "install_failed": "Falló la instalación: {}", + "needs_js": "Fuente en Dart: no se puede ejecutar aquí", + "android_only": "Solo Android", + "empty": "Aquí no hay nada todavía. Prueba con otro idioma o quita los filtros.", + "unreachable": "No se pudo acceder al catálogo de fuentes." + }, + "mode": { + "video": "Ver", + "manga": "Manga", + "novel": "Novelas", + "none_installed": "Aún no hay fuentes de {} instaladas." + }, + "home_rails": { + "title": "Diseño del inicio", + "entry_title": "Personalizar inicio", + "entry_subtitle": "Elige qué filas aparecen y en qué orden", + "hint": "Arrastra para reordenar. Toca el ojo para ocultar una fila: conserva su sitio, así que al volver a activarla vuelve donde estaba.", + "hero": "Banner destacado", + "resume": "Seguir viendo", + "genres": "Géneros", + "live_tv": "TV en vivo", + "catalogue": "Filas del catálogo" + }, + "stats": { + "title": "Estadísticas", + "entry_subtitle": "Tiempo visto, racha, en qué se fue", + "watched": "Visto", + "hours_short": "h", + "minutes_short": "min", + "since": "Contando desde {}", + "completed": "Terminados", + "streak": "Racha de días", + "last_days": "Últimos {} días", + "by_source": "Por fuente", + "empty_title": "Aún no hay nada contado", + "empty_subtitle": "Mira algo y los totales empiezan aquí. Lo que veas en incógnito no cuenta." + }, + "discord": { + "sign_in": "Iniciar sesión con Discord", + "sign_in_desc": "Inicia sesión en la propia página de Discord. El token se recoge automáticamente y se guarda solo en este dispositivo.", + "or_paste": "o pega tú un token", + "web_login_title": "Conectar Discord", + "web_login_paste": "Pegar el token", + "web_login_paste_title": "Pegar el token de Discord", + "web_login_paste_hint": "Tu token de usuario de Discord", + "title": "Discord", + "what_it_does": "Muestra en tu perfil de Discord lo que estás viendo: el título, el episodio y por dónde vas. Tus amigos lo ven en su lista de amigos.", + "enable": "Mostrar en Discord", + "enable_desktop_desc": "Necesita la app de Discord abierta en este ordenador", + "enable_mobile_desc": "Necesita tu token de Discord: lee antes la advertencia de abajo", + "connected": "Conectado", + "not_connected": "Sin conectar", + "risk_title": "Lee esto antes de continuar", + "risk_body": "En un móvil no hay app de Discord a la que conectarse, así que Sozo tiene que iniciar sesión como tú con tu propio token. Discord no lo permite: pueden suspenderte la cuenta. El token da acceso completo a tu cuenta de Discord, y Sozo lo guarda solo en este dispositivo, nunca en un servidor. Puedes borrarlo cuando quieras con Olvidar.", + "token_label": "Token de Discord", + "token_hint": "Pega tu token", + "token_saved": "Hay un token guardado: pega uno nuevo para sustituirlo", + "token_save": "Guardar", + "token_forget": "Olvidar", + "brand_title": "Rich Presence", + "brand_subtitle": "Tus amigos ven en directo en tu perfil lo que estás viendo", + "preview_heading": "VIENDO", + "preview_title": "Aquí aparece el título", + "preview_subtitle": "Temporada y episodio", + "preview_elapsed": "{} transcurrido", + "preview_elapsed_placeholder": "Tiempo transcurrido", + "row_off": "Desactivado", + "row_live": "Se muestra en tu perfil", + "row_waiting": "Activado: esperando a Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision: la imagen puede verse verde o morada en este dispositivo", + "atmos": "Audio Atmos: este dispositivo podría reproducirlo sin sonido", + "indirect_host": "Este espejo abre una página en lugar del vídeo", + "uhd": "4K: más pesado de decodificar y de descargar", + "large_file": "Archivo grande: tarda en empezar con datos móviles", + "unreachable": "Este enlace no respondió al comprobarlo" + }, + "update": { + "available_title": "Nueva versión disponible (v{})", + "available_body": "Hay una nueva versión de la aplicación disponible.", + "later": "Más tarde", + "now": "Actualizar", + "open_store": "Abrir en el App Store", + "required_title": "Actualización necesaria", + "required_body": "Instala la nueva versión (v{}) para seguir usando la aplicación.", + "exit_app": "Salir de la app", + "downloading": "Descargando la actualización…" + }, + "ext": { + "cloudstream_title": "Fuentes de CloudStream", + "aniyomi_title": "Fuentes de Aniyomi", + "mangayomi_title": "Fuentes de Mangayomi", + "android_only": "Esta función solo está disponible en Android", + "needs_webview": "Las extensiones JavaScript necesitan un WebView, que no existe en esta plataforma.", + "check_updates": "Buscar actualizaciones", + "check_extension_updates": "Buscar actualizaciones de extensiones", + "installed_sources": "FUENTES INSTALADAS", + "installed_repos": "REPOSITORIOS INSTALADOS", + "sources_heading": "FUENTES", + "recommended": "RECOMENDADAS", + "repo_url": "URL del repositorio", + "index_url": "URL del índice", + "browse_plugins": "Ver complementos", + "no_sources_yet": "Aún no hay fuentes", + "no_repos_yet": "Aún no hay repositorios", + "add_source": "Añadir fuente", + "add_repo": "Añadir repositorio", + "installing": "Instalando…", + "installing_extensions": "Instalando extensiones…", + "installing_plugins": "Instalando complementos…", + "installing_progress": "Instalando {} / {}", + "installing_plugins_progress": "Instalando {} / {} complementos…", + "installing_extensions_progress": "Instalando {} / {} extensiones…", + "installing_long": "Instalando extensiones… en repositorios grandes puede tardar un poco.", + "checking_updates": "Buscando actualizaciones…", + "checking_extension_updates": "Buscando actualizaciones de extensiones…", + "all_up_to_date": "Todas las extensiones están al día.", + "updated_count": "Se actualizaron {} extensión(es).", + "updated_list": "Actualizadas {}: {}", + "update_check_failed": "Fallo al buscar actualizaciones: {}", + "source_removed": "Fuente eliminada.", + "repo_removed": "Repositorio eliminado.", + "none_found": "No se encontraron extensiones en esa URL.", + "none_found_js": "No se encontraron extensiones JavaScript en esa URL.", + "added_sources": "Se añadieron {} fuente(s).", + "added_sources_providers": "Se añadieron {} fuente(s) · {} proveedor(es).", + "installed_from_file": "Se instalaron {} fuente(s).", + "could_not_install": "No se pudo instalar: {}", + "could_not_install_plugin": "No se pudo instalar el complemento", + "could_not_remove_plugin": "No se pudo quitar el complemento", + "error": "Error: {}", + "cloudstream_desc": "Añade un repositorio de CloudStream e instala solo los complementos que quieras: solo esos aparecen como proveedores. Se ejecutan en tu dispositivo (solo Android).", + "aniyomi_desc": "Añade repositorios de extensiones de Aniyomi para instalar más proveedores de anime. Se ejecutan en tu dispositivo (solo Android).", + "mangayomi_desc": "Las extensiones de Mangayomi son JavaScript, no apps de Android; por eso, a diferencia de las fuentes de CloudStream, Aniyomi y Manga, también funcionan en iOS, macOS y Windows.", + "plugins_summary": "{} complementos · {} instalados", + "install_all": "Instalar todo", + "repo_load_failed": "No se pudo cargar este repositorio.", + "no_plugins": "No se encontraron complementos en este repositorio", + "search_plugins": "Buscar complementos…", + "no_matches": "Sin resultados", + "import_title": "¿Instalar el repositorio de extensiones?", + "import_pick_library": "Los repositorios de anime y manga usan el mismo formato de archivo, así que elige a qué biblioteca pertenece este.", + "import_js_only": "En esta plataforma solo se pueden instalar extensiones de Mangayomi (JavaScript): los repositorios de CloudStream, Aniyomi y Mihon incluyen paquetes de apps de Android.", + "import_none_found": "No se encontraron extensiones en este archivo. Puede que sea de otra aplicación o que se haya elegido el tipo incorrecto.", + "mangayomi_url_only": "Los repositorios de Mangayomi se instalan desde una URL: abre el enlace del índice en lugar de una copia descargada.", + "skipped_dart": "Se omitieron {} fuente(s) solo de Dart: están integradas en Mangayomi y aquí no funcionan.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Novela" + }, + "ux": { + "quick_search": "Búsqueda rápida ({})", + "quick_search_note": "La búsqueda rápida consulta {} de {} fuentes. Elige fuentes para una búsqueda específica.", + "torrents": "Torrents", + "filters": "Filtros", + "change_source": "Cambiar fuente", + "manage_sources": "Gestionar fuentes", + "no_source_match": "No hay fuentes que coincidan con tu búsqueda.", + "download_options": "Opciones de descarga", + "download_options_note": "Elige una fuente y una calidad disponibles. El audio depende de la fuente elegida.", + "source_number": "Fuente {}", + "checking_quality": "Comprobando la calidad disponible…", + "quality_unavailable": "No se pudo comprobar la calidad. Reintenta o descarga la transmisión original sin garantía de calidad.", + "quality_unknown": "Calidad desconocida", + "size_unknown": "La fuente no indica el tamaño", + "interface_language_note": "Solo cambia la interfaz de la aplicación. Los títulos, las descripciones y el resto del contenido de las fuentes permanecen en su idioma original.", + "continue": "Continuar" + } +} diff --git a/assets/translations/fr.json b/assets/translations/fr.json new file mode 100644 index 00000000..00d78bf0 --- /dev/null +++ b/assets/translations/fr.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Chargement…", + "error": "Un problème est survenu", + "retry": "Réessayer", + "cancel": "Annuler", + "confirm": "Confirmer", + "ok": "OK", + "yes": "Oui", + "no": "Non", + "back": "Retour", + "next": "Suivant", + "done": "Terminé", + "save": "Enregistrer", + "delete": "Supprimer", + "edit": "Modifier", + "search": "Rechercher", + "close": "Fermer", + "settings": "Réglages", + "logout": "Déconnexion", + "no_internet": "Pas de connexion internet", + "try_again": "Réessaie", + "see_all": "Tout voir", + "unknown": "Inconnu", + "off": "Désactivé", + "on": "Activé", + "new_badge": "NOUVEAU", + "reset": "Réinitialiser", + "clear": "Effacer", + "copy": "Copier", + "share": "Partager", + "install": "Installer", + "remove": "Retirer", + "show": "Afficher", + "hide": "Masquer", + "installed": "Installé" + }, + "auth": { + "login": "Connexion", + "register": "Inscription", + "email": "E-mail", + "password": "Mot de passe", + "username": "Nom d’utilisateur", + "email_hint": "Saisis ton e-mail", + "password_hint": "Saisis ton mot de passe", + "username_hint": "Saisis ton nom d’utilisateur", + "forgot_password": "Mot de passe oublié ?", + "dont_have_account": "Pas encore de compte ?", + "already_have_account": "Tu as déjà un compte ?", + "sign_up": "Créer un compte", + "sign_in": "Se connecter", + "login_success": "Connexion réussie", + "register_success": "Inscription réussie", + "logout_success": "Tu es déconnecté", + "invalid_email": "Adresse e-mail invalide", + "invalid_password": "Le mot de passe doit faire au moins 6 caractères", + "invalid_username": "Le nom d’utilisateur doit faire au moins 3 caractères", + "confirm_password": "Confirmer le mot de passe", + "passwords_not_match": "Les mots de passe ne correspondent pas", + "terms_agree": "J’accepte les Conditions et la Politique de confidentialité", + "or_continue_with": "Ou continue avec", + "welcome_back": "Content de te revoir", + "create_account": "Créer un compte", + "login_subtitle": "Connecte-toi pour utiliser ton profil et tes données enregistrées.", + "register_subtitle": "Crée un compte avec un e-mail, un nom d’utilisateur et un mot de passe.", + "forgot_password_title": "Réinitialise ton mot de passe", + "forgot_password_subtitle": "Saisis l’e-mail de ton compte et nous t’enverrons un code à 6 chiffres.", + "reset_password_subtitle": "Nous avons envoyé un code à {email}. Saisis-le avec ton nouveau mot de passe.", + "send_reset_code": "Envoyer le code", + "reset_code_sent": "Si cet e-mail est enregistré, un code est en route", + "reset_code_hint": "Code à 6 chiffres", + "invalid_reset_code": "Saisis le code à 6 chiffres", + "new_password_hint": "Nouveau mot de passe", + "confirm_password_hint": "Répète le nouveau mot de passe", + "reset_password": "Réinitialiser le mot de passe", + "resend_code": "Envoyer un autre code", + "resend_in": "Renvoyer dans {seconds} s", + "identifier_hint": "E-mail ou nom d’utilisateur", + "identifier_required": "E-mail ou nom d’utilisateur requis", + "continue_with_google": "Continuer avec Google", + "google_unavailable": "La connexion Google n’est pas disponible sur cet appareil", + "google_failed": "Impossible de se connecter avec Google. Réessaie", + "weak": "Faible", + "medium": "Moyen", + "strong": "Fort", + "code_resent": "Code renvoyé. Vérifie tes e-mails.", + "verify": "Vérifier", + "avatar_bad_format": "Format d’image non pris en charge (jpg, png, webp)", + "verification_code": "Code de vérification" + }, + "onboarding": { + "skip": "Passer", + "continue_with_email": "Continuer avec l’e-mail", + "slide_1_title": "Des milliers de films et séries", + "slide_1_body": "Nouveautés, cinéma du monde et animation — le tout dans une seule appli.", + "slide_2_title": "L’anime, une bibliothèque entière", + "slide_2_body": "Sorties de la saison, classiques et sous-titres. Relie AniList et ta liste se met à jour toute seule.", + "slide_3_title": "Sur grand écran aussi", + "slide_3_body": "Chaînes de TV en direct, appairage du téléphone avec la télévision et visionnage à plusieurs — Sozo tourne aussi sur TV." + }, + "home": { + "title": "Accueil", + "continue_watching": "Reprendre", + "trending": "Tendances du moment", + "new_releases": "Nouveautés", + "top_rated": "Les mieux notés", + "for_you": "Pour toi", + "all_categories": "Toutes les catégories", + "genres": "Genres", + "get_all": "Tout récupérer", + "popular": "Populaire", + "watch_now": "Regarder maintenant", + "more_info": "Plus d’infos", + "add_to_list": "Ajouter à la liste", + "my_list": "Ma liste", + "featured": "À la une", + "action": "Action", + "comedy": "Comédie", + "drama": "Drame", + "thriller": "Thriller", + "horror": "Horreur", + "romance": "Romance", + "sci_fi": "Science-fiction", + "documentary": "Documentaire", + "animation": "Animation", + "telegram_promo_title": "Rejoins Sozo sur Telegram", + "telegram_promo_subtitle": "Reçois les nouveautés, les nouvelles fonctions et les alertes de contenu.", + "telegram_join_channel": "Rejoindre le canal", + "dont_show_again": "Ne plus afficher", + "downloaded": "Téléchargé", + "view_all": "Tout voir", + "remove_from_continue": "Retirer de Reprendre", + "ep_number": "ÉP {}", + "untitled": "Sans titre", + "empty_title": "Rien ici pour l’instant", + "empty_subtitle": "Choisis une autre source, ou tire vers le bas pour actualiser.", + "list_empty_title": "Cette liste est vide", + "list_empty_subtitle": "Rien à afficher dans cette section pour le moment." + }, + "player": { + "needs_media_kit": "Nécessite le moteur media_kit", + "auto_quality_desc": "Le lecteur choisit le meilleur que ta connexion supporte", + "on": "Activé", + "info_title": "Infos du lecteur", + "info_resolution": "Résolution", + "info_engine": "Moteur", + "info_provider": "Source", + "info_server": "Serveur", + "info_quality": "Qualité", + "info_codec": "Codec", + "info_hdr": "HDR", + "info_audio": "Audio", + "info_container": "Conteneur", + "info_size": "Taille du fichier", + "info_host": "Hôte", + "info_position": "Position", + "info_buffer": "Mis en mémoire", + "info_speed": "Débit", + "info_state": "État", + "switching_to": "Passage à {}…", + "fault": { + "decoder": "Cet appareil n’a pas pu décoder la vidéo. Essaie une autre qualité, ou réessaie.", + "unsupported_format": "Ce format vidéo n’est pas pris en charge sur ton appareil. Tu peux le lire dans ton navigateur.", + "source_blocked": "Impossible d’ouvrir la source vidéo — le serveur l’a peut-être bloquée. Essaie une autre qualité.", + "network": "Erreur réseau — vérifie ta connexion.", + "no_source": "Aucune source pour cet épisode.", + "empty_url": "Cette source n’a renvoyé aucune adresse vidéo.", + "engine_unavailable": "Le lecteur n’a pas démarré. Redémarre complètement l’appli.", + "unknown": "Impossible de charger la vidéo." + }, + "play": "Lire", + "pause": "Pause", + "resume": "Reprendre", + "forward": "Avancer de 10 s", + "backward": "Reculer de 10 s", + "fullscreen": "Plein écran", + "exit_fullscreen": "Quitter le plein écran", + "quality": "Qualité", + "subtitles": "Sous-titres", + "audio": "Audio", + "speed": "Vitesse de lecture", + "auto": "Auto", + "off": "Désactivé", + "episodes": "Épisodes", + "next_episode": "Épisode suivant", + "season": "Saison {n}", + "fit_original": "Original", + "fit_fill": "Remplir", + "fit_stretch": "Étirer", + "play_in_browser": "Lire dans le navigateur", + "view_logs": "Voir les journaux", + "speed_2x": "Vitesse 2x", + "tap_to_unlock": "Appuie pour déverrouiller", + "previous": "Précédent", + "live": "EN DIRECT", + "go_live": "Revenir au direct", + "lang_sub": "Sous-titré (SUB)", + "lang_dub": "Doublé (DUB)", + "lang_softsub": "Sous-titres incrustés souples", + "audio_language": "Langue audio", + "speed_short": "Vitesse", + "aspect": "Format", + "aspect_ratio": "Format d’image", + "subtitle_style": "Style des sous-titres", + "audio_track": "Piste audio", + "diagnostics_logs": "Diagnostic / journaux", + "extracting_media": "Extraction du média…", + "loading_video": "Chargement de la vidéo…", + "fetching_link": "Récupération du lien de lecture auprès de la source", + "preparing_stream": "Préparation du flux vidéo", + "episode_n": "Épisode {}", + "search_online": "Rechercher en ligne", + "subtitle_sync": "Synchro des sous-titres", + "subtitle_sync_desc": "Décale le timing pour coller à l’audio", + "subtitle_sync_help": "Sous-titres en avance ? Augmente (+). En retard ? Diminue (−).", + "search_subtitles": "Rechercher des sous-titres", + "subtitle_search_hint": "Titre / nom du film", + "searching_subtitles": "Recherche de sous-titres…", + "no_subtitles_found": "Aucun sous-titre trouvé", + "search_failed": "Échec de la recherche : {}", + "online_subtitles_count": "Sous-titres en ligne · {}", + "loading_subtitle": "Chargement du sous-titre…", + "subtitle_loaded": "Sous-titre chargé", + "subtitle_failed_download": "Impossible de télécharger ce sous-titre", + "subtitle_failed_empty": "Ce fichier de sous-titres ne contient aucun texte", + "subtitle_failed_archive": "Les sous-titres archivés (.zip / .rar) ne sont pas pris en charge", + "subtitle_failed_html": "Le serveur a renvoyé une page web, pas un sous-titre", + "subtitle_failed_format": "Format de sous-titres non pris en charge", + "subtitle_rate": "Conversion de fréquence d’images", + "subtitle_rate_help": "Le décalage s’aggrave avec le temps ? Le sous-titre a été calé sur une autre fréquence d’images — choisis la conversion.", + "subtitle_rate_off": "Désactivée", + "reset": "Réinitialiser", + "font_size": "Taille du texte", + "text_color": "Couleur du texte", + "background": "Fond", + "none": "Aucun", + "solid": "Plein", + "edge": "Bordure", + "shadow": "Ombre", + "outline": "Contour", + "position": "Position", + "lower": "Plus bas", + "default": "Par défaut", + "higher": "Plus haut", + "bold": "Gras", + "audio_track_engine_only": "media_kit uniquement", + "audio_track_single": "Une seule piste", + "external_player": "Lire dans un lecteur externe", + "external_player_gated": "Cette source a besoin d’en-têtes particuliers que VLC et MX Player ne peuvent pas envoyer. Elle échouera très probablement. Essayer quand même ?", + "external_player_try": "Essayer quand même", + "external_player_none": "Aucun lecteur vidéo externe trouvé", + "audio_track_numbered": "Audio {}", + "engine_sheet_title": "Comment lire cette vidéo ?", + "engine_sheet_subtitle": "Choisis le moteur pour cette vidéo. Tu peux le changer à tout moment dans Réglages → Lecteur.", + "engine_sheet_dont_ask": "Ne plus demander", + "engine_sheet_play": "Lire", + "subtitle_preview_text": "Portez ce vieux whisky au juge blond qui fume", + "server": "Serveur", + "stream_not_found": "Cette source cache son flux derrière un lecteur protégé. Essaie une autre source, ou ouvre-la dans ton navigateur.", + "subtitle_translation": "Traduction des sous-titres", + "auto_translate": "Traduction automatique", + "auto_translate_desc": "Si la source n’a pas de sous-titre dans ta langue, l’IA le traduit", + "translate_to": "Traduire en", + "translate_note": "La traduction est automatique et peut se tromper. Une limite quotidienne s’applique.", + "translate_ai": "Traduire par IA", + "translating_subtitle": "Traduction…", + "translate_failed": "Traduction impossible", + "ai_translate_title": "Traduire en {} avec l’IA", + "ai_translate_hint": "Choisis un sous-titre dans la liste et appuie sur IA", + "ai_translate_remaining": "Il te reste {}/{} traductions aujourd’hui", + "ai_translate_used_up": "Tu as épuisé ta limite de traductions du jour", + "ai_translate_menu": "Traduire par IA", + "ai_translate_menu_desc": "Traduit un sous-titre en {}", + "ready_translations": "Traductions prêtes", + "lines": "lignes", + "sleep_timer": "Minuteur d’arrêt", + "sleep_minutes": "{} minutes", + "sleep_hours": "{} heures", + "sleep_minutes_short": "{} min", + "sleep_episode_end": "Fin de l’épisode", + "sleep_fired": "Minuteur — lecture en pause", + "skip_opening": "Passer l’intro", + "skip_ending": "Passer le générique", + "alt_sources": "Essayer une autre source", + "alt_searching": "Recherche dans d’autres sources…", + "alt_none": "Aucune autre source n’a ce titre", + "alt_failed": "Impossible d’ouvrir cette source", + "alt_no_episode": "Cette source n’a pas l’épisode {}", + "alt_search_all": "Chercher dans toutes les sources", + "guide": "Programme", + "change_source": "Changer de source", + "cast": "Diffuser sur la TV", + "cast_to": "Diffuser sur", + "cast_searching": "Recherche d’appareils sur ce Wi‑Fi…", + "cast_none": "Aucun appareil trouvé. La télé doit être sur le même Wi‑Fi que ce téléphone.", + "cast_failed": "Connexion à {} impossible", + "cast_unsupported": "Ce flux ne peut pas être diffusé", + "cast_active": "Lecture sur {}", + "cast_stop": "Arrêter la diffusion", + "color_profile": "Image", + "color_natural": "Naturel", + "color_cinema": "Cinéma", + "color_night": "Nuit", + "color_anime": "Anime", + "color_restore": "Rétablir", + "shaders": "Améliorer (Anime4K)", + "shaders_note": "Mise à l’échelle GPU pour l’animation. Les fichiers de shader se téléchargent une seule fois, à la première utilisation.", + "shader_off": "Désactivé", + "shader_off_desc": "Aucune amélioration", + "shader_sharpen": "Netteté", + "shader_sharpen_desc": "Restaure le détail — idéal pour les sources propres", + "shader_deblur": "Défloutage", + "shader_deblur_desc": "Restauration plus douce — pour les sources floues", + "shader_denoise": "Débruitage", + "shader_denoise_desc": "Nettoie le grain — pour les sources très compressées", + "shader_tier": "BUDGET GPU", + "shader_tier_mid": "Équilibré", + "shader_tier_mid_desc": "Fluide sur la plupart des téléphones", + "shader_tier_high": "Maximum", + "shader_tier_high_desc": "Le plus net, demande un GPU puissant", + "font_family": "Police", + "font_system": "Par défaut", + "font_sans": "Sans empattement", + "font_serif": "Avec empattement", + "font_condensed": "Condensée", + "font_mono": "Monospace", + "second_track": "Deuxième piste", + "second_track_on": "{} s’affiche en deuxième piste", + "second_track_same": "Cette piste est déjà la principale", + "second_track_hint": "Appuie longuement sur une piste pour l’afficher à côté de la première", + "share_frame": "Partager cette image", + "share_frame_failed": "Impossible de capturer l’image", + "save_frame": "Enregistrer l’image", + "save_frame_done": "Enregistrée dans la galerie", + "save_frame_failed": "Impossible d’enregistrer l’image", + "save_frame_denied": "L’accès à la galerie est nécessaire pour enregistrer l’image", + "switching_server": "Changement de serveur…", + "recommended": "Recommandé", + "episode_page_failed": "Impossible de charger la suite de la série", + "info_fields_desc": "Choisis ce qui s’affiche par-dessus la vidéo. Cela apparaît avec les commandes.", + "layout_title": "Commandes du lecteur", + "layout_desc": "Fais glisser pour réordonner, ou déplace une commande vers une autre partie de la barre.", + "layout_top_bar": "Barre du haut", + "layout_bottom_left": "En bas à gauche", + "layout_bottom_right": "En bas à droite", + "layout_hidden": "Masquées", + "layout_hidden_desc": "Toujours accessibles depuis les Réglages, dans le lecteur.", + "layout_pinned": "Réglages ne bouge pas — c’est le chemin de retour vers tout le reste.", + "layout_top_bar_full": "La barre du haut est pleine. Enlève d’abord quelque chose.", + "layout_unknown_control": "Cette commande n’est plus disponible.", + "layout_move_to": "Déplacer", + "layout_empty": "Rien ici.", + "rotate": "Pivoter", + "lock": "Verrouiller", + "enhance": "Améliorer", + "fit": "Ajuster", + "pip": "Image dans l’image", + "layout_pinned_badge": "Épinglé", + "info_fields_title": "Lignes de l’encart d’informations", + "logs_title": "Journaux du lecteur ({})", + "logs_copied": "Journaux copiés", + "logs_empty": "Aucun journal pour l’instant" + }, + "profile": { + "title": "Profil", + "my_account": "Mon compte", + "notifications": "Notifications", + "language": "Langue", + "theme": "Thème", + "privacy": "Politique de confidentialité", + "help": "Aide et assistance", + "about": "À propos", + "version": "Version", + "subscription": "Abonnement", + "downloads": "Téléchargements", + "watch_history": "Historique", + "manage_profiles": "Gérer les profils", + "parental_controls": "Contrôle parental", + "guest_title": "Connecte-toi à ton profil", + "guest_subtitle": "Connecte-toi pour garder favoris et historique synchronisés.", + "auth_provider": "Méthode de connexion", + "joined": "Inscrit depuis", + "providers": "Sources", + "providers_error": "Impossible de charger les sources.", + "offline_title": "Serveur injoignable", + "offline_local_available": "{} source(s) installée(s) sur cet appareil fonctionnent encore. Les sources cloud sont indisponibles tant que le serveur n’est pas revenu.", + "offline_no_local": "Le serveur ne répond pas et aucune source n’est installée sur cet appareil. Installe un plugin CloudStream pour continuer à regarder hors ligne.", + "offline_cached_at": "Liste cloud du {}", + "offline_install_plugins": "Installer des plugins", + "offline_badge": "HORS LIGNE", + "provider_needs_server": "Cette source a besoin du serveur, actuellement injoignable. Choisis une source installée sur cet appareil.", + "logout_confirm": "Veux-tu te déconnecter de ton compte ?", + "connections": "Connexions", + "coming_soon": "Bientôt", + "favorites": "Favoris", + "all_providers": "Toutes les sources", + "add_favorite": "Ajouter aux favoris", + "cloudstream_sources": "Sources CloudStream", + "aniyomi_sources": "Sources Aniyomi", + "signin_account_title": "Connecte-toi à ton compte", + "signin_account_subtitle": "Connecte-toi pour enregistrer tes favoris et ton historique.", + "sign_in": "Se connecter", + "sign_out": "Se déconnecter", + "sign_out_confirm": "Veux-tu vraiment te déconnecter ?", + "section_providers": "Sources", + "section_content": "CONTENU", + "sources_row": "Sources d’extension", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Source", + "choose_provider": "Changer de source", + "search_providers_hint": "Rechercher des sources…", + "count_of_total_shown": "{} sur {} affichées", + "no_providers_in_category": "Aucune source dans cette catégorie", + "try_select_all": "Sélectionne « Toutes » pour voir chaque source", + "section_activity": "ACTIVITÉ", + "share_sources_desktop": "Partager les sources avec le bureau", + "desktop_sources": "Sources du bureau", + "developer_role": "Développeur mobile", + "section_about": "À PROPOS", + "developer": "Développeur", + "website": "Site web", + "server": "Serveur", + "expired": "Expiré", + "support_title": "Garde Sozo en ligne", + "server_expired": "Serveur expiré", + "days": "Jours", + "hours": "Heures", + "min": "Min", + "sec": "Sec", + "support_body_expired": "Le serveur a expiré et le contenu ne peut plus être chargé. Ton soutien peut aider à le remettre en ligne.", + "support_body": "Sozo repose sur un serveur qu’il faut renouveler régulièrement. Si l’appli te plaît, ton soutien aide à ce que tout continue de tourner.", + "support_developer": "Soutenir le développeur", + "section_appearance": "APPARENCE", + "native_window_bar": "Barre de fenêtre native", + "native_window_bar_subtitle": "Utiliser la barre de titre standard de Windows au lieu de celle de l’appli.", + "liquid_glass_nav": "Navigation liquid glass", + "liquid_glass_nav_subtitle": "Barre d’onglets flottante en verre dépoli (iOS 26). Désactive pour une barre pleine.", + "nav_style": "Barre de navigation", + "nav_style_glass": "Liquid glass", + "nav_style_solid": "Pleine", + "nav_style_classic": "Classique", + "section_player": "Lecteur", + "player_engine_native": "Lecteur système", + "player_engine_native_desc": "Le lecteur de la plateforme. Meilleure autonomie et décodage matériel. Ne peut pas changer de piste audio.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Lit plus de formats et permet de changer de piste audio et de sous-titres. Consomme un peu plus de batterie.", + "player_engine_external": "Lecteur externe", + "player_engine_external_desc": "Envoyer la vidéo vers VLC ou MX Player au lieu de la lire ici.", + "player_engine_external_note": "Certaines sources ne marchent que dans Sozo parce qu’elles ont besoin d’en-têtes particuliers. Elles ne se liront pas dans une appli externe.", + "player_engine_badge_default": "PAR DÉFAUT", + "player_engine_section": "MOTEUR DE LECTURE", + "ask_engine_on_play": "Demander à chaque fois", + "ask_engine_on_play_desc": "Choisir le moteur à chaque démarrage de vidéo", + "playback_section": "LECTURE", + "default_speed": "Vitesse par défaut", + "default_speed_desc": "Vitesse à laquelle chaque vidéo démarre", + "default_fit": "Mode de remplissage par défaut", + "default_fit_desc": "Comment la vidéo s’adapte à l’écran", + "auto_next_episode": "Lire l’épisode suivant", + "auto_next_episode_desc": "Démarre l’épisode suivant quand un épisode se termine", + "keep_screen_on": "Garder l’écran allumé", + "keep_screen_on_desc": "Empêche l’écran de s’éteindre pendant la lecture", + "gestures_section": "GESTES", + "seek_step": "Saut au double appui", + "seek_step_desc": "De combien saute un double appui", + "long_press_boost": "Vitesse en appui long", + "long_press_boost_desc": "Vitesse tant que tu maintiens le doigt sur la vidéo", + "brightness_gesture": "Glisser pour la luminosité", + "brightness_gesture_desc": "Glisse de haut en bas sur le côté gauche", + "volume_gesture": "Glisser pour le volume", + "volume_gesture_desc": "Glisse de haut en bas sur le côté droit", + "seconds_short": "{} s", + "subtitle_style_note": "Cela vaut pour toutes les vidéos. Le calage des sous-titres reste propre à chaque épisode et se règle dans le lecteur.", + "searching_all_sources": "recherche dans toutes les sources", + "section_connections": "CONNEXIONS", + "section_extensions": "SOURCES", + "section_settings": "RÉGLAGES", + "remove_favorite": "Retirer des favoris", + "edit_profile": "Modifier le profil", + "display_name_hint": "Ton nom", + "display_name_too_long": "Le nom ne doit pas dépasser 60 caractères", + "pick_from_gallery": "Choisir dans la galerie", + "take_photo": "Prendre une photo", + "remove_photo": "Supprimer la photo", + "photo_pick_failed": "Impossible de choisir une photo", + "incognito": "Navigation privée", + "incognito_desc": "Regarder sans alimenter l’historique ni les trackers", + "incognito_off": "Navigation privée désactivée", + "auto_skip_intro": "Passer les intros automatiquement", + "auto_skip_intro_desc": "Passe les génériques d’ouverture et de fin sans demander", + "incognito_active": "La navigation privée est active. Rien de ce que tu regardes n’est enregistré dans l’historique ni envoyé à AniList et MAL.", + "incognito_turn_off": "Désactiver", + "all_languages": "Toutes les langues", + "section_library": "TA BIBLIOTHÈQUE", + "section_watch": "REGARDER", + "hero_trailer": "Lire la bande-annonce dans l’en-tête", + "hero_trailer_desc": "Peu après l’ouverture d’un titre, l’affiche cède la place à la bande-annonce, sans son", + "section_overview": "APERÇU", + "section_app": "APPLI", + "library": "Bibliothèque", + "library_subtitle": "Favoris, à voir plus tard, liste privée", + "sources_title": "Sources", + "section_active_source": "SOURCE ACTIVE", + "active_source_footnote": "La source cloud dans laquelle Sozo cherche et depuis laquelle il lit. Les extensions ci-dessous ajoutent des sources qui tournent sur cet appareil.", + "settings_title": "Réglages", + "settings_subtitle": "Langue, apparence, lecteur, sécurité", + "section_general": "GÉNÉRAL", + "section_data": "DONNÉES", + "section_lists": "LISTES", + "section_accounts": "COMPTES", + "section_devices": "APPAREILS", + "language_desc": "Langue de l’interface", + "not_connected": "Non connecté", + "joined_on": "Inscrit le {}", + "watch_later": "À voir plus tard", + "watched": "Vu", + "stat_hours": "heures vues", + "stat_completed": "terminés", + "stat_streak": "série de jours", + "guest_benefit_sync": "Favoris et historique synchronisés sur tous tes appareils", + "guest_benefit_streak": "Série, statistiques et rang au trivia", + "guest_benefit_trackers": "AniList, MyAnimeList et Watch Party", + "activity": "Activité", + "activity_subtitle": "Historique, téléchargements, suivi, statistiques", + "connections_subtitle": "Trackers, Discord, TV, bureau", + "trackers": "Trackers", + "stat_days": "jours regardés", + "mangayomi_sources": "Sources Mangayomi", + "view_only": "LECTURE SEULE" + }, + "appearance": { + "title": "Apparence", + "entry_subtitle": "Couleur d’accent, noir pur", + "section_preview": "APERÇU", + "section_accent": "COULEUR D’ACCENT", + "accent_footnote": "L’accent colore les boutons, les surlignages, les barres de progression et l’onglet actif — partout dans l’appli.", + "accent_custom": "Personnalisée", + "accent_red": "Rouge Sozo", + "accent_ember": "Braise", + "accent_amber": "Ambre", + "accent_emerald": "Émeraude", + "accent_teal": "Bleu canard", + "accent_sky": "Ciel", + "accent_ocean": "Océan", + "accent_indigo": "Indigo", + "accent_violet": "Violet", + "accent_magenta": "Magenta", + "accent_rose": "Rose", + "accent_slate": "Ardoise", + "section_darkness": "ARRIÈRE-PLAN", + "darkness_dark": "Sombre", + "darkness_dark_desc": "Les gris sombres habituels", + "darkness_black": "Noir pur", + "darkness_black_desc": "Des fonds vraiment noirs", + "amoled_footnote": "Sur un écran OLED, un pixel noir est éteint : le noir pur donne donc plus de contraste et consomme moins de batterie.", + "reset": "Revenir aux valeurs par défaut", + "reset_tooltip": "Revenir à l’apparence par défaut", + "custom_title": "Couleur personnalisée", + "custom_hue": "TEINTE", + "custom_saturation": "SATURATION", + "custom_lightness": "LUMINOSITÉ", + "custom_apply": "Utiliser cette couleur", + "custom_adjusted": "Un peu assombrie pour que le texte blanc reste lisible dessus.", + "section_library": "DE TA BIBLIOTHÈQUE", + "library_footnote": "Couleurs tirées des affiches de ce que tu as regardé.", + "section_extras": "PLUS", + "tint_nav": "Colorer la barre d’onglets", + "tint_nav_desc": "Peint l’onglet actif dans ton accent au lieu du blanc", + "shuffle": "Essayer la couleur suivante", + "shuffle_desc": "Parcours la palette accent par accent" + }, + "search": { + "title": "Recherche", + "hint": "Rechercher des films, des séries…", + "no_results": "Aucun résultat", + "no_results_for": "Aucun résultat pour « {query} »", + "recent": "Recherches récentes", + "popular_searches": "Recherches populaires", + "categories": "Genres", + "all": "Tout", + "movies": "Films", + "series": "Séries", + "filter": "Filtrer", + "sort": "Trier par", + "sort_newest": "Plus récents", + "sort_oldest": "Plus anciens", + "sort_rating": "Note", + "type": "Type", + "apply": "Appliquer", + "clear_filter": "Effacer", + "sources": "Sources", + "sources_n": "{} sources", + "all_sources": "Tout sélectionner", + "this_server": "Ce serveur", + "local_group": "Local", + "search_in": "Rechercher dans", + "sources_hint": "Cherche dans plusieurs sources à la fois. Laisse vide pour utiliser ta source actuelle.", + "searching": "Recherche…", + "all_source_search": "Recherche toutes sources", + "cross_hint": "Cherche dans tes sources…", + "pick_sources": "Choisis les sources à interroger.", + "choose_sources": "Choisir les sources", + "no_sources_selected": "Aucune source sélectionnée — appuie pour choisir", + "sources_selected": "{} sélectionnées — appuie pour modifier", + "type_to_search_n": "Tape pour chercher dans {} sources à la fois.", + "found_in": "Trouvé dans {} sources sur {}", + "results_n": "{} résultats", + "waiting_for": "Recherche dans {}…", + "no_results_any": "Aucun résultat dans les sources sélectionnées.", + "offline_note": "Hors ligne — seules les sources de l’appareil peuvent être interrogées.", + "group_by_source": "Grouper par source", + "merge_titles": "Fusionner les titres", + "open_from": "Ouvrir depuis", + "load_more": "Charger plus", + "search_failed": "La recherche a échoué", + "source_failed": "Cette source est indisponible pour le moment", + "try_all_sources": "Essayer toutes les sources", + "weak_results": "Rien ici ne ressemble à « {query} » — cette source a trouvé autre chose. D’autres sources l’ont peut-être.", + "did_you_mean": "Vouliez-vous dire", + "search_sources": "Chercher des sources", + "filter_providers": "Filtrer les sources…", + "selected_n": "{} sélectionnées", + "apply_n": "Appliquer ({})", + "many_sources_warning": "Interroger {} sources peut être lent. L’appli reste réactive, mais moins de sources va plus vite.", + "all_sources_n": "Toutes les sources ({})", + "more_sources": "Plus…", + "select_all_sources": "Tout sélectionner", + "search_all_sources": "Chercher dans toutes les sources", + "apply_all": "Chercher dans toutes les sources", + "selected_n_of_m": "{} sources sur {}", + "loading_sources": "Chargement de tes sources…", + "no_sources_available": "Aucune source disponible. Ajoute ou active une source pour chercher.", + "min_query_n": "Tape au moins {} caractères.", + "retry_failed": "Réessayer celles en échec", + "status_searching": "Recherche…", + "status_searching_n": "{} en cours", + "status_with_results_n": "{} avec résultats", + "status_no_match_n": "{} sans correspondance", + "status_timed_out_n": "{} hors délai", + "status_failed_n": "{} en échec", + "all_sources_failed": "Les {} sources n’ont pas répondu. Ce n’est pas un résultat vide — réessaie.", + "no_results_with_failures": "Aucune correspondance dans les sources qui ont répondu ({}). {} étaient injoignables.", + "all_sources_failed_one": "La source interrogée n’a pas répondu. Ce n’est pas un résultat vide — réessaie.", + "voice": "Recherche vocale", + "voice_listening": "J’écoute…", + "voice_done": "Écoute terminée", + "voice_hint": "Dis le nom d’un film ou d’une série", + "try_torrents": "Chercher des torrents", + "capped_note": "Recherche dans les {} sources les plus fiables sur {} — restreins la sélection pour en atteindre une précise." + }, + "movie": { + "rating": "Note", + "year": "Année", + "quality": "Qualité", + "duration": "Durée", + "genre": "Genre", + "director": "Réalisation", + "cast": "Distribution", + "description": "Description", + "similar": "Titres similaires", + "trailer": "Bande-annonce", + "share": "Partager", + "download": "Télécharger", + "rate": "Noter", + "episodes": "Épisodes", + "seasons": "Saisons" + }, + "errors": { + "network": "Erreur réseau. Vérifie ta connexion.", + "server": "Erreur serveur. Réessaie.", + "unauthorized": "Connecte-toi pour continuer.", + "not_found": "Contenu introuvable.", + "unknown": "Une erreur inconnue est survenue.", + "timeout": "Délai dépassé. Réessaie.", + "empty": "Aucun contenu disponible.", + "source_http": "Le serveur de la source a répondu par une erreur ({})." + }, + "navigation": { + "home": "Accueil", + "search": "Recherche", + "shorts": "Shorts", + "downloads": "Téléchargements", + "profile": "Profil", + "my_list": "Ma liste", + "history": "Historique", + "following": "Suivis", + "premiere": "Première", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "TV en direct" + }, + "premiere": { + "tab_title": "Avant-premières", + "live_now": "En direct", + "upcoming": "À venir", + "live": "EN DIRECT", + "catching_up": "Rattrapage vers le direct", + "starts_in": "Commence dans", + "starts_at": "Commence {when}", + "days": "JOURS", + "hours": "H", + "minutes": "MIN", + "seconds": "SEC", + "waiting": "{count} en attente", + "watching": "{count} en train de regarder", + "join": "Rejoindre l’avant-première", + "join_now": "Rejoindre", + "set_reminder": "Programmer un rappel", + "reminder_set": "Rappel programmé", + "notify_me": "Préviens-moi au démarrage", + "guest_watch_hint": "Tu peux regarder en tant qu’invité.", + "login_to_chat_react": "Connecte-toi pour discuter et réagir", + "login_to_chat": "Connecte-toi pour discuter", + "chat_hint": "Dis bonjour — le chat est ouvert", + "chat_empty": "Dis bonjour 👋 Le chat démarre avec l’avant-première", + "chat_placeholder": "Message…", + "slow_mode": "Mode lent : {sec} s", + "react": "Réagir", + "paused_by_host": "Mis en pause par l’hôte", + "buffering": "Mise en mémoire…", + "reconnecting": "Reconnexion…", + "connecting": "Connexion à l’avant-première…", + "ended_title": "Avant-première terminée", + "ended_watch_vod": "Regarder à la demande", + "canceled": "Cette avant-première a été annulée", + "empty_title": "Aucune avant-première pour l’instant", + "empty_subtitle": "Les événements en direct apparaîtront ici", + "error_generic": "Un problème est survenu", + "error_full": "Cette avant-première est complète", + "error_not_found": "Avant-première introuvable", + "retry": "Réessayer", + "share": "Partager", + "back": "Retour", + "resolve_failed": "La lecture n’a pas pu démarrer sur cet appareil" + }, + "nav_customize": { + "entry_title": "Personnaliser les onglets", + "entry_subtitle": "Choisis les onglets affichés et leur ordre", + "title": "Barre du bas", + "shown": "Affichés", + "available": "Disponibles", + "min_hint": "Garde au moins {count} onglets", + "max_hint": "Jusqu’à {count} onglets", + "pick_range": "Choisis entre {min} et {max} onglets", + "reset": "Réinitialiser", + "cancel": "Annuler", + "save": "Enregistrer" + }, + "app_lock": { + "section_label": "SÉCURITÉ", + "app_lock": "Verrouillage de l’appli", + "settings_title": "Verrouillage de l’appli", + "setup_title": "Définir un code", + "enable": "Activer le verrouillage", + "state_on": "Activé", + "state_off": "Désactivé", + "choose_length_title": "Longueur du code", + "choose_length_subtitle": "Choisis combien de chiffres aura ton code.", + "length_n_digits": "Code à {} chiffres", + "create_pin_title": "Crée un nouveau code", + "create_pin_subtitle": "Choisis quelque chose que tu ne partageras avec personne.", + "confirm_pin_title": "Confirme ton code", + "confirm_pin_subtitle": "Saisis ton nouveau code une seconde fois.", + "verify_title": "Appli verrouillée", + "verify_subtitle": "Saisis ton code pour continuer.", + "verify_current_pin": "Saisis ton code actuel", + "change_pin": "Changer le code", + "biometric": "Déverrouiller par biométrie", + "biometric_unavailable": "La biométrie n’est pas disponible sur cet appareil", + "biometric_enable_reason": "Authentifie-toi pour activer le déverrouillage biométrique", + "biometric_unlock_reason": "Authentifie-toi pour ouvrir l’appli", + "pin_mismatch": "Les codes ne correspondent pas", + "pin_wrong": "Code incorrect", + "disable_title": "Désactiver le verrouillage ?", + "disable_subtitle": "Ton code et ta préférence biométrique seront supprimés.", + "enabled_hint": "Ton code te sera demandé au démarrage de l’appli.", + "disabled_hint": "Le verrouillage est désactivé. Active-le pour protéger ce que tu regardes.", + "private_list": "Liste privée", + "move_to_private": "Déplacer vers privé", + "moved_to_private": "Déplacé dans la liste privée", + "removed_from_private": "Retiré du privé", + "move_to_my_list": "Remettre dans Ma liste", + "private_empty": "Rien de privé pour l’instant", + "private_setup_required": "Définis d’abord un code pour utiliser la liste privée", + "private_long_press_hint": "Appui long pour déplacer vers ta liste privée — protégée par ton code, et son historique n’est pas enregistré.", + "private_always_ask": "Toujours demander le code pour la liste privée", + "private_always_ask_hint": "Saisis ton code à chaque fois, pas une seule fois par session", + "reset_title": "Réinitialiser le verrouillage ?", + "reset_body": "Cela supprime le code et déverrouille Sozo sur cet ordinateur. Ta bibliothèque et ton historique restent. Tu pourras définir un nouveau code plus tard dans Profil → Verrouillage de l’appli.", + "reset": "Réinitialiser", + "forgot_pin": "Code oublié ?" + }, + "my_list": { + "saved_account": "Enregistré sur le compte", + "saved_local": "Sur cet appareil", + "untitled": "Sans titre", + "empty_title": "Ta liste est vide", + "empty_subtitle": "Appuie sur + sur n’importe quel titre pour l’enregistrer ici.", + "unauth_title": "Connecte-toi pour voir ta liste", + "unauth_subtitle": "Tes titres enregistrés apparaîtront ici dès que tu seras connecté.", + "error_title": "Impossible de charger la liste" + }, + "deeplink": { + "opt_in_title": "Ouvrir les liens Sozo dans cette appli ?", + "opt_in_body_android": "Les liens sozo.azamov.me partagés s’ouvriront directement dans Sozo. Active « Ouvrir les liens pris en charge » dans les réglages du système.", + "opt_in_body_ios": "Les liens Sozo s’ouvrent automatiquement dans l’appli. Si un lien s’ouvre dans Safari, appuie longuement dessus et choisis « Ouvrir dans Sozo ».", + "opt_in_enable": "Activer", + "opt_in_later": "Plus tard" + }, + "streak": { + "section_label": "SÉRIE", + "empty_title": "Lance une série", + "empty_subtitle": "Regarde un épisode aujourd’hui pour démarrer ta série quotidienne.", + "current_n_days": "{} jours", + "locked_today": "Aujourd’hui est validé. Reviens demain !", + "keep_going": "Garde ta série en vie — n’oublie pas de regarder aujourd’hui.", + "risk_subtitle": "Ta série repart à zéro à minuit. Regarde quelque chose maintenant pour la sauver.", + "record_label": "Record", + "next_milestone": "Prochain objectif", + "weekday_mon": "lun", + "weekday_tue": "mar", + "weekday_wed": "mer", + "weekday_thu": "jeu", + "weekday_fri": "ven", + "weekday_sat": "sam", + "weekday_sun": "dim", + "milestone_title": "Série de {} jours !", + "milestone_subtitle": "Une régularité impressionnante. Continue et bats ton record personnel.", + "share_text": "J’ai atteint une série de {} jours sur Sozo ! 🔥", + "cta_continue": "Continuer", + "cta_share": "Partager", + "freeze_label": "Gels", + "freeze_count": "{} / {}", + "total_days_label": "Jours au total", + "this_week_label": "Cette semaine", + "calendar_label": "35 derniers jours", + "days_to_milestone": "{} jours avant le prochain palier", + "freeze_saved_title": "Série sauvée !", + "freeze_saved_subtitle": "Un gel a couvert le jour manqué — ta série continue.", + "freeze_awarded_title": "Tu as gagné un gel !", + "freeze_awarded_subtitle": "Les gels protègent ta série quand tu manques une seule journée.", + "freeze_explainer": "Un gel sauve automatiquement ta série un jour où tu ne regardes pas. Tu en gagnes de nouveaux en regardant régulièrement." + }, + "watch_party": { + "title": "Watch Party", + "entry_subtitle": "Regardez en même temps entre amis — l’un lance, tout le monde suit.", + "entry_create": "Créer une session", + "entry_create_hint": "Ouvre un salon et invite tes amis avec un code", + "entry_join": "Rejoindre avec un code", + "entry_join_hint": "Saisis le code qu’un ami t’a envoyé", + "not_available": "Regarder ensemble n’est pas encore possible pour ce titre", + "create": "Créer la session", + "join": "Rejoindre la session", + "join_hint": "Saisis le code de la session", + "invalid_code": "Code invalide", + "code_label": "Code de la session", + "copy_code": "Copier le code", + "copied": "Copié", + "share_invite": "Partager l’invitation", + "invite": "Inviter", + "lobby_waiting_host": "En attente du lancement par l’hôte…", + "host_label": "Hôte", + "members": "Membres", + "member_count": "{} / {}", + "you": "Toi", + "start_watching": "Commencer à regarder", + "close_party": "Fermer la session", + "leave_party": "Quitter la session", + "leave_confirm": "Quitter cette Watch Party ?", + "only_host_controls": "Seul l’hôte contrôle la lecture", + "host_changed": "L’hôte a changé", + "room_closed": "La session a été fermée", + "room_closed_empty": "La session s’est fermée parce que tout le monde est parti", + "content_none": "Aucun titre choisi pour l’instant", + "resolving": "Préparation de la vidéo…", + "resolve_failed": "Impossible de charger ce titre sur ton appareil", + "plugin_required_title": "Plugin requis", + "plugin_required_body": "Installe le plugin de source nécessaire pour regarder ça ensemble.", + "install_plugin": "Installer le plugin", + "chat_hint": "Dis quelque chose…", + "chat_empty": "Aucun message pour l’instant", + "reactions": "Réactions", + "error_not_found": "Session introuvable ou déjà fermée", + "error_full": "Cette session est complète", + "error_forbidden": "Tu n’as pas accès à cette session", + "error_rate": "Doucement", + "error_unauthorized": "Connecte-toi pour rejoindre une Watch Party", + "error_generic": "Un problème est survenu", + "reconnecting": "Reconnexion…", + "connecting": "Connexion…", + "connect_failed_title": "Connexion impossible", + "connect_failed_msg": "Vérifie ta connexion internet et réessaie." + }, + "notifications": { + "title": "Notifications", + "mark_all_read": "Tout marquer comme lu", + "empty_title": "Aucune notification pour l’instant", + "empty_subtitle": "Dès qu’il y aura du nouveau, tu le verras ici.", + "time_now": "à l’instant", + "time_minutes": "{} min", + "time_hours": "{} h", + "time_days": "{} j" + }, + "manga": { + "sources_title": "Sources manga", + "sources_desc": "Ajoute des dépôts d’extensions manga pour installer d’autres fournisseurs de manga, manhwa et webtoon. Ils tournent sur ton appareil (Android uniquement).", + "repo_url": "URL du dépôt", + "add_source": "Ajouter une source", + "installing": "Installation…", + "installing_long": "Installation des extensions… cela peut prendre un moment pour les gros dépôts.", + "installing_progress": "Installation de {} / {} extensions…", + "no_extensions": "Aucune extension trouvée à cette URL.", + "added_sources": "{} source(s) · {} fournisseur(s) ajoutés.", + "source_removed": "Source supprimée.", + "recommended": "RECOMMANDÉ", + "installed_sources": "SOURCES INSTALLÉES", + "source_settings": "RÉGLAGES DE LA SOURCE", + "no_sources": "Aucune source pour l’instant", + "show_nsfw": "Sources pour adultes", + "show_nsfw_hint": "Affiche les sources marquées 18+ dans cette liste et dans le sélecteur de sources. Désactivé par défaut.", + "show": "Afficher", + "hide": "Masquer", + "installed": "Installée", + "android_only": "Cette fonction n’existe que sur Android", + "error_prefix": "Erreur : {}", + "no_preferences": "Cette source n’a aucun réglage configurable", + "not_selected": "Non sélectionné", + "n_selected": "{} sélectionnées", + "reading_mode": "Mode de lecture", + "mode_continuous": "Continu", + "mode_paged": "Page par page", + "direction": "Sens", + "dir_ltr": "De gauche à droite (LTR)", + "dir_rtl": "De droite à gauche (RTL)", + "background": "Fond", + "bg_black": "Noir", + "bg_gray": "Gris", + "bg_white": "Blanc", + "chapters": "Chapitres", + "chapter_ended": "Fin du chapitre", + "next_chapter": "Chapitre suivant", + "first_chapter": "Premier chapitre", + "last_chapter": "Dernier chapitre", + "no_pages": "Aucune page trouvée", + "tap_to_reload": "Appuie pour recharger", + "download_started": "Téléchargement lancé", + "downloaded_pages": "{} / {} pages", + "check_updates": "Chercher des mises à jour", + "checking_updates": "Recherche de mises à jour d’extensions…", + "up_to_date": "Toutes les extensions sont à jour.", + "updated_n": "{} extension(s) mise(s) à jour.", + "spread": "Double page", + "spread_desc": "Côte à côte en paysage, comme le livre a été dessiné" + }, + "cloudflare": { + "title": "Confirme que tu es humain", + "hint": "Termine la vérification ci-dessous — cette fenêtre se ferme ensuite toute seule.", + "solve": "Résoudre Cloudflare", + "done": "Terminé" + }, + "desktop": { + "saved_refresh_on_pc": "Enregistré — appuie sur « Actualiser les sources » sur le PC pour appliquer.", + "sources_turned_off": "Sources du bureau désactivées.", + "saved_loading_sources": "Enregistré — chargement des sources depuis ton téléphone…", + "refreshing_sources": "Actualisation des sources depuis ton téléphone…", + "share_title": "Partager les sources avec le bureau", + "sources_title": "Sources du bureau", + "share_with_desktop": "Partager les sources avec le bureau", + "let_pc_use_sources": "Autorise l’appli Sozo pour PC à utiliser les sources de ce téléphone.", + "your_link": "Ton lien", + "link_copied": "Lien copié", + "no_wifi_address": "Activé, mais aucune adresse Wi‑Fi n’a été trouvée. Connecte ce téléphone au Wi‑Fi (pas aux données mobiles), puis rouvre cet écran.", + "step_host_same_wifi": "Garde ce téléphone et ton PC sur le même réseau Wi‑Fi.", + "step_host_turn_on": "Active l’interrupteur ci-dessus.", + "step_host_pick_sources": "Choisis les sources à partager (ou laisse « Tout partager » activé).", + "step_host_paste_on_pc": "Sur le PC, ouvre Sozo → Profil → Sources du bureau et colle le lien.", + "step_host_keep_open": "Laisse Sozo ouvert sur ce téléphone pendant que tu navigues sur le PC.", + "sources_to_share": "Sources à partager", + "reload_from_phone": "Recharger depuis le téléphone", + "share_all_sources": "Partager toutes les sources", + "every_source_shared": "Toutes les sources de ce téléphone sont partagées", + "sharing_count": "{} sur {} partagées", + "save_send_to_desktop": "Enregistrer et envoyer au bureau", + "n_selected": "{} sélectionnées", + "select_all": "Tout sélectionner", + "clear": "Effacer", + "no_sources_yet": "Aucune source sur ce téléphone pour l’instant. Ajoutes-en dans Profil → CloudStream / Aniyomi / Sources manga, puis recharge ici.", + "phone_link": "Lien du téléphone", + "refresh_sources": "Actualiser les sources", + "step_client_same_wifi": "Garde ce PC et ton téléphone sur le même réseau Wi‑Fi.", + "step_client_open_on_phone": "Sur le téléphone, ouvre Sozo → Profil → Partager les sources avec le bureau et active-le.", + "step_client_choose_sources": "Sur le téléphone, choisis les sources à partager (ou laisse « Tout partager » activé).", + "step_client_copy_link": "Copie le lien affiché là-bas, colle-le ci-dessus, puis appuie sur Enregistrer.", + "step_client_refresh": "Tu as modifié les sources partagées sur le téléphone ? Appuie ici sur « Actualiser les sources ».", + "step_client_keep_open": "Laisse Sozo ouvert sur le téléphone pendant que tu navigues ici.", + "step_client_emulator": "Tu utilises un émulateur Android ? Son IP n’est pas joignable directement — lance « adb forward tcp:8765 tcp:8765 » sur ce PC, puis colle http://127.0.0.1:8765 ci-dessus (active quand même l’interrupteur dans l’émulateur).", + "how_it_works": "Comment ça marche", + "how_it_works_desc": "Utilise tes sources CloudStream, Aniyomi et manga dans l’appli Sozo pour ordinateur. Le téléphone exécute les sources ; le PC ne fait que les afficher.", + "internet_still_unavailable": "Internet est toujours indisponible", + "offline_watch_downloads": "Tu peux continuer à regarder les vidéos téléchargées pendant la coupure.", + "refresh": "Actualiser" + }, + "detail": { + "download_will_resolve": "Résolution…", + "download_needs_playback": "Cette source cache son flux derrière un lecteur protégé. Lis l’épisode une fois, puis télécharge-le depuis le lecteur.", + "download_choose_server": "Téléchargement · choisir le serveur", + "download_playing_now": "En cours de lecture", + "similar": "Similaires", + "comments": "Commentaires", + "screenshots": "Captures", + "play": "Lire", + "no_episodes": "Aucun épisode disponible", + "no_playable_source": "Aucune source lisible", + "added_to_my_list": "Ajouté à Ma liste", + "removed_from_my_list": "Retiré de Ma liste", + "failed_resolve_pages": "Impossible de résoudre les pages", + "episodes_count": "{} épisodes", + "episodes_count_of": "{} épisodes sur {}", + "episode_n": "Épisode {}", + "no_films": "Aucun film trouvé", + "filmography": "Filmographie", + "actor": "Acteur", + "continue_watching": "Continuer", + "continue_ep": "Continuer · ép. {}", + "no_cast": "Aucune distribution disponible", + "no_recommendations": "Aucune recommandation disponible", + "untitled": "Sans titre", + "read": "Lire", + "watch_later": "À voir plus tard", + "watched": "Vu", + "follow_series": "Suivre la série", + "find_other_sources": "Chercher d’autres sources", + "anilist_track": "Suivre sur AniList", + "anilist_tracked": "Suivi sur AniList", + "following_on": "Suivi", + "following_off": "Non suivi", + "no_screenshots": "Aucune capture", + "show_more": "Afficher plus", + "show_less": "Afficher moins", + "time_left": "{} restant", + "liked": "{} % ont aimé", + "watched_pct": "{} % vu", + "copy_link": "Copier le lien", + "link_copied": "Lien copié", + "report_problem": "Signaler un problème", + "selected_n": "{} sélectionnés", + "select_all": "Tout sélectionner", + "select_none": "Effacer", + "download_n": "Télécharger {}", + "download_queueing": "Mise en file…", + "download_action": "Télécharger", + "download_already": "Déjà téléchargé", + "download_in_progress": "Téléchargement en cours", + "download_started": "Téléchargement lancé", + "download_needs_permission": "L’autorisation de notification est nécessaire pour télécharger", + "download_resolve_failed": "Impossible d’obtenir un lien de téléchargement pour cet épisode", + "download_queued_n": "{} ajoutés aux téléchargements", + "download_none_queued": "Rien n’a été ajouté — c’est sans doute déjà téléchargé", + "download_batch_title": "Télécharger plusieurs épisodes ?", + "download_batch_body": "Les épisodes {range} seront mis en file — {count} au total. En données mobiles, cela peut représenter plusieurs gigaoctets.", + "relations": "Titres liés", + "no_relations": "Aucun titre lié à celui-ci n’a été trouvé.", + "relation_sequel": "SUITE", + "relation_prequel": "PRÉQUELLE", + "relation_parent": "HISTOIRE PRINCIPALE", + "relation_side_story": "HISTOIRE PARALLÈLE", + "relation_alternative": "VERSION ALTERNATIVE", + "relation_spin_off": "SPIN-OFF", + "relation_summary": "RÉSUMÉ", + "relation_adaptation": "ADAPTATION", + "relation_source": "ŒUVRE D’ORIGINE", + "relation_character": "MÊMES PERSONNAGES", + "relation_other": "AUTRE", + "trailer_action": "Bande-annonce", + "more_options": "Plus d’options", + "in_private_list": "Dans ta liste privée", + "add_to_my_list_action": "Ajouter à Ma liste", + "remove_from_my_list_action": "Retirer de Ma liste", + "trailer_mute": "Couper le son de la bande-annonce", + "trailer_unmute": "Activer le son de la bande-annonce" + }, + "episodes": { + "filter_hint": "Numéro ou titre de l’épisode", + "filter_none": "Aucun épisode ne correspond à « {query} » dans ce bloc", + "jump_to": "Aller à l’épisode {n}", + "jump_loads": "Charge {range}", + "jump_missing": "L’épisode {n} n’est pas sur cette page", + "range_of": "Épisodes {from}–{to} sur {total}" + }, + "shorts": { + "refresh": "Actualiser", + "opening": "Ouverture…", + "content_unavailable": "Contenu indisponible", + "loading": "Chargement des Shorts…", + "empty_title": "Pas encore de Shorts", + "empty_subtitle": "Les clips courts apparaîtront ici dès qu’ils seront disponibles.", + "error_title": "Impossible de charger les Shorts", + "video_unavailable": "Vidéo indisponible", + "speed_2x": "Vitesse 2x", + "report_submitted": "Signalement envoyé, merci", + "watch_full_movie": "Voir le film entier", + "browse_movies": "Parcourir les films", + "sign_in_to_like": "Connecte-toi pour aimer" + }, + "comments": { + "please_sign_in": "Connecte-toi d’abord", + "report_submitted": "Signalement envoyé, merci", + "edited": "· modifié", + "like": "J’aime", + "reply": "Répondre", + "hide": "Masquer", + "replies_one": "1 réponse", + "replies_other": "{} réponses", + "report": "Signaler", + "edit_hint": "Modifie ton commentaire…", + "reply_hint": "Écris une réponse…", + "comment_hint": "Écris un commentaire…", + "editing": "Modification", + "replying_to": "Réponse à {}", + "sign_in_to_comment": "Connecte-toi pour commenter", + "empty_title": "Pas encore de commentaires", + "empty_subtitle": "Sois le premier à donner ton avis", + "count_one": "1 commentaire", + "count_other": "{} commentaires", + "show_more": "Afficher plus", + "delete_confirm_title": "Supprimer ce commentaire ?", + "delete_confirm_body": "Cette action est irréversible.", + "sign_in_prompt": "Connecte-toi pour laisser un commentaire" + }, + "downloads": { + "delete_all_title": "Supprimer tous les téléchargements ?", + "file_not_found": "Fichier introuvable", + "clear_all": "Tout effacer", + "segments_progress": "{} / {} segments", + "downloaded_amount": "{} téléchargés", + "pages_count": "{} pages", + "downloaded": "Téléchargé", + "failed": "Échec", + "pending": "En attente", + "remove": "Retirer", + "empty_title": "Aucun téléchargement pour l’instant", + "empty_subtitle": "Les vidéos téléchargées apparaîtront ici", + "paused": "En pause", + "wifi_only": "Télécharger uniquement en Wi‑Fi", + "wifi_only_desc": "Les téléchargements en file attendent que tu sois en Wi‑Fi", + "waiting_for_wifi": "En attente du Wi‑Fi — les téléchargements reprennent tout seuls", + "export": "Enregistrer dans Téléchargements", + "export_hint": "Rend le fichier visible aux autres applis", + "export_running": "Enregistrement…", + "export_done": "Enregistré dans {}", + "export_failed": "Impossible d’enregistrer dans Téléchargements", + "downloading": "Téléchargement", + "missing": "Le fichier a disparu — appuie pour le retélécharger", + "redownload_started": "Nouveau téléchargement…", + "file_missing_retry": "Ce fichier a disparu. Il est de nouveau téléchargé.", + "download_again": "Retélécharger", + "pause": "Mettre en pause", + "resume": "Reprendre", + "retry_all": "Relancer tout ce qui a échoué", + "sort_by": "TRIER PAR", + "sort_newest": "Plus récents d’abord", + "sort_title": "Titre", + "sort_size": "Plus gros d’abord", + "filter_all": "Tous", + "filter_active": "En cours", + "filter_done": "Terminés", + "filter_problems": "À vérifier", + "filter_empty_title": "Rien ne correspond à ce filtre", + "filter_empty_subtitle": "Tes téléchargements sont toujours là — c’est juste cette vue qui est restreinte.", + "filter_clear": "Afficher tous les téléchargements", + "storage_used": "{} occupés par les téléchargements", + "storage_free": "{} libres", + "orphans_found": "{} de restes de téléchargements annulés", + "orphans_clean": "Nettoyer", + "group_summary": "{} sur {} téléchargés", + "group_problems": "À vérifier", + "error": { + "network": "La connexion a été coupée", + "refused": "La source a refusé le téléchargement — lis-le une fois, puis réessaie", + "gone": "Le fichier n’est plus chez la source", + "no_space": "Pas assez d’espace sur cet appareil", + "not_media": "La source a envoyé une page web au lieu de la vidéo", + "incomplete": "Le téléchargement s’est terminé incomplet", + "unknown": "Le téléchargement a échoué" + }, + "location": "Où sont rangés les téléchargements", + "location_hint": "Uniquement les emplacements où l’appli peut écrire sans autorisation. Les téléchargements suivent le réglage ; rien n’est supprimé tant que chaque fichier n’est pas arrivé.", + "location_internal": "Stockage du téléphone (appli)", + "location_external": "Stockage du téléphone", + "location_sdcard": "Carte SD", + "location_moved": "Téléchargements déplacés", + "location_unchanged": "Déjà là", + "location_failed": "Impossible de déplacer les téléchargements — ils sont restés où ils étaient" + }, + "history": { + "clear_title": "Effacer l’historique", + "clear_confirm": "Veux-tu vraiment effacer ton historique de visionnage ?", + "clear": "Effacer", + "clear_all": "Tout effacer", + "remove": "Retirer", + "empty_title": "Aucun historique pour l’instant", + "empty_subtitle": "Commence à regarder et ton historique apparaîtra ici" + }, + "main": { + "shorts_refresh_title": "Actualiser les Shorts", + "shorts_refresh_body": "Appuie de nouveau sur l’onglet Shorts. Le fil se recharge et de nouvelles vidéos arrivent.", + "double_tap": "Appuie deux fois", + "tap_again": "Appuie encore" + }, + "reports": { + "title": "Signaler", + "reason_spam": "Spam", + "reason_abuse": "Harcèlement", + "reason_sexual": "Contenu sexuel", + "reason_violence": "Violence", + "reason_copyright": "Droits d’auteur", + "reason_other": "Autre", + "note_hint": "Remarque supplémentaire (facultatif)", + "submit": "Envoyer" + }, + "ios": { + "sources_title": "Sources d’extension", + "how_it_works_desc": "Utilise tes sources CloudStream, Aniyomi et manga dans Sozo pour iOS. Ton téléphone Android exécute les sources ; cet appareil ne fait que les afficher.", + "step_client_same_wifi": "Garde cet appareil et ton téléphone Android sur le même réseau Wi‑Fi." + }, + "trivia": { + "fan_test_title": "Test du fan", + "fan_test_subtitle": "Prouve que tu es le plus grand fan", + "hub_tagline": "Connais-tu vraiment bien le cinéma ?", + "view_leaderboards": "Voir les classements", + "my_daily_rank": "Mon rang du jour", + "rank_value": "n°{}", + "unranked": "Non classé", + "points_value": "{} pts", + "popular_now": "Populaire en ce moment", + "results": "Résultats", + "searching": "Recherche…", + "search_cast_hint": "Chercher des acteurs", + "no_results": "Aucun résultat", + "no_results_hint": "Essaie un autre nom", + "something_wrong": "Un problème est survenu", + "filmography": "Filmographie", + "n_titles": "{count} titres", + "start_fan_test": "Lancer le test", + "challenge_friend": "Défier un ami", + "top_fans": "Meilleurs fans", + "no_fans_yet": "Aucun fan pour l’instant", + "be_first_fan": "Sois le premier fan", + "fans_meta": "{rounds} manches · {fandom} % de fandom moyen", + "you": "Toi", + "you_rank": "Toi · n°{rank}", + "best_score": "Record {score}", + "fandom": "Fandom", + "challenge_share_text": "Tu connais {name} mieux que moi ? Fais le test du fan sur Sozo !", + "scope_daily": "Jour", + "scope_weekly": "Semaine", + "scope_all_time": "Tous les temps", + "scope_friends": "Amis", + "leaderboard": "Classement", + "no_friends_board": "Aucun ami au classement pour l’instant", + "no_scores_yet": "Aucun score pour l’instant", + "correct_of": "{count} bonnes réponses", + "forfeit_title": "Quitter la partie ?", + "forfeit_body": "Ta progression dans cette manche sera perdue.", + "keep_playing": "Continuer à jouer", + "forfeit_confirm": "Quitter", + "correct": "Correct", + "the_answer": "La réponse", + "watch_full": "Voir le film entier", + "building_round": "Préparation de ta manche…", + "round_failed": "Impossible de lancer la manche", + "go_back": "Retour", + "fan_test_done": "Test du fan terminé", + "round_done": "Manche terminée", + "score": "Score", + "rank": "Rang", + "play_again": "Rejouer", + "share_result": "Partager le résultat", + "share_text": "Je viens de jouer à Buff sur Sozo !", + "challenge_text": "Tu crois mieux connaître le cinéma ? Défie-moi sur Buff dans Sozo !", + "share_tagline": "Joue à Buff sur Sozo", + "challenge_from": "Défi de {name}", + "same_clips_head_to_head": "Les mêmes extraits, face à face", + "score_to_beat": "Score à battre", + "play_challenge": "Jouer le défi", + "challenge_not_found": "Défi introuvable", + "no_actors_yet": "Aucun acteur pour l’instant", + "no_actors_yet_body": "Buff se remplit peu à peu. Les extraits sont validés un par un — les acteurs apparaissent ici dès que leurs extraits sont approuvés.", + "actor_not_ready": "Cet acteur n’est pas encore prêt", + "actor_not_ready_body": "Il n’y a pas encore assez d’extraits approuvés pour une manche. Essaie un autre acteur.", + "round_short_note": "Cette manche comptait {count} extraits — tous les extraits approuvés pour cet acteur à ce jour.", + "todays_top": "Le top du jour", + "how_it_works": "Comment marche Buff", + "how_step_1": "Choisis un acteur", + "how_step_2": "Regarde un court extrait", + "how_step_3": "Nomme le film" + }, + "outage": { + "body_with_local": "Les serveurs Sozo sont injoignables. Tes {} sources locales fonctionnent toujours.", + "body_no_local": "Les serveurs Sozo sont injoignables. Ajoute une source CloudStream, Aniyomi ou manga pour continuer à naviguer sans notre API.", + "pick_source": "Choisir une source" + }, + "link_tv": { + "title": "Associer une TV", + "tile_subtitle": "Scanne le QR sur ta TV, ou saisis son code", + "subtitle": "Scanne le QR code affiché sur ta télévision, ou saisis le code de 8 caractères en dessous.", + "scan_button": "Scanner le QR code", + "scan_hint": "Dirige la caméra vers le QR code à l’écran de la TV", + "manual_title": "Ou saisis le code", + "confirm": "Confirmer", + "approved": "TV associée", + "approved_named": "« {} » associée", + "approved_hint": "La TV se connectera à ton compte dans quelques secondes.", + "linked_devices": "Appareils associés", + "no_devices": "Aucun appareil associé pour l’instant", + "unnamed_device": "Appareil sans nom", + "last_seen": "Vu pour la dernière fois : {}", + "unlink_title": "Dissocier l’appareil", + "unlink_message": "Cet appareil sera déconnecté et aura besoin d’un nouveau QR code pour être associé à nouveau.", + "unlink_confirm": "Dissocier", + "camera_denied": "Accès à la caméra refusé. Autorise-le dans les Réglages, ou saisis le code.", + "camera_error": "La caméra n’a pas pu démarrer.", + "error_bad_code": "Le code doit faire 8 caractères.", + "error_not_found": "Ce code est introuvable. Demandes-en un nouveau sur la TV.", + "error_expired": "Le code a expiré. Appuie sur Actualiser sur la TV.", + "error_already_used": "Ce code a déjà été utilisé.", + "error_unauthorized": "Connecte-toi d’abord.", + "error_rate_limited": "Trop de tentatives. Réessaie dans un instant.", + "error_network": "Pas de connexion internet.", + "error_generic": "Un problème est survenu." + }, + "anilist": { + "connections_title": "Connexions", + "connect": "Connecter AniList", + "disconnect": "Déconnecter", + "disconnect_confirm": "Ton compte AniList sera déconnecté et les titres liés oubliés. Ta liste reste sur AniList.", + "not_connected": "Non connecté", + "connect_explainer": "Connecte AniList et les épisodes que tu regardes sont cochés automatiquement, avec ta liste affichée ici.", + "connected_explainer": "Les épisodes vus sont écrits dans ta liste AniList. La connexion est stockée sur ton compte, ta télé et tes autres appareils ne redemanderont donc pas de te connecter.", + "connect_prompt": "Connecte ton compte pour voir ta bibliothèque AniList ici.", + "upcoming_connect_prompt": "Connecte AniList pour savoir quand sortent les prochains épisodes des séries que tu suis.", + "sign_in_first": "Connecte-toi d’abord à ton compte Sozo", + "browser_failed": "Impossible d’ouvrir le navigateur", + "open_library": "Ma bibliothèque AniList", + "linked_titles": "Titres liés", + "linked_titles_count": "{} titres liés", + "linked_titles_none": "Rien de lié pour l’instant", + "unlink": "Délier", + "unlinked": "{} délié", + "auto_matched": "Automatique", + "privacy_note": "Ton jeton AniList est stocké sur ton compte Sozo. L’appli n’écrit que le numéro de l’épisode que tu as vu.", + "status_label": "STATUT", + "status_current": "En cours", + "status_planning": "Prévu", + "status_completed": "Terminé", + "status_paused": "En pause", + "status_dropped": "Abandonné", + "status_repeating": "Revisionnage", + "episodes_watched": "épisodes vus", + "empty_status": "Ta liste « {} » est vide", + "behind_n": "{} nouveaux", + "mark_episode_watched": "Marquer l’épisode {} comme vu", + "find_in_sources": "Chercher dans les sources", + "find_in_sources_hint": "Chercher ce titre dans toutes les sources", + "open_on_anilist": "Ouvrir sur AniList", + "refresh": "Actualiser", + "retry": "Réessayer", + "upcoming_title": "À venir", + "upcoming_empty": "Aucune des séries que tu suis n’a d’épisode suivant annoncé.", + "today": "Aujourd’hui", + "tomorrow": "Demain", + "episode_n": "Épisode {}", + "aired": "Diffusé", + "in_days": "dans {} jours", + "in_hours": "dans {} heures", + "in_minutes": "dans {} min", + "link_title": "Lier à AniList", + "link_hint": "Choisis à quel anime AniList ce titre correspond.", + "link_search_hint": "Nom de l’anime", + "link_no_results": "Rien trouvé", + "linked_to": "Lié à {}", + "tracking_on": "Suivi sur AniList", + "track_on_anilist": "Suivre sur AniList", + "n_episodes_short": "{} ép.", + "open_anilist": "AniList", + "connect_tagline": "Suis automatiquement ce que tu regardes", + "connect_short": "Connecter", + "calendar_title": "Calendrier de diffusion", + "calendar_all": "Tout", + "calendar_mine": "Ma liste", + "calendar_episode": "Épisode {episode}", + "calendar_empty": "Rien n’est diffusé ce jour-là.", + "calendar_empty_mine": "Rien de ta liste n’est diffusé ce jour-là.", + "calendar_open": "Calendrier de diffusion", + "calendar_error": "Impossible de charger la grille de diffusion.", + "calendar_rate_limited": "AniList est surchargé en ce moment. Réessaie dans un instant.", + "calendar_list_error": "Impossible de charger ta liste AniList.", + "calendar_now": "maintenant", + "calendar_count": "{count} diffusions", + "calendar_count_mine": "{mine} sur {total} sont à toi", + "calendar_episode_range": "Épisodes {from}-{to}", + "calendar_add_planning": "Ajouter à Prévu", + "calendar_added_planning": "Ajouté à ta liste Prévu", + "calendar_add_failed": "Impossible d’ajouter cela à ta liste", + "calendar_already_on_list": "Déjà dans ta liste", + "browse_title": "Découvrir", + "browse_trending": "Tendances", + "browse_season": "Cette saison", + "browse_popular": "Populaires de tous les temps", + "browse_upcoming": "Bientôt", + "browse_empty": "Rien à afficher ici.", + "browse_failed": "AniList était injoignable.", + "episodes_count": "{count} ép.", + "reminder_body": "L’épisode {episode} sort dans 10 minutes", + "reminders_on": "Rappels activés — avant et au moment de la diffusion", + "reminders_off": "Les rappels d’épisode sont désactivés", + "connect_first": "Connecte d’abord AniList", + "save_failed": "Impossible d’enregistrer sur AniList", + "search_in_source": "CHERCHER DANS UNE SOURCE", + "no_sources": "Aucune source installée pour l’instant", + "released_body": "L’épisode {episode} est sorti", + "remove_entry": "Retirer de ma liste", + "remove_confirm": "Retirer « {} » de ton AniList ? Ta progression dessus est aussi supprimée.", + "removed": "Retiré d’AniList" + }, + "mal": { + "connect": "Connecter MyAnimeList", + "disconnect": "Déconnecter MyAnimeList", + "disconnect_confirm": "Ton compte MyAnimeList sera déconnecté et les titres associés oubliés. Ta liste reste sur MyAnimeList.", + "connect_explainer": "Connecte MyAnimeList et les épisodes que tu regardes sont cochés automatiquement.", + "connected_explainer": "Les épisodes vus sont écrits dans ta MyAnimeList. La connexion est stockée sur ton compte, ta télé et tes autres appareils ne redemanderont donc pas de te connecter.", + "not_configured": "MyAnimeList n’est pas encore configuré sur le serveur", + "matching_title": "Comment les titres sont associés", + "matching_note": "L’association passe par AniList, qui porte l’identifiant MyAnimeList de la même entrée. Lie un titre une fois et les deux trackers le suivent.", + "privacy_note": "Tes jetons AniList et MyAnimeList sont stockés sur ton compte Sozo. L’appli n’écrit que le numéro de l’épisode que tu as vu.", + "tracking_on": "Suivi sur MyAnimeList", + "save_failed": "Impossible d’enregistrer sur MyAnimeList", + "open_library": "Ma MyAnimeList", + "linked_titles": "Titres liés", + "connect_prompt": "Connecte ton compte pour voir ta MyAnimeList ici.", + "connect_first": "Connecte d’abord MyAnimeList", + "search_failed": "MyAnimeList était injoignable.", + "link_title": "Lier à MyAnimeList", + "link_hint": "Choisis à quel anime MyAnimeList ce titre correspond.", + "linked_to": "Lié à {}", + "track": "Suivre sur MyAnimeList", + "tracked": "Suivi sur MyAnimeList", + "score_label": "NOTE", + "remove_entry": "Retirer de ma liste", + "remove_confirm": "Retirer « {} » de ta MyAnimeList ? Ta progression dessus est aussi supprimée.", + "removed": "Retiré de MyAnimeList", + "rewatching_note": "Tu es en train de le revoir. Sozo ne touche pas au statut pour ne pas clore le revisionnage." + }, + "tracker": { + "title": "Suivi", + "tab_following": "Suivis", + "tab_upcoming": "À venir", + "following_empty": "Suis une série pour être prévenu à la sortie de nouveaux épisodes.", + "new_episodes_found": "{} titres ont de nouveaux épisodes", + "no_new_episodes": "Aucun nouvel épisode", + "n_episodes": "{} épisodes", + "not_checked": "Pas encore vérifié", + "unfollow": "Ne plus suivre", + "unfollowed": "{} n’est plus suivi", + "undo": "Annuler" + }, + "remote": { + "title": "Télécommande TV", + "no_devices": "Aucune TV associée pour l’instant. Appaire-en une depuis l’appli TV d’abord.", + "load_failed": "Impossible de charger tes TV.", + "tv_offline": "La TV est hors ligne", + "idle": "Rien en lecture", + "command_failed": "Ça n’est pas arrivé jusqu’à la TV", + "search_on_tv": "RECHERCHER SUR LA TV", + "search_hint": "Tape ici, la recherche se fait sur la TV", + "back": "Retour", + "home": "Accueil", + "open_on_tv": "Lire sur la TV", + "sent_to_tv": "Envoyé à {device}", + "connected_to": "Connecté à {device}", + "not_connected_to": "{device} n’est pas connectée", + "turn_tv_on": "Ouvre Sozo sur la TV pour la contrôler", + "retry": "Réessayer", + "navigation": "NAVIGATION", + "playback": "LECTURE", + "pick_device": "Quelle TV ?", + "device_online": "En ligne", + "device_offline": "Hors ligne — ouvre Sozo dessus" + }, + "live_tv": { + "title": "TV en direct", + "favourites": "Favoris", + "recent": "Vues récemment", + "categories": "Catégories", + "countries": "Pays", + "results": "Résultats de recherche", + "search_hint": "Nom de la chaîne…", + "search_in": "Rechercher dans {folder}…", + "no_match": "Aucune chaîne ne correspond.", + "empty": "Aucune chaîne pour l’instant.", + "load_failed": "Impossible de charger les chaînes.", + "retry": "Réessayer", + "now": "Maintenant", + "next": "Ensuite", + "guide": "Programme TV", + "no_guide": "Pas de programme pour cette chaîne.", + "watch": "Regarder", + "favourite_add": "Ajouter aux favoris", + "favourite_remove": "Retirer des favoris", + "hint_long_press": "Appui long sur une chaîne pour le programme et les favoris.", + "channel_count": { + "one": "{} chaîne", + "other": "{} chaînes" + }, + "up_next": "ENSUITE", + "minutes_left": "{} min restantes" + }, + "time": { + "now": "à l’instant", + "minutes": "il y a {} min", + "hours": "il y a {} h", + "days": "il y a {} j" + }, + "user_lists": { + "title": "Mes listes", + "remove": "Retirer", + "empty_title": "Rien d’enregistré pour l’instant", + "empty_subtitle": "Les titres que tu enregistres apparaissent ici." + }, + "torrent": { + "title": "Torrents", + "search_hint": "Rechercher des torrents…", + "empty_hint": "Interroge Nyaa, Tokyo Toshokan et nekoBT d’un coup.", + "searching": "Recherche sur les trackers…", + "no_results": "Rien trouvé sur les trackers activés", + "no_results_hint": "Essaie un titre plus court, ou assouplis les filtres.", + "results_n": "{} résultats", + "seeders": "sources", + "leechers": "clients", + "unknown_swarm": "Taille de l’essaim inconnue", + "dead": "Aucune source", + "dead_warning": "Ce torrent n’a aucune source et ne peut pas être lu en streaming.", + "trusted": "De confiance", + "remake": "Remis en ligne", + "filters": "Filtres", + "category": "Catégorie", + "cat_all": "Toutes", + "cat_anime": "Anime", + "cat_english": "Sous-titres anglais", + "cat_non_english": "Autres langues", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Prise de vues réelles", + "sort": "Trier par", + "sort_seeders": "Sources", + "sort_date": "Plus récents", + "sort_size": "Taille", + "sort_downloads": "Téléchargements", + "min_seeders": "Sources minimum", + "quality": "Qualité", + "any_quality": "Peu importe", + "trusted_only": "Uniquement les uploadeurs de confiance", + "exclude_hardsubs": "Masquer les versions hardsub", + "exclude_mini": "Masquer les mini-encodages", + "exclude_remakes": "Masquer les remises en ligne", + "require_dual_audio": "Uniquement double audio", + "exclude_batches": "Masquer les packs de saison", + "trackers": "Trackers", + "reset_filters": "Réinitialiser", + "consent_title": "Lire via BitTorrent ?", + "consent_body": "Les torrents fonctionnent en pair-à-pair. Pendant la lecture, les autres membres de l’essaim voient ton adresse IP et ce que tu télécharges, et ton appareil leur envoie aussi des données.\n\nUtilise un VPN si cela compte là où tu es.", + "consent_accept": "J’ai compris", + "consent_remember": "Ne plus demander", + "preparing": "Démarrage du moteur torrent…", + "fetching_metadata": "Récupération de la liste des fichiers dans l’essaim…", + "metadata_timeout": "Aucune réponse de l’essaim. Le torrent n’a peut-être plus de sources.", + "engine_failed": "Impossible de démarrer le moteur torrent.", + "unsupported": "La lecture par torrent n’existe que sur Android.", + "pick_file": "Choisis un fichier", + "pick_file_hint": "Ce torrent contient {} vidéos.", + "play": "Lire", + "copy_magnet": "Copier le lien magnet", + "magnet_copied": "Lien magnet copié", + "open_external": "Ouvrir dans une appli torrent", + "open_page": "Ouvrir sur le tracker", + "stats_peers": "{} pairs", + "buffering": "Mise en mémoire {} %", + "still_searching": "{} trackers répondent encore", + "buffering_title": "Mise en mémoire…", + "no_video_in_torrent": "Ce torrent ne contient aucune vidéo lisible.", + "tracker_failed": "Impossible d’interroger {} — les résultats sont peut-être incomplets." + }, + "backup": { + "title": "Sauvegarde et restauration", + "subtitle": "Historique, listes, sources et réglages. Les connexions ne sont pas incluses.", + "export": "Créer une sauvegarde", + "export_desc": "Enregistre un fichier que tu peux garder ou t’envoyer", + "restore": "Restaurer", + "restore_desc": "Relire un fichier de sauvegarde dans l’appli", + "restore_warning": "Les valeurs de la sauvegarde écraseront celles de cet appareil. Ce qui n’est pas dans le fichier reste intact.", + "restored": "{} valeurs restaurées", + "not_a_backup": "Ce fichier n’est pas une sauvegarde Sozo", + "too_new": "Cette sauvegarde vient d’une version plus récente de l’appli", + "export_failed": "Impossible de créer la sauvegarde", + "restore_failed": "Impossible de lire cette sauvegarde", + "file_subject": "Sauvegarde Sozo", + "last_export": "Dernière sauvegarde : {}", + "never_exported": "Aucune sauvegarde n’a encore été faite sur cet appareil." + }, + "provider_test": { + "title": "Tester les sources", + "intro": "Lance une vraie recherche, ouvre un titre et demande les premiers octets au CDN. Les résultats valent pour cet appareil et ce réseau, maintenant.", + "summary": "{} sur {} ont démarré", + "run_all": "Tout tester", + "run_one": "Tester", + "stop": "Arrêter", + "stage_catalogue": "Catalogue", + "stage_detail": "Titre", + "stage_stream": "Lien", + "stage_playback": "Lecture", + "entry": "Tester les sources" + }, + "catalog": { + "title": "Sources par langue", + "subtitle": "Parcours toutes les sources d’extension des quatre écosystèmes", + "type_all": "Toutes", + "type_anime": "Anime", + "type_video": "Films et séries", + "type_manga": "Manga", + "type_novel": "Romans", + "add_repo": "Ajouter un dépôt", + "installed": "{} source(s) ajoutée(s) depuis {}", + "install_empty": "Ce dépôt n’a rien renvoyé d’installable.", + "install_failed": "Échec de l’installation : {}", + "needs_js": "Source Dart — impossible à exécuter ici", + "android_only": "Android uniquement", + "empty": "Rien ici pour l’instant. Essaie une autre langue ou enlève les filtres.", + "unreachable": "Le catalogue de sources est injoignable." + }, + "mode": { + "video": "Regarder", + "manga": "Manga", + "novel": "Romans", + "none_installed": "Aucune source {} installée pour l’instant." + }, + "home_rails": { + "title": "Disposition de l’accueil", + "entry_title": "Personnaliser l’accueil", + "entry_subtitle": "Choisis les rangées affichées et leur ordre", + "hint": "Fais glisser pour réordonner. Appuie sur l’œil pour masquer une rangée — elle garde sa place, donc la réactiver la remet là où elle était.", + "hero": "Bannière à la une", + "resume": "Reprendre", + "genres": "Genres", + "live_tv": "TV en direct", + "catalogue": "Rangées du catalogue" + }, + "stats": { + "title": "Statistiques", + "entry_subtitle": "Temps de visionnage, série, où il est passé", + "watched": "Regardé", + "hours_short": "h", + "minutes_short": "min", + "since": "Compté depuis {}", + "completed": "Terminés", + "streak": "Série de jours", + "last_days": "{} derniers jours", + "by_source": "Par source", + "empty_title": "Rien de compté pour l’instant", + "empty_subtitle": "Regarde quelque chose et les totaux démarrent ici. Ce qui est vu en navigation privée ne compte pas." + }, + "discord": { + "sign_in": "Se connecter avec Discord", + "sign_in_desc": "Connecte-toi sur la page de connexion de Discord. Le jeton est récupéré automatiquement et stocké uniquement sur cet appareil.", + "or_paste": "ou colle un jeton toi-même", + "web_login_title": "Connecter Discord", + "web_login_paste": "Coller un jeton", + "web_login_paste_title": "Coller le jeton Discord", + "web_login_paste_hint": "Ton jeton utilisateur Discord", + "title": "Discord", + "what_it_does": "Affiche sur ton profil Discord ce que tu regardes — le titre, l’épisode et où tu en es. Tes amis le voient dans leur liste d’amis.", + "enable": "Afficher sur Discord", + "enable_desktop_desc": "Nécessite l’appli Discord ouverte sur cet ordinateur", + "enable_mobile_desc": "Nécessite ton jeton Discord — lis d’abord l’avertissement ci-dessous", + "connected": "Connecté", + "not_connected": "Non connecté", + "risk_title": "Lis ceci avant de continuer", + "risk_body": "Sur téléphone il n’y a pas d’appli Discord à laquelle se connecter, alors Sozo doit se connecter en tant que toi avec ton propre jeton. Discord ne l’autorise pas : ton compte peut être suspendu. Le jeton donne un accès complet à ton compte Discord, et Sozo le stocke uniquement sur cet appareil — jamais sur un serveur. Retire-le quand tu veux avec Oublier.", + "token_label": "Jeton Discord", + "token_hint": "Colle ton jeton", + "token_saved": "Un jeton est enregistré — colles-en un nouveau pour le remplacer", + "token_save": "Enregistrer", + "token_forget": "Oublier", + "brand_title": "Rich Presence", + "brand_subtitle": "Tes amis voient en direct sur ton profil ce que tu regardes", + "preview_heading": "REGARDE", + "preview_title": "Le titre apparaît ici", + "preview_subtitle": "Saison et épisode", + "preview_elapsed": "{} écoulées", + "preview_elapsed_placeholder": "Temps écoulé", + "row_off": "Désactivé", + "row_live": "Affiché sur ton profil", + "row_waiting": "Activé — en attente de Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — l’image peut paraître verte ou violette sur cet appareil", + "atmos": "Audio Atmos — cet appareil peut le lire sans son", + "indirect_host": "Ce miroir ouvre une page au lieu de la vidéo", + "uhd": "4K — plus lourd à décoder et à télécharger", + "large_file": "Fichier volumineux — long à démarrer en données mobiles", + "unreachable": "Ce lien n’a pas répondu lors de la vérification" + }, + "update": { + "available_title": "Nouvelle version disponible (v{})", + "available_body": "Une nouvelle version de l’application est disponible.", + "later": "Plus tard", + "now": "Mettre à jour", + "open_store": "Ouvrir dans l’App Store", + "required_title": "Mise à jour requise", + "required_body": "Installe la nouvelle version (v{}) pour continuer à utiliser l’application.", + "exit_app": "Quitter l’application", + "downloading": "Téléchargement de la mise à jour…" + }, + "ext": { + "cloudstream_title": "Sources CloudStream", + "aniyomi_title": "Sources Aniyomi", + "mangayomi_title": "Sources Mangayomi", + "android_only": "Cette fonction n’est disponible que sur Android", + "needs_webview": "Les extensions JavaScript ont besoin d’un WebView, absent de cette plateforme.", + "check_updates": "Rechercher des mises à jour", + "check_extension_updates": "Rechercher des mises à jour d’extensions", + "installed_sources": "SOURCES INSTALLÉES", + "installed_repos": "DÉPÔTS INSTALLÉS", + "sources_heading": "SOURCES", + "recommended": "RECOMMANDÉS", + "repo_url": "URL du dépôt", + "index_url": "URL de l’index", + "browse_plugins": "Parcourir les plug-ins", + "no_sources_yet": "Aucune source pour l’instant", + "no_repos_yet": "Aucun dépôt pour l’instant", + "add_source": "Ajouter une source", + "add_repo": "Ajouter un dépôt", + "installing": "Installation…", + "installing_extensions": "Installation des extensions…", + "installing_plugins": "Installation des plug-ins…", + "installing_progress": "Installation {} / {}", + "installing_plugins_progress": "Installation de {} / {} plug-ins…", + "installing_extensions_progress": "Installation de {} / {} extensions…", + "installing_long": "Installation des extensions… cela peut prendre un moment pour les gros dépôts.", + "checking_updates": "Recherche de mises à jour…", + "checking_extension_updates": "Recherche de mises à jour d’extensions…", + "all_up_to_date": "Toutes les extensions sont à jour.", + "updated_count": "{} extension(s) mise(s) à jour.", + "updated_list": "{} mise(s) à jour : {}", + "update_check_failed": "Échec de la recherche de mises à jour : {}", + "source_removed": "Source supprimée.", + "repo_removed": "Dépôt supprimé.", + "none_found": "Aucune extension trouvée à cette URL.", + "none_found_js": "Aucune extension JavaScript trouvée à cette URL.", + "added_sources": "{} source(s) ajoutée(s).", + "added_sources_providers": "{} source(s) · {} fournisseur(s) ajoutés.", + "installed_from_file": "{} source(s) installée(s).", + "could_not_install": "Impossible d’installer : {}", + "could_not_install_plugin": "Impossible d’installer le plug-in", + "could_not_remove_plugin": "Impossible de retirer le plug-in", + "error": "Erreur : {}", + "cloudstream_desc": "Ajoute un dépôt CloudStream, puis installe seulement les plug-ins que tu veux — eux seuls apparaissent comme fournisseurs. Ils s’exécutent sur ton appareil (Android uniquement).", + "aniyomi_desc": "Ajoute des dépôts d’extensions Aniyomi pour installer d’autres fournisseurs d’anime. Ils s’exécutent sur ton appareil (Android uniquement).", + "mangayomi_desc": "Les extensions Mangayomi sont en JavaScript, pas des applis Android — contrairement aux sources CloudStream, Aniyomi et Manga, elles fonctionnent donc aussi sur iOS, macOS et Windows.", + "plugins_summary": "{} plug-ins · {} installés", + "install_all": "Tout installer", + "repo_load_failed": "Impossible de charger ce dépôt.", + "no_plugins": "Aucun plug-in trouvé dans ce dépôt", + "search_plugins": "Rechercher des plug-ins…", + "no_matches": "Aucun résultat", + "import_title": "Installer ce dépôt d’extensions ?", + "import_pick_library": "Les dépôts d’anime et de manga utilisent le même format de fichier : choisis donc à quelle bibliothèque celui-ci appartient.", + "import_js_only": "Sur cette plateforme, seules les extensions Mangayomi (JavaScript) peuvent être installées — les dépôts CloudStream, Aniyomi et Mihon fournissent des paquets d’applications Android.", + "import_none_found": "Aucune extension trouvée dans ce fichier. Il vient peut-être d’une autre application, ou le type choisi n’est pas le bon.", + "mangayomi_url_only": "Les dépôts Mangayomi s’installent depuis une URL — ouvre le lien de l’index plutôt qu’une copie téléchargée.", + "skipped_dart": "{} source(s) uniquement Dart ignorée(s) — elles sont intégrées à Mangayomi et ne fonctionnent pas ici.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Roman" + }, + "ux": { + "quick_search": "Recherche rapide ({})", + "quick_search_note": "La recherche rapide couvre {} sources sur {}. Choisissez des sources pour une recherche ciblée.", + "torrents": "Torrents", + "filters": "Filtres", + "change_source": "Changer de source", + "manage_sources": "Gérer les sources", + "no_source_match": "Aucune source ne correspond à votre recherche.", + "download_options": "Options de téléchargement", + "download_options_note": "Choisissez une source et une qualité disponibles. L’audio dépend de la source choisie.", + "source_number": "Source {}", + "checking_quality": "Vérification de la qualité disponible…", + "quality_unavailable": "Impossible de vérifier la qualité. Réessayez ou téléchargez le flux d’origine sans garantie de qualité.", + "quality_unknown": "Qualité inconnue", + "size_unknown": "La source ne précise pas la taille", + "interface_language_note": "Seule l’interface de l’application change. Les titres, descriptions et autres contenus des sources restent dans leur langue d’origine.", + "continue": "Continuer" + } +} diff --git a/assets/translations/id.json b/assets/translations/id.json new file mode 100644 index 00000000..868d5e85 --- /dev/null +++ b/assets/translations/id.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Memuat…", + "error": "Ada yang tidak beres", + "retry": "Coba lagi", + "cancel": "Batal", + "confirm": "Konfirmasi", + "ok": "Oke", + "yes": "Ya", + "no": "Tidak", + "back": "Kembali", + "next": "Lanjut", + "done": "Selesai", + "save": "Simpan", + "delete": "Hapus", + "edit": "Edit", + "search": "Cari", + "close": "Tutup", + "settings": "Pengaturan", + "logout": "Keluar", + "no_internet": "Tidak ada koneksi internet", + "try_again": "Coba lagi", + "see_all": "Lihat semua", + "unknown": "Tidak diketahui", + "off": "Mati", + "on": "Nyala", + "new_badge": "BARU", + "reset": "Setel ulang", + "clear": "Bersihkan", + "copy": "Salin", + "share": "Bagikan", + "install": "Pasang", + "remove": "Hapus", + "show": "Tampilkan", + "hide": "Sembunyikan", + "installed": "Terpasang" + }, + "auth": { + "login": "Masuk", + "register": "Daftar", + "email": "Email", + "password": "Kata sandi", + "username": "Nama pengguna", + "email_hint": "Masukkan emailmu", + "password_hint": "Masukkan kata sandimu", + "username_hint": "Masukkan nama penggunamu", + "forgot_password": "Lupa kata sandi?", + "dont_have_account": "Belum punya akun?", + "already_have_account": "Sudah punya akun?", + "sign_up": "Daftar", + "sign_in": "Masuk", + "login_success": "Berhasil masuk", + "register_success": "Pendaftaran berhasil", + "logout_success": "Berhasil keluar", + "invalid_email": "Alamat email tidak valid", + "invalid_password": "Kata sandi minimal 6 karakter", + "invalid_username": "Nama pengguna minimal 3 karakter", + "confirm_password": "Konfirmasi kata sandi", + "passwords_not_match": "Kata sandi tidak cocok", + "terms_agree": "Saya setuju dengan Ketentuan dan Kebijakan Privasi", + "or_continue_with": "Atau lanjut dengan", + "welcome_back": "Senang melihatmu lagi", + "create_account": "Buat akun", + "login_subtitle": "Masuk untuk memakai profil dan data tersimpanmu.", + "register_subtitle": "Buat akun dengan email, nama pengguna, dan kata sandi.", + "forgot_password_title": "Setel ulang kata sandimu", + "forgot_password_subtitle": "Masukkan email akunmu dan kami kirimkan kode 6 digit.", + "reset_password_subtitle": "Kami mengirim kode ke {email}. Masukkan bersama kata sandi barumu.", + "send_reset_code": "Kirim kode", + "reset_code_sent": "Kalau email itu terdaftar, kodenya sedang dikirim", + "reset_code_hint": "Kode 6 digit", + "invalid_reset_code": "Masukkan kode 6 digit", + "new_password_hint": "Kata sandi baru", + "confirm_password_hint": "Ulangi kata sandi baru", + "reset_password": "Setel ulang kata sandi", + "resend_code": "Kirim kode lagi", + "resend_in": "Kirim lagi dalam {seconds}d", + "identifier_hint": "Email atau nama pengguna", + "identifier_required": "Email atau nama pengguna wajib diisi", + "continue_with_google": "Lanjut dengan Google", + "google_unavailable": "Masuk dengan Google tidak tersedia di perangkat ini", + "google_failed": "Tidak bisa masuk dengan Google. Coba lagi", + "weak": "Lemah", + "medium": "Sedang", + "strong": "Kuat", + "code_resent": "Kode dikirim ulang. Periksa emailmu.", + "verify": "Verifikasi", + "avatar_bad_format": "Format gambar tidak didukung (jpg, png, webp)", + "verification_code": "Kode verifikasi" + }, + "onboarding": { + "skip": "Lewati", + "continue_with_email": "Lanjut dengan email", + "slide_1_title": "Ribuan film dan serial", + "slide_1_body": "Rilisan baru, sinema dunia, dan animasi — semuanya dalam satu aplikasi.", + "slide_2_title": "Anime, satu perpustakaan penuh", + "slide_2_body": "Rilisan musiman, klasik, dan subtitle. Hubungkan AniList dan daftarmu memperbarui dirinya sendiri.", + "slide_3_title": "Di layar besar juga", + "slide_3_body": "Kanal TV langsung, memasangkan ponsel dengan televisi, dan menonton bersama — Sozo juga jalan di TV." + }, + "home": { + "title": "Beranda", + "continue_watching": "Lanjutkan menonton", + "trending": "Sedang tren", + "new_releases": "Rilisan baru", + "top_rated": "Nilai tertinggi", + "for_you": "Untukmu", + "all_categories": "Semua kategori", + "genres": "Genre", + "get_all": "Ambil semua", + "popular": "Populer", + "watch_now": "Tonton sekarang", + "more_info": "Info selengkapnya", + "add_to_list": "Tambahkan ke daftar", + "my_list": "Daftar saya", + "featured": "Unggulan", + "action": "Aksi", + "comedy": "Komedi", + "drama": "Drama", + "thriller": "Thriller", + "horror": "Horor", + "romance": "Romansa", + "sci_fi": "Fiksi ilmiah", + "documentary": "Dokumenter", + "animation": "Animasi", + "telegram_promo_title": "Gabung Sozo di Telegram", + "telegram_promo_subtitle": "Dapatkan kabar terbaru, fitur baru, dan pemberitahuan konten.", + "telegram_join_channel": "Gabung kanal", + "dont_show_again": "Jangan tampilkan lagi", + "downloaded": "Terunduh", + "view_all": "Lihat semua", + "remove_from_continue": "Hapus dari Lanjutkan menonton", + "ep_number": "EP {}", + "untitled": "Tanpa judul", + "empty_title": "Belum ada apa-apa di sini", + "empty_subtitle": "Pilih sumber lain, atau tarik ke bawah untuk menyegarkan.", + "list_empty_title": "Daftar ini kosong", + "list_empty_subtitle": "Belum ada yang bisa ditampilkan di bagian ini." + }, + "player": { + "needs_media_kit": "Butuh mesin media_kit", + "auto_quality_desc": "Pemutar memilih yang terbaik sesuai kemampuan koneksimu", + "on": "Nyala", + "info_title": "Info pemutar", + "info_resolution": "Resolusi", + "info_engine": "Mesin", + "info_provider": "Sumber", + "info_server": "Server", + "info_quality": "Kualitas", + "info_codec": "Codec", + "info_hdr": "HDR", + "info_audio": "Audio", + "info_container": "Kontainer", + "info_size": "Ukuran berkas", + "info_host": "Host", + "info_position": "Posisi", + "info_buffer": "Buffer di depan", + "info_speed": "Kecepatan", + "info_state": "Status", + "switching_to": "Beralih ke {}…", + "fault": { + "decoder": "Perangkat ini tidak bisa mendekode videonya. Coba kualitas lain, atau coba lagi.", + "unsupported_format": "Format video ini tidak didukung di perangkatmu. Kamu bisa memutarnya di peramban.", + "source_blocked": "Sumber video tidak bisa dibuka — servernya mungkin memblokirnya. Coba kualitas lain.", + "network": "Kesalahan jaringan — periksa koneksimu.", + "no_source": "Tidak ada sumber untuk episode ini.", + "empty_url": "Sumber ini tidak mengembalikan alamat video.", + "engine_unavailable": "Pemutar tidak jalan. Mulai ulang aplikasi sepenuhnya.", + "unknown": "Video tidak bisa dimuat." + }, + "play": "Putar", + "pause": "Jeda", + "resume": "Lanjutkan", + "forward": "Maju 10 dtk", + "backward": "Mundur 10 dtk", + "fullscreen": "Layar penuh", + "exit_fullscreen": "Keluar dari layar penuh", + "quality": "Kualitas", + "subtitles": "Subtitle", + "audio": "Audio", + "speed": "Kecepatan pemutaran", + "auto": "Otomatis", + "off": "Mati", + "episodes": "Episode", + "next_episode": "Episode berikutnya", + "season": "Musim {n}", + "fit_original": "Asli", + "fit_fill": "Penuhi", + "fit_stretch": "Regangkan", + "play_in_browser": "Putar di peramban", + "view_logs": "Lihat log", + "speed_2x": "Kecepatan 2x", + "tap_to_unlock": "Ketuk untuk membuka kunci", + "previous": "Sebelumnya", + "live": "LANGSUNG", + "go_live": "Ke siaran langsung", + "lang_sub": "Bersubtitle (SUB)", + "lang_dub": "Sulih suara (DUB)", + "lang_softsub": "Subtitle lunak", + "audio_language": "Bahasa audio", + "speed_short": "Kecepatan", + "aspect": "Rasio", + "aspect_ratio": "Rasio aspek", + "subtitle_style": "Gaya subtitle", + "audio_track": "Trek audio", + "diagnostics_logs": "Diagnostik / log", + "extracting_media": "Mengekstrak media…", + "loading_video": "Memuat video…", + "fetching_link": "Mengambil tautan pemutaran dari sumber", + "preparing_stream": "Menyiapkan aliran video", + "episode_n": "Episode {}", + "search_online": "Cari daring", + "subtitle_sync": "Sinkron subtitle", + "subtitle_sync_desc": "Geser waktunya agar pas dengan audio", + "subtitle_sync_help": "Subtitle terlalu cepat? Naikkan (+). Terlalu lambat? Turunkan (−).", + "search_subtitles": "Cari subtitle", + "subtitle_search_hint": "Judul / nama film", + "searching_subtitles": "Mencari subtitle…", + "no_subtitles_found": "Subtitle tidak ditemukan", + "search_failed": "Pencarian gagal: {}", + "online_subtitles_count": "Subtitle daring · {}", + "loading_subtitle": "Memuat subtitle…", + "subtitle_loaded": "Subtitle dimuat", + "subtitle_failed_download": "Subtitle ini tidak bisa diunduh", + "subtitle_failed_empty": "Berkas subtitle ini tidak berisi teks", + "subtitle_failed_archive": "Subtitle dalam arsip (.zip / .rar) tidak didukung", + "subtitle_failed_html": "Server mengembalikan halaman web, bukan subtitle", + "subtitle_failed_format": "Format subtitle tidak didukung", + "subtitle_rate": "Konversi laju bingkai", + "subtitle_rate_help": "Makin lama makin melenceng? Subtitle-nya disetel untuk laju bingkai lain — pilih konversinya.", + "subtitle_rate_off": "Mati", + "reset": "Setel ulang", + "font_size": "Ukuran huruf", + "text_color": "Warna teks", + "background": "Latar", + "none": "Tidak ada", + "solid": "Padat", + "edge": "Tepi", + "shadow": "Bayangan", + "outline": "Garis luar", + "position": "Posisi", + "lower": "Lebih rendah", + "default": "Bawaan", + "higher": "Lebih tinggi", + "bold": "Tebal", + "audio_track_engine_only": "hanya media_kit", + "audio_track_single": "Hanya satu trek", + "external_player": "Putar di pemutar eksternal", + "external_player_gated": "Sumber ini butuh header permintaan khusus yang tidak bisa dikirim VLC dan MX Player. Kemungkinan besar gagal dimuat. Tetap coba?", + "external_player_try": "Tetap coba", + "external_player_none": "Tidak ada pemutar video eksternal yang ditemukan", + "audio_track_numbered": "Audio {}", + "engine_sheet_title": "Bagaimana ini harus diputar?", + "engine_sheet_subtitle": "Pilih mesin untuk video ini. Kamu bisa mengubahnya kapan saja di Pengaturan → Pemutar.", + "engine_sheet_dont_ask": "Jangan tanya lagi", + "engine_sheet_play": "Putar", + "subtitle_preview_text": "Muharjo seorang xenofobia universal yang takut pada kelinci", + "server": "Server", + "stream_not_found": "Sumber ini menyembunyikan alirannya di balik pemutar terlindungi. Coba sumber lain, atau buka di peramban.", + "subtitle_translation": "Terjemahan subtitle", + "auto_translate": "Terjemahkan otomatis", + "auto_translate_desc": "Kalau sumbernya tidak punya subtitle dalam bahasamu, AI menerjemahkannya", + "translate_to": "Terjemahkan ke", + "translate_note": "Terjemahan dibuat mesin dan bisa salah. Ada batas harian.", + "translate_ai": "Terjemahkan dengan AI", + "translating_subtitle": "Menerjemahkan…", + "translate_failed": "Tidak bisa diterjemahkan", + "ai_translate_title": "Terjemahkan ke {} dengan AI", + "ai_translate_hint": "Pilih satu subtitle dari daftar lalu ketuk AI", + "ai_translate_remaining": "Sisa {}/{} terjemahan hari ini", + "ai_translate_used_up": "Batas terjemahan hari ini sudah habis", + "ai_translate_menu": "Terjemahkan dengan AI", + "ai_translate_menu_desc": "Menerjemahkan satu subtitle ke {}", + "ready_translations": "Terjemahan siap pakai", + "lines": "baris", + "sleep_timer": "Timer tidur", + "sleep_minutes": "{} menit", + "sleep_hours": "{} jam", + "sleep_minutes_short": "{} mnt", + "sleep_episode_end": "Akhir episode", + "sleep_fired": "Timer tidur — pemutaran dijeda", + "skip_opening": "Lewati pembuka", + "skip_ending": "Lewati penutup", + "alt_sources": "Coba sumber lain", + "alt_searching": "Mencari di sumber lain…", + "alt_none": "Tidak ada sumber lain yang punya judul ini", + "alt_failed": "Sumber ini tidak bisa dibuka", + "alt_no_episode": "Sumber ini tidak punya episode {}", + "alt_search_all": "Cari di semua sumber", + "guide": "Panduan", + "change_source": "Ganti sumber", + "cast": "Tayangkan ke TV", + "cast_to": "Tayangkan ke", + "cast_searching": "Mencari perangkat di Wi-Fi ini…", + "cast_none": "Tidak ada perangkat ditemukan. TV harus berada di Wi-Fi yang sama dengan ponsel ini.", + "cast_failed": "Tidak bisa terhubung ke {}", + "cast_unsupported": "Aliran ini tidak bisa ditayangkan", + "cast_active": "Diputar di {}", + "cast_stop": "Hentikan penayangan", + "color_profile": "Gambar", + "color_natural": "Alami", + "color_cinema": "Bioskop", + "color_night": "Malam", + "color_anime": "Anime", + "color_restore": "Pulihkan", + "shaders": "Tingkatkan (Anime4K)", + "shaders_note": "Peningkatan skala lewat GPU untuk animasi. Berkas shader diunduh sekali saja saat pertama dipakai.", + "shader_off": "Mati", + "shader_off_desc": "Tanpa peningkatan", + "shader_sharpen": "Pertajam", + "shader_sharpen_desc": "Mengembalikan detail — paling cocok untuk sumber bersih", + "shader_deblur": "Hilangkan buram", + "shader_deblur_desc": "Pemulihan lebih lembut — untuk sumber yang buram", + "shader_denoise": "Kurangi derau", + "shader_denoise_desc": "Membersihkan bintik — untuk sumber yang sangat terkompresi", + "shader_tier": "ANGGARAN GPU", + "shader_tier_mid": "Seimbang", + "shader_tier_mid_desc": "Lancar di kebanyakan ponsel", + "shader_tier_high": "Maksimum", + "shader_tier_high_desc": "Paling tajam, butuh GPU kuat", + "font_family": "Huruf", + "font_system": "Bawaan", + "font_sans": "Tanpa kait", + "font_serif": "Berkait", + "font_condensed": "Rapat", + "font_mono": "Monospasi", + "second_track": "Trek kedua", + "second_track_on": "Menampilkan {} sebagai trek kedua", + "second_track_same": "Trek itu sudah jadi yang utama", + "second_track_hint": "Tekan lama sebuah trek untuk menampilkannya bersama yang pertama", + "share_frame": "Bagikan bingkai ini", + "share_frame_failed": "Bingkai tidak bisa diambil", + "save_frame": "Simpan bingkai", + "save_frame_done": "Tersimpan ke galeri", + "save_frame_failed": "Bingkai tidak bisa disimpan", + "save_frame_denied": "Butuh akses galeri untuk menyimpan bingkai", + "switching_server": "Mengganti server…", + "recommended": "Disarankan", + "episode_page_failed": "Bagian berikutnya dari serial ini tidak bisa dimuat", + "info_fields_desc": "Pilih apa yang tampil di atas video. Muncul bersama tombol kontrol.", + "layout_title": "Kontrol pemutar", + "layout_desc": "Seret untuk mengurutkan, atau pindahkan sebuah kontrol ke bagian bilah yang lain.", + "layout_top_bar": "Bilah atas", + "layout_bottom_left": "Kiri bawah", + "layout_bottom_right": "Kanan bawah", + "layout_hidden": "Disembunyikan", + "layout_hidden_desc": "Masih bisa dijangkau lewat Pengaturan di dalam pemutar.", + "layout_pinned": "Pengaturan tetap di tempatnya — itulah jalan kembali ke semua yang lain.", + "layout_top_bar_full": "Bilah atas sudah penuh. Keluarkan sesuatu dulu.", + "layout_unknown_control": "Kontrol itu sudah tidak tersedia.", + "layout_move_to": "Pindahkan", + "layout_empty": "Tidak ada apa-apa di sini.", + "rotate": "Putar arah", + "lock": "Kunci", + "enhance": "Tingkatkan", + "fit": "Sesuaikan", + "pip": "Gambar dalam gambar", + "layout_pinned_badge": "Disematkan", + "info_fields_title": "Baris lapisan info", + "logs_title": "Log pemutar ({})", + "logs_copied": "Log disalin", + "logs_empty": "Belum ada log" + }, + "profile": { + "title": "Profil", + "my_account": "Akun saya", + "notifications": "Notifikasi", + "language": "Bahasa", + "theme": "Tema", + "privacy": "Kebijakan Privasi", + "help": "Bantuan & dukungan", + "about": "Tentang", + "version": "Versi", + "subscription": "Langganan", + "downloads": "Unduhan", + "watch_history": "Riwayat tontonan", + "manage_profiles": "Kelola profil", + "parental_controls": "Kontrol orang tua", + "guest_title": "Masuk ke profilmu", + "guest_subtitle": "Masuk agar favorit dan riwayat tontonan tetap tersinkron.", + "auth_provider": "Metode masuk", + "joined": "Bergabung", + "providers": "Sumber", + "providers_error": "Sumber tidak bisa dimuat.", + "offline_title": "Server tidak terjangkau", + "offline_local_available": "{} sumber yang terpasang di perangkat ini masih berfungsi. Sumber awan tidak tersedia sampai server kembali.", + "offline_no_local": "Server tidak merespons dan tidak ada sumber terpasang di perangkat ini. Pasang plugin CloudStream agar bisa terus menonton secara luring.", + "offline_cached_at": "Daftar awan dari {}", + "offline_install_plugins": "Pasang plugin", + "offline_badge": "LURING", + "provider_needs_server": "Sumber ini butuh server, yang saat ini tidak terjangkau. Pilih sumber yang terpasang di perangkat ini.", + "logout_confirm": "Mau keluar dari akunmu?", + "connections": "Koneksi", + "coming_soon": "Segera hadir", + "favorites": "Favorit", + "all_providers": "Semua sumber", + "add_favorite": "Tambahkan ke favorit", + "cloudstream_sources": "Sumber CloudStream", + "aniyomi_sources": "Sumber Aniyomi", + "signin_account_title": "Masuk ke akunmu", + "signin_account_subtitle": "Masuk untuk menyimpan favorit dan riwayat tontonanmu.", + "sign_in": "Masuk", + "sign_out": "Keluar", + "sign_out_confirm": "Yakin ingin keluar?", + "section_providers": "Sumber", + "section_content": "KONTEN", + "sources_row": "Sumber ekstensi", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Sumber", + "choose_provider": "Ganti sumber", + "search_providers_hint": "Cari sumber…", + "count_of_total_shown": "{} dari {} ditampilkan", + "no_providers_in_category": "Tidak ada sumber di kategori ini", + "try_select_all": "Pilih “Semua” untuk melihat setiap sumber", + "section_activity": "AKTIVITAS", + "share_sources_desktop": "Bagikan sumber ke desktop", + "desktop_sources": "Sumber desktop", + "developer_role": "Pengembang mobile", + "section_about": "TENTANG", + "developer": "Pengembang", + "website": "Situs web", + "server": "Server", + "expired": "Kedaluwarsa", + "support_title": "Bantu Sozo tetap jalan", + "server_expired": "Server kedaluwarsa", + "days": "Hari", + "hours": "Jam", + "min": "Mnt", + "sec": "Dtk", + "support_body_expired": "Server sudah kedaluwarsa dan konten tidak bisa dimuat lagi. Dukunganmu bisa membantu menghidupkannya kembali.", + "support_body": "Sozo bergantung pada server yang harus diperpanjang berkala. Kalau kamu suka aplikasinya, dukunganmu membantu semuanya tetap berjalan lancar.", + "support_developer": "Dukung pengembang", + "section_appearance": "TAMPILAN", + "native_window_bar": "Bilah jendela bawaan", + "native_window_bar_subtitle": "Pakai bilah judul standar Windows, bukan milik aplikasi.", + "liquid_glass_nav": "Navigasi liquid glass", + "liquid_glass_nav_subtitle": "Bilah tab mengambang bergaya kaca buram (iOS 26). Matikan untuk bilah polos.", + "nav_style": "Bilah navigasi", + "nav_style_glass": "Liquid glass", + "nav_style_solid": "Polos", + "nav_style_classic": "Klasik", + "section_player": "Pemutar", + "player_engine_native": "Pemutar sistem", + "player_engine_native_desc": "Pemutar bawaan platform. Paling irit baterai dan pakai dekode perangkat keras. Tidak bisa ganti trek audio.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Memutar lebih banyak format dan bisa ganti trek audio serta subtitle. Sedikit lebih boros baterai.", + "player_engine_external": "Pemutar eksternal", + "player_engine_external_desc": "Kirim video ke VLC atau MX Player, bukan memutarnya di sini.", + "player_engine_external_note": "Sebagian sumber hanya jalan di dalam Sozo karena butuh header permintaan khusus. Yang seperti itu tidak akan diputar di aplikasi lain.", + "player_engine_badge_default": "BAWAAN", + "player_engine_section": "MESIN PEMUTARAN", + "ask_engine_on_play": "Tanya setiap kali", + "ask_engine_on_play_desc": "Pilih mesinnya setiap kali video dimulai", + "playback_section": "PEMUTARAN", + "default_speed": "Kecepatan bawaan", + "default_speed_desc": "Kecepatan awal setiap video", + "default_fit": "Mode isi bawaan", + "default_fit_desc": "Cara video menyesuaikan layar", + "auto_next_episode": "Putar episode berikutnya otomatis", + "auto_next_episode_desc": "Mulai episode berikutnya begitu satu selesai", + "keep_screen_on": "Biarkan layar menyala", + "keep_screen_on_desc": "Mencegah layar mati saat menonton", + "gestures_section": "GESTUR", + "seek_step": "Lompatan ketuk ganda", + "seek_step_desc": "Seberapa jauh ketuk ganda melompat", + "long_press_boost": "Kecepatan saat ditekan lama", + "long_press_boost_desc": "Kecepatan selama kamu menahan jari di video", + "brightness_gesture": "Geser untuk kecerahan", + "brightness_gesture_desc": "Geser naik-turun di sisi kiri", + "volume_gesture": "Geser untuk volume", + "volume_gesture_desc": "Geser naik-turun di sisi kanan", + "seconds_short": "{} dtk", + "subtitle_style_note": "Ini berlaku untuk semua video. Sinkron subtitle tetap per episode dan diatur di dalam pemutar.", + "searching_all_sources": "mencari di semua sumber", + "section_connections": "KONEKSI", + "section_extensions": "SUMBER", + "section_settings": "PENGATURAN", + "remove_favorite": "Hapus dari favorit", + "edit_profile": "Edit profil", + "display_name_hint": "Namamu", + "display_name_too_long": "Nama maksimal 60 karakter", + "pick_from_gallery": "Pilih dari galeri", + "take_photo": "Ambil foto", + "remove_photo": "Hapus foto", + "photo_pick_failed": "Foto tidak bisa dipilih", + "incognito": "Mode samaran", + "incognito_desc": "Menonton tanpa masuk riwayat atau pelacak", + "incognito_off": "Mode samaran dimatikan", + "auto_skip_intro": "Lewati pembuka otomatis", + "auto_skip_intro_desc": "Melewati pembuka dan penutup anime tanpa bertanya", + "incognito_active": "Mode samaran menyala. Apa pun yang kamu tonton tidak disimpan ke riwayat atau dikirim ke AniList dan MAL.", + "incognito_turn_off": "Matikan", + "all_languages": "Semua bahasa", + "section_library": "PERPUSTAKAANMU", + "section_watch": "TONTON", + "hero_trailer": "Putar trailer di bagian atas", + "hero_trailer_desc": "Sesaat setelah sebuah judul dibuka, posternya berganti jadi trailer, tanpa suara", + "section_overview": "IKHTISAR", + "section_app": "APLIKASI", + "library": "Perpustakaan", + "library_subtitle": "Favorit, tonton nanti, daftar pribadi", + "sources_title": "Sumber", + "section_active_source": "SUMBER AKTIF", + "active_source_footnote": "Sumber awan tempat Sozo mencari dan memutar. Ekstensi di bawah menambah sumber yang berjalan di perangkat ini.", + "settings_title": "Pengaturan", + "settings_subtitle": "Bahasa, tampilan, pemutar, keamanan", + "section_general": "UMUM", + "section_data": "DATA", + "section_lists": "DAFTAR", + "section_accounts": "AKUN", + "section_devices": "PERANGKAT", + "language_desc": "Bahasa antarmuka", + "not_connected": "Belum terhubung", + "joined_on": "Bergabung {}", + "watch_later": "Tonton nanti", + "watched": "Sudah ditonton", + "stat_hours": "jam ditonton", + "stat_completed": "selesai", + "stat_streak": "rentetan hari", + "guest_benefit_sync": "Favorit dan riwayat tersinkron di semua perangkatmu", + "guest_benefit_streak": "Rentetan, statistik, dan peringkat trivia", + "guest_benefit_trackers": "AniList, MyAnimeList, dan Watch Party", + "activity": "Aktivitas", + "activity_subtitle": "Riwayat, unduhan, pelacakan, statistik", + "connections_subtitle": "Pelacak, Discord, TV, desktop", + "trackers": "Pelacak", + "stat_days": "hari ditonton", + "mangayomi_sources": "Sumber Mangayomi", + "view_only": "HANYA TONTON" + }, + "appearance": { + "title": "Tampilan", + "entry_subtitle": "Warna aksen, hitam pekat", + "section_preview": "PRATINJAU", + "section_accent": "WARNA AKSEN", + "accent_footnote": "Aksen mewarnai tombol, sorotan, bilah kemajuan, dan tab aktif — di seluruh aplikasi.", + "accent_custom": "Kustom", + "accent_red": "Merah Sozo", + "accent_ember": "Bara", + "accent_amber": "Ambar", + "accent_emerald": "Zamrud", + "accent_teal": "Biru kehijauan", + "accent_sky": "Langit", + "accent_ocean": "Samudra", + "accent_indigo": "Nila", + "accent_violet": "Ungu", + "accent_magenta": "Magenta", + "accent_rose": "Merah muda", + "accent_slate": "Batu tulis", + "section_darkness": "LATAR", + "darkness_dark": "Gelap", + "darkness_dark_desc": "Abu-abu gelap yang biasa", + "darkness_black": "Hitam pekat", + "darkness_black_desc": "Latar hitam sungguhan", + "amoled_footnote": "Di layar OLED, piksel hitam berarti mati, jadi hitam pekat memberi kontras lebih dalam dan hemat baterai.", + "reset": "Kembalikan ke bawaan", + "reset_tooltip": "Kembali ke tampilan bawaan", + "custom_title": "Warna kustom", + "custom_hue": "RONA", + "custom_saturation": "KEJENUHAN", + "custom_lightness": "KECERAHAN", + "custom_apply": "Pakai warna ini", + "custom_adjusted": "Sedikit digelapkan supaya teks putih di atasnya tetap terbaca.", + "section_library": "DARI PERPUSTAKAANMU", + "library_footnote": "Warna diambil dari poster tontonan terakhirmu.", + "section_extras": "LAINNYA", + "tint_nav": "Warnai bilah tab", + "tint_nav_desc": "Mewarnai tab terpilih dengan aksenmu, bukan putih", + "shuffle": "Coba warna berikutnya", + "shuffle_desc": "Telusuri palet satu aksen demi satu" + }, + "search": { + "title": "Cari", + "hint": "Cari film, serial…", + "no_results": "Tidak ada hasil", + "no_results_for": "Tidak ada hasil untuk “{query}”", + "recent": "Pencarian terakhir", + "popular_searches": "Pencarian populer", + "categories": "Genre", + "all": "Semua", + "movies": "Film", + "series": "Serial", + "filter": "Filter", + "sort": "Urutkan menurut", + "sort_newest": "Terbaru", + "sort_oldest": "Terlama", + "sort_rating": "Rating", + "type": "Jenis", + "apply": "Terapkan", + "clear_filter": "Bersihkan", + "sources": "Sumber", + "sources_n": "{} sumber", + "all_sources": "Pilih semua", + "this_server": "Server ini", + "local_group": "Lokal", + "search_in": "Cari di", + "sources_hint": "Cari di beberapa sumber sekaligus. Biarkan kosong untuk memakai sumbermu saat ini.", + "searching": "Mencari…", + "all_source_search": "Pencarian semua sumber", + "cross_hint": "Cari di sumber-sumbermu…", + "pick_sources": "Pilih sumber mana yang akan dicari.", + "choose_sources": "Pilih sumber", + "no_sources_selected": "Belum ada sumber dipilih — ketuk untuk memilih", + "sources_selected": "{} dipilih — ketuk untuk mengubah", + "type_to_search_n": "Ketik untuk mencari di {} sumber sekaligus.", + "found_in": "Ditemukan di {} dari {} sumber", + "results_n": "{} hasil", + "waiting_for": "Mencari di {}…", + "no_results_any": "Tidak ada hasil di sumber mana pun yang dipilih.", + "offline_note": "Luring — hanya sumber di perangkat yang bisa dicari.", + "group_by_source": "Kelompokkan per sumber", + "merge_titles": "Gabungkan judul", + "open_from": "Buka dari", + "load_more": "Muat lagi", + "search_failed": "Pencarian gagal", + "source_failed": "Sumber ini sedang tidak tersedia", + "try_all_sources": "Coba semua sumber", + "weak_results": "Tidak ada yang mirip “{query}” di sini — sumber ini cocok dengan hal lain. Sumber lain mungkin punya.", + "did_you_mean": "Maksudmu", + "search_sources": "Cari sumber", + "filter_providers": "Saring sumber…", + "selected_n": "{} dipilih", + "apply_n": "Terapkan ({})", + "many_sources_warning": "Mencari di {} sumber bisa lambat. Aplikasi tetap responsif, tapi lebih sedikit lebih gesit.", + "all_sources_n": "Semua sumber ({})", + "more_sources": "Lainnya…", + "select_all_sources": "Pilih semua", + "search_all_sources": "Cari di semua sumber", + "apply_all": "Cari di semua sumber", + "selected_n_of_m": "{} dari {} sumber", + "loading_sources": "Memuat sumber-sumbermu…", + "no_sources_available": "Tidak ada sumber tersedia. Tambahkan atau aktifkan sebuah sumber untuk mencari.", + "min_query_n": "Ketik minimal {} karakter.", + "retry_failed": "Ulangi yang gagal", + "status_searching": "Mencari…", + "status_searching_n": "{} sedang dicari", + "status_with_results_n": "{} ada hasil", + "status_no_match_n": "{} tanpa kecocokan", + "status_timed_out_n": "{} kehabisan waktu", + "status_failed_n": "{} gagal", + "all_sources_failed": "Semua {} sumber tidak menjawab. Ini bukan hasil kosong — coba lagi.", + "no_results_with_failures": "Tidak ada kecocokan dari sumber yang menjawab ({}). {} tidak bisa dijangkau.", + "all_sources_failed_one": "Sumber yang kamu cari tidak menjawab. Ini bukan hasil kosong — coba lagi.", + "voice": "Cari dengan suara", + "voice_listening": "Mendengarkan…", + "voice_done": "Berhenti mendengarkan", + "voice_hint": "Sebutkan nama film atau serial", + "try_torrents": "Cari torrent", + "capped_note": "Mencari di {} sumber paling andal dari {} — persempit cakupan untuk menjangkau sumber tertentu." + }, + "movie": { + "rating": "Rating", + "year": "Tahun", + "quality": "Kualitas", + "duration": "Durasi", + "genre": "Genre", + "director": "Sutradara", + "cast": "Pemeran", + "description": "Deskripsi", + "similar": "Judul serupa", + "trailer": "Trailer", + "share": "Bagikan", + "download": "Unduh", + "rate": "Beri nilai", + "episodes": "Episode", + "seasons": "Musim" + }, + "errors": { + "network": "Kesalahan jaringan. Periksa koneksimu.", + "server": "Kesalahan server. Coba lagi.", + "unauthorized": "Masuk dulu untuk melanjutkan.", + "not_found": "Konten tidak ditemukan.", + "unknown": "Terjadi kesalahan yang tidak diketahui.", + "timeout": "Permintaan kehabisan waktu. Coba lagi.", + "empty": "Tidak ada konten tersedia.", + "source_http": "Server sumber menjawab dengan kesalahan ({})." + }, + "navigation": { + "home": "Beranda", + "search": "Cari", + "shorts": "Shorts", + "downloads": "Unduhan", + "profile": "Profil", + "my_list": "Daftar saya", + "history": "Riwayat", + "following": "Diikuti", + "premiere": "Perdana", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "TV langsung" + }, + "premiere": { + "tab_title": "Penayangan perdana", + "live_now": "Sedang langsung", + "upcoming": "Akan datang", + "live": "LANGSUNG", + "catching_up": "Menyusul ke siaran langsung", + "starts_in": "Mulai dalam", + "starts_at": "Mulai {when}", + "days": "HARI", + "hours": "JAM", + "minutes": "MNT", + "seconds": "DTK", + "waiting": "{count} menunggu", + "watching": "{count} menonton", + "join": "Ikut penayangan perdana", + "join_now": "Ikut sekarang", + "set_reminder": "Pasang pengingat", + "reminder_set": "Pengingat dipasang", + "notify_me": "Beri tahu saya saat mulai", + "guest_watch_hint": "Kamu bisa menonton sebagai tamu.", + "login_to_chat_react": "Masuk untuk mengobrol dan memberi reaksi", + "login_to_chat": "Masuk untuk mengobrol", + "chat_hint": "Sapa dulu — obrolan sudah dibuka", + "chat_empty": "Sapa dulu 👋 Obrolan dimulai bersama penayangan perdana", + "chat_placeholder": "Pesan…", + "slow_mode": "Mode lambat: {sec} dtk", + "react": "Beri reaksi", + "paused_by_host": "Dijeda oleh tuan rumah", + "buffering": "Menyangga…", + "reconnecting": "Menyambung ulang…", + "connecting": "Menyambung ke penayangan perdana…", + "ended_title": "Penayangan perdana selesai", + "ended_watch_vod": "Tonton sesuai permintaan", + "canceled": "Penayangan perdana ini dibatalkan", + "empty_title": "Belum ada penayangan perdana", + "empty_subtitle": "Acara langsung akan muncul di sini", + "error_generic": "Ada yang tidak beres", + "error_full": "Penayangan perdana ini penuh", + "error_not_found": "Penayangan perdana tidak ditemukan", + "retry": "Coba lagi", + "share": "Bagikan", + "back": "Kembali", + "resolve_failed": "Pemutaran tidak bisa dimulai di perangkat ini" + }, + "nav_customize": { + "entry_title": "Sesuaikan tab", + "entry_subtitle": "Pilih tab mana yang muncul dan urutannya", + "title": "Bilah bawah", + "shown": "Ditampilkan", + "available": "Tersedia", + "min_hint": "Sisakan minimal {count} tab", + "max_hint": "Maksimal {count} tab", + "pick_range": "Pilih antara {min} dan {max} tab", + "reset": "Setel ulang", + "cancel": "Batal", + "save": "Simpan" + }, + "app_lock": { + "section_label": "KEAMANAN", + "app_lock": "Kunci aplikasi", + "settings_title": "Kunci aplikasi", + "setup_title": "Atur PIN", + "enable": "Aktifkan kunci aplikasi", + "state_on": "Nyala", + "state_off": "Mati", + "choose_length_title": "Panjang PIN", + "choose_length_subtitle": "Pilih berapa digit PIN-mu.", + "length_n_digits": "PIN {} digit", + "create_pin_title": "Buat PIN baru", + "create_pin_subtitle": "Pilih sesuatu yang tidak akan kamu bagikan ke siapa pun.", + "confirm_pin_title": "Konfirmasi PIN-mu", + "confirm_pin_subtitle": "Masukkan PIN barumu sekali lagi.", + "verify_title": "Aplikasi terkunci", + "verify_subtitle": "Masukkan PIN-mu untuk melanjutkan.", + "verify_current_pin": "Masukkan PIN-mu yang sekarang", + "change_pin": "Ubah PIN", + "biometric": "Buka dengan biometrik", + "biometric_unavailable": "Biometrik tidak tersedia di perangkat ini", + "biometric_enable_reason": "Autentikasi untuk mengaktifkan buka kunci biometrik", + "biometric_unlock_reason": "Autentikasi untuk membuka aplikasi", + "pin_mismatch": "PIN tidak cocok", + "pin_wrong": "PIN salah", + "disable_title": "Matikan kunci aplikasi?", + "disable_subtitle": "PIN dan preferensi biometrikmu akan dihapus.", + "enabled_hint": "PIN-mu akan diminta setiap aplikasi dibuka.", + "disabled_hint": "Kunci aplikasi mati. Nyalakan untuk melindungi apa yang kamu tonton.", + "private_list": "Daftar pribadi", + "move_to_private": "Pindahkan ke pribadi", + "moved_to_private": "Dipindahkan ke daftar pribadi", + "removed_from_private": "Dikeluarkan dari pribadi", + "move_to_my_list": "Kembalikan ke Daftar saya", + "private_empty": "Belum ada yang pribadi", + "private_setup_required": "Atur PIN dulu untuk memakai daftar pribadi", + "private_long_press_hint": "Tekan lama untuk memindahkan ke daftar pribadimu — tersembunyi di balik PIN, dan riwayat tontonannya tidak disimpan.", + "private_always_ask": "Selalu minta PIN untuk daftar pribadi", + "private_always_ask_hint": "Masukkan PIN setiap kali, bukan sekali per sesi", + "reset_title": "Setel ulang kunci aplikasi?", + "reset_body": "Ini menghapus PIN dan membuka Sozo di komputer ini. Perpustakaan dan riwayatmu tetap ada. Kamu bisa memasang PIN baru nanti di Profil → Kunci aplikasi.", + "reset": "Setel ulang", + "forgot_pin": "Lupa PIN?" + }, + "my_list": { + "saved_account": "Tersimpan di akun", + "saved_local": "Di perangkat ini", + "untitled": "Tanpa judul", + "empty_title": "Daftarmu kosong", + "empty_subtitle": "Ketuk tombol + pada judul mana pun untuk menyimpannya di sini.", + "unauth_title": "Masuk untuk melihat daftarmu", + "unauth_subtitle": "Judul yang kamu simpan akan muncul di sini setelah kamu masuk.", + "error_title": "Daftar tidak bisa dimuat" + }, + "deeplink": { + "opt_in_title": "Buka tautan Sozo di aplikasi ini?", + "opt_in_body_android": "Tautan sozo.azamov.me yang dibagikan akan langsung terbuka di Sozo. Aktifkan “Buka tautan yang didukung” di pengaturan sistem.", + "opt_in_body_ios": "Tautan Sozo terbuka otomatis di aplikasi. Kalau tautan terbuka di Safari, tekan lama lalu pilih “Buka di Sozo”.", + "opt_in_enable": "Aktifkan", + "opt_in_later": "Nanti" + }, + "streak": { + "section_label": "RENTETAN", + "empty_title": "Mulai rentetan", + "empty_subtitle": "Tonton satu episode hari ini untuk memulai rentetan harianmu.", + "current_n_days": "{} hari", + "locked_today": "Hari ini sudah aman. Datang lagi besok!", + "keep_going": "Jaga rentetanmu tetap hidup — jangan lupa menonton hari ini.", + "risk_subtitle": "Rentetanmu diatur ulang tengah malam. Tonton sesuatu sekarang untuk menyelamatkannya.", + "record_label": "Terbaik", + "next_milestone": "Target berikutnya", + "weekday_mon": "Sen", + "weekday_tue": "Sel", + "weekday_wed": "Rab", + "weekday_thu": "Kam", + "weekday_fri": "Jum", + "weekday_sat": "Sab", + "weekday_sun": "Min", + "milestone_title": "Rentetan {} hari!", + "milestone_subtitle": "Konsistensi yang luar biasa. Terus muncul dan pecahkan rekor pribadimu.", + "share_text": "Berhasil mencapai rentetan {} hari di Sozo! 🔥", + "cta_continue": "Lanjutkan", + "cta_share": "Bagikan", + "freeze_label": "Pembeku", + "freeze_count": "{} / {}", + "total_days_label": "Total hari", + "this_week_label": "Minggu ini", + "calendar_label": "35 hari terakhir", + "days_to_milestone": "{} hari lagi menuju tonggak berikutnya", + "freeze_saved_title": "Rentetan terselamatkan!", + "freeze_saved_subtitle": "Sebuah pembeku menutup hari yang kamu lewatkan — rentetanmu tetap jalan.", + "freeze_awarded_title": "Kamu dapat satu pembeku!", + "freeze_awarded_subtitle": "Pembeku melindungi rentetanmu saat kamu melewatkan satu hari.", + "freeze_explainer": "Pembeku otomatis menyelamatkan rentetanmu pada hari yang kamu lewatkan. Kamu dapat yang baru dengan menonton secara rutin." + }, + "watch_party": { + "title": "Watch Party", + "entry_subtitle": "Nonton barengan serempak — satu orang memutar, semua ikut.", + "entry_create": "Buat ruang", + "entry_create_hint": "Buka sebuah ruang dan undang teman dengan kode", + "entry_join": "Gabung pakai kode", + "entry_join_hint": "Masukkan kode yang dibagikan temanmu", + "not_available": "Nonton bareng belum tersedia untuk judul ini", + "create": "Buat ruang", + "join": "Gabung ruang", + "join_hint": "Masukkan kode ruang", + "invalid_code": "Kode tidak valid", + "code_label": "Kode ruang", + "copy_code": "Salin kode", + "copied": "Disalin", + "share_invite": "Bagikan undangan", + "invite": "Undang", + "lobby_waiting_host": "Menunggu tuan rumah memulai…", + "host_label": "Tuan rumah", + "members": "Anggota", + "member_count": "{} / {}", + "you": "Kamu", + "start_watching": "Mulai menonton", + "close_party": "Tutup ruang", + "leave_party": "Keluar dari ruang", + "leave_confirm": "Keluar dari Watch Party ini?", + "only_host_controls": "Hanya tuan rumah yang bisa mengatur pemutaran", + "host_changed": "Tuan rumah berganti", + "room_closed": "Ruang sudah ditutup", + "room_closed_empty": "Ruang ditutup karena semua orang keluar", + "content_none": "Belum ada judul yang dipilih", + "resolving": "Menyiapkan videonya…", + "resolve_failed": "Judul ini tidak bisa dimuat di perangkatmu", + "plugin_required_title": "Perlu plugin", + "plugin_required_body": "Pasang plugin sumber yang diperlukan untuk menonton ini bersama.", + "install_plugin": "Pasang plugin", + "chat_hint": "Tulis sesuatu…", + "chat_empty": "Belum ada pesan", + "reactions": "Reaksi", + "error_not_found": "Ruang tidak ditemukan atau sudah ditutup", + "error_full": "Ruang ini penuh", + "error_forbidden": "Kamu tidak punya akses ke ruang ini", + "error_rate": "Pelan-pelan sedikit", + "error_unauthorized": "Masuk dulu untuk ikut Watch Party", + "error_generic": "Ada yang tidak beres", + "reconnecting": "Menyambung ulang…", + "connecting": "Menyambung…", + "connect_failed_title": "Tidak bisa tersambung", + "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi." + }, + "notifications": { + "title": "Notifikasi", + "mark_all_read": "Tandai semua dibaca", + "empty_title": "Belum ada notifikasi", + "empty_subtitle": "Begitu ada yang baru, kamu akan melihatnya di sini.", + "time_now": "sekarang", + "time_minutes": "{} mnt", + "time_hours": "{} jam", + "time_days": "{} hr" + }, + "manga": { + "sources_title": "Sumber manga", + "sources_desc": "Tambahkan repo ekstensi manga untuk memasang penyedia manga, manhwa, dan webtoon tambahan. Semuanya berjalan di perangkatmu (hanya Android).", + "repo_url": "URL repo", + "add_source": "Tambah sumber", + "installing": "Memasang…", + "installing_long": "Memasang ekstensi… untuk repo besar ini bisa makan waktu.", + "installing_progress": "Memasang {} / {} ekstensi…", + "no_extensions": "Tidak ada ekstensi di URL itu.", + "added_sources": "Menambahkan {} sumber · {} penyedia.", + "source_removed": "Sumber dihapus.", + "recommended": "DISARANKAN", + "installed_sources": "SUMBER TERPASANG", + "source_settings": "PENGATURAN SUMBER", + "no_sources": "Belum ada sumber", + "show_nsfw": "Sumber dewasa", + "show_nsfw_hint": "Menampilkan sumber bertanda 18+ di daftar ini dan di pemilih sumber. Mati secara bawaan.", + "show": "Tampilkan", + "hide": "Sembunyikan", + "installed": "Terpasang", + "android_only": "Fitur ini hanya ada di Android", + "error_prefix": "Kesalahan: {}", + "no_preferences": "Sumber ini tidak punya pengaturan yang bisa diubah", + "not_selected": "Belum dipilih", + "n_selected": "{} dipilih", + "reading_mode": "Mode baca", + "mode_continuous": "Menerus", + "mode_paged": "Per halaman", + "direction": "Arah", + "dir_ltr": "Kiri ke kanan (LTR)", + "dir_rtl": "Kanan ke kiri (RTL)", + "background": "Latar", + "bg_black": "Hitam", + "bg_gray": "Abu-abu", + "bg_white": "Putih", + "chapters": "Bab", + "chapter_ended": "Bab selesai", + "next_chapter": "Bab berikutnya", + "first_chapter": "Bab pertama", + "last_chapter": "Bab terakhir", + "no_pages": "Halaman tidak ditemukan", + "tap_to_reload": "Ketuk untuk memuat ulang", + "download_started": "Unduhan dimulai", + "downloaded_pages": "{} / {} halaman", + "check_updates": "Periksa pembaruan", + "checking_updates": "Memeriksa pembaruan ekstensi…", + "up_to_date": "Semua ekstensi sudah versi terbaru.", + "updated_n": "{} ekstensi diperbarui.", + "spread": "Dua halaman", + "spread_desc": "Berdampingan dalam mode lanskap, seperti bukunya digambar" + }, + "cloudflare": { + "title": "Verifikasi bahwa kamu manusia", + "hint": "Selesaikan pemeriksaan di bawah — jendela ini tertutup sendiri setelah selesai.", + "solve": "Selesaikan Cloudflare", + "done": "Selesai" + }, + "desktop": { + "saved_refresh_on_pc": "Tersimpan — ketuk “Segarkan sumber” di PC untuk menerapkannya.", + "sources_turned_off": "Sumber desktop dimatikan.", + "saved_loading_sources": "Tersimpan — memuat sumber dari ponselmu…", + "refreshing_sources": "Menyegarkan sumber dari ponselmu…", + "share_title": "Bagikan sumber ke desktop", + "sources_title": "Sumber desktop", + "share_with_desktop": "Bagikan sumber ke desktop", + "let_pc_use_sources": "Izinkan aplikasi Sozo di PC memakai sumber dari ponsel ini.", + "your_link": "Tautanmu", + "link_copied": "Tautan disalin", + "no_wifi_address": "Sudah nyala, tapi alamat Wi‑Fi tidak ditemukan. Sambungkan ponsel ini ke Wi‑Fi (bukan data seluler), lalu buka layar ini lagi.", + "step_host_same_wifi": "Jaga ponsel ini dan PC-mu di jaringan Wi‑Fi yang sama.", + "step_host_turn_on": "Nyalakan sakelar di atas.", + "step_host_pick_sources": "Pilih sumber mana yang dibagikan (atau biarkan “Bagikan semua” menyala).", + "step_host_paste_on_pc": "Di PC, buka Sozo → Profil → Sumber desktop lalu tempel tautannya.", + "step_host_keep_open": "Biarkan Sozo terbuka di ponsel ini selama kamu menjelajah di PC.", + "sources_to_share": "Sumber yang dibagikan", + "reload_from_phone": "Muat ulang dari ponsel", + "share_all_sources": "Bagikan semua sumber", + "every_source_shared": "Semua sumber di ponsel ini dibagikan", + "sharing_count": "Membagikan {} dari {}", + "save_send_to_desktop": "Simpan & kirim ke desktop", + "n_selected": "{} dipilih", + "select_all": "Pilih semua", + "clear": "Bersihkan", + "no_sources_yet": "Belum ada sumber di ponsel ini. Tambahkan lewat Profil → CloudStream / Aniyomi / Sumber manga, lalu muat ulang di sini.", + "phone_link": "Tautan ponsel", + "refresh_sources": "Segarkan sumber", + "step_client_same_wifi": "Jaga PC ini dan ponselmu di jaringan Wi‑Fi yang sama.", + "step_client_open_on_phone": "Di ponsel, buka Sozo → Profil → Bagikan sumber ke desktop lalu nyalakan.", + "step_client_choose_sources": "Di ponsel, pilih sumber mana yang dibagikan (atau biarkan “Bagikan semua” menyala).", + "step_client_copy_link": "Salin tautan yang tampil di sana, tempel di atas, lalu ketuk Simpan.", + "step_client_refresh": "Mengubah sumber yang dibagikan di ponsel? Ketuk “Segarkan sumber” di sini.", + "step_client_keep_open": "Biarkan Sozo terbuka di ponsel selama kamu menjelajah di sini.", + "step_client_emulator": "Memakai emulator Android? IP-nya tidak bisa dijangkau langsung — jalankan “adb forward tcp:8765 tcp:8765” di PC ini, lalu tempel http://127.0.0.1:8765 di atas (sakelar di emulator tetap harus dinyalakan).", + "how_it_works": "Cara kerjanya", + "how_it_works_desc": "Pakai sumber CloudStream, Aniyomi, dan manga-mu di aplikasi Sozo untuk desktop. Ponsel menjalankan sumbernya; PC hanya menampilkannya.", + "internet_still_unavailable": "Internet masih tidak tersedia", + "offline_watch_downloads": "Kamu tetap bisa menonton video yang sudah diunduh selama koneksi terputus.", + "refresh": "Segarkan" + }, + "detail": { + "download_will_resolve": "Menelusuri…", + "download_needs_playback": "Sumber ini menyembunyikan alirannya di balik pemutar terlindungi. Putar episodenya sekali, lalu unduh dari pemutar.", + "download_choose_server": "Unduh · pilih server", + "download_playing_now": "Sedang diputar", + "similar": "Serupa", + "comments": "Komentar", + "screenshots": "Tangkapan layar", + "play": "Putar", + "no_episodes": "Tidak ada episode tersedia", + "no_playable_source": "Tidak ada sumber yang bisa diputar", + "added_to_my_list": "Ditambahkan ke Daftar saya", + "removed_from_my_list": "Dihapus dari Daftar saya", + "failed_resolve_pages": "Halaman gagal ditelusuri", + "episodes_count": "{} episode", + "episodes_count_of": "{} dari {} episode", + "episode_n": "Episode {}", + "no_films": "Film tidak ditemukan", + "filmography": "Filmografi", + "actor": "Pemeran", + "continue_watching": "Lanjutkan", + "continue_ep": "Lanjutkan · ep. {}", + "no_cast": "Data pemeran tidak tersedia", + "no_recommendations": "Tidak ada rekomendasi", + "untitled": "Tanpa judul", + "read": "Baca", + "watch_later": "Tonton nanti", + "watched": "Sudah ditonton", + "follow_series": "Ikuti serial", + "find_other_sources": "Cari sumber lain", + "anilist_track": "Lacak di AniList", + "anilist_tracked": "Dilacak di AniList", + "following_on": "Diikuti", + "following_off": "Tidak diikuti", + "no_screenshots": "Tidak ada tangkapan layar", + "show_more": "Tampilkan lebih banyak", + "show_less": "Tampilkan lebih sedikit", + "time_left": "sisa {}", + "liked": "{}% menyukai", + "watched_pct": "{}% ditonton", + "copy_link": "Salin tautan", + "link_copied": "Tautan disalin", + "report_problem": "Laporkan masalah", + "selected_n": "{} dipilih", + "select_all": "Pilih semua", + "select_none": "Bersihkan", + "download_n": "Unduh {}", + "download_queueing": "Memasukkan ke antrean…", + "download_action": "Unduh", + "download_already": "Sudah diunduh", + "download_in_progress": "Unduhan sedang berjalan", + "download_started": "Unduhan dimulai", + "download_needs_permission": "Izin notifikasi diperlukan untuk mengunduh", + "download_resolve_failed": "Tautan unduhan untuk episode ini tidak bisa didapat", + "download_queued_n": "{} ditambahkan ke unduhan", + "download_none_queued": "Tidak ada yang ditambahkan — mungkin sudah diunduh", + "download_batch_title": "Unduh beberapa episode?", + "download_batch_body": "Episode {range} akan diantrekan — total {count}. Lewat data seluler ini bisa beberapa gigabita.", + "relations": "Judul terkait", + "no_relations": "Tidak ditemukan judul yang terkait dengan yang ini.", + "relation_sequel": "SEKUEL", + "relation_prequel": "PREKUEL", + "relation_parent": "CERITA UTAMA", + "relation_side_story": "CERITA SAMPINGAN", + "relation_alternative": "VERSI ALTERNATIF", + "relation_spin_off": "SPIN-OFF", + "relation_summary": "RINGKASAN", + "relation_adaptation": "ADAPTASI", + "relation_source": "KARYA ASAL", + "relation_character": "TOKOH YANG SAMA", + "relation_other": "LAINNYA", + "trailer_action": "Trailer", + "more_options": "Opsi lainnya", + "in_private_list": "Di daftar pribadimu", + "add_to_my_list_action": "Tambahkan ke Daftar saya", + "remove_from_my_list_action": "Hapus dari Daftar saya", + "trailer_mute": "Bisukan trailer", + "trailer_unmute": "Nyalakan suara trailer" + }, + "episodes": { + "filter_hint": "Nomor atau judul episode", + "filter_none": "Tidak ada episode yang cocok dengan “{query}” di blok ini", + "jump_to": "Ke episode {n}", + "jump_loads": "Memuat {range}", + "jump_missing": "Episode {n} tidak ada di halaman ini", + "range_of": "Episode {from}–{to} dari {total}" + }, + "shorts": { + "refresh": "Segarkan", + "opening": "Membuka…", + "content_unavailable": "Konten tidak tersedia", + "loading": "Memuat shorts…", + "empty_title": "Belum ada shorts", + "empty_subtitle": "Klip pendek akan muncul di sini begitu tersedia.", + "error_title": "Shorts tidak bisa dimuat", + "video_unavailable": "Video tidak tersedia", + "speed_2x": "Kecepatan 2x", + "report_submitted": "Laporan terkirim, terima kasih", + "watch_full_movie": "Tonton film lengkap", + "browse_movies": "Jelajahi film", + "sign_in_to_like": "Masuk dulu untuk menyukai" + }, + "comments": { + "please_sign_in": "Masuk dulu", + "report_submitted": "Laporan terkirim, terima kasih", + "edited": "· diedit", + "like": "Suka", + "reply": "Balas", + "hide": "Sembunyikan", + "replies_one": "1 balasan", + "replies_other": "{} balasan", + "report": "Laporkan", + "edit_hint": "Edit komentarmu…", + "reply_hint": "Tulis balasan…", + "comment_hint": "Tulis komentar…", + "editing": "Sedang mengedit", + "replying_to": "Membalas {}", + "sign_in_to_comment": "Masuk untuk berkomentar", + "empty_title": "Belum ada komentar", + "empty_subtitle": "Jadilah yang pertama menyampaikan pendapat", + "count_one": "1 komentar", + "count_other": "{} komentar", + "show_more": "Tampilkan lebih banyak", + "delete_confirm_title": "Hapus komentar ini?", + "delete_confirm_body": "Tindakan ini tidak bisa dibatalkan.", + "sign_in_prompt": "Masuk untuk meninggalkan komentar" + }, + "downloads": { + "delete_all_title": "Hapus semua unduhan?", + "file_not_found": "Berkas tidak ditemukan", + "clear_all": "Bersihkan semua", + "segments_progress": "{} / {} segmen", + "downloaded_amount": "{} terunduh", + "pages_count": "{} halaman", + "downloaded": "Terunduh", + "failed": "Gagal", + "pending": "Menunggu", + "remove": "Singkirkan", + "empty_title": "Belum ada unduhan", + "empty_subtitle": "Video yang diunduh akan muncul di sini", + "paused": "Dijeda", + "wifi_only": "Unduh hanya lewat Wi‑Fi", + "wifi_only_desc": "Unduhan dalam antrean menunggu sampai kamu tersambung ke Wi‑Fi", + "waiting_for_wifi": "Menunggu Wi‑Fi — unduhan lanjut sendiri", + "export": "Simpan ke Unduhan", + "export_hint": "Membuat berkas terlihat oleh aplikasi lain", + "export_running": "Menyimpan…", + "export_done": "Disimpan ke {}", + "export_failed": "Tidak bisa menyimpan ke Unduhan", + "downloading": "Mengunduh", + "missing": "Berkas hilang — ketuk untuk mengunduh lagi", + "redownload_started": "Mengunduh lagi…", + "file_missing_retry": "Berkas itu hilang. Sedang diunduh lagi.", + "download_again": "Unduh lagi", + "pause": "Jeda", + "resume": "Lanjutkan", + "retry_all": "Ulangi semua yang gagal", + "sort_by": "URUTKAN MENURUT", + "sort_newest": "Terbaru dulu", + "sort_title": "Judul", + "sort_size": "Terbesar dulu", + "filter_all": "Semua", + "filter_active": "Sedang berjalan", + "filter_done": "Selesai", + "filter_problems": "Perlu perhatian", + "filter_empty_title": "Tidak ada yang cocok dengan filter ini", + "filter_empty_subtitle": "Unduhanmu masih ada — tampilan ini saja yang dipersempit.", + "filter_clear": "Tampilkan semua unduhan", + "storage_used": "{} dipakai unduhan", + "storage_free": "{} kosong", + "orphans_found": "{} berupa sisa unduhan yang dibatalkan", + "orphans_clean": "Bersihkan", + "group_summary": "{} dari {} terunduh", + "group_problems": "Perlu perhatian", + "error": { + "network": "Koneksi terputus", + "refused": "Sumber menolak unduhan — putar sekali, lalu coba lagi", + "gone": "Berkasnya sudah tidak ada di sumber", + "no_space": "Ruang di perangkat ini tidak cukup", + "not_media": "Sumber mengirim halaman web, bukan videonya", + "incomplete": "Unduhan selesai tapi tidak lengkap", + "unknown": "Unduhan gagal" + }, + "location": "Tempat unduhan disimpan", + "location_hint": "Hanya tempat yang bisa ditulisi aplikasi tanpa izin. Unduhan ikut pindah bersama pengaturan ini; tidak ada yang dihapus sampai setiap berkas sampai.", + "location_internal": "Penyimpanan ponsel (aplikasi)", + "location_external": "Penyimpanan ponsel", + "location_sdcard": "Kartu SD", + "location_moved": "Unduhan dipindahkan", + "location_unchanged": "Sudah di sana", + "location_failed": "Unduhan tidak bisa dipindahkan — semuanya masih di tempat semula" + }, + "history": { + "clear_title": "Hapus riwayat", + "clear_confirm": "Yakin ingin menghapus riwayat tontonanmu?", + "clear": "Hapus", + "clear_all": "Hapus semua", + "remove": "Singkirkan", + "empty_title": "Belum ada riwayat tontonan", + "empty_subtitle": "Mulai menonton agar riwayatmu muncul di sini" + }, + "main": { + "shorts_refresh_title": "Segarkan Shorts", + "shorts_refresh_body": "Ketuk tab Shorts sekali lagi. Feed disegarkan dan video baru dimuat.", + "double_tap": "Ketuk dua kali", + "tap_again": "Ketuk lagi" + }, + "reports": { + "title": "Laporkan", + "reason_spam": "Spam", + "reason_abuse": "Pelecehan", + "reason_sexual": "Konten seksual", + "reason_violence": "Kekerasan", + "reason_copyright": "Hak cipta", + "reason_other": "Lainnya", + "note_hint": "Catatan tambahan (opsional)", + "submit": "Kirim" + }, + "ios": { + "sources_title": "Sumber ekstensi", + "how_it_works_desc": "Pakai sumber CloudStream, Aniyomi, dan manga milikmu di Sozo untuk iOS. Ponsel Android-mu menjalankan sumbernya; perangkat ini hanya menampilkannya.", + "step_client_same_wifi": "Jaga perangkat ini dan ponsel Android-mu di jaringan Wi‑Fi yang sama." + }, + "trivia": { + "fan_test_title": "Tes penggemar", + "fan_test_subtitle": "Buktikan kamu penggemar terbesar", + "hub_tagline": "Seberapa dalam kamu benar-benar tahu soal film?", + "view_leaderboards": "Lihat papan peringkat", + "my_daily_rank": "Peringkat harianku", + "rank_value": "#{}", + "unranked": "Tanpa peringkat", + "points_value": "{} poin", + "popular_now": "Populer sekarang", + "results": "Hasil", + "searching": "Mencari…", + "search_cast_hint": "Cari pemeran", + "no_results": "Tidak ada hasil", + "no_results_hint": "Coba nama lain", + "something_wrong": "Ada yang tidak beres", + "filmography": "Filmografi", + "n_titles": "{count} judul", + "start_fan_test": "Mulai tes penggemar", + "challenge_friend": "Tantang teman", + "top_fans": "Penggemar teratas", + "no_fans_yet": "Belum ada penggemar", + "be_first_fan": "Jadilah penggemar pertama", + "fans_meta": "{rounds} ronde · rata-rata {fandom}% fandom", + "you": "Kamu", + "you_rank": "Kamu · #{rank}", + "best_score": "Terbaik {score}", + "fandom": "Fandom", + "challenge_share_text": "Bisa kalahkan aku soal {name}? Ikut tes penggemar di Sozo!", + "scope_daily": "Harian", + "scope_weekly": "Mingguan", + "scope_all_time": "Sepanjang masa", + "scope_friends": "Teman", + "leaderboard": "Papan peringkat", + "no_friends_board": "Belum ada teman di papan peringkat", + "no_scores_yet": "Belum ada skor", + "correct_of": "{count} benar", + "forfeit_title": "Keluar dari permainan?", + "forfeit_body": "Kemajuanmu di ronde ini akan hilang.", + "keep_playing": "Lanjut main", + "forfeit_confirm": "Keluar", + "correct": "Benar", + "the_answer": "Jawabannya", + "watch_full": "Tonton film lengkap", + "building_round": "Menyusun rondemu…", + "round_failed": "Ronde tidak bisa dimulai", + "go_back": "Kembali", + "fan_test_done": "Tes penggemar selesai", + "round_done": "Ronde selesai", + "score": "Skor", + "rank": "Peringkat", + "play_again": "Main lagi", + "share_result": "Bagikan hasil", + "share_text": "Aku baru saja main Buff di Sozo!", + "challenge_text": "Merasa lebih paham film? Tantang aku di Buff dalam Sozo!", + "share_tagline": "Main Buff di Sozo", + "challenge_from": "Tantangan dari {name}", + "same_clips_head_to_head": "Klip yang sama, adu langsung", + "score_to_beat": "Skor yang harus dikalahkan", + "play_challenge": "Mainkan tantangan", + "challenge_not_found": "Tantangan tidak ditemukan", + "no_actors_yet": "Belum ada pemeran", + "no_actors_yet_body": "Buff sedang diisi. Klip ditinjau satu per satu — pemeran muncul di sini begitu klipnya disetujui.", + "actor_not_ready": "Pemeran ini belum siap", + "actor_not_ready_body": "Klip yang disetujui belum cukup untuk satu ronde. Coba pemeran lain.", + "round_short_note": "Ronde ini berisi {count} klip — semua klip pemeran ini yang sudah disetujui sejauh ini.", + "todays_top": "Teratas hari ini", + "how_it_works": "Cara kerja Buff", + "how_step_1": "Pilih seorang pemeran", + "how_step_2": "Tonton klip pendek", + "how_step_3": "Sebutkan judul filmnya" + }, + "outage": { + "body_with_local": "Server Sozo tidak bisa dijangkau. {} sumber di perangkatmu masih berfungsi.", + "body_no_local": "Server Sozo tidak bisa dijangkau. Tambahkan sumber CloudStream, Aniyomi, atau manga agar tetap bisa menjelajah tanpa API kami.", + "pick_source": "Pilih sumber" + }, + "link_tv": { + "title": "Hubungkan TV", + "tile_subtitle": "Pindai QR di TV-mu, atau ketik kodenya", + "subtitle": "Pindai kode QR di televisimu, atau ketik kode 8 karakter yang tampil di bawahnya.", + "scan_button": "Pindai kode QR", + "scan_hint": "Arahkan kamera ke kode QR di layar TV", + "manual_title": "Atau masukkan kodenya", + "confirm": "Konfirmasi", + "approved": "TV terhubung", + "approved_named": "“{}” terhubung", + "approved_hint": "TV akan masuk ke akunmu dalam beberapa detik.", + "linked_devices": "Perangkat terhubung", + "no_devices": "Belum ada perangkat terhubung", + "unnamed_device": "Perangkat tanpa nama", + "last_seen": "Terakhir terlihat: {}", + "unlink_title": "Putuskan perangkat", + "unlink_message": "Perangkat ini akan keluar dari akun dan butuh kode QR baru untuk terhubung lagi.", + "unlink_confirm": "Putuskan", + "camera_denied": "Akses kamera ditolak. Izinkan di Pengaturan, atau ketik kodenya saja.", + "camera_error": "Kamera tidak bisa dijalankan.", + "error_bad_code": "Kodenya harus 8 karakter.", + "error_not_found": "Kode itu tidak ditemukan. Ambil yang baru di TV.", + "error_expired": "Kodenya kedaluwarsa. Tekan Segarkan di TV.", + "error_already_used": "Kode itu sudah dipakai.", + "error_unauthorized": "Masuk dulu.", + "error_rate_limited": "Terlalu banyak percobaan. Coba lagi sebentar.", + "error_network": "Tidak ada koneksi internet.", + "error_generic": "Ada yang tidak beres." + }, + "anilist": { + "connections_title": "Koneksi", + "connect": "Hubungkan AniList", + "disconnect": "Putuskan", + "disconnect_confirm": "Akun AniList-mu akan diputus dan judul yang tertaut dilupakan. Daftarmu tetap ada di AniList.", + "not_connected": "Belum terhubung", + "connect_explainer": "Hubungkan AniList dan episode yang kamu tonton ditandai otomatis, dengan daftarmu tampil di sini.", + "connected_explainer": "Episode yang ditonton ditulis ke daftar AniList-mu. Koneksinya tersimpan di akunmu, jadi TV dan perangkat lainmu tidak akan meminta masuk lagi.", + "connect_prompt": "Hubungkan akunmu untuk melihat perpustakaan AniList-mu di sini.", + "upcoming_connect_prompt": "Hubungkan AniList untuk melihat kapan episode berikutnya dari serial yang kamu ikuti tayang.", + "sign_in_first": "Masuk dulu ke akun Sozo-mu", + "browser_failed": "Peramban tidak bisa dibuka", + "open_library": "Perpustakaan AniList saya", + "linked_titles": "Judul yang tertaut", + "linked_titles_count": "{} judul tertaut", + "linked_titles_none": "Belum ada yang tertaut", + "unlink": "Lepas tautan", + "unlinked": "Tautan {} dilepas", + "auto_matched": "Otomatis", + "privacy_note": "Token AniList-mu tersimpan di akun Sozo-mu. Aplikasi hanya menulis nomor episode yang kamu tonton.", + "status_label": "STATUS", + "status_current": "Sedang ditonton", + "status_planning": "Direncanakan", + "status_completed": "Selesai", + "status_paused": "Dijeda", + "status_dropped": "Ditinggalkan", + "status_repeating": "Ditonton ulang", + "episodes_watched": "episode ditonton", + "empty_status": "Daftar “{}” milikmu kosong", + "behind_n": "{} baru", + "mark_episode_watched": "Tandai episode {} sudah ditonton", + "find_in_sources": "Cari di sumber", + "find_in_sources_hint": "Cari judul ini di semua sumber", + "open_on_anilist": "Buka di AniList", + "refresh": "Segarkan", + "retry": "Coba lagi", + "upcoming_title": "Akan datang", + "upcoming_empty": "Belum ada serial yang kamu ikuti yang mengumumkan episode berikutnya.", + "today": "Hari ini", + "tomorrow": "Besok", + "episode_n": "Episode {}", + "aired": "Sudah tayang", + "in_days": "dalam {} hari", + "in_hours": "dalam {} jam", + "in_minutes": "dalam {} mnt", + "link_title": "Tautkan ke AniList", + "link_hint": "Pilih anime AniList mana yang cocok dengan judul ini.", + "link_search_hint": "Nama anime", + "link_no_results": "Tidak ada yang ditemukan", + "linked_to": "Tertaut ke {}", + "tracking_on": "Dilacak di AniList", + "track_on_anilist": "Lacak di AniList", + "n_episodes_short": "{} ep", + "open_anilist": "AniList", + "connect_tagline": "Catat otomatis apa yang kamu tonton", + "connect_short": "Hubungkan", + "calendar_title": "Kalender tayang", + "calendar_all": "Semua", + "calendar_mine": "Daftar saya", + "calendar_episode": "Episode {episode}", + "calendar_empty": "Tidak ada yang tayang pada hari ini.", + "calendar_empty_mine": "Tidak ada dari daftarmu yang tayang pada hari ini.", + "calendar_open": "Kalender tayang", + "calendar_error": "Jadwal tayang tidak bisa dimuat.", + "calendar_rate_limited": "AniList sedang sibuk. Coba lagi sebentar.", + "calendar_list_error": "Daftar AniList-mu tidak bisa dimuat.", + "calendar_now": "sekarang", + "calendar_count": "{count} tayangan", + "calendar_count_mine": "{mine} dari {total} milikmu", + "calendar_episode_range": "Episode {from}-{to}", + "calendar_add_planning": "Tambahkan ke Direncanakan", + "calendar_added_planning": "Ditambahkan ke daftar Direncanakan-mu", + "calendar_add_failed": "Ini tidak bisa ditambahkan ke daftarmu", + "calendar_already_on_list": "Sudah ada di daftarmu", + "browse_title": "Jelajah", + "browse_trending": "Sedang tren", + "browse_season": "Musim ini", + "browse_popular": "Populer sepanjang masa", + "browse_upcoming": "Segera hadir", + "browse_empty": "Tidak ada yang bisa ditampilkan di sini.", + "browse_failed": "AniList tidak bisa dijangkau.", + "episodes_count": "{count} ep", + "reminder_body": "Episode {episode} tayang 10 menit lagi", + "reminders_on": "Pengingat aktif — sebelum dan saat episode tayang", + "reminders_off": "Pengingat episode mati", + "connect_first": "Hubungkan AniList dulu", + "save_failed": "Gagal menyimpan ke AniList", + "search_in_source": "CARI DI SATU SUMBER", + "no_sources": "Belum ada sumber terpasang", + "released_body": "Episode {episode} sudah keluar", + "remove_entry": "Hapus dari daftar saya", + "remove_confirm": "Hapus “{}” dari AniList-mu? Progresmu untuk judul itu ikut terhapus.", + "removed": "Dihapus dari AniList" + }, + "mal": { + "connect": "Hubungkan MyAnimeList", + "disconnect": "Putuskan MyAnimeList", + "disconnect_confirm": "Akun MyAnimeList-mu akan diputus dan judul yang dicocokkan dilupakan. Daftarmu tetap ada di MyAnimeList.", + "connect_explainer": "Hubungkan MyAnimeList dan episode yang kamu tonton ditandai otomatis.", + "connected_explainer": "Episode yang ditonton ditulis ke MyAnimeList-mu. Koneksinya tersimpan di akunmu, jadi TV dan perangkat lainmu tidak akan meminta masuk lagi.", + "not_configured": "MyAnimeList belum disiapkan di server", + "matching_title": "Cara judul dicocokkan", + "matching_note": "Pencocokan lewat AniList, yang membawa id MyAnimeList untuk entri yang sama. Tautkan sebuah judul sekali dan kedua pelacak mengikutinya.", + "privacy_note": "Token AniList dan MyAnimeList-mu tersimpan di akun Sozo-mu. Aplikasi hanya menulis nomor episode yang kamu tonton.", + "tracking_on": "Dilacak di MyAnimeList", + "save_failed": "Gagal menyimpan ke MyAnimeList", + "open_library": "MyAnimeList saya", + "linked_titles": "Judul yang tertaut", + "connect_prompt": "Hubungkan akunmu untuk melihat MyAnimeList-mu di sini.", + "connect_first": "Hubungkan MyAnimeList dulu", + "search_failed": "MyAnimeList tidak bisa dijangkau.", + "link_title": "Tautkan ke MyAnimeList", + "link_hint": "Pilih anime MyAnimeList mana yang cocok dengan judul ini.", + "linked_to": "Tertaut ke {}", + "track": "Lacak di MyAnimeList", + "tracked": "Dilacak di MyAnimeList", + "score_label": "SKOR", + "remove_entry": "Hapus dari daftar saya", + "remove_confirm": "Hapus “{}” dari MyAnimeList-mu? Progresmu untuk judul itu ikut terhapus.", + "removed": "Dihapus dari MyAnimeList", + "rewatching_note": "Kamu sedang menonton ulang ini. Sozo membiarkan statusnya agar tontonan ulangmu tidak diakhiri." + }, + "tracker": { + "title": "Pelacakan", + "tab_following": "Diikuti", + "tab_upcoming": "Akan datang", + "following_empty": "Ikuti sebuah serial untuk diberi tahu saat episode baru rilis.", + "new_episodes_found": "{} judul punya episode baru", + "no_new_episodes": "Tidak ada episode baru", + "n_episodes": "{} episode", + "not_checked": "Belum diperiksa", + "unfollow": "Berhenti mengikuti", + "unfollowed": "{} tidak diikuti lagi", + "undo": "Urungkan" + }, + "remote": { + "title": "Remote TV", + "no_devices": "Belum ada TV yang terhubung. Pasangkan satu lewat aplikasi TV dulu.", + "load_failed": "TV-mu tidak bisa dimuat.", + "tv_offline": "TV sedang luring", + "idle": "Tidak ada yang diputar", + "command_failed": "Perintah itu tidak sampai ke TV", + "search_on_tv": "CARI DI TV", + "search_hint": "Ketik di sini, pencarian berjalan di TV", + "back": "Kembali", + "home": "Beranda", + "open_on_tv": "Putar di TV", + "sent_to_tv": "Dikirim ke {device}", + "connected_to": "Tersambung ke {device}", + "not_connected_to": "{device} tidak tersambung", + "turn_tv_on": "Buka Sozo di TV untuk mengendalikannya", + "retry": "Coba lagi", + "navigation": "NAVIGASI", + "playback": "PEMUTARAN", + "pick_device": "TV yang mana?", + "device_online": "Daring", + "device_offline": "Luring — buka Sozo di sana" + }, + "live_tv": { + "title": "TV langsung", + "favourites": "Favorit", + "recent": "Baru ditonton", + "categories": "Kategori", + "countries": "Negara", + "results": "Hasil pencarian", + "search_hint": "Nama kanal…", + "search_in": "Cari di {folder}…", + "no_match": "Tidak ada kanal yang cocok.", + "empty": "Belum ada kanal.", + "load_failed": "Kanal tidak bisa dimuat.", + "retry": "Coba lagi", + "now": "Sekarang", + "next": "Berikutnya", + "guide": "Panduan TV", + "no_guide": "Tidak ada panduan untuk kanal ini.", + "watch": "Tonton", + "favourite_add": "Tambahkan ke favorit", + "favourite_remove": "Hapus dari favorit", + "hint_long_press": "Tekan lama sebuah kanal untuk panduan dan favorit.", + "channel_count": { + "one": "{} kanal", + "other": "{} kanal" + }, + "up_next": "BERIKUTNYA", + "minutes_left": "sisa {} mnt" + }, + "time": { + "now": "baru saja", + "minutes": "{} mnt lalu", + "hours": "{} jam lalu", + "days": "{} hr lalu" + }, + "user_lists": { + "title": "Daftar saya", + "remove": "Singkirkan", + "empty_title": "Belum ada yang disimpan", + "empty_subtitle": "Judul yang kamu simpan muncul di sini." + }, + "torrent": { + "title": "Torrent", + "search_hint": "Cari torrent…", + "empty_hint": "Cari di Nyaa, Tokyo Toshokan, dan nekoBT sekaligus.", + "searching": "Mencari di tracker…", + "no_results": "Tidak ada yang ditemukan di tracker yang aktif", + "no_results_hint": "Coba judul yang lebih pendek, atau longgarkan filternya.", + "results_n": "{} hasil", + "seeders": "seeder", + "leechers": "leecher", + "unknown_swarm": "Ukuran swarm tidak diketahui", + "dead": "Tanpa seeder", + "dead_warning": "Torrent ini tidak punya seeder dan tidak bisa diputar streaming.", + "trusted": "Tepercaya", + "remake": "Unggahan ulang", + "filters": "Filter", + "category": "Kategori", + "cat_all": "Semua", + "cat_anime": "Anime", + "cat_english": "Subtitle Inggris", + "cat_non_english": "Bahasa lain", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Live action", + "sort": "Urutkan menurut", + "sort_seeders": "Seeder", + "sort_date": "Terbaru", + "sort_size": "Ukuran", + "sort_downloads": "Unduhan", + "min_seeders": "Seeder minimum", + "quality": "Kualitas", + "any_quality": "Apa saja", + "trusted_only": "Hanya pengunggah tepercaya", + "exclude_hardsubs": "Sembunyikan rilisan hardsub", + "exclude_mini": "Sembunyikan mini-encode", + "exclude_remakes": "Sembunyikan unggahan ulang", + "require_dual_audio": "Hanya dual audio", + "exclude_batches": "Sembunyikan paket musim", + "trackers": "Tracker", + "reset_filters": "Setel ulang", + "consent_title": "Streaming lewat BitTorrent?", + "consent_body": "Torrent bersifat peer-to-peer. Selama satu torrent diputar, orang lain di swarm bisa melihat alamat IP-mu dan apa yang kamu unduh, dan perangkatmu juga mengunggah ke mereka.\n\nPakai VPN kalau hal itu penting di tempatmu.", + "consent_accept": "Saya mengerti", + "consent_remember": "Jangan tanya lagi", + "preparing": "Menjalankan mesin torrent…", + "fetching_metadata": "Mengambil daftar berkas dari swarm…", + "metadata_timeout": "Tidak ada jawaban dari swarm. Torrent ini mungkin sudah tanpa seeder.", + "engine_failed": "Mesin torrent tidak bisa dijalankan.", + "unsupported": "Streaming torrent hanya tersedia di Android.", + "pick_file": "Pilih sebuah berkas", + "pick_file_hint": "Torrent ini berisi {} video.", + "play": "Putar", + "copy_magnet": "Salin tautan magnet", + "magnet_copied": "Tautan magnet disalin", + "open_external": "Buka di aplikasi torrent", + "open_page": "Buka di tracker", + "stats_peers": "{} peer", + "buffering": "Menyangga {}%", + "still_searching": "{} tracker masih menjawab", + "buffering_title": "Menyangga…", + "no_video_in_torrent": "Torrent ini tidak berisi video yang bisa diputar.", + "tracker_failed": "{} tidak bisa dicari — hasilnya mungkin tidak lengkap." + }, + "backup": { + "title": "Cadangkan & pulihkan", + "subtitle": "Riwayat tontonan, daftar, sumber, dan pengaturan. Sesi login tidak termasuk.", + "export": "Buat cadangan", + "export_desc": "Simpan berkas yang bisa kamu simpan atau kirim ke dirimu sendiri", + "restore": "Pulihkan", + "restore_desc": "Baca kembali berkas cadangan ke dalam aplikasi", + "restore_warning": "Nilai dari cadangan akan menimpa yang ada di perangkat ini. Apa pun yang tidak ada di berkas dibiarkan apa adanya.", + "restored": "{} nilai dipulihkan", + "not_a_backup": "Berkas itu bukan cadangan Sozo", + "too_new": "Cadangan itu dibuat oleh versi aplikasi yang lebih baru", + "export_failed": "Cadangan tidak bisa dibuat", + "restore_failed": "Cadangan itu tidak bisa dibaca", + "file_subject": "Cadangan Sozo", + "last_export": "Cadangan terakhir: {}", + "never_exported": "Belum ada cadangan yang dibuat di perangkat ini." + }, + "provider_test": { + "title": "Uji sumber", + "intro": "Menjalankan pencarian sungguhan, membuka satu judul, dan meminta byte pertama ke CDN. Hasilnya berlaku untuk perangkat dan jaringan ini, saat ini.", + "summary": "{} dari {} berhasil diputar", + "run_all": "Uji semua", + "run_one": "Uji", + "stop": "Hentikan", + "stage_catalogue": "Katalog", + "stage_detail": "Judul", + "stage_stream": "Tautan", + "stage_playback": "Pemutaran", + "entry": "Uji sumber" + }, + "catalog": { + "title": "Sumber menurut bahasa", + "subtitle": "Jelajahi semua sumber ekstensi dari keempat ekosistem", + "type_all": "Semua", + "type_anime": "Anime", + "type_video": "Film & serial", + "type_manga": "Manga", + "type_novel": "Novel", + "add_repo": "Tambah repo", + "installed": "Menambahkan {} sumber dari {}", + "install_empty": "Repo itu tidak mengembalikan apa pun yang bisa dipasang.", + "install_failed": "Pemasangan gagal: {}", + "needs_js": "Sumber Dart — tidak bisa dijalankan di sini", + "android_only": "Android saja", + "empty": "Belum ada apa-apa di sini. Coba bahasa lain atau bersihkan filternya.", + "unreachable": "Katalog sumber tidak bisa dijangkau." + }, + "mode": { + "video": "Tonton", + "manga": "Manga", + "novel": "Novel", + "none_installed": "Belum ada sumber {} yang terpasang." + }, + "home_rails": { + "title": "Tata letak beranda", + "entry_title": "Sesuaikan beranda", + "entry_subtitle": "Pilih baris mana yang muncul dan urutannya", + "hint": "Seret untuk mengurutkan. Ketuk ikon mata untuk menyembunyikan baris — tempatnya tetap, jadi menyalakannya lagi mengembalikannya ke posisi semula.", + "hero": "Banner unggulan", + "resume": "Lanjutkan menonton", + "genres": "Genre", + "live_tv": "TV langsung", + "catalogue": "Baris katalog" + }, + "stats": { + "title": "Statistik", + "entry_subtitle": "Waktu tonton, rentetan, ke mana perginya", + "watched": "Ditonton", + "hours_short": "j", + "minutes_short": "mnt", + "since": "Dihitung sejak {}", + "completed": "Selesai", + "streak": "Rentetan hari", + "last_days": "{} hari terakhir", + "by_source": "Menurut sumber", + "empty_title": "Belum ada yang dihitung", + "empty_subtitle": "Tonton sesuatu dan totalnya mulai di sini. Yang ditonton dalam mode samaran tidak dihitung." + }, + "discord": { + "sign_in": "Masuk dengan Discord", + "sign_in_desc": "Masuk di halaman login Discord sendiri. Tokennya diambil otomatis dan disimpan hanya di perangkat ini.", + "or_paste": "atau tempel token sendiri", + "web_login_title": "Hubungkan Discord", + "web_login_paste": "Tempel token", + "web_login_paste_title": "Tempel token Discord", + "web_login_paste_hint": "Token pengguna Discord-mu", + "title": "Discord", + "what_it_does": "Menampilkan apa yang kamu tonton di profil Discord-mu — judulnya, episodenya, dan sudah sampai mana. Temanmu melihatnya di daftar teman mereka.", + "enable": "Tampilkan di Discord", + "enable_desktop_desc": "Butuh aplikasi Discord berjalan di komputer ini", + "enable_mobile_desc": "Butuh token Discord-mu — baca dulu peringatan di bawah", + "connected": "Terhubung", + "not_connected": "Belum terhubung", + "risk_title": "Baca ini sebelum lanjut", + "risk_body": "Di ponsel tidak ada aplikasi Discord untuk disambungkan, jadi Sozo harus masuk sebagai kamu memakai tokenmu sendiri. Discord tidak mengizinkan ini: akunmu bisa ditangguhkan. Token memberi akses penuh ke akun Discord-mu, dan Sozo menyimpannya hanya di perangkat ini — tidak pernah di server. Hapus kapan saja dengan Lupakan.", + "token_label": "Token Discord", + "token_hint": "Tempel tokenmu", + "token_saved": "Sudah ada token tersimpan — tempel yang baru untuk menggantinya", + "token_save": "Simpan", + "token_forget": "Lupakan", + "brand_title": "Rich Presence", + "brand_subtitle": "Temanmu melihat langsung di profilmu apa yang sedang kamu tonton", + "preview_heading": "SEDANG MENONTON", + "preview_title": "Judulnya muncul di sini", + "preview_subtitle": "Musim dan episode", + "preview_elapsed": "{} berlalu", + "preview_elapsed_placeholder": "Waktu berlalu", + "row_off": "Mati", + "row_live": "Tampil di profilmu", + "row_waiting": "Nyala — menunggu Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — gambar bisa tampak hijau atau ungu di perangkat ini", + "atmos": "Audio Atmos — perangkat ini mungkin memutarnya tanpa suara", + "indirect_host": "Cermin ini membuka halaman, bukan videonya", + "uhd": "4K — lebih berat untuk didekode dan diunduh", + "large_file": "Berkas besar — lambat dimulai di data seluler", + "unreachable": "Tautan ini tidak menjawab saat diperiksa" + }, + "update": { + "available_title": "Versi baru tersedia (v{})", + "available_body": "Versi baru aplikasi sudah tersedia.", + "later": "Nanti", + "now": "Perbarui", + "open_store": "Buka di App Store", + "required_title": "Perlu pembaruan", + "required_body": "Pasang versi baru (v{}) untuk terus menggunakan aplikasi.", + "exit_app": "Keluar dari aplikasi", + "downloading": "Mengunduh pembaruan…" + }, + "ext": { + "cloudstream_title": "Sumber CloudStream", + "aniyomi_title": "Sumber Aniyomi", + "mangayomi_title": "Sumber Mangayomi", + "android_only": "Fitur ini hanya tersedia di Android", + "needs_webview": "Ekstensi JavaScript memerlukan WebView, yang tidak tersedia di platform ini.", + "check_updates": "Periksa pembaruan", + "check_extension_updates": "Periksa pembaruan ekstensi", + "installed_sources": "SUMBER TERPASANG", + "installed_repos": "REPO TERPASANG", + "sources_heading": "SUMBER", + "recommended": "DIREKOMENDASIKAN", + "repo_url": "URL repo", + "index_url": "URL indeks", + "browse_plugins": "Jelajahi plugin", + "no_sources_yet": "Belum ada sumber", + "no_repos_yet": "Belum ada repo", + "add_source": "Tambah sumber", + "add_repo": "Tambah repo", + "installing": "Memasang…", + "installing_extensions": "Memasang ekstensi…", + "installing_plugins": "Memasang plugin…", + "installing_progress": "Memasang {} / {}", + "installing_plugins_progress": "Memasang {} / {} plugin…", + "installing_extensions_progress": "Memasang {} / {} ekstensi…", + "installing_long": "Memasang ekstensi… untuk repo besar ini bisa memakan waktu.", + "checking_updates": "Memeriksa pembaruan…", + "checking_extension_updates": "Memeriksa pembaruan ekstensi…", + "all_up_to_date": "Semua ekstensi sudah terbaru.", + "updated_count": "{} ekstensi diperbarui.", + "updated_list": "{} diperbarui: {}", + "update_check_failed": "Gagal memeriksa pembaruan: {}", + "source_removed": "Sumber dihapus.", + "repo_removed": "Repo dihapus.", + "none_found": "Tidak ada ekstensi di URL tersebut.", + "none_found_js": "Tidak ada ekstensi JavaScript di URL tersebut.", + "added_sources": "{} sumber ditambahkan.", + "added_sources_providers": "{} sumber · {} penyedia ditambahkan.", + "installed_from_file": "{} sumber dipasang.", + "could_not_install": "Tidak dapat memasang: {}", + "could_not_install_plugin": "Tidak dapat memasang plugin", + "could_not_remove_plugin": "Tidak dapat menghapus plugin", + "error": "Kesalahan: {}", + "cloudstream_desc": "Tambahkan repo CloudStream, lalu pasang hanya plugin yang kamu mau — hanya itu yang muncul sebagai penyedia. Semuanya berjalan di perangkatmu (hanya Android).", + "aniyomi_desc": "Tambahkan repo ekstensi Aniyomi untuk memasang penyedia anime tambahan. Semuanya berjalan di perangkatmu (hanya Android).", + "mangayomi_desc": "Ekstensi Mangayomi berupa JavaScript, bukan aplikasi Android — jadi tidak seperti sumber CloudStream, Aniyomi, dan Manga, ekstensi ini juga berjalan di iOS, macOS, dan Windows.", + "plugins_summary": "{} plugin · {} terpasang", + "install_all": "Pasang semua", + "repo_load_failed": "Repo ini tidak dapat dimuat.", + "no_plugins": "Tidak ada plugin di repo ini", + "search_plugins": "Cari plugin…", + "no_matches": "Tidak ada hasil", + "import_title": "Pasang repo ekstensi?", + "import_pick_library": "Repo anime dan manga memakai format berkas yang sama, jadi pilih repo ini masuk ke pustaka yang mana.", + "import_js_only": "Di platform ini hanya ekstensi Mangayomi (JavaScript) yang bisa dipasang — repo CloudStream, Aniyomi, dan Mihon berisi paket aplikasi Android.", + "import_none_found": "Tidak ada ekstensi di berkas ini. Mungkin berkas ini untuk aplikasi lain, atau tipenya salah pilih.", + "mangayomi_url_only": "Repo Mangayomi dipasang dari URL — buka tautan indeksnya, bukan salinan yang sudah diunduh.", + "skipped_dart": "{} sumber khusus Dart dilewati — semuanya tertanam di Mangayomi dan tidak bisa berjalan di sini.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Novel" + }, + "ux": { + "quick_search": "Pencarian cepat ({})", + "quick_search_note": "Pencarian cepat mencakup {} dari {} sumber. Pilih sumber untuk pencarian tertentu.", + "torrents": "Torrent", + "filters": "Filter", + "change_source": "Ganti sumber", + "manage_sources": "Kelola sumber", + "no_source_match": "Tidak ada sumber yang cocok dengan pencarian.", + "download_options": "Opsi unduhan", + "download_options_note": "Pilih sumber dan kualitas yang tersedia. Audio mengikuti sumber yang dipilih.", + "source_number": "Sumber {}", + "checking_quality": "Memeriksa kualitas yang tersedia…", + "quality_unavailable": "Kualitas tidak dapat diperiksa. Coba lagi atau unduh streaming asli tanpa jaminan kualitas.", + "quality_unknown": "Kualitas tidak diketahui", + "size_unknown": "Sumber tidak memberikan ukuran berkas", + "interface_language_note": "Hanya antarmuka aplikasi yang berubah. Judul film, deskripsi, dan konten sumber lainnya tetap dalam bahasa aslinya.", + "continue": "Lanjutkan" + } +} diff --git a/assets/translations/nl.json b/assets/translations/nl.json new file mode 100644 index 00000000..15cf718a --- /dev/null +++ b/assets/translations/nl.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Laden…", + "error": "Er ging iets mis", + "retry": "Opnieuw", + "cancel": "Annuleren", + "confirm": "Bevestigen", + "ok": "Oké", + "yes": "Ja", + "no": "Nee", + "back": "Terug", + "next": "Volgende", + "done": "Klaar", + "save": "Opslaan", + "delete": "Verwijderen", + "edit": "Bewerken", + "search": "Zoeken", + "close": "Sluiten", + "settings": "Instellingen", + "logout": "Uitloggen", + "no_internet": "Geen internetverbinding", + "try_again": "Probeer opnieuw", + "see_all": "Alles bekijken", + "unknown": "Onbekend", + "off": "Uit", + "on": "Aan", + "new_badge": "NIEUW", + "reset": "Herstellen", + "clear": "Wissen", + "copy": "Kopiëren", + "share": "Delen", + "install": "Installeren", + "remove": "Verwijderen", + "show": "Tonen", + "hide": "Verbergen", + "installed": "Geïnstalleerd" + }, + "auth": { + "login": "Inloggen", + "register": "Registreren", + "email": "E-mail", + "password": "Wachtwoord", + "username": "Gebruikersnaam", + "email_hint": "Vul je e-mailadres in", + "password_hint": "Vul je wachtwoord in", + "username_hint": "Vul je gebruikersnaam in", + "forgot_password": "Wachtwoord vergeten?", + "dont_have_account": "Nog geen account?", + "already_have_account": "Al een account?", + "sign_up": "Registreren", + "sign_in": "Inloggen", + "login_success": "Inloggen gelukt", + "register_success": "Registratie gelukt", + "logout_success": "Je bent uitgelogd", + "invalid_email": "Ongeldig e-mailadres", + "invalid_password": "Het wachtwoord moet minstens 6 tekens hebben", + "invalid_username": "De gebruikersnaam moet minstens 3 tekens hebben", + "confirm_password": "Wachtwoord bevestigen", + "passwords_not_match": "De wachtwoorden komen niet overeen", + "terms_agree": "Ik ga akkoord met de voorwaarden en het privacybeleid", + "or_continue_with": "Of ga verder met", + "welcome_back": "Welkom terug", + "create_account": "Account aanmaken", + "login_subtitle": "Log in om je profiel en opgeslagen gegevens te gebruiken.", + "register_subtitle": "Maak een account met e-mail, gebruikersnaam en wachtwoord.", + "forgot_password_title": "Wachtwoord opnieuw instellen", + "forgot_password_subtitle": "Vul het e-mailadres van je account in, dan sturen we een code van 6 cijfers.", + "reset_password_subtitle": "We hebben een code naar {email} gestuurd. Vul hem in met je nieuwe wachtwoord.", + "send_reset_code": "Code versturen", + "reset_code_sent": "Als dat e-mailadres bekend is, is er een code onderweg", + "reset_code_hint": "Code van 6 cijfers", + "invalid_reset_code": "Vul de code van 6 cijfers in", + "new_password_hint": "Nieuw wachtwoord", + "confirm_password_hint": "Herhaal nieuw wachtwoord", + "reset_password": "Wachtwoord opnieuw instellen", + "resend_code": "Nog een code sturen", + "resend_in": "Opnieuw sturen over {seconds}s", + "identifier_hint": "E-mail of gebruikersnaam", + "identifier_required": "E-mail of gebruikersnaam verplicht", + "continue_with_google": "Doorgaan met Google", + "google_unavailable": "Inloggen met Google werkt niet op dit apparaat", + "google_failed": "Inloggen met Google is mislukt. Probeer het opnieuw", + "weak": "Zwak", + "medium": "Gemiddeld", + "strong": "Sterk", + "code_resent": "Code opnieuw verstuurd. Kijk in je mail.", + "verify": "Bevestigen", + "avatar_bad_format": "Beeldformaat wordt niet ondersteund (jpg, png, webp)", + "verification_code": "Verificatiecode" + }, + "onboarding": { + "skip": "Overslaan", + "continue_with_email": "Doorgaan met e-mail", + "slide_1_title": "Duizenden films en series", + "slide_1_body": "Nieuwe releases, wereldcinema en animatie — alles in één app.", + "slide_2_title": "Anime, een complete bibliotheek", + "slide_2_body": "Seizoensreleases, klassiekers en ondertitels. Koppel AniList en je lijst werkt zichzelf bij.", + "slide_3_title": "Ook op het grote scherm", + "slide_3_body": "Live-tv-kanalen, je telefoon koppelen aan de televisie en samen kijken — Sozo draait ook op tv." + }, + "home": { + "title": "Start", + "continue_watching": "Verder kijken", + "trending": "Nu populair", + "new_releases": "Nieuwe releases", + "top_rated": "Best beoordeeld", + "for_you": "Voor jou", + "all_categories": "Alle categorieën", + "genres": "Genres", + "get_all": "Alles halen", + "popular": "Populair", + "watch_now": "Nu kijken", + "more_info": "Meer info", + "add_to_list": "Aan lijst toevoegen", + "my_list": "Mijn lijst", + "featured": "Uitgelicht", + "action": "Actie", + "comedy": "Komedie", + "drama": "Drama", + "thriller": "Thriller", + "horror": "Horror", + "romance": "Romantiek", + "sci_fi": "Sciencefiction", + "documentary": "Documentaire", + "animation": "Animatie", + "telegram_promo_title": "Doe mee met Sozo op Telegram", + "telegram_promo_subtitle": "Krijg updates, nieuwe functies en meldingen over nieuwe content.", + "telegram_join_channel": "Kanaal volgen", + "dont_show_again": "Niet meer tonen", + "downloaded": "Gedownload", + "view_all": "Alles bekijken", + "remove_from_continue": "Uit Verder kijken halen", + "ep_number": "AFL. {}", + "untitled": "Zonder titel", + "empty_title": "Hier staat nog niets", + "empty_subtitle": "Kies een andere bron, of trek omlaag om te verversen.", + "list_empty_title": "Deze lijst is leeg", + "list_empty_subtitle": "Voor dit onderdeel valt nu niets te tonen." + }, + "player": { + "needs_media_kit": "Vereist de media_kit-engine", + "auto_quality_desc": "De speler kiest het beste dat je verbinding aankan", + "on": "Aan", + "info_title": "Spelerinfo", + "info_resolution": "Resolutie", + "info_engine": "Engine", + "info_provider": "Bron", + "info_server": "Server", + "info_quality": "Kwaliteit", + "info_codec": "Codec", + "info_hdr": "HDR", + "info_audio": "Audio", + "info_container": "Container", + "info_size": "Bestandsgrootte", + "info_host": "Host", + "info_position": "Positie", + "info_buffer": "Voorgebufferd", + "info_speed": "Snelheid", + "info_state": "Status", + "switching_to": "Overschakelen naar {}…", + "fault": { + "decoder": "Dit apparaat kon de video niet decoderen. Probeer een andere kwaliteit of opnieuw.", + "unsupported_format": "Dit videoformaat wordt niet ondersteund op je apparaat. Je kunt het in je browser afspelen.", + "source_blocked": "De videobron kon niet worden geopend — de server heeft hem mogelijk geblokkeerd. Probeer een andere kwaliteit.", + "network": "Netwerkfout — controleer je verbinding.", + "no_source": "Geen bron voor deze aflevering.", + "empty_url": "Deze bron gaf geen videoadres terug.", + "engine_unavailable": "De speler is niet gestart. Start de app volledig opnieuw op.", + "unknown": "Video kon niet worden geladen." + }, + "play": "Afspelen", + "pause": "Pauze", + "resume": "Hervatten", + "forward": "10 s vooruit", + "backward": "10 s terug", + "fullscreen": "Volledig scherm", + "exit_fullscreen": "Volledig scherm sluiten", + "quality": "Kwaliteit", + "subtitles": "Ondertiteling", + "audio": "Audio", + "speed": "Afspeelsnelheid", + "auto": "Automatisch", + "off": "Uit", + "episodes": "Afleveringen", + "next_episode": "Volgende aflevering", + "season": "Seizoen {n}", + "fit_original": "Origineel", + "fit_fill": "Vullen", + "fit_stretch": "Uitrekken", + "play_in_browser": "In browser afspelen", + "view_logs": "Logboek bekijken", + "speed_2x": "2x snelheid", + "tap_to_unlock": "Tik om te ontgrendelen", + "previous": "Vorige", + "live": "LIVE", + "go_live": "Naar live", + "lang_sub": "Ondertiteld (SUB)", + "lang_dub": "Nagesynchroniseerd (DUB)", + "lang_softsub": "Zachte ondertiteling", + "audio_language": "Audiotaal", + "speed_short": "Snelheid", + "aspect": "Verhouding", + "aspect_ratio": "Beeldverhouding", + "subtitle_style": "Ondertitelstijl", + "audio_track": "Audiospoor", + "diagnostics_logs": "Diagnose / logboek", + "extracting_media": "Media uitpakken…", + "loading_video": "Video laden…", + "fetching_link": "Afspeellink ophalen bij de bron", + "preparing_stream": "Videostream voorbereiden", + "episode_n": "Aflevering {}", + "search_online": "Online zoeken", + "subtitle_sync": "Ondertitelsynchronisatie", + "subtitle_sync_desc": "Timing verschuiven zodat hij bij het geluid past", + "subtitle_sync_help": "Ondertitels te vroeg? Verhogen (+). Te laat? Verlagen (−).", + "search_subtitles": "Ondertitels zoeken", + "subtitle_search_hint": "Titel / filmnaam", + "searching_subtitles": "Ondertitels zoeken…", + "no_subtitles_found": "Geen ondertitels gevonden", + "search_failed": "Zoeken mislukt: {}", + "online_subtitles_count": "Online ondertitels · {}", + "loading_subtitle": "Ondertitel laden…", + "subtitle_loaded": "Ondertitel geladen", + "subtitle_failed_download": "Deze ondertitel kon niet worden gedownload", + "subtitle_failed_empty": "Dit ondertitelbestand bevat geen tekst", + "subtitle_failed_archive": "Ingepakte ondertitels (.zip / .rar) worden niet ondersteund", + "subtitle_failed_html": "De server gaf een webpagina terug, geen ondertitel", + "subtitle_failed_format": "Ondertitelformaat wordt niet ondersteund", + "subtitle_rate": "Beeldsnelheidsconversie", + "subtitle_rate_help": "Loopt het steeds verder uiteen? De ondertitel is getimed voor een andere beeldsnelheid — kies de conversie.", + "subtitle_rate_off": "Uit", + "reset": "Herstellen", + "font_size": "Tekengrootte", + "text_color": "Tekstkleur", + "background": "Achtergrond", + "none": "Geen", + "solid": "Dekkend", + "edge": "Rand", + "shadow": "Schaduw", + "outline": "Omlijning", + "position": "Positie", + "lower": "Lager", + "default": "Standaard", + "higher": "Hoger", + "bold": "Vet", + "audio_track_engine_only": "alleen media_kit", + "audio_track_single": "Slechts één spoor", + "external_player": "In externe speler afspelen", + "external_player_gated": "Deze bron heeft speciale request-headers nodig die VLC en MX Player niet kunnen sturen. Hij zal hoogstwaarschijnlijk niet laden. Toch proberen?", + "external_player_try": "Toch proberen", + "external_player_none": "Geen externe videospeler gevonden", + "audio_track_numbered": "Audio {}", + "engine_sheet_title": "Hoe moet dit afspelen?", + "engine_sheet_subtitle": "Kies de engine voor deze video. Je kunt dit altijd wijzigen bij Instellingen → Speler.", + "engine_sheet_dont_ask": "Niet meer vragen", + "engine_sheet_play": "Afspelen", + "subtitle_preview_text": "Pa's wijze lynx bezag vroom het fikse aquaduct", + "server": "Server", + "stream_not_found": "Deze bron verstopt zijn stream achter een beveiligde speler. Probeer een andere bron of open hem in je browser.", + "subtitle_translation": "Ondertitelvertaling", + "auto_translate": "Automatisch vertalen", + "auto_translate_desc": "Heeft de bron geen ondertitel in jouw taal, dan vertaalt de AI hem", + "translate_to": "Vertalen naar", + "translate_note": "De vertaling is machinaal en kan fout zijn. Er geldt een daglimiet.", + "translate_ai": "AI-vertaling", + "translating_subtitle": "Vertalen…", + "translate_failed": "Vertalen mislukt", + "ai_translate_title": "Met AI naar {} vertalen", + "ai_translate_hint": "Kies een ondertitel uit de lijst en tik op AI", + "ai_translate_remaining": "Nog {}/{} vertalingen vandaag", + "ai_translate_used_up": "Je daglimiet voor vertalingen is op", + "ai_translate_menu": "AI-vertaling", + "ai_translate_menu_desc": "Vertaalt een ondertitel naar {}", + "ready_translations": "Klaarstaande vertalingen", + "lines": "regels", + "sleep_timer": "Slaaptimer", + "sleep_minutes": "{} minuten", + "sleep_hours": "{} uur", + "sleep_minutes_short": "{} m", + "sleep_episode_end": "Einde van de aflevering", + "sleep_fired": "Slaaptimer — afspelen gepauzeerd", + "skip_opening": "Intro overslaan", + "skip_ending": "Aftiteling overslaan", + "alt_sources": "Andere bron proberen", + "alt_searching": "Andere bronnen doorzoeken…", + "alt_none": "Geen andere bron heeft deze titel", + "alt_failed": "Deze bron kon niet worden geopend", + "alt_no_episode": "Deze bron heeft aflevering {} niet", + "alt_search_all": "Alle bronnen doorzoeken", + "guide": "Gids", + "change_source": "Bron wisselen", + "cast": "Casten naar tv", + "cast_to": "Casten naar", + "cast_searching": "Zoeken naar apparaten op deze wifi…", + "cast_none": "Geen apparaten gevonden. De tv moet op dezelfde wifi zitten als deze telefoon.", + "cast_failed": "Kon geen verbinding maken met {}", + "cast_unsupported": "Deze stream kan niet worden gecast", + "cast_active": "Speelt af op {}", + "cast_stop": "Casten stoppen", + "color_profile": "Beeld", + "color_natural": "Natuurlijk", + "color_cinema": "Bioscoop", + "color_night": "Nacht", + "color_anime": "Anime", + "color_restore": "Herstellen", + "shaders": "Verbeteren (Anime4K)", + "shaders_note": "GPU-opschaling voor animatie. De shaderbestanden worden bij het eerste gebruik één keer gedownload.", + "shader_off": "Uit", + "shader_off_desc": "Geen verbetering", + "shader_sharpen": "Verscherpen", + "shader_sharpen_desc": "Detail terughalen — het beste voor schone bronnen", + "shader_deblur": "Ontwazen", + "shader_deblur_desc": "Zachter herstel — voor wazige bronnen", + "shader_denoise": "Ruis verwijderen", + "shader_denoise_desc": "Korrel opruimen — voor sterk gecomprimeerde bronnen", + "shader_tier": "GPU-BUDGET", + "shader_tier_mid": "Gebalanceerd", + "shader_tier_mid_desc": "Soepel op de meeste telefoons", + "shader_tier_high": "Maximaal", + "shader_tier_high_desc": "Het scherpst, vraagt een sterke GPU", + "font_family": "Lettertype", + "font_system": "Standaard", + "font_sans": "Schreefloos", + "font_serif": "Schreef", + "font_condensed": "Smal", + "font_mono": "Monospace", + "second_track": "Tweede spoor", + "second_track_on": "{} wordt als tweede spoor getoond", + "second_track_same": "Dat spoor is al het eerste", + "second_track_hint": "Houd een spoor ingedrukt om het naast het eerste te tonen", + "share_frame": "Dit beeld delen", + "share_frame_failed": "Het beeld kon niet worden vastgelegd", + "save_frame": "Beeld opslaan", + "save_frame_done": "Opgeslagen in galerij", + "save_frame_failed": "Het beeld kon niet worden opgeslagen", + "save_frame_denied": "Toegang tot de galerij is nodig om het beeld op te slaan", + "switching_server": "Server wisselen…", + "recommended": "Aanbevolen", + "episode_page_failed": "Het volgende deel van de serie kon niet worden geladen", + "info_fields_desc": "Kies wat er over de video verschijnt. Het komt samen met de bediening in beeld.", + "layout_title": "Spelerbediening", + "layout_desc": "Sleep om te herordenen, of verplaats een knop naar een ander deel van de balk.", + "layout_top_bar": "Bovenbalk", + "layout_bottom_left": "Linksonder", + "layout_bottom_right": "Rechtsonder", + "layout_hidden": "Verborgen", + "layout_hidden_desc": "Nog steeds bereikbaar via Instellingen in de speler.", + "layout_pinned": "Instellingen blijft staan — het is de weg terug naar al het andere.", + "layout_top_bar_full": "De bovenbalk is vol. Haal er eerst iets af.", + "layout_unknown_control": "Die knop is niet meer beschikbaar.", + "layout_move_to": "Verplaatsen", + "layout_empty": "Hier staat niets.", + "rotate": "Draaien", + "lock": "Vergrendelen", + "enhance": "Verbeteren", + "fit": "Passend maken", + "pip": "Beeld in beeld", + "layout_pinned_badge": "Vastgezet", + "info_fields_title": "Rijen van de info-overlay", + "logs_title": "Spelerlogboek ({})", + "logs_copied": "Logboek gekopieerd", + "logs_empty": "Nog geen logboek" + }, + "profile": { + "title": "Profiel", + "my_account": "Mijn account", + "notifications": "Meldingen", + "language": "Taal", + "theme": "Thema", + "privacy": "Privacybeleid", + "help": "Hulp en ondersteuning", + "about": "Over", + "version": "Versie", + "subscription": "Abonnement", + "downloads": "Downloads", + "watch_history": "Kijkgeschiedenis", + "manage_profiles": "Profielen beheren", + "parental_controls": "Ouderlijk toezicht", + "guest_title": "Log in op je profiel", + "guest_subtitle": "Log in zodat favorieten en kijkgeschiedenis gesynchroniseerd blijven.", + "auth_provider": "Aanmeldmethode", + "joined": "Lid sinds", + "providers": "Bronnen", + "providers_error": "Bronnen konden niet worden geladen.", + "offline_title": "Server onbereikbaar", + "offline_local_available": "{} bron(nen) op dit apparaat werken nog. Cloudbronnen zijn niet beschikbaar tot de server terug is.", + "offline_no_local": "De server reageert niet en er staat geen bron op dit apparaat. Installeer een CloudStream-plug-in om offline te blijven kijken.", + "offline_cached_at": "Cloudlijst van {}", + "offline_install_plugins": "Plug-ins installeren", + "offline_badge": "OFFLINE", + "provider_needs_server": "Deze bron heeft de server nodig, en die is nu onbereikbaar. Kies een bron die op dit apparaat staat.", + "logout_confirm": "Wil je uitloggen bij je account?", + "connections": "Verbindingen", + "coming_soon": "Binnenkort", + "favorites": "Favorieten", + "all_providers": "Alle bronnen", + "add_favorite": "Aan favorieten toevoegen", + "cloudstream_sources": "CloudStream-bronnen", + "aniyomi_sources": "Aniyomi-bronnen", + "signin_account_title": "Log in op je account", + "signin_account_subtitle": "Log in om je favorieten en kijkgeschiedenis te bewaren.", + "sign_in": "Inloggen", + "sign_out": "Uitloggen", + "sign_out_confirm": "Weet je zeker dat je wilt uitloggen?", + "section_providers": "Bronnen", + "section_content": "INHOUD", + "sources_row": "Extensiebronnen", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Bron", + "choose_provider": "Bron wijzigen", + "search_providers_hint": "Bronnen zoeken…", + "count_of_total_shown": "{} van {} getoond", + "no_providers_in_category": "Geen bronnen in deze categorie", + "try_select_all": "Kies „Alles” om elke bron te zien", + "section_activity": "ACTIVITEIT", + "share_sources_desktop": "Bronnen delen met desktop", + "desktop_sources": "Desktopbronnen", + "developer_role": "Mobiele ontwikkelaar", + "section_about": "OVER", + "developer": "Ontwikkelaar", + "website": "Website", + "server": "Server", + "expired": "Verlopen", + "support_title": "Houd Sozo draaiende", + "server_expired": "Server verlopen", + "days": "Dagen", + "hours": "Uren", + "min": "Min", + "sec": "Sec", + "support_body_expired": "De server is verlopen en er kan geen inhoud meer worden geladen. Jouw steun kan hem weer online krijgen.", + "support_body": "Sozo draait op een server die geregeld verlengd moet worden. Vind je de app fijn, dan helpt jouw steun om alles soepel te laten lopen.", + "support_developer": "Steun de ontwikkelaar", + "section_appearance": "WEERGAVE", + "native_window_bar": "Native vensterbalk", + "native_window_bar_subtitle": "Gebruik de standaard titelbalk van Windows in plaats van die van de app.", + "liquid_glass_nav": "Liquid glass-navigatie", + "liquid_glass_nav_subtitle": "Zwevende tabbalk van matglas (iOS 26). Zet uit voor een gewone, dekkende balk.", + "nav_style": "Navigatiebalk", + "nav_style_glass": "Liquid glass", + "nav_style_solid": "Dekkend", + "nav_style_classic": "Klassiek", + "section_player": "Speler", + "player_engine_native": "Systeemspeler", + "player_engine_native_desc": "De speler van het platform. Beste accuduur en hardwaredecodering. Kan niet van audiospoor wisselen.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Speelt meer formaten af en laat je van audio- en ondertitelspoor wisselen. Gebruikt iets meer accu.", + "player_engine_external": "Externe speler", + "player_engine_external_desc": "Stuur de video naar VLC of MX Player in plaats van hem hier af te spelen.", + "player_engine_external_note": "Sommige bronnen werken alleen binnen Sozo omdat ze speciale request-headers nodig hebben. Die spelen niet af in een externe app.", + "player_engine_badge_default": "STANDAARD", + "player_engine_section": "AFSPEELENGINE", + "ask_engine_on_play": "Elke keer vragen", + "ask_engine_on_play_desc": "Kies de engine telkens als een video start", + "playback_section": "AFSPELEN", + "default_speed": "Standaardsnelheid", + "default_speed_desc": "Snelheid waarmee elke video begint", + "default_fit": "Standaard vulmodus", + "default_fit_desc": "Hoe de video op het scherm past", + "auto_next_episode": "Volgende aflevering automatisch", + "auto_next_episode_desc": "Start de volgende aflevering zodra er een eindigt", + "keep_screen_on": "Scherm aan houden", + "keep_screen_on_desc": "Voorkomt dat het scherm uitgaat tijdens het kijken", + "gestures_section": "GEBAREN", + "seek_step": "Sprong bij dubbeltik", + "seek_step_desc": "Hoe ver een dubbeltik springt", + "long_press_boost": "Snelheid bij ingedrukt houden", + "long_press_boost_desc": "Snelheid zolang je de video ingedrukt houdt", + "brightness_gesture": "Helderheid vegen", + "brightness_gesture_desc": "Veeg omhoog en omlaag aan de linkerkant", + "volume_gesture": "Volume vegen", + "volume_gesture_desc": "Veeg omhoog en omlaag aan de rechterkant", + "seconds_short": "{} s", + "subtitle_style_note": "Dit geldt voor elke video. De ondertiteltiming blijft per aflevering en wordt in de speler ingesteld.", + "searching_all_sources": "alle bronnen doorzoeken", + "section_connections": "VERBINDINGEN", + "section_extensions": "BRONNEN", + "section_settings": "INSTELLINGEN", + "remove_favorite": "Uit favorieten halen", + "edit_profile": "Profiel bewerken", + "display_name_hint": "Je naam", + "display_name_too_long": "De naam mag maximaal 60 tekens hebben", + "pick_from_gallery": "Kies uit galerij", + "take_photo": "Maak een foto", + "remove_photo": "Foto verwijderen", + "photo_pick_failed": "Er kon geen foto worden gekozen", + "incognito": "Incognito", + "incognito_desc": "Kijken zonder toe te voegen aan geschiedenis of trackers", + "incognito_off": "Incognito uitgezet", + "auto_skip_intro": "Intro's automatisch overslaan", + "auto_skip_intro_desc": "Anime-intro's en -aftitelingen overslaan zonder te vragen", + "incognito_active": "Incognito staat aan. Niets wat je kijkt gaat naar de geschiedenis of naar AniList en MAL.", + "incognito_turn_off": "Uitzetten", + "all_languages": "Alle talen", + "section_library": "JOUW BIBLIOTHEEK", + "section_watch": "KIJKEN", + "hero_trailer": "Trailer in de kop afspelen", + "hero_trailer_desc": "Even nadat een titel opengaat maakt de poster plaats voor de trailer, zonder geluid", + "section_overview": "OVERZICHT", + "section_app": "APP", + "library": "Bibliotheek", + "library_subtitle": "Favorieten, later kijken, privélijst", + "sources_title": "Bronnen", + "section_active_source": "ACTIEVE BRON", + "active_source_footnote": "De cloudbron waarin Sozo zoekt en waaruit het afspeelt. De extensies hieronder voegen bronnen toe die op dit apparaat draaien.", + "settings_title": "Instellingen", + "settings_subtitle": "Taal, weergave, speler, beveiliging", + "section_general": "ALGEMEEN", + "section_data": "GEGEVENS", + "section_lists": "LIJSTEN", + "section_accounts": "ACCOUNTS", + "section_devices": "APPARATEN", + "language_desc": "Taal van de interface", + "not_connected": "Niet verbonden", + "joined_on": "Lid sinds {}", + "watch_later": "Later kijken", + "watched": "Bekeken", + "stat_hours": "uur gekeken", + "stat_completed": "afgerond", + "stat_streak": "dagenstreak", + "guest_benefit_sync": "Favorieten en geschiedenis gesynchroniseerd op al je apparaten", + "guest_benefit_streak": "Streak, statistieken en trivia-rang", + "guest_benefit_trackers": "AniList, MyAnimeList en watch party", + "activity": "Activiteit", + "activity_subtitle": "Geschiedenis, downloads, volgen, statistieken", + "connections_subtitle": "Trackers, Discord, tv, desktop", + "trackers": "Trackers", + "stat_days": "dagen gekeken", + "mangayomi_sources": "Mangayomi-bronnen", + "view_only": "ALLEEN KIJKEN" + }, + "appearance": { + "title": "Weergave", + "entry_subtitle": "Accentkleur, diepzwart", + "section_preview": "VOORBEELD", + "section_accent": "ACCENTKLEUR", + "accent_footnote": "Het accent kleurt knoppen, markeringen, voortgangsbalken en het actieve tabblad — overal in de app.", + "accent_custom": "Eigen", + "accent_red": "Sozo-rood", + "accent_ember": "Gloed", + "accent_amber": "Amber", + "accent_emerald": "Smaragd", + "accent_teal": "Petrol", + "accent_sky": "Hemelsblauw", + "accent_ocean": "Oceaan", + "accent_indigo": "Indigo", + "accent_violet": "Violet", + "accent_magenta": "Magenta", + "accent_rose": "Roze", + "accent_slate": "Leigrijs", + "section_darkness": "ACHTERGROND", + "darkness_dark": "Donker", + "darkness_dark_desc": "De gewone donkere grijstinten", + "darkness_black": "Diepzwart", + "darkness_black_desc": "Echt zwarte achtergronden", + "amoled_footnote": "Op een OLED-scherm staat een zwarte pixel uit, dus diepzwart geeft meer contrast en kost minder accu.", + "reset": "Terug naar standaard", + "reset_tooltip": "Terug naar het standaarduiterlijk", + "custom_title": "Eigen kleur", + "custom_hue": "TINT", + "custom_saturation": "VERZADIGING", + "custom_lightness": "HELDERHEID", + "custom_apply": "Deze kleur gebruiken", + "custom_adjusted": "Iets donkerder gemaakt zodat witte tekst erop leesbaar blijft.", + "section_library": "UIT JOUW BIBLIOTHEEK", + "library_footnote": "Kleuren uit de posters van wat je hebt gekeken.", + "section_extras": "MEER", + "tint_nav": "Tabbalk inkleuren", + "tint_nav_desc": "Kleurt het gekozen tabblad in je accent in plaats van wit", + "shuffle": "Volgende kleur proberen", + "shuffle_desc": "Loop het palet accent voor accent door" + }, + "search": { + "title": "Zoeken", + "hint": "Zoek films, series…", + "no_results": "Geen resultaten gevonden", + "no_results_for": "Geen resultaten voor „{query}”", + "recent": "Recent gezocht", + "popular_searches": "Populaire zoekopdrachten", + "categories": "Genres", + "all": "Alles", + "movies": "Films", + "series": "Series", + "filter": "Filter", + "sort": "Sorteren op", + "sort_newest": "Nieuwste", + "sort_oldest": "Oudste", + "sort_rating": "Beoordeling", + "type": "Type", + "apply": "Toepassen", + "clear_filter": "Wissen", + "sources": "Bronnen", + "sources_n": "{} bronnen", + "all_sources": "Alles selecteren", + "this_server": "Deze server", + "local_group": "Lokaal", + "search_in": "Zoeken in", + "sources_hint": "Doorzoek meerdere bronnen tegelijk. Laat leeg om je huidige bron te gebruiken.", + "searching": "Bezig met zoeken…", + "all_source_search": "Zoeken in alle bronnen", + "cross_hint": "Zoek in je bronnen…", + "pick_sources": "Kies welke bronnen worden doorzocht.", + "choose_sources": "Bronnen kiezen", + "no_sources_selected": "Geen bronnen gekozen — tik om te kiezen", + "sources_selected": "{} gekozen — tik om te wijzigen", + "type_to_search_n": "Typ om {} bronnen tegelijk te doorzoeken.", + "found_in": "Gevonden in {} van {} bronnen", + "results_n": "{} resultaten", + "waiting_for": "{} doorzoeken…", + "no_results_any": "Geen resultaten in een van de gekozen bronnen.", + "offline_note": "Offline — alleen bronnen op dit apparaat kunnen worden doorzocht.", + "group_by_source": "Groeperen per bron", + "merge_titles": "Titels samenvoegen", + "open_from": "Openen uit", + "load_more": "Meer laden", + "search_failed": "Zoeken mislukt", + "source_failed": "Deze bron is nu niet beschikbaar", + "try_all_sources": "Alle bronnen proberen", + "weak_results": "Niets hier lijkt op „{query}” — deze bron matchte op iets anders. Andere bronnen hebben het misschien wel.", + "did_you_mean": "Bedoelde je", + "search_sources": "Bronnen doorzoeken", + "filter_providers": "Bronnen filteren…", + "selected_n": "{} geselecteerd", + "apply_n": "Toepassen ({})", + "many_sources_warning": "{} bronnen doorzoeken kan traag zijn. De app blijft reageren, maar minder is vlotter.", + "all_sources_n": "Alle bronnen ({})", + "more_sources": "Meer…", + "select_all_sources": "Alles selecteren", + "search_all_sources": "Alle bronnen doorzoeken", + "apply_all": "Alle bronnen doorzoeken", + "selected_n_of_m": "{} van {} bronnen", + "loading_sources": "Je bronnen laden…", + "no_sources_available": "Geen bronnen beschikbaar. Voeg een bron toe of zet er een aan om te zoeken.", + "min_query_n": "Typ minstens {} tekens.", + "retry_failed": "Mislukte opnieuw proberen", + "status_searching": "Bezig met zoeken…", + "status_searching_n": "{} bezig", + "status_with_results_n": "{} met resultaten", + "status_no_match_n": "{} zonder treffer", + "status_timed_out_n": "{} time-out", + "status_failed_n": "{} mislukt", + "all_sources_failed": "Alle {} bronnen gaven geen antwoord. Dit is geen leeg resultaat — probeer het opnieuw.", + "no_results_with_failures": "Geen treffers bij de bronnen die antwoordden ({}). {} waren onbereikbaar.", + "all_sources_failed_one": "De doorzochte bron gaf geen antwoord. Dit is geen leeg resultaat — probeer het opnieuw.", + "voice": "Zoeken met je stem", + "voice_listening": "Luisteren…", + "voice_done": "Gestopt met luisteren", + "voice_hint": "Zeg de naam van een film of serie", + "try_torrents": "Torrents zoeken", + "capped_note": "De {} betrouwbaarste van {} bronnen doorzocht — beperk de selectie om een specifieke te bereiken." + }, + "movie": { + "rating": "Beoordeling", + "year": "Jaar", + "quality": "Kwaliteit", + "duration": "Duur", + "genre": "Genre", + "director": "Regisseur", + "cast": "Cast", + "description": "Beschrijving", + "similar": "Vergelijkbare titels", + "trailer": "Trailer", + "share": "Delen", + "download": "Downloaden", + "rate": "Beoordelen", + "episodes": "Afleveringen", + "seasons": "Seizoenen" + }, + "errors": { + "network": "Netwerkfout. Controleer je verbinding.", + "server": "Serverfout. Probeer het opnieuw.", + "unauthorized": "Log in om verder te gaan.", + "not_found": "Inhoud niet gevonden.", + "unknown": "Er is een onbekende fout opgetreden.", + "timeout": "Time-out. Probeer het opnieuw.", + "empty": "Geen inhoud beschikbaar.", + "source_http": "De server van de bron gaf een fout terug ({})." + }, + "navigation": { + "home": "Start", + "search": "Zoeken", + "shorts": "Shorts", + "downloads": "Downloads", + "profile": "Profiel", + "my_list": "Mijn lijst", + "history": "Geschiedenis", + "following": "Gevolgd", + "premiere": "Première", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "Live tv" + }, + "premiere": { + "tab_title": "Premières", + "live_now": "Nu live", + "upcoming": "Binnenkort", + "live": "LIVE", + "catching_up": "Inhalen tot live", + "starts_in": "Begint over", + "starts_at": "Begint {when}", + "days": "DAGEN", + "hours": "UUR", + "minutes": "MIN", + "seconds": "SEC", + "waiting": "{count} wachten", + "watching": "{count} kijken", + "join": "Deelnemen aan première", + "join_now": "Nu deelnemen", + "set_reminder": "Herinnering instellen", + "reminder_set": "Herinnering ingesteld", + "notify_me": "Waarschuw me als het live gaat", + "guest_watch_hint": "Je kunt als gast kijken.", + "login_to_chat_react": "Log in om te chatten en te reageren", + "login_to_chat": "Log in om te chatten", + "chat_hint": "Zeg hallo — de chat is open", + "chat_empty": "Zeg hallo 👋 De chat begint als de première begint", + "chat_placeholder": "Bericht…", + "slow_mode": "Trage modus: {sec}s", + "react": "Reageren", + "paused_by_host": "Gepauzeerd door de host", + "buffering": "Bufferen…", + "reconnecting": "Opnieuw verbinden…", + "connecting": "Verbinden met de première…", + "ended_title": "Première afgelopen", + "ended_watch_vod": "Op aanvraag kijken", + "canceled": "Deze première is afgelast", + "empty_title": "Nog geen premières", + "empty_subtitle": "Live-evenementen verschijnen hier", + "error_generic": "Er ging iets mis", + "error_full": "Deze première zit vol", + "error_not_found": "Première niet gevonden", + "retry": "Opnieuw", + "share": "Delen", + "back": "Terug", + "resolve_failed": "Afspelen kon op dit apparaat niet worden gestart" + }, + "nav_customize": { + "entry_title": "Tabbladen aanpassen", + "entry_subtitle": "Kies welke tabbladen verschijnen en in welke volgorde", + "title": "Onderbalk", + "shown": "Zichtbaar", + "available": "Beschikbaar", + "min_hint": "Houd minstens {count} tabbladen", + "max_hint": "Maximaal {count} tabbladen", + "pick_range": "Kies tussen {min} en {max} tabbladen", + "reset": "Herstellen", + "cancel": "Annuleren", + "save": "Opslaan" + }, + "app_lock": { + "section_label": "BEVEILIGING", + "app_lock": "App-vergrendeling", + "settings_title": "App-vergrendeling", + "setup_title": "Pincode instellen", + "enable": "App-vergrendeling aanzetten", + "state_on": "Aan", + "state_off": "Uit", + "choose_length_title": "Lengte van de pincode", + "choose_length_subtitle": "Kies uit hoeveel cijfers je pincode bestaat.", + "length_n_digits": "Pincode van {} cijfers", + "create_pin_title": "Maak een nieuwe pincode", + "create_pin_subtitle": "Kies iets dat je met niemand deelt.", + "confirm_pin_title": "Bevestig je pincode", + "confirm_pin_subtitle": "Voer je nieuwe pincode nog een keer in.", + "verify_title": "App vergrendeld", + "verify_subtitle": "Voer je pincode in om verder te gaan.", + "verify_current_pin": "Voer je huidige pincode in", + "change_pin": "Pincode wijzigen", + "biometric": "Ontgrendelen met biometrie", + "biometric_unavailable": "Biometrie is niet beschikbaar op dit apparaat", + "biometric_enable_reason": "Verifieer jezelf om biometrisch ontgrendelen aan te zetten", + "biometric_unlock_reason": "Verifieer jezelf om de app te openen", + "pin_mismatch": "De pincodes komen niet overeen", + "pin_wrong": "Verkeerde pincode", + "disable_title": "App-vergrendeling uitzetten?", + "disable_subtitle": "Je pincode en je biometrie-voorkeur worden verwijderd.", + "enabled_hint": "Bij het starten van de app wordt om je pincode gevraagd.", + "disabled_hint": "App-vergrendeling staat uit. Zet hem aan om te beschermen wat je kijkt.", + "private_list": "Privélijst", + "move_to_private": "Naar privé verplaatsen", + "moved_to_private": "Verplaatst naar de privélijst", + "removed_from_private": "Uit privé gehaald", + "move_to_my_list": "Terug naar Mijn lijst", + "private_empty": "Nog niets privé", + "private_setup_required": "Stel eerst een pincode in om de privélijst te gebruiken", + "private_long_press_hint": "Houd ingedrukt om naar je privélijst te verplaatsen — achter je pincode, en de kijkgeschiedenis wordt niet bewaard.", + "private_always_ask": "Altijd pincode vragen voor de privélijst", + "private_always_ask_hint": "Voer je pincode elke keer in, niet één keer per sessie", + "reset_title": "App-vergrendeling herstellen?", + "reset_body": "Dit verwijdert de pincode en ontgrendelt Sozo op deze computer. Je bibliotheek en geschiedenis blijven. Je kunt later een nieuwe pincode instellen bij Profiel → App-vergrendeling.", + "reset": "Herstellen", + "forgot_pin": "Pincode vergeten?" + }, + "my_list": { + "saved_account": "Opgeslagen in account", + "saved_local": "Op dit apparaat", + "untitled": "Zonder titel", + "empty_title": "Je lijst is leeg", + "empty_subtitle": "Tik op + bij een titel om hem hier op te slaan.", + "unauth_title": "Log in om je lijst te bekijken", + "unauth_subtitle": "Je opgeslagen titels verschijnen hier zodra je bent ingelogd.", + "error_title": "Lijst kon niet worden geladen" + }, + "deeplink": { + "opt_in_title": "Sozo-links in deze app openen?", + "opt_in_body_android": "Gedeelde sozo.azamov.me-links openen direct in Sozo. Zet „Ondersteunde links openen” aan in je systeeminstellingen.", + "opt_in_body_ios": "Sozo-links openen automatisch in de app. Opent een link toch in Safari, houd hem dan ingedrukt en kies „Openen in Sozo”.", + "opt_in_enable": "Inschakelen", + "opt_in_later": "Later" + }, + "streak": { + "section_label": "STREAK", + "empty_title": "Begin een streak", + "empty_subtitle": "Kijk vandaag één aflevering om je dagelijkse streak te starten.", + "current_n_days": "{} dagen", + "locked_today": "Vandaag staat vast. Kom morgen terug!", + "keep_going": "Houd je streak in leven — vergeet niet vandaag te kijken.", + "risk_subtitle": "Je streak gaat om middernacht op nul. Kijk nu iets om hem te redden.", + "record_label": "Record", + "next_milestone": "Volgend doel", + "weekday_mon": "ma", + "weekday_tue": "di", + "weekday_wed": "wo", + "weekday_thu": "do", + "weekday_fri": "vr", + "weekday_sat": "za", + "weekday_sun": "zo", + "milestone_title": "Streak van {} dagen!", + "milestone_subtitle": "Ongelooflijk volgehouden. Blijf komen en zet een nieuw persoonlijk record neer.", + "share_text": "Streak van {} dagen gehaald op Sozo! 🔥", + "cta_continue": "Verder", + "cta_share": "Delen", + "freeze_label": "Freezes", + "freeze_count": "{} / {}", + "total_days_label": "Dagen totaal", + "this_week_label": "Deze week", + "calendar_label": "Laatste 35 dagen", + "days_to_milestone": "Nog {} dagen tot de volgende mijlpaal", + "freeze_saved_title": "Streak gered!", + "freeze_saved_subtitle": "Een freeze ving je gemiste dag op — je streak loopt door.", + "freeze_awarded_title": "Je hebt een freeze verdiend!", + "freeze_awarded_subtitle": "Freezes beschermen je streak als je één dag mist.", + "freeze_explainer": "Een freeze redt je streak automatisch op een dag die je mist. Je verdient nieuwe door regelmatig te kijken." + }, + "watch_party": { + "title": "Watch party", + "entry_subtitle": "Gelijk kijken met vrienden — één speelt af, iedereen volgt.", + "entry_create": "Een party maken", + "entry_create_hint": "Start een kamer en nodig vrienden uit met een code", + "entry_join": "Meedoen met een code", + "entry_join_hint": "Vul een code in die een vriend je stuurde", + "not_available": "Samen kijken kan nog niet bij deze titel", + "create": "Party maken", + "join": "Meedoen", + "join_hint": "Vul de partycode in", + "invalid_code": "Ongeldige code", + "code_label": "Partycode", + "copy_code": "Code kopiëren", + "copied": "Gekopieerd", + "share_invite": "Uitnodiging delen", + "invite": "Uitnodigen", + "lobby_waiting_host": "Wachten tot de host begint…", + "host_label": "Host", + "members": "Deelnemers", + "member_count": "{} / {}", + "you": "Jij", + "start_watching": "Beginnen met kijken", + "close_party": "Party sluiten", + "leave_party": "Party verlaten", + "leave_confirm": "Deze watch party verlaten?", + "only_host_controls": "Alleen de host kan het afspelen bedienen", + "host_changed": "De host is gewisseld", + "room_closed": "De party is gesloten", + "room_closed_empty": "De party sloot omdat iedereen weg is", + "content_none": "Nog geen titel gekozen", + "resolving": "De video wordt voorbereid…", + "resolve_failed": "Deze titel kon niet op je apparaat worden geladen", + "plugin_required_title": "Plug-in nodig", + "plugin_required_body": "Installeer de benodigde bron-plug-in om dit samen te kijken.", + "install_plugin": "Plug-in installeren", + "chat_hint": "Zeg iets…", + "chat_empty": "Nog geen berichten", + "reactions": "Reacties", + "error_not_found": "Party niet gevonden of al gesloten", + "error_full": "Deze party zit vol", + "error_forbidden": "Je hebt geen toegang tot deze party", + "error_rate": "Rustig aan", + "error_unauthorized": "Log in om aan een watch party mee te doen", + "error_generic": "Er ging iets mis", + "reconnecting": "Opnieuw verbinden…", + "connecting": "Verbinden…", + "connect_failed_title": "Verbinden mislukt", + "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw." + }, + "notifications": { + "title": "Meldingen", + "mark_all_read": "Alles gelezen", + "empty_title": "Nog geen meldingen", + "empty_subtitle": "Zodra er iets nieuws is, zie je het hier.", + "time_now": "nu", + "time_minutes": "{} min", + "time_hours": "{} u", + "time_days": "{} d" + }, + "manga": { + "sources_title": "Mangabronnen", + "sources_desc": "Voeg manga-extensierepo's toe om extra manga-, manhwa- en webtoon-aanbieders te installeren. Ze draaien op je apparaat (alleen Android).", + "repo_url": "Repo-URL", + "add_source": "Bron toevoegen", + "installing": "Installeren…", + "installing_long": "Extensies installeren… bij grote repo's kan dit even duren.", + "installing_progress": "{} / {} extensies installeren…", + "no_extensions": "Op die URL zijn geen extensies gevonden.", + "added_sources": "{} bron(nen) · {} aanbieder(s) toegevoegd.", + "source_removed": "Bron verwijderd.", + "recommended": "AANBEVOLEN", + "installed_sources": "GEÏNSTALLEERDE BRONNEN", + "source_settings": "BRONINSTELLINGEN", + "no_sources": "Nog geen bronnen", + "show_nsfw": "Bronnen voor volwassenen", + "show_nsfw_hint": "Toont bronnen met 18+-markering in deze lijst en in de bronkiezer. Staat standaard uit.", + "show": "Tonen", + "hide": "Verbergen", + "installed": "Geïnstalleerd", + "android_only": "Deze functie bestaat alleen op Android", + "error_prefix": "Fout: {}", + "no_preferences": "Deze bron heeft geen instelbare opties", + "not_selected": "Niet gekozen", + "n_selected": "{} gekozen", + "reading_mode": "Leesmodus", + "mode_continuous": "Doorlopend", + "mode_paged": "Per pagina", + "direction": "Richting", + "dir_ltr": "Links naar rechts (LTR)", + "dir_rtl": "Rechts naar links (RTL)", + "background": "Achtergrond", + "bg_black": "Zwart", + "bg_gray": "Grijs", + "bg_white": "Wit", + "chapters": "Hoofdstukken", + "chapter_ended": "Hoofdstuk afgelopen", + "next_chapter": "Volgend hoofdstuk", + "first_chapter": "Eerste hoofdstuk", + "last_chapter": "Laatste hoofdstuk", + "no_pages": "Geen pagina's gevonden", + "tap_to_reload": "Tik om opnieuw te laden", + "download_started": "Download gestart", + "downloaded_pages": "{} / {} pagina's", + "check_updates": "Controleren op updates", + "checking_updates": "Zoeken naar extensie-updates…", + "up_to_date": "Alle extensies zijn up-to-date.", + "updated_n": "{} extensie(s) bijgewerkt.", + "spread": "Twee pagina's", + "spread_desc": "Naast elkaar in liggende stand, zoals het boek getekend is" + }, + "cloudflare": { + "title": "Bevestig dat je een mens bent", + "hint": "Rond de controle hieronder af — dit venster sluit daarna vanzelf.", + "solve": "Cloudflare oplossen", + "done": "Klaar" + }, + "desktop": { + "saved_refresh_on_pc": "Opgeslagen — tik op de pc op „Bronnen verversen” om het door te voeren.", + "sources_turned_off": "Desktopbronnen uitgezet.", + "saved_loading_sources": "Opgeslagen — bronnen van je telefoon laden…", + "refreshing_sources": "Bronnen van je telefoon verversen…", + "share_title": "Bronnen delen met desktop", + "sources_title": "Desktopbronnen", + "share_with_desktop": "Bronnen delen met desktop", + "let_pc_use_sources": "Laat de Sozo-pc-app de bronnen van deze telefoon gebruiken.", + "your_link": "Jouw link", + "link_copied": "Link gekopieerd", + "no_wifi_address": "Aangezet, maar er is geen wifi-adres gevonden. Verbind deze telefoon met wifi (geen mobiele data) en open dit scherm opnieuw.", + "step_host_same_wifi": "Houd deze telefoon en je pc op hetzelfde wifi-netwerk.", + "step_host_turn_on": "Zet de schakelaar hierboven aan.", + "step_host_pick_sources": "Kies welke bronnen je deelt (of laat „Alles delen” aan staan).", + "step_host_paste_on_pc": "Open op de pc Sozo → Profiel → Desktopbronnen en plak de link.", + "step_host_keep_open": "Laat Sozo op deze telefoon open terwijl je op de pc bladert.", + "sources_to_share": "Te delen bronnen", + "reload_from_phone": "Opnieuw laden vanaf telefoon", + "share_all_sources": "Alle bronnen delen", + "every_source_shared": "Elke bron op deze telefoon wordt gedeeld", + "sharing_count": "{} van {} gedeeld", + "save_send_to_desktop": "Opslaan en naar desktop sturen", + "n_selected": "{} geselecteerd", + "select_all": "Alles selecteren", + "clear": "Wissen", + "no_sources_yet": "Nog geen bronnen op deze telefoon. Voeg ze toe bij Profiel → CloudStream / Aniyomi / Mangabronnen en laad hier opnieuw.", + "phone_link": "Telefoonlink", + "refresh_sources": "Bronnen verversen", + "step_client_same_wifi": "Houd deze pc en je telefoon op hetzelfde wifi-netwerk.", + "step_client_open_on_phone": "Open op de telefoon Sozo → Profiel → Bronnen delen met desktop en zet het aan.", + "step_client_choose_sources": "Kies op de telefoon welke bronnen je deelt (of laat „Alles delen” aan staan).", + "step_client_copy_link": "Kopieer de link die daar staat, plak hem hierboven en tik op Opslaan.", + "step_client_refresh": "De gedeelde bronnen op de telefoon gewijzigd? Tik hier op „Bronnen verversen”.", + "step_client_keep_open": "Laat Sozo op de telefoon open terwijl je hier bladert.", + "step_client_emulator": "Gebruik je een Android-emulator? Zijn IP is niet direct bereikbaar — voer op deze pc „adb forward tcp:8765 tcp:8765” uit en plak hierboven http://127.0.0.1:8765 (zet de schakelaar in de emulator alsnog aan).", + "how_it_works": "Hoe het werkt", + "how_it_works_desc": "Gebruik je CloudStream-, Aniyomi- en mangabronnen in de Sozo-desktopapp. De telefoon draait de bronnen; de pc bekijkt ze alleen.", + "internet_still_unavailable": "Er is nog steeds geen internet", + "offline_watch_downloads": "Gedownloade video's kun je gewoon blijven kijken zonder verbinding.", + "refresh": "Verversen" + }, + "detail": { + "download_will_resolve": "Bezig met opzoeken…", + "download_needs_playback": "Deze bron verstopt zijn stream achter een beveiligde speler. Speel de aflevering één keer af en download hem daarna vanuit de speler.", + "download_choose_server": "Download · server kiezen", + "download_playing_now": "Speelt nu", + "similar": "Vergelijkbaar", + "comments": "Reacties", + "screenshots": "Schermafbeeldingen", + "play": "Afspelen", + "no_episodes": "Geen afleveringen beschikbaar", + "no_playable_source": "Geen afspeelbare bron", + "added_to_my_list": "Toegevoegd aan Mijn lijst", + "removed_from_my_list": "Verwijderd uit Mijn lijst", + "failed_resolve_pages": "Pagina's konden niet worden opgezocht", + "episodes_count": "{} afleveringen", + "episodes_count_of": "{} van {} afleveringen", + "episode_n": "Aflevering {}", + "no_films": "Geen films gevonden", + "filmography": "Filmografie", + "actor": "Acteur", + "continue_watching": "Verder", + "continue_ep": "Verder · afl. {}", + "no_cast": "Geen cast beschikbaar", + "no_recommendations": "Geen aanbevelingen beschikbaar", + "untitled": "Zonder titel", + "read": "Lezen", + "watch_later": "Later kijken", + "watched": "Bekeken", + "follow_series": "Serie volgen", + "find_other_sources": "Andere bronnen zoeken", + "anilist_track": "Volgen op AniList", + "anilist_tracked": "Gevolgd op AniList", + "following_on": "Wordt gevolgd", + "following_off": "Niet gevolgd", + "no_screenshots": "Geen schermafbeeldingen", + "show_more": "Meer tonen", + "show_less": "Minder tonen", + "time_left": "nog {}", + "liked": "{}% vindt dit leuk", + "watched_pct": "{}% bekeken", + "copy_link": "Link kopiëren", + "link_copied": "Link gekopieerd", + "report_problem": "Probleem melden", + "selected_n": "{} geselecteerd", + "select_all": "Alles selecteren", + "select_none": "Selectie wissen", + "download_n": "{} downloaden", + "download_queueing": "In de wachtrij zetten…", + "download_action": "Downloaden", + "download_already": "Al gedownload", + "download_in_progress": "Download bezig", + "download_started": "Download gestart", + "download_needs_permission": "Voor downloads is toestemming voor meldingen nodig", + "download_resolve_failed": "Er kon geen downloadlink voor deze aflevering worden gevonden", + "download_queued_n": "{} aan de downloads toegevoegd", + "download_none_queued": "Er is niets toegevoegd — waarschijnlijk al gedownload", + "download_batch_title": "Meerdere afleveringen downloaden?", + "download_batch_body": "Afleveringen {range} komen in de wachtrij — {count} in totaal. Via mobiele data kan dit meerdere gigabytes zijn.", + "relations": "Verwante titels", + "no_relations": "Er zijn geen verwante titels bij deze gevonden.", + "relation_sequel": "VERVOLG", + "relation_prequel": "VOORGESCHIEDENIS", + "relation_parent": "HOOFDVERHAAL", + "relation_side_story": "ZIJVERHAAL", + "relation_alternative": "ALTERNATIEVE VERSIE", + "relation_spin_off": "SPIN-OFF", + "relation_summary": "SAMENVATTING", + "relation_adaptation": "BEWERKING", + "relation_source": "BRONWERK", + "relation_character": "ZELFDE PERSONAGES", + "relation_other": "OVERIG", + "trailer_action": "Trailer", + "more_options": "Meer opties", + "in_private_list": "In je privélijst", + "add_to_my_list_action": "Aan Mijn lijst toevoegen", + "remove_from_my_list_action": "Uit Mijn lijst verwijderen", + "trailer_mute": "Trailer dempen", + "trailer_unmute": "Geluid trailer aan" + }, + "episodes": { + "filter_hint": "Afleveringsnummer of titel", + "filter_none": "Geen aflevering die past bij “{query}” in dit blok", + "jump_to": "Naar aflevering {n}", + "jump_loads": "Laadt {range}", + "jump_missing": "Aflevering {n} staat niet op deze pagina", + "range_of": "Afleveringen {from}–{to} van {total}" + }, + "shorts": { + "refresh": "Verversen", + "opening": "Openen…", + "content_unavailable": "Inhoud niet beschikbaar", + "loading": "Shorts laden…", + "empty_title": "Nog geen shorts", + "empty_subtitle": "Korte clips verschijnen hier zodra ze beschikbaar zijn.", + "error_title": "Shorts konden niet worden geladen", + "video_unavailable": "Video niet beschikbaar", + "speed_2x": "2x snelheid", + "report_submitted": "Melding verstuurd, bedankt", + "watch_full_movie": "Hele film kijken", + "browse_movies": "Films bekijken", + "sign_in_to_like": "Log in om te liken" + }, + "comments": { + "please_sign_in": "Log eerst in", + "report_submitted": "Melding verstuurd, bedankt", + "edited": "· bewerkt", + "like": "Leuk", + "reply": "Reageren", + "hide": "Verbergen", + "replies_one": "1 reactie", + "replies_other": "{} reacties", + "report": "Melden", + "edit_hint": "Bewerk je reactie…", + "reply_hint": "Schrijf een antwoord…", + "comment_hint": "Schrijf een reactie…", + "editing": "Bezig met bewerken", + "replying_to": "Antwoord aan {}", + "sign_in_to_comment": "Log in om te reageren", + "empty_title": "Nog geen reacties", + "empty_subtitle": "Deel als eerste wat je ervan vindt", + "count_one": "1 reactie", + "count_other": "{} reacties", + "show_more": "Meer tonen", + "delete_confirm_title": "Deze reactie verwijderen?", + "delete_confirm_body": "Dit kan niet ongedaan worden gemaakt.", + "sign_in_prompt": "Log in om een reactie te plaatsen" + }, + "downloads": { + "delete_all_title": "Alle downloads verwijderen?", + "file_not_found": "Bestand niet gevonden", + "clear_all": "Alles wissen", + "segments_progress": "{} / {} segmenten", + "downloaded_amount": "{} gedownload", + "pages_count": "{} pagina's", + "downloaded": "Gedownload", + "failed": "Mislukt", + "pending": "In wachtrij", + "remove": "Verwijderen", + "empty_title": "Nog geen downloads", + "empty_subtitle": "Gedownloade video's verschijnen hier", + "paused": "Gepauzeerd", + "wifi_only": "Alleen downloaden via wifi", + "wifi_only_desc": "Downloads in de wachtrij wachten tot je op wifi zit", + "waiting_for_wifi": "Wachten op wifi — downloads gaan vanzelf verder", + "export": "Opslaan in Downloads", + "export_hint": "Maakt het bestand zichtbaar voor andere apps", + "export_running": "Opslaan…", + "export_done": "Opgeslagen in {}", + "export_failed": "Opslaan in Downloads is mislukt", + "downloading": "Bezig met downloaden", + "missing": "Bestand ontbreekt — tik om opnieuw te downloaden", + "redownload_started": "Opnieuw downloaden…", + "file_missing_retry": "Dat bestand is weg. Het wordt opnieuw gedownload.", + "download_again": "Opnieuw downloaden", + "pause": "Pauzeren", + "resume": "Hervatten", + "retry_all": "Alles wat mislukte opnieuw proberen", + "sort_by": "SORTEREN OP", + "sort_newest": "Nieuwste eerst", + "sort_title": "Titel", + "sort_size": "Grootste eerst", + "filter_all": "Alles", + "filter_active": "Bezig", + "filter_done": "Klaar", + "filter_problems": "Vraagt aandacht", + "filter_empty_title": "Niets past bij dit filter", + "filter_empty_subtitle": "Je downloads staan er nog — alleen deze weergave is beperkt.", + "filter_clear": "Alle downloads tonen", + "storage_used": "{} in gebruik door downloads", + "storage_free": "{} vrij", + "orphans_found": "{} aan restanten van afgebroken downloads", + "orphans_clean": "Opruimen", + "group_summary": "{} van {} gedownload", + "group_problems": "Vraagt aandacht", + "error": { + "network": "De verbinding viel weg", + "refused": "De bron weigerde de download — speel het één keer af en probeer daarna opnieuw", + "gone": "Het bestand staat niet meer bij de bron", + "no_space": "Niet genoeg ruimte op dit apparaat", + "not_media": "De bron stuurde een webpagina in plaats van de video", + "incomplete": "De download eindigde onvolledig", + "unknown": "De download is mislukt" + }, + "location": "Waar downloads worden bewaard", + "location_hint": "Alleen plekken waar de app zonder toestemming mag schrijven. Downloads verhuizen mee met de instelling; er wordt niets verwijderd tot elk bestand is aangekomen.", + "location_internal": "Telefoonopslag (app)", + "location_external": "Telefoonopslag", + "location_sdcard": "SD-kaart", + "location_moved": "Downloads verplaatst", + "location_unchanged": "Staan er al", + "location_failed": "De downloads konden niet worden verplaatst — ze staan nog waar ze stonden" + }, + "history": { + "clear_title": "Geschiedenis wissen", + "clear_confirm": "Weet je zeker dat je je kijkgeschiedenis wilt wissen?", + "clear": "Wissen", + "clear_all": "Alles wissen", + "remove": "Verwijderen", + "empty_title": "Nog geen kijkgeschiedenis", + "empty_subtitle": "Begin met kijken, dan verschijnt je geschiedenis hier" + }, + "main": { + "shorts_refresh_title": "Shorts verversen", + "shorts_refresh_body": "Tik nogmaals op het Shorts-tabblad. De feed ververst en nieuwe video's worden geladen.", + "double_tap": "Dubbeltik", + "tap_again": "Tik nogmaals" + }, + "reports": { + "title": "Rapporteren", + "reason_spam": "Spam", + "reason_abuse": "Intimidatie", + "reason_sexual": "Seksuele inhoud", + "reason_violence": "Geweld", + "reason_copyright": "Auteursrecht", + "reason_other": "Anders", + "note_hint": "Extra opmerking (optioneel)", + "submit": "Versturen" + }, + "ios": { + "sources_title": "Extensiebronnen", + "how_it_works_desc": "Gebruik je CloudStream-, Aniyomi- en mangabronnen in Sozo voor iOS. Je Android-telefoon draait de bronnen, dit apparaat bekijkt ze alleen.", + "step_client_same_wifi": "Houd dit apparaat en je Android-telefoon op hetzelfde wifi-netwerk." + }, + "trivia": { + "fan_test_title": "Fantest", + "fan_test_subtitle": "Bewijs dat jij de grootste fan bent", + "hub_tagline": "Hoe goed ken je de film echt?", + "view_leaderboards": "Ranglijsten bekijken", + "my_daily_rank": "Mijn dagpositie", + "rank_value": "#{}", + "unranked": "Geen positie", + "points_value": "{} pnt", + "popular_now": "Nu populair", + "results": "Resultaten", + "searching": "Bezig met zoeken…", + "search_cast_hint": "Acteurs zoeken", + "no_results": "Geen resultaten", + "no_results_hint": "Probeer een andere naam", + "something_wrong": "Er ging iets mis", + "filmography": "Filmografie", + "n_titles": "{count} titels", + "start_fan_test": "Fantest starten", + "challenge_friend": "Daag een vriend uit", + "top_fans": "Topfans", + "no_fans_yet": "Nog geen fans", + "be_first_fan": "Wees de eerste fan", + "fans_meta": "{rounds} rondes · {fandom}% gemiddelde fandom", + "you": "Jij", + "you_rank": "Jij · #{rank}", + "best_score": "Beste {score}", + "fandom": "Fandom", + "challenge_share_text": "Ken jij {name} beter dan ik? Doe de fantest op Sozo!", + "scope_daily": "Dagelijks", + "scope_weekly": "Wekelijks", + "scope_all_time": "Aller tijden", + "scope_friends": "Vrienden", + "leaderboard": "Ranglijst", + "no_friends_board": "Nog geen vrienden op de lijst", + "no_scores_yet": "Nog geen scores", + "correct_of": "{count} goed", + "forfeit_title": "Spel verlaten?", + "forfeit_body": "Je voortgang in deze ronde gaat verloren.", + "keep_playing": "Doorspelen", + "forfeit_confirm": "Verlaten", + "correct": "Goed", + "the_answer": "Het antwoord", + "watch_full": "Hele film kijken", + "building_round": "Je ronde wordt samengesteld…", + "round_failed": "De ronde kon niet worden gestart", + "go_back": "Terug", + "fan_test_done": "Fantest voltooid", + "round_done": "Ronde voltooid", + "score": "Score", + "rank": "Positie", + "play_again": "Opnieuw spelen", + "share_result": "Resultaat delen", + "share_text": "Ik heb net Buff gespeeld op Sozo!", + "challenge_text": "Denk je dat je films beter kent? Daag me uit bij Buff in Sozo!", + "share_tagline": "Speel Buff op Sozo", + "challenge_from": "Uitdaging van {name}", + "same_clips_head_to_head": "Dezelfde clips, kop aan kop", + "score_to_beat": "Te verslaan score", + "play_challenge": "Uitdaging spelen", + "challenge_not_found": "Uitdaging niet gevonden", + "no_actors_yet": "Nog geen acteurs", + "no_actors_yet_body": "Buff wordt nog gevuld. Clips worden een voor een gecontroleerd — acteurs verschijnen hier zodra hun clips zijn goedgekeurd.", + "actor_not_ready": "Deze acteur is nog niet klaar", + "actor_not_ready_body": "Er zijn nog niet genoeg goedgekeurde clips voor een ronde. Probeer een andere acteur.", + "round_short_note": "Deze ronde had {count} clips — alle tot nu toe goedgekeurde clips van deze acteur.", + "todays_top": "Top van vandaag", + "how_it_works": "Zo werkt Buff", + "how_step_1": "Kies een acteur", + "how_step_2": "Bekijk een korte clip", + "how_step_3": "Noem de film" + }, + "outage": { + "body_with_local": "De Sozo-servers zijn onbereikbaar. Je {} bronnen op het apparaat werken nog wel.", + "body_no_local": "De Sozo-servers zijn onbereikbaar. Voeg een CloudStream-, Aniyomi- of mangabron toe om te blijven bladeren zonder onze API.", + "pick_source": "Bron kiezen" + }, + "link_tv": { + "title": "Tv koppelen", + "tile_subtitle": "Scan de QR-code op je tv, of typ de code in", + "subtitle": "Scan de QR-code op je televisie, of typ de code van 8 tekens die eronder staat.", + "scan_button": "QR-code scannen", + "scan_hint": "Richt de camera op de QR-code op het tv-scherm", + "manual_title": "Of vul de code in", + "confirm": "Bevestigen", + "approved": "Tv gekoppeld", + "approved_named": "„{}” gekoppeld", + "approved_hint": "De tv logt binnen een paar seconden in op je account.", + "linked_devices": "Gekoppelde apparaten", + "no_devices": "Nog geen gekoppelde apparaten", + "unnamed_device": "Naamloos apparaat", + "last_seen": "Laatst gezien: {}", + "unlink_title": "Apparaat ontkoppelen", + "unlink_message": "Dit apparaat wordt uitgelogd en heeft een nieuwe QR-code nodig om opnieuw te koppelen.", + "unlink_confirm": "Ontkoppelen", + "camera_denied": "Cameratoegang geweigerd. Sta hem toe in Instellingen, of typ de code in.", + "camera_error": "De camera kon niet worden gestart.", + "error_bad_code": "De code moet 8 tekens hebben.", + "error_not_found": "Die code is niet gevonden. Haal een nieuwe op de tv.", + "error_expired": "De code is verlopen. Druk op Verversen op de tv.", + "error_already_used": "Die code is al gebruikt.", + "error_unauthorized": "Log eerst in.", + "error_rate_limited": "Te veel pogingen. Probeer het zo meteen opnieuw.", + "error_network": "Geen internetverbinding.", + "error_generic": "Er ging iets mis." + }, + "anilist": { + "connections_title": "Verbindingen", + "connect": "AniList koppelen", + "disconnect": "Ontkoppelen", + "disconnect_confirm": "Je AniList-account wordt ontkoppeld en gekoppelde titels worden vergeten. Je lijst blijft op AniList staan.", + "not_connected": "Niet verbonden", + "connect_explainer": "Koppel AniList en de afleveringen die je kijkt worden automatisch afgevinkt, met je lijst hier zichtbaar.", + "connected_explainer": "Bekeken afleveringen worden naar je AniList-lijst geschreven. De koppeling staat op je account, dus je tv en andere apparaten vragen niet opnieuw om in te loggen.", + "connect_prompt": "Koppel je account om je AniList-bibliotheek hier te zien.", + "upcoming_connect_prompt": "Koppel AniList om te zien wanneer de volgende afleveringen van je series komen.", + "sign_in_first": "Log eerst in op je Sozo-account", + "browser_failed": "De browser kon niet worden geopend", + "open_library": "Mijn AniList-bibliotheek", + "linked_titles": "Gekoppelde titels", + "linked_titles_count": "{} titels gekoppeld", + "linked_titles_none": "Nog niets gekoppeld", + "unlink": "Ontkoppelen", + "unlinked": "{} ontkoppeld", + "auto_matched": "Automatisch", + "privacy_note": "Je AniList-token staat op je Sozo-account. De app schrijft alleen het nummer van de aflevering die je hebt gekeken.", + "status_label": "STATUS", + "status_current": "Aan het kijken", + "status_planning": "Gepland", + "status_completed": "Afgerond", + "status_paused": "Gepauzeerd", + "status_dropped": "Gestopt", + "status_repeating": "Opnieuw aan het kijken", + "episodes_watched": "afleveringen bekeken", + "empty_status": "Je lijst „{}” is leeg", + "behind_n": "{} nieuw", + "mark_episode_watched": "Aflevering {} als bekeken markeren", + "find_in_sources": "In bronnen zoeken", + "find_in_sources_hint": "Elke bron doorzoeken op deze titel", + "open_on_anilist": "Openen op AniList", + "refresh": "Verversen", + "retry": "Probeer opnieuw", + "upcoming_title": "Binnenkort", + "upcoming_empty": "Van geen enkele serie die je volgt is een volgende aflevering aangekondigd.", + "today": "Vandaag", + "tomorrow": "Morgen", + "episode_n": "Aflevering {}", + "aired": "Uitgezonden", + "in_days": "over {} dagen", + "in_hours": "over {} uur", + "in_minutes": "over {} min", + "link_title": "Koppelen aan AniList", + "link_hint": "Kies welke AniList-anime deze titel is.", + "link_search_hint": "Animenaam", + "link_no_results": "Niets gevonden", + "linked_to": "Gekoppeld aan {}", + "tracking_on": "Gevolgd op AniList", + "track_on_anilist": "Volgen op AniList", + "n_episodes_short": "{} afl.", + "open_anilist": "AniList", + "connect_tagline": "Houd automatisch bij wat je kijkt", + "connect_short": "Koppelen", + "calendar_title": "Uitzendkalender", + "calendar_all": "Alles", + "calendar_mine": "Mijn lijst", + "calendar_episode": "Aflevering {episode}", + "calendar_empty": "Op deze dag wordt niets uitgezonden.", + "calendar_empty_mine": "Op deze dag wordt niets uit je lijst uitgezonden.", + "calendar_open": "Uitzendkalender", + "calendar_error": "Het uitzendschema kon niet worden geladen.", + "calendar_rate_limited": "AniList heeft het nu druk. Probeer het zo meteen opnieuw.", + "calendar_list_error": "Je AniList-lijst kon niet worden geladen.", + "calendar_now": "nu", + "calendar_count": "{count} uitzendingen", + "calendar_count_mine": "{mine} van {total} zijn van jou", + "calendar_episode_range": "Afleveringen {from}-{to}", + "calendar_add_planning": "Aan Gepland toevoegen", + "calendar_added_planning": "Toegevoegd aan je lijst Gepland", + "calendar_add_failed": "Dit kon niet aan je lijst worden toegevoegd", + "calendar_already_on_list": "Staat al op je lijst", + "browse_title": "Ontdekken", + "browse_trending": "Populair nu", + "browse_season": "Dit seizoen", + "browse_popular": "Aller tijden populair", + "browse_upcoming": "Binnenkort", + "browse_empty": "Hier valt niets te tonen.", + "browse_failed": "AniList was niet bereikbaar.", + "episodes_count": "{count} afl.", + "reminder_body": "Aflevering {episode} begint over 10 minuten", + "reminders_on": "Herinneringen aan — vooraf en zodra een aflevering komt", + "reminders_off": "Afleveringsherinneringen staan uit", + "connect_first": "Koppel eerst AniList", + "save_failed": "Opslaan naar AniList mislukt", + "search_in_source": "IN ÉÉN BRON ZOEKEN", + "no_sources": "Nog geen bronnen geïnstalleerd", + "released_body": "Aflevering {episode} is er", + "remove_entry": "Uit mijn lijst verwijderen", + "remove_confirm": "„{}” van je AniList verwijderen? Je voortgang ervoor wordt ook gewist.", + "removed": "Verwijderd van AniList" + }, + "mal": { + "connect": "MyAnimeList koppelen", + "disconnect": "MyAnimeList ontkoppelen", + "disconnect_confirm": "Je MyAnimeList-account wordt ontkoppeld en gekoppelde titels worden vergeten. Je lijst blijft op MyAnimeList staan.", + "connect_explainer": "Koppel MyAnimeList en de afleveringen die je kijkt worden automatisch afgevinkt.", + "connected_explainer": "Bekeken afleveringen worden naar je MyAnimeList geschreven. De koppeling staat op je account, dus je tv en andere apparaten vragen niet opnieuw om in te loggen.", + "not_configured": "MyAnimeList is nog niet ingesteld op de server", + "matching_title": "Hoe titels worden gekoppeld", + "matching_note": "De koppeling loopt via AniList, dat het MyAnimeList-id van dezelfde titel meedraagt. Koppel een titel één keer en beide trackers volgen hem.", + "privacy_note": "Je AniList- en MyAnimeList-tokens staan op je Sozo-account. De app schrijft alleen het nummer van de aflevering die je hebt gekeken.", + "tracking_on": "Gevolgd op MyAnimeList", + "save_failed": "Opslaan naar MyAnimeList mislukt", + "open_library": "Mijn MyAnimeList", + "linked_titles": "Gekoppelde titels", + "connect_prompt": "Koppel je account om je MyAnimeList hier te zien.", + "connect_first": "Koppel eerst MyAnimeList", + "search_failed": "MyAnimeList was niet bereikbaar.", + "link_title": "Koppelen aan MyAnimeList", + "link_hint": "Kies welke MyAnimeList-anime deze titel is.", + "linked_to": "Gekoppeld aan {}", + "track": "Volgen op MyAnimeList", + "tracked": "Gevolgd op MyAnimeList", + "score_label": "SCORE", + "remove_entry": "Uit mijn lijst verwijderen", + "remove_confirm": "„{}” van je MyAnimeList verwijderen? Je voortgang ervoor wordt ook gewist.", + "removed": "Verwijderd van MyAnimeList", + "rewatching_note": "Je kijkt dit opnieuw. Sozo laat de status met rust zodat de herhaling niet wordt afgesloten." + }, + "tracker": { + "title": "Volgen", + "tab_following": "Gevolgd", + "tab_upcoming": "Binnenkort", + "following_empty": "Volg een serie om een melding te krijgen bij nieuwe afleveringen.", + "new_episodes_found": "{} titels hebben nieuwe afleveringen", + "no_new_episodes": "Geen nieuwe afleveringen", + "n_episodes": "{} afleveringen", + "not_checked": "Nog niet gecontroleerd", + "unfollow": "Ontvolgen", + "unfollowed": "{} ontvolgd", + "undo": "Ongedaan maken" + }, + "remote": { + "title": "Tv-afstandsbediening", + "no_devices": "Nog geen gekoppelde tv's. Koppel er eerst een vanuit de tv-app.", + "load_failed": "Je tv's konden niet worden geladen.", + "tv_offline": "Tv is offline", + "idle": "Er speelt niets", + "command_failed": "Dat kwam niet aan bij de tv", + "search_on_tv": "ZOEKEN OP TV", + "search_hint": "Typ hier, het zoeken gebeurt op de tv", + "back": "Terug", + "home": "Start", + "open_on_tv": "Op tv afspelen", + "sent_to_tv": "Verstuurd naar {device}", + "connected_to": "Verbonden met {device}", + "not_connected_to": "{device} is niet verbonden", + "turn_tv_on": "Open Sozo op de tv om hem te bedienen", + "retry": "Probeer opnieuw", + "navigation": "NAVIGATIE", + "playback": "AFSPELEN", + "pick_device": "Welke tv?", + "device_online": "Online", + "device_offline": "Offline — open Sozo erop" + }, + "live_tv": { + "title": "Live tv", + "favourites": "Favorieten", + "recent": "Laatst bekeken", + "categories": "Categorieën", + "countries": "Landen", + "results": "Zoekresultaten", + "search_hint": "Kanaalnaam…", + "search_in": "Zoeken in {folder}…", + "no_match": "Geen kanaal komt daarmee overeen.", + "empty": "Nog geen kanalen.", + "load_failed": "De kanalen konden niet worden geladen.", + "retry": "Probeer opnieuw", + "now": "Nu", + "next": "Straks", + "guide": "Tv-gids", + "no_guide": "Geen gids voor dit kanaal.", + "watch": "Kijken", + "favourite_add": "Aan favorieten toevoegen", + "favourite_remove": "Uit favorieten halen", + "hint_long_press": "Houd een kanaal ingedrukt voor de gids en favorieten.", + "channel_count": { + "one": "{} kanaal", + "other": "{} kanalen" + }, + "up_next": "STRAKS", + "minutes_left": "nog {} min" + }, + "time": { + "now": "zojuist", + "minutes": "{} min geleden", + "hours": "{} u geleden", + "days": "{} d geleden" + }, + "user_lists": { + "title": "Mijn lijsten", + "remove": "Verwijderen", + "empty_title": "Nog niets opgeslagen", + "empty_subtitle": "Titels die je opslaat verschijnen hier." + }, + "torrent": { + "title": "Torrents", + "search_hint": "Torrents zoeken…", + "empty_hint": "Doorzoek Nyaa, Tokyo Toshokan en nekoBT tegelijk.", + "searching": "Trackers doorzoeken…", + "no_results": "Niets gevonden op de ingeschakelde trackers", + "no_results_hint": "Probeer een kortere titel, of maak de filters ruimer.", + "results_n": "{} resultaten", + "seeders": "seeders", + "leechers": "leechers", + "unknown_swarm": "Zwermgrootte onbekend", + "dead": "Geen seeders", + "dead_warning": "Deze torrent heeft geen seeders en kan niet worden gestreamd.", + "trusted": "Vertrouwd", + "remake": "Opnieuw geüpload", + "filters": "Filters", + "category": "Categorie", + "cat_all": "Alles", + "cat_anime": "Anime", + "cat_english": "Engelse ondertiteling", + "cat_non_english": "Andere talen", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Live action", + "sort": "Sorteren op", + "sort_seeders": "Seeders", + "sort_date": "Nieuwste", + "sort_size": "Grootte", + "sort_downloads": "Downloads", + "min_seeders": "Minimum aantal seeders", + "quality": "Kwaliteit", + "any_quality": "Maakt niet uit", + "trusted_only": "Alleen vertrouwde uploaders", + "exclude_hardsubs": "Hardsub-releases verbergen", + "exclude_mini": "Mini-encodes verbergen", + "exclude_remakes": "Heruploads verbergen", + "require_dual_audio": "Alleen dual audio", + "exclude_batches": "Seizoenspakketten verbergen", + "trackers": "Trackers", + "reset_filters": "Herstellen", + "consent_title": "Streamen via BitTorrent?", + "consent_body": "Torrents werken peer-to-peer. Zolang er een streamt, kunnen anderen in de zwerm je IP-adres zien en wat je binnenhaalt, en je apparaat uploadt ook naar hen.\n\nGebruik een VPN als dat bij jou uitmaakt.", + "consent_accept": "Begrepen", + "consent_remember": "Niet meer vragen", + "preparing": "Torrent-engine starten…", + "fetching_metadata": "Bestandslijst ophalen uit de zwerm…", + "metadata_timeout": "Geen reactie uit de zwerm. De torrent heeft misschien geen seeders meer.", + "engine_failed": "De torrent-engine kon niet worden gestart.", + "unsupported": "Torrentstreaming bestaat alleen op Android.", + "pick_file": "Kies een bestand", + "pick_file_hint": "Deze torrent bevat {} video's.", + "play": "Afspelen", + "copy_magnet": "Magnetlink kopiëren", + "magnet_copied": "Magnetlink gekopieerd", + "open_external": "Openen in torrent-app", + "open_page": "Openen op de tracker", + "stats_peers": "{} peers", + "buffering": "Bufferen {}%", + "still_searching": "{} trackers antwoorden nog", + "buffering_title": "Bufferen…", + "no_video_in_torrent": "Deze torrent bevat geen afspeelbare video.", + "tracker_failed": "{} kon niet worden doorzocht — de resultaten zijn mogelijk onvolledig." + }, + "backup": { + "title": "Back-up en herstel", + "subtitle": "Kijkgeschiedenis, lijsten, bronnen en instellingen. Aanmeldingen horen er niet bij.", + "export": "Back-up maken", + "export_desc": "Sla een bestand op dat je kunt bewaren of naar jezelf sturen", + "restore": "Herstellen", + "restore_desc": "Een back-upbestand terug inlezen in de app", + "restore_warning": "Waarden uit de back-up overschrijven die op dit apparaat. Wat niet in het bestand staat, blijft ongemoeid.", + "restored": "{} waarden hersteld", + "not_a_backup": "Dat bestand is geen Sozo-back-up", + "too_new": "Die back-up komt van een nieuwere versie van de app", + "export_failed": "De back-up kon niet worden gemaakt", + "restore_failed": "Die back-up kon niet worden gelezen", + "file_subject": "Sozo-back-up", + "last_export": "Laatste back-up: {}", + "never_exported": "Op dit apparaat is nog geen back-up gemaakt." + }, + "provider_test": { + "title": "Bronnen testen", + "intro": "Doet een echte zoekopdracht, opent een titel en vraagt het CDN om de eerste bytes. De uitkomst geldt voor dit apparaat en dit netwerk, nu.", + "summary": "{} van {} speelden af", + "run_all": "Alles testen", + "run_one": "Testen", + "stop": "Stoppen", + "stage_catalogue": "Catalogus", + "stage_detail": "Titel", + "stage_stream": "Link", + "stage_playback": "Afspelen", + "entry": "Bronnen testen" + }, + "catalog": { + "title": "Bronnen per taal", + "subtitle": "Blader door alle extensiebronnen uit alle vier de ecosystemen", + "type_all": "Alles", + "type_anime": "Anime", + "type_video": "Films en series", + "type_manga": "Manga", + "type_novel": "Romans", + "add_repo": "Repo toevoegen", + "installed": "{} bron(nen) toegevoegd uit {}", + "install_empty": "Die repo leverde niets installeerbaars op.", + "install_failed": "Installeren mislukt: {}", + "needs_js": "Dart-bron — kan hier niet draaien", + "android_only": "Alleen Android", + "empty": "Hier staat nog niets. Probeer een andere taal of wis de filters.", + "unreachable": "De broncatalogus was niet bereikbaar." + }, + "mode": { + "video": "Kijken", + "manga": "Manga", + "novel": "Romans", + "none_installed": "Nog geen {}-bronnen geïnstalleerd." + }, + "home_rails": { + "title": "Indeling startscherm", + "entry_title": "Startscherm aanpassen", + "entry_subtitle": "Kies welke rijen verschijnen en in welke volgorde", + "hint": "Sleep om te herordenen. Tik op het oog om een rij te verbergen — hij houdt zijn plek, dus weer aanzetten zet hem terug waar hij stond.", + "hero": "Uitgelichte banner", + "resume": "Verder kijken", + "genres": "Genres", + "live_tv": "Live tv", + "catalogue": "Catalogusrijen" + }, + "stats": { + "title": "Statistieken", + "entry_subtitle": "Kijktijd, streak, waar het heen ging", + "watched": "Bekeken", + "hours_short": "u", + "minutes_short": "m", + "since": "Geteld sinds {}", + "completed": "Afgerond", + "streak": "Dagenstreak", + "last_days": "Laatste {} dagen", + "by_source": "Per bron", + "empty_title": "Nog niets geteld", + "empty_subtitle": "Kijk iets en de totalen beginnen hier. Wat je incognito kijkt, telt niet mee." + }, + "discord": { + "sign_in": "Inloggen met Discord", + "sign_in_desc": "Log in op de eigen inlogpagina van Discord. Het token wordt automatisch opgepikt en alleen op dit apparaat bewaard.", + "or_paste": "of plak zelf een token", + "web_login_title": "Discord koppelen", + "web_login_paste": "Token plakken", + "web_login_paste_title": "Discord-token plakken", + "web_login_paste_hint": "Je Discord-gebruikerstoken", + "title": "Discord", + "what_it_does": "Laat op je Discord-profiel zien wat je kijkt — de titel, de aflevering en hoe ver je bent. Je vrienden zien het in hun vriendenlijst.", + "enable": "Tonen op Discord", + "enable_desktop_desc": "Vereist dat de Discord-app op deze computer draait", + "enable_mobile_desc": "Vereist je Discord-token — lees eerst de waarschuwing hieronder", + "connected": "Verbonden", + "not_connected": "Niet verbonden", + "risk_title": "Lees dit voordat je verdergaat", + "risk_body": "Op een telefoon is er geen Discord-app om mee te verbinden, dus moet Sozo als jou inloggen met je eigen token. Discord staat dat niet toe: je account kan erdoor geschorst worden. Het token geeft volledige toegang tot je Discord-account, en Sozo bewaart het alleen op dit apparaat — nooit op een server. Verwijder het wanneer je wilt met Vergeten.", + "token_label": "Discord-token", + "token_hint": "Plak je token", + "token_saved": "Er is een token opgeslagen — plak een nieuw token om het te vervangen", + "token_save": "Opslaan", + "token_forget": "Vergeten", + "brand_title": "Rich Presence", + "brand_subtitle": "Je vrienden zien live op je profiel wat je kijkt", + "preview_heading": "KIJKT NU", + "preview_title": "Hier komt de titel", + "preview_subtitle": "Seizoen en aflevering", + "preview_elapsed": "{} verstreken", + "preview_elapsed_placeholder": "Verstreken tijd", + "row_off": "Uit", + "row_live": "Wordt op je profiel getoond", + "row_waiting": "Aan — wachten op Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — het beeld kan op dit apparaat groen of paars lijken", + "atmos": "Atmos-audio — dit apparaat speelt het mogelijk zonder geluid af", + "indirect_host": "Deze mirror opent een pagina in plaats van de video", + "uhd": "4K — zwaarder om te decoderen en te downloaden", + "large_file": "Groot bestand — start traag via mobiele data", + "unreachable": "Deze link reageerde niet bij de controle" + }, + "update": { + "available_title": "Nieuwe versie beschikbaar (v{})", + "available_body": "Er is een nieuwe versie van de app beschikbaar.", + "later": "Later", + "now": "Bijwerken", + "open_store": "Openen in de App Store", + "required_title": "Update vereist", + "required_body": "Installeer de nieuwe versie (v{}) om de app te blijven gebruiken.", + "exit_app": "App afsluiten", + "downloading": "Update downloaden…" + }, + "ext": { + "cloudstream_title": "CloudStream-bronnen", + "aniyomi_title": "Aniyomi-bronnen", + "mangayomi_title": "Mangayomi-bronnen", + "android_only": "Deze functie is alleen beschikbaar op Android", + "needs_webview": "JavaScript-extensies hebben een WebView nodig, en die is er niet op dit platform.", + "check_updates": "Controleren op updates", + "check_extension_updates": "Controleren op extensie-updates", + "installed_sources": "GEÏNSTALLEERDE BRONNEN", + "installed_repos": "GEÏNSTALLEERDE REPO’S", + "sources_heading": "BRONNEN", + "recommended": "AANBEVOLEN", + "repo_url": "Repo-URL", + "index_url": "Index-URL", + "browse_plugins": "Plug-ins bekijken", + "no_sources_yet": "Nog geen bronnen", + "no_repos_yet": "Nog geen repo’s", + "add_source": "Bron toevoegen", + "add_repo": "Repo toevoegen", + "installing": "Installeren…", + "installing_extensions": "Extensies installeren…", + "installing_plugins": "Plug-ins installeren…", + "installing_progress": "Installeren {} / {}", + "installing_plugins_progress": "{} / {} plug-ins installeren…", + "installing_extensions_progress": "{} / {} extensies installeren…", + "installing_long": "Extensies installeren… bij grote repo’s duurt dit even.", + "checking_updates": "Controleren op updates…", + "checking_extension_updates": "Controleren op extensie-updates…", + "all_up_to_date": "Alle extensies zijn up-to-date.", + "updated_count": "{} extensie(s) bijgewerkt.", + "updated_list": "{} bijgewerkt: {}", + "update_check_failed": "Controleren op updates mislukt: {}", + "source_removed": "Bron verwijderd.", + "repo_removed": "Repo verwijderd.", + "none_found": "Op die URL zijn geen extensies gevonden.", + "none_found_js": "Op die URL zijn geen JavaScript-extensies gevonden.", + "added_sources": "{} bron(nen) toegevoegd.", + "added_sources_providers": "{} bron(nen) · {} aanbieder(s) toegevoegd.", + "installed_from_file": "{} bron(nen) geïnstalleerd.", + "could_not_install": "Installeren mislukt: {}", + "could_not_install_plugin": "Plug-in installeren mislukt", + "could_not_remove_plugin": "Plug-in verwijderen mislukt", + "error": "Fout: {}", + "cloudstream_desc": "Voeg een CloudStream-repo toe en installeer alleen de plug-ins die je wilt — alleen die verschijnen als aanbieder. Ze draaien op je apparaat (alleen Android).", + "aniyomi_desc": "Voeg Aniyomi-extensierepo’s toe om extra anime-aanbieders te installeren. Ze draaien op je apparaat (alleen Android).", + "mangayomi_desc": "Mangayomi-extensies zijn JavaScript, geen Android-apps — anders dan CloudStream-, Aniyomi- en Manga-bronnen werken ze dus ook op iOS, macOS en Windows.", + "plugins_summary": "{} plug-ins · {} geïnstalleerd", + "install_all": "Alles installeren", + "repo_load_failed": "Deze repo kon niet worden geladen.", + "no_plugins": "Geen plug-ins gevonden in deze repo", + "search_plugins": "Plug-ins zoeken…", + "no_matches": "Geen resultaten", + "import_title": "Extensierepo installeren?", + "import_pick_library": "Anime- en mangarepo’s gebruiken hetzelfde bestandsformaat, dus kies bij welke bibliotheek deze hoort.", + "import_js_only": "Op dit platform kun je alleen Mangayomi-extensies (JavaScript) installeren — CloudStream-, Aniyomi- en Mihon-repo’s leveren Android-app-pakketten.", + "import_none_found": "Geen extensies gevonden in dit bestand. Misschien hoort het bij een andere app, of is het verkeerde type gekozen.", + "mangayomi_url_only": "Mangayomi-repo’s installeer je via een URL — open de indexlink in plaats van een gedownloade kopie.", + "skipped_dart": "{} Dart-only bron(nen) overgeslagen — die zitten ingebouwd in Mangayomi en werken hier niet.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Novel" + }, + "ux": { + "quick_search": "Snel zoeken ({})", + "quick_search_note": "Snel zoeken doorzoekt {} van {} bronnen. Kies bronnen om gericht te zoeken.", + "torrents": "Torrents", + "filters": "Filters", + "change_source": "Bron wijzigen", + "manage_sources": "Bronnen beheren", + "no_source_match": "Geen bronnen komen overeen met je zoekopdracht.", + "download_options": "Downloadopties", + "download_options_note": "Kies een beschikbare bron en kwaliteit. De audio hangt af van de gekozen bron.", + "source_number": "Bron {}", + "checking_quality": "Beschikbare kwaliteit controleren…", + "quality_unavailable": "De kwaliteit kon niet worden gecontroleerd. Probeer opnieuw of download de oorspronkelijke stream zonder kwaliteitsgarantie.", + "quality_unknown": "Kwaliteit onbekend", + "size_unknown": "De bron geeft geen bestandsgrootte op", + "interface_language_note": "Alleen de app-interface verandert. Filmtitels, beschrijvingen en andere broninhoud blijven in de oorspronkelijke taal.", + "continue": "Doorgaan" + } +} diff --git a/assets/translations/pt.json b/assets/translations/pt.json new file mode 100644 index 00000000..fa677bdf --- /dev/null +++ b/assets/translations/pt.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Carregando…", + "error": "Algo deu errado", + "retry": "Tentar de novo", + "cancel": "Cancelar", + "confirm": "Confirmar", + "ok": "OK", + "yes": "Sim", + "no": "Não", + "back": "Voltar", + "next": "Avançar", + "done": "Pronto", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "search": "Buscar", + "close": "Fechar", + "settings": "Configurações", + "logout": "Sair", + "no_internet": "Sem conexão com a internet", + "try_again": "Tente novamente", + "see_all": "Ver tudo", + "unknown": "Desconhecido", + "off": "Desligado", + "on": "Ligado", + "new_badge": "NOVO", + "reset": "Redefinir", + "clear": "Limpar", + "copy": "Copiar", + "share": "Partilhar", + "install": "Instalar", + "remove": "Remover", + "show": "Mostrar", + "hide": "Ocultar", + "installed": "Instalado" + }, + "auth": { + "login": "Entrar", + "register": "Cadastrar", + "email": "E-mail", + "password": "Senha", + "username": "Nome de usuário", + "email_hint": "Digite seu e-mail", + "password_hint": "Digite sua senha", + "username_hint": "Digite seu nome de usuário", + "forgot_password": "Esqueceu a senha?", + "dont_have_account": "Ainda não tem conta?", + "already_have_account": "Já tem conta?", + "sign_up": "Criar conta", + "sign_in": "Entrar", + "login_success": "Login realizado", + "register_success": "Cadastro concluído", + "logout_success": "Você saiu da conta", + "invalid_email": "E-mail inválido", + "invalid_password": "A senha precisa ter pelo menos 6 caracteres", + "invalid_username": "O nome de usuário precisa ter pelo menos 3 caracteres", + "confirm_password": "Confirmar senha", + "passwords_not_match": "As senhas não coincidem", + "terms_agree": "Concordo com os Termos e a Política de Privacidade", + "or_continue_with": "Ou continue com", + "welcome_back": "Que bom te ver de novo", + "create_account": "Criar conta", + "login_subtitle": "Entre para usar seu perfil e seus dados salvos.", + "register_subtitle": "Crie uma conta com e-mail, nome de usuário e senha.", + "forgot_password_title": "Redefina sua senha", + "forgot_password_subtitle": "Digite o e-mail da sua conta e enviaremos um código de 6 dígitos.", + "reset_password_subtitle": "Enviamos um código para {email}. Digite-o junto com a nova senha.", + "send_reset_code": "Enviar código", + "reset_code_sent": "Se esse e-mail estiver cadastrado, um código está a caminho", + "reset_code_hint": "Código de 6 dígitos", + "invalid_reset_code": "Digite o código de 6 dígitos", + "new_password_hint": "Nova senha", + "confirm_password_hint": "Repita a nova senha", + "reset_password": "Redefinir senha", + "resend_code": "Enviar outro código", + "resend_in": "Enviar de novo em {seconds}s", + "identifier_hint": "E-mail ou nome de usuário", + "identifier_required": "E-mail ou nome de usuário obrigatório", + "continue_with_google": "Continuar com o Google", + "google_unavailable": "O login com o Google não está disponível neste aparelho", + "google_failed": "Não foi possível entrar com o Google. Tente novamente", + "weak": "Fraca", + "medium": "Média", + "strong": "Forte", + "code_resent": "Código reenviado. Confira seu e-mail.", + "verify": "Verificar", + "avatar_bad_format": "Formato de imagem não suportado (jpg, png, webp)", + "verification_code": "Código de verificação" + }, + "onboarding": { + "skip": "Pular", + "continue_with_email": "Continuar com e-mail", + "slide_1_title": "Milhares de filmes e séries", + "slide_1_body": "Lançamentos, cinema mundial e animação — tudo em um só app.", + "slide_2_title": "Anime, uma biblioteca inteira", + "slide_2_body": "Lançamentos da temporada, clássicos e legendas. Conecte o AniList e sua lista se atualiza sozinha.", + "slide_3_title": "Na tela grande também", + "slide_3_body": "Canais de TV ao vivo, parear o celular com a televisão e assistir junto — o Sozo também roda na TV." + }, + "home": { + "title": "Início", + "continue_watching": "Continuar assistindo", + "trending": "Em alta agora", + "new_releases": "Lançamentos", + "top_rated": "Mais bem avaliados", + "for_you": "Para você", + "all_categories": "Todas as categorias", + "genres": "Gêneros", + "get_all": "Ver tudo", + "popular": "Populares", + "watch_now": "Assistir agora", + "more_info": "Mais informações", + "add_to_list": "Adicionar à lista", + "my_list": "Minha lista", + "featured": "Destaque", + "action": "Ação", + "comedy": "Comédia", + "drama": "Drama", + "thriller": "Suspense", + "horror": "Terror", + "romance": "Romance", + "sci_fi": "Ficção científica", + "documentary": "Documentário", + "animation": "Animação", + "telegram_promo_title": "Entre no Sozo no Telegram", + "telegram_promo_subtitle": "Receba novidades, novos recursos e avisos de conteúdo.", + "telegram_join_channel": "Entrar no canal", + "dont_show_again": "Não mostrar de novo", + "downloaded": "Baixado", + "view_all": "Ver tudo", + "remove_from_continue": "Tirar de Continuar assistindo", + "ep_number": "EP {}", + "untitled": "Sem título", + "empty_title": "Ainda não há nada aqui", + "empty_subtitle": "Escolha outra fonte, ou puxe para atualizar.", + "list_empty_title": "Esta lista está vazia", + "list_empty_subtitle": "Nada para mostrar nesta seção agora." + }, + "player": { + "needs_media_kit": "Precisa do motor media_kit", + "auto_quality_desc": "O player escolhe o melhor que sua conexão aguenta", + "on": "Ligado", + "info_title": "Informações do player", + "info_resolution": "Resolução", + "info_engine": "Motor", + "info_provider": "Fonte", + "info_server": "Servidor", + "info_quality": "Qualidade", + "info_codec": "Codec", + "info_hdr": "HDR", + "info_audio": "Áudio", + "info_container": "Contêiner", + "info_size": "Tamanho", + "info_host": "Host", + "info_position": "Posição", + "info_buffer": "Pré-carregado", + "info_speed": "Velocidade", + "info_state": "Estado", + "switching_to": "Mudando para {}…", + "fault": { + "decoder": "Este aparelho não conseguiu decodificar o vídeo. Tente outra qualidade ou tente de novo.", + "unsupported_format": "Este formato de vídeo não é compatível com seu aparelho. Você pode reproduzi-lo no navegador.", + "source_blocked": "Não foi possível abrir a fonte do vídeo — o servidor pode tê-la bloqueado. Tente outra qualidade.", + "network": "Erro de rede — verifique sua conexão.", + "no_source": "Nenhuma fonte para este episódio.", + "empty_url": "Esta fonte não devolveu nenhum endereço de vídeo.", + "engine_unavailable": "O player não iniciou. Reinicie o app por completo.", + "unknown": "Não foi possível carregar o vídeo." + }, + "play": "Reproduzir", + "pause": "Pausar", + "resume": "Retomar", + "forward": "Avançar 10 s", + "backward": "Voltar 10 s", + "fullscreen": "Tela cheia", + "exit_fullscreen": "Sair da tela cheia", + "quality": "Qualidade", + "subtitles": "Legendas", + "audio": "Áudio", + "speed": "Velocidade de reprodução", + "auto": "Automática", + "off": "Desligado", + "episodes": "Episódios", + "next_episode": "Próximo episódio", + "season": "Temporada {n}", + "fit_original": "Original", + "fit_fill": "Preencher", + "fit_stretch": "Esticar", + "play_in_browser": "Reproduzir no navegador", + "view_logs": "Ver registros", + "speed_2x": "Velocidade 2x", + "tap_to_unlock": "Toque para desbloquear", + "previous": "Anterior", + "live": "AO VIVO", + "go_live": "Ir para o ao vivo", + "lang_sub": "Legendado (SUB)", + "lang_dub": "Dublado (DUB)", + "lang_softsub": "Legendas soft", + "audio_language": "Idioma do áudio", + "speed_short": "Velocidade", + "aspect": "Proporção", + "aspect_ratio": "Proporção de tela", + "subtitle_style": "Estilo das legendas", + "audio_track": "Faixa de áudio", + "diagnostics_logs": "Diagnóstico / registros", + "extracting_media": "Extraindo mídia…", + "loading_video": "Carregando vídeo…", + "fetching_link": "Buscando o link de reprodução na fonte", + "preparing_stream": "Preparando o stream de vídeo", + "episode_n": "Episódio {}", + "search_online": "Buscar on-line", + "subtitle_sync": "Sincronia da legenda", + "subtitle_sync_desc": "Desloque o tempo para casar com o áudio", + "subtitle_sync_help": "Legendas adiantadas? Aumente (+). Atrasadas? Diminua (−).", + "search_subtitles": "Buscar legendas", + "subtitle_search_hint": "Título / nome do filme", + "searching_subtitles": "Buscando legendas…", + "no_subtitles_found": "Nenhuma legenda encontrada", + "search_failed": "A busca falhou: {}", + "online_subtitles_count": "Legendas on-line · {}", + "loading_subtitle": "Carregando legenda…", + "subtitle_loaded": "Legenda carregada", + "subtitle_failed_download": "Não foi possível baixar esta legenda", + "subtitle_failed_empty": "Este arquivo de legenda não tem texto", + "subtitle_failed_archive": "Legendas compactadas (.zip / .rar) não são suportadas", + "subtitle_failed_html": "O servidor devolveu uma página web, não uma legenda", + "subtitle_failed_format": "Formato de legenda não suportado", + "subtitle_rate": "Conversão de taxa de quadros", + "subtitle_rate_help": "Está saindo cada vez mais do lugar? A legenda foi sincronizada para outra taxa de quadros — escolha a conversão.", + "subtitle_rate_off": "Desligada", + "reset": "Redefinir", + "font_size": "Tamanho da fonte", + "text_color": "Cor do texto", + "background": "Fundo", + "none": "Nenhum", + "solid": "Sólido", + "edge": "Borda", + "shadow": "Sombra", + "outline": "Contorno", + "position": "Posição", + "lower": "Mais baixo", + "default": "Padrão", + "higher": "Mais alto", + "bold": "Negrito", + "audio_track_engine_only": "só media_kit", + "audio_track_single": "Só uma faixa", + "external_player": "Reproduzir em player externo", + "external_player_gated": "Esta fonte precisa de cabeçalhos especiais que o VLC e o MX Player não conseguem enviar. Muito provavelmente não vai carregar. Tentar mesmo assim?", + "external_player_try": "Tentar mesmo assim", + "external_player_none": "Nenhum player de vídeo externo encontrado", + "audio_track_numbered": "Áudio {}", + "engine_sheet_title": "Como isto deve tocar?", + "engine_sheet_subtitle": "Escolha o motor para este vídeo. Você pode mudar quando quiser em Configurações → Player.", + "engine_sheet_dont_ask": "Não perguntar de novo", + "engine_sheet_play": "Reproduzir", + "subtitle_preview_text": "Um pequeno jabuti xereta viu dez cegonhas felizes", + "server": "Servidor", + "stream_not_found": "Esta fonte esconde o stream atrás de um player protegido. Tente outra fonte ou abra no navegador.", + "subtitle_translation": "Tradução de legendas", + "auto_translate": "Traduzir automaticamente", + "auto_translate_desc": "Quando a fonte não tem legenda no seu idioma, a IA traduz", + "translate_to": "Traduzir para", + "translate_note": "A tradução é feita por máquina e pode errar. Há um limite diário.", + "translate_ai": "Traduzir com IA", + "translating_subtitle": "Traduzindo…", + "translate_failed": "Não foi possível traduzir", + "ai_translate_title": "Traduzir para {} com IA", + "ai_translate_hint": "Escolha uma legenda da lista e toque em IA", + "ai_translate_remaining": "Restam {}/{} traduções hoje", + "ai_translate_used_up": "Você usou o limite de traduções de hoje", + "ai_translate_menu": "Traduzir com IA", + "ai_translate_menu_desc": "Traduz uma legenda para {}", + "ready_translations": "Traduções prontas", + "lines": "linhas", + "sleep_timer": "Timer de desligamento", + "sleep_minutes": "{} minutos", + "sleep_hours": "{} horas", + "sleep_minutes_short": "{} min", + "sleep_episode_end": "Fim do episódio", + "sleep_fired": "Timer — reprodução pausada", + "skip_opening": "Pular abertura", + "skip_ending": "Pular encerramento", + "alt_sources": "Tentar outra fonte", + "alt_searching": "Procurando em outras fontes…", + "alt_none": "Nenhuma outra fonte tem este título", + "alt_failed": "Não foi possível abrir esta fonte", + "alt_no_episode": "Esta fonte não tem o episódio {}", + "alt_search_all": "Buscar em todas as fontes", + "guide": "Guia", + "change_source": "Trocar de fonte", + "cast": "Transmitir para a TV", + "cast_to": "Transmitir para", + "cast_searching": "Procurando aparelhos nesta Wi-Fi…", + "cast_none": "Nenhum aparelho encontrado. A TV precisa estar na mesma Wi-Fi que este celular.", + "cast_failed": "Não foi possível conectar a {}", + "cast_unsupported": "Este stream não pode ser transmitido", + "cast_active": "Reproduzindo em {}", + "cast_stop": "Parar transmissão", + "color_profile": "Imagem", + "color_natural": "Natural", + "color_cinema": "Cinema", + "color_night": "Noite", + "color_anime": "Anime", + "color_restore": "Restaurar", + "shaders": "Aprimorar (Anime4K)", + "shaders_note": "Upscaling por GPU para animação. Os arquivos de shader são baixados uma única vez no primeiro uso.", + "shader_off": "Desligado", + "shader_off_desc": "Sem aprimoramento", + "shader_sharpen": "Nitidez", + "shader_sharpen_desc": "Recupera detalhe — melhor para fontes limpas", + "shader_deblur": "Tirar borrão", + "shader_deblur_desc": "Recuperação mais suave — para fontes borradas", + "shader_denoise": "Reduzir ruído", + "shader_denoise_desc": "Limpa o granulado — para fontes muito comprimidas", + "shader_tier": "ORÇAMENTO DE GPU", + "shader_tier_mid": "Equilibrado", + "shader_tier_mid_desc": "Suave na maioria dos celulares", + "shader_tier_high": "Máximo", + "shader_tier_high_desc": "O mais nítido, exige uma GPU forte", + "font_family": "Fonte", + "font_system": "Padrão", + "font_sans": "Sem serifa", + "font_serif": "Com serifa", + "font_condensed": "Condensada", + "font_mono": "Monoespaçada", + "second_track": "Segunda faixa", + "second_track_on": "Mostrando {} como segunda faixa", + "second_track_same": "Essa faixa já é a principal", + "second_track_hint": "Segure uma faixa para mostrá-la junto com a primeira", + "share_frame": "Compartilhar este quadro", + "share_frame_failed": "Não foi possível capturar o quadro", + "save_frame": "Salvar quadro", + "save_frame_done": "Salvo na galeria", + "save_frame_failed": "Não foi possível salvar o quadro", + "save_frame_denied": "É preciso acesso à galeria para salvar o quadro", + "switching_server": "Trocando de servidor…", + "recommended": "Recomendado", + "episode_page_failed": "Não foi possível carregar a próxima parte da série", + "info_fields_desc": "Escolha o que aparece sobre o vídeo. Ele surge junto com os controles.", + "layout_title": "Controles do player", + "layout_desc": "Arraste para reordenar, ou mova um controle para outra parte da barra.", + "layout_top_bar": "Barra de cima", + "layout_bottom_left": "Inferior esquerdo", + "layout_bottom_right": "Inferior direito", + "layout_hidden": "Ocultos", + "layout_hidden_desc": "Ainda acessíveis pelas Configurações dentro do player.", + "layout_pinned": "Configurações fica onde está — é o caminho de volta para todo o resto.", + "layout_top_bar_full": "A barra de cima está cheia. Tire algo dela primeiro.", + "layout_unknown_control": "Esse controle não está mais disponível.", + "layout_move_to": "Mover", + "layout_empty": "Não há nada aqui.", + "rotate": "Girar", + "lock": "Bloquear", + "enhance": "Aprimorar", + "fit": "Ajustar", + "pip": "Picture in picture", + "layout_pinned_badge": "Fixado", + "info_fields_title": "Linhas do painel de informações", + "logs_title": "Registos do reprodutor ({})", + "logs_copied": "Registos copiados", + "logs_empty": "Ainda não há registos" + }, + "profile": { + "title": "Perfil", + "my_account": "Minha conta", + "notifications": "Notificações", + "language": "Idioma", + "theme": "Tema", + "privacy": "Política de Privacidade", + "help": "Ajuda e suporte", + "about": "Sobre", + "version": "Versão", + "subscription": "Assinatura", + "downloads": "Downloads", + "watch_history": "Histórico", + "manage_profiles": "Gerenciar perfis", + "parental_controls": "Controle dos pais", + "guest_title": "Entre no seu perfil", + "guest_subtitle": "Entre para manter favoritos e histórico sincronizados.", + "auth_provider": "Método de acesso", + "joined": "Entrou em", + "providers": "Fontes", + "providers_error": "Não foi possível carregar as fontes.", + "offline_title": "Servidor inacessível", + "offline_local_available": "{} fonte(s) instaladas neste aparelho continuam funcionando. As fontes na nuvem ficam indisponíveis até o servidor voltar.", + "offline_no_local": "O servidor não responde e não há fontes instaladas neste aparelho. Instale um plugin do CloudStream para continuar assistindo off-line.", + "offline_cached_at": "Lista da nuvem de {}", + "offline_install_plugins": "Instalar plugins", + "offline_badge": "OFF-LINE", + "provider_needs_server": "Esta fonte precisa do servidor, que está inacessível agora. Escolha uma fonte instalada neste aparelho.", + "logout_confirm": "Quer sair da sua conta?", + "connections": "Conexões", + "coming_soon": "Em breve", + "favorites": "Favoritos", + "all_providers": "Todas as fontes", + "add_favorite": "Adicionar aos favoritos", + "cloudstream_sources": "Fontes do CloudStream", + "aniyomi_sources": "Fontes do Aniyomi", + "signin_account_title": "Entre na sua conta", + "signin_account_subtitle": "Entre para salvar seus favoritos e seu histórico.", + "sign_in": "Entrar", + "sign_out": "Sair", + "sign_out_confirm": "Tem certeza de que quer sair?", + "section_providers": "Fontes", + "section_content": "CONTEÚDO", + "sources_row": "Fontes de extensão", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Fonte", + "choose_provider": "Trocar fonte", + "search_providers_hint": "Buscar fontes…", + "count_of_total_shown": "{} de {} exibidas", + "no_providers_in_category": "Nenhuma fonte nesta categoria", + "try_select_all": "Selecione “Todas” para ver todas as fontes", + "section_activity": "ATIVIDADE", + "share_sources_desktop": "Compartilhar fontes com o desktop", + "desktop_sources": "Fontes do desktop", + "developer_role": "Desenvolvedor mobile", + "section_about": "SOBRE", + "developer": "Desenvolvedor", + "website": "Site", + "server": "Servidor", + "expired": "Expirado", + "support_title": "Mantenha o Sozo no ar", + "server_expired": "Servidor expirado", + "days": "Dias", + "hours": "Horas", + "min": "Min", + "sec": "Seg", + "support_body_expired": "O servidor expirou e o conteúdo não pode mais ser carregado. Seu apoio pode ajudar a colocá-lo de volta no ar.", + "support_body": "O Sozo depende de um servidor que precisa ser renovado de tempos em tempos. Se você curte o app, seu apoio ajuda a manter tudo funcionando.", + "support_developer": "Apoiar o desenvolvedor", + "section_appearance": "APARÊNCIA", + "native_window_bar": "Barra de janela nativa", + "native_window_bar_subtitle": "Usar a barra de título padrão do Windows em vez da do app.", + "liquid_glass_nav": "Navegação liquid glass", + "liquid_glass_nav_subtitle": "Barra de abas flutuante em vidro fosco (iOS 26). Desative para uma barra sólida simples.", + "nav_style": "Barra de navegação", + "nav_style_glass": "Liquid glass", + "nav_style_solid": "Sólida", + "nav_style_classic": "Clássica", + "section_player": "Player", + "player_engine_native": "Player do sistema", + "player_engine_native_desc": "O player da plataforma. Melhor bateria e decodificação por hardware. Não troca faixas de áudio.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Toca mais formatos e permite trocar faixas de áudio e legenda. Gasta um pouco mais de bateria.", + "player_engine_external": "Player externo", + "player_engine_external_desc": "Enviar o vídeo para o VLC ou o MX Player em vez de tocá-lo aqui.", + "player_engine_external_note": "Algumas fontes só funcionam dentro do Sozo porque precisam de cabeçalhos especiais. Essas não vão tocar em um app externo.", + "player_engine_badge_default": "PADRÃO", + "player_engine_section": "MOTOR DE REPRODUÇÃO", + "ask_engine_on_play": "Perguntar sempre", + "ask_engine_on_play_desc": "Escolha o motor toda vez que um vídeo começar", + "playback_section": "REPRODUÇÃO", + "default_speed": "Velocidade padrão", + "default_speed_desc": "Velocidade com que todo vídeo começa", + "default_fit": "Modo de preenchimento padrão", + "default_fit_desc": "Como o vídeo se encaixa na tela", + "auto_next_episode": "Tocar o próximo episódio", + "auto_next_episode_desc": "Começa o próximo episódio quando um termina", + "keep_screen_on": "Manter a tela ligada", + "keep_screen_on_desc": "Impede que a tela apague enquanto você assiste", + "gestures_section": "GESTOS", + "seek_step": "Salto no toque duplo", + "seek_step_desc": "Quanto um toque duplo avança", + "long_press_boost": "Velocidade ao segurar", + "long_press_boost_desc": "Velocidade enquanto você segura o dedo no vídeo", + "brightness_gesture": "Deslizar para o brilho", + "brightness_gesture_desc": "Deslize para cima e para baixo no lado esquerdo", + "volume_gesture": "Deslizar para o volume", + "volume_gesture_desc": "Deslize para cima e para baixo no lado direito", + "seconds_short": "{} s", + "subtitle_style_note": "Isso vale para todos os vídeos. A sincronia da legenda é por episódio e se ajusta dentro do player.", + "searching_all_sources": "buscando em todas as fontes", + "section_connections": "CONEXÕES", + "section_extensions": "FONTES", + "section_settings": "CONFIGURAÇÕES", + "remove_favorite": "Remover dos favoritos", + "edit_profile": "Editar perfil", + "display_name_hint": "Seu nome", + "display_name_too_long": "O nome deve ter no máximo 60 caracteres", + "pick_from_gallery": "Escolher da galeria", + "take_photo": "Tirar uma foto", + "remove_photo": "Remover foto", + "photo_pick_failed": "Não foi possível escolher uma foto", + "incognito": "Anônimo", + "incognito_desc": "Assista sem entrar no histórico nem nos rastreadores", + "incognito_off": "Modo anônimo desativado", + "auto_skip_intro": "Pular aberturas automaticamente", + "auto_skip_intro_desc": "Pula aberturas e encerramentos de anime sem perguntar", + "incognito_active": "O modo anônimo está ligado. Nada do que você assiste vai para o histórico nem para o AniList e o MAL.", + "incognito_turn_off": "Desativar", + "all_languages": "Todos os idiomas", + "section_library": "SUA BIBLIOTECA", + "section_watch": "ASSISTIR", + "hero_trailer": "Tocar o trailer no cabeçalho", + "hero_trailer_desc": "Pouco depois de abrir um título, o pôster dá lugar ao trailer, sem som", + "section_overview": "VISÃO GERAL", + "section_app": "APP", + "library": "Biblioteca", + "library_subtitle": "Favoritos, assistir depois, lista privada", + "sources_title": "Fontes", + "section_active_source": "FONTE ATIVA", + "active_source_footnote": "A fonte na nuvem em que o Sozo busca e de onde reproduz. As extensões abaixo adicionam fontes que rodam neste aparelho.", + "settings_title": "Configurações", + "settings_subtitle": "Idioma, aparência, player, segurança", + "section_general": "GERAL", + "section_data": "DADOS", + "section_lists": "LISTAS", + "section_accounts": "CONTAS", + "section_devices": "APARELHOS", + "language_desc": "Idioma da interface", + "not_connected": "Não conectado", + "joined_on": "Entrou em {}", + "watch_later": "Assistir depois", + "watched": "Assistido", + "stat_hours": "horas assistidas", + "stat_completed": "concluídos", + "stat_streak": "sequência de dias", + "guest_benefit_sync": "Favoritos e histórico sincronizados em todos os seus aparelhos", + "guest_benefit_streak": "Sequência, estatísticas e ranking do trivia", + "guest_benefit_trackers": "AniList, MyAnimeList e Watch Party", + "activity": "Atividade", + "activity_subtitle": "Histórico, downloads, acompanhamento, estatísticas", + "connections_subtitle": "Rastreadores, Discord, TV, desktop", + "trackers": "Rastreadores", + "stat_days": "dias assistidos", + "mangayomi_sources": "Fontes do Mangayomi", + "view_only": "SÓ VER" + }, + "appearance": { + "title": "Aparência", + "entry_subtitle": "Cor de destaque, preto puro", + "section_preview": "PRÉVIA", + "section_accent": "COR DE DESTAQUE", + "accent_footnote": "O destaque colore botões, realces, barras de progresso e a aba ativa — no app inteiro.", + "accent_custom": "Personalizada", + "accent_red": "Vermelho Sozo", + "accent_ember": "Brasa", + "accent_amber": "Âmbar", + "accent_emerald": "Esmeralda", + "accent_teal": "Verde-azulado", + "accent_sky": "Céu", + "accent_ocean": "Oceano", + "accent_indigo": "Índigo", + "accent_violet": "Violeta", + "accent_magenta": "Magenta", + "accent_rose": "Rosa", + "accent_slate": "Ardósia", + "section_darkness": "FUNDO", + "darkness_dark": "Escuro", + "darkness_dark_desc": "Os cinzas escuros de sempre", + "darkness_black": "Preto puro", + "darkness_black_desc": "Fundos pretos de verdade", + "amoled_footnote": "Numa tela OLED um pixel preto fica desligado, então o preto puro dá mais contraste e gasta menos bateria.", + "reset": "Voltar ao padrão", + "reset_tooltip": "Voltar à aparência padrão", + "custom_title": "Cor personalizada", + "custom_hue": "MATIZ", + "custom_saturation": "SATURAÇÃO", + "custom_lightness": "LUMINOSIDADE", + "custom_apply": "Usar esta cor", + "custom_adjusted": "Escurecida um pouco para o texto branco continuar legível em cima dela.", + "section_library": "DA SUA BIBLIOTECA", + "library_footnote": "Cores tiradas dos pôsteres do que você andou assistindo.", + "section_extras": "MAIS", + "tint_nav": "Colorir a barra de abas", + "tint_nav_desc": "Pinta a aba selecionada com sua cor de destaque em vez de branco", + "shuffle": "Testar a próxima cor", + "shuffle_desc": "Percorra a paleta uma cor por vez" + }, + "search": { + "title": "Buscar", + "hint": "Busque filmes, séries…", + "no_results": "Nenhum resultado encontrado", + "no_results_for": "Nenhum resultado para “{query}”", + "recent": "Buscas recentes", + "popular_searches": "Buscas populares", + "categories": "Gêneros", + "all": "Tudo", + "movies": "Filmes", + "series": "Séries", + "filter": "Filtrar", + "sort": "Ordenar por", + "sort_newest": "Mais recentes", + "sort_oldest": "Mais antigos", + "sort_rating": "Nota", + "type": "Tipo", + "apply": "Aplicar", + "clear_filter": "Limpar", + "sources": "Fontes", + "sources_n": "{} fontes", + "all_sources": "Selecionar tudo", + "this_server": "Este servidor", + "local_group": "Local", + "search_in": "Buscar em", + "sources_hint": "Busque em várias fontes ao mesmo tempo. Deixe vazio para usar sua fonte atual.", + "searching": "Buscando…", + "all_source_search": "Busca em todas as fontes", + "cross_hint": "Busque nas suas fontes…", + "pick_sources": "Escolha em quais fontes buscar.", + "choose_sources": "Escolher fontes", + "no_sources_selected": "Nenhuma fonte escolhida — toque para escolher", + "sources_selected": "{} escolhidas — toque para mudar", + "type_to_search_n": "Digite para buscar em {} fontes de uma vez.", + "found_in": "Encontrado em {} de {} fontes", + "results_n": "{} resultados", + "waiting_for": "Buscando em {}…", + "no_results_any": "Nenhum resultado em qualquer fonte escolhida.", + "offline_note": "Off-line — só dá para buscar nas fontes do aparelho.", + "group_by_source": "Agrupar por fonte", + "merge_titles": "Unificar títulos", + "open_from": "Abrir de", + "load_more": "Carregar mais", + "search_failed": "A busca falhou", + "source_failed": "Esta fonte está indisponível agora", + "try_all_sources": "Tentar todas as fontes", + "weak_results": "Nada aqui parece “{query}” — esta fonte casou com outra coisa. Outras fontes podem ter.", + "did_you_mean": "Você quis dizer", + "search_sources": "Buscar fontes", + "filter_providers": "Filtrar fontes…", + "selected_n": "{} selecionadas", + "apply_n": "Aplicar ({})", + "many_sources_warning": "Buscar em {} fontes pode demorar. O app continua respondendo, mas com menos fica mais rápido.", + "all_sources_n": "Todas as fontes ({})", + "more_sources": "Mais…", + "select_all_sources": "Selecionar tudo", + "search_all_sources": "Buscar em todas as fontes", + "apply_all": "Buscar em todas as fontes", + "selected_n_of_m": "{} de {} fontes", + "loading_sources": "Carregando suas fontes…", + "no_sources_available": "Nenhuma fonte disponível. Adicione ou ative uma fonte para buscar.", + "min_query_n": "Digite pelo menos {} caracteres.", + "retry_failed": "Tentar de novo as que falharam", + "status_searching": "Buscando…", + "status_searching_n": "{} buscando", + "status_with_results_n": "{} com resultados", + "status_no_match_n": "{} sem correspondência", + "status_timed_out_n": "{} expiraram", + "status_failed_n": "{} falharam", + "all_sources_failed": "As {} fontes não responderam. Isto não é um resultado vazio — tente de novo.", + "no_results_with_failures": "Nenhuma correspondência nas fontes que responderam ({}). Não foi possível acessar {}.", + "all_sources_failed_one": "A fonte que você buscou não respondeu. Isto não é um resultado vazio — tente de novo.", + "voice": "Buscar por voz", + "voice_listening": "Ouvindo…", + "voice_done": "Parei de ouvir", + "voice_hint": "Diga o nome de um filme ou série", + "try_torrents": "Buscar torrents", + "capped_note": "Buscamos nas {} fontes mais confiáveis de {} — restrinja a seleção para chegar a uma específica." + }, + "movie": { + "rating": "Nota", + "year": "Ano", + "quality": "Qualidade", + "duration": "Duração", + "genre": "Gênero", + "director": "Direção", + "cast": "Elenco", + "description": "Descrição", + "similar": "Títulos parecidos", + "trailer": "Trailer", + "share": "Compartilhar", + "download": "Baixar", + "rate": "Avaliar", + "episodes": "Episódios", + "seasons": "Temporadas" + }, + "errors": { + "network": "Erro de rede. Verifique sua conexão.", + "server": "Erro no servidor. Tente novamente.", + "unauthorized": "Entre para continuar.", + "not_found": "Conteúdo não encontrado.", + "unknown": "Ocorreu um erro desconhecido.", + "timeout": "Tempo esgotado. Tente novamente.", + "empty": "Nenhum conteúdo disponível.", + "source_http": "O servidor da fonte respondeu com um erro ({})." + }, + "navigation": { + "home": "Início", + "search": "Buscar", + "shorts": "Shorts", + "downloads": "Downloads", + "profile": "Perfil", + "my_list": "Minha lista", + "history": "Histórico", + "following": "Seguindo", + "premiere": "Estreia", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "TV ao vivo" + }, + "premiere": { + "tab_title": "Estreias", + "live_now": "Ao vivo agora", + "upcoming": "Em breve", + "live": "AO VIVO", + "catching_up": "Alcançando o ao vivo", + "starts_in": "Começa em", + "starts_at": "Começa {when}", + "days": "DIAS", + "hours": "H", + "minutes": "MIN", + "seconds": "SEG", + "waiting": "{count} esperando", + "watching": "{count} assistindo", + "join": "Entrar na estreia", + "join_now": "Entrar agora", + "set_reminder": "Criar lembrete", + "reminder_set": "Lembrete criado", + "notify_me": "Me avise quando começar", + "guest_watch_hint": "Você pode assistir como convidado.", + "login_to_chat_react": "Entre para conversar e reagir", + "login_to_chat": "Entre para conversar", + "chat_hint": "Diga oi — o chat está aberto", + "chat_empty": "Diga oi 👋 O chat começa junto com a estreia", + "chat_placeholder": "Mensagem…", + "slow_mode": "Modo lento: {sec}s", + "react": "Reagir", + "paused_by_host": "Pausado pelo anfitrião", + "buffering": "Carregando…", + "reconnecting": "Reconectando…", + "connecting": "Conectando à estreia…", + "ended_title": "A estreia acabou", + "ended_watch_vod": "Assistir sob demanda", + "canceled": "Esta estreia foi cancelada", + "empty_title": "Ainda sem estreias", + "empty_subtitle": "Os eventos ao vivo aparecerão aqui", + "error_generic": "Algo deu errado", + "error_full": "Esta estreia está lotada", + "error_not_found": "Estreia não encontrada", + "retry": "Tentar de novo", + "share": "Compartilhar", + "back": "Voltar", + "resolve_failed": "Não foi possível iniciar a reprodução neste aparelho" + }, + "nav_customize": { + "entry_title": "Personalizar abas", + "entry_subtitle": "Escolha quais abas aparecem e em que ordem", + "title": "Barra inferior", + "shown": "Visíveis", + "available": "Disponíveis", + "min_hint": "Mantenha pelo menos {count} abas", + "max_hint": "Até {count} abas", + "pick_range": "Escolha entre {min} e {max} abas", + "reset": "Redefinir", + "cancel": "Cancelar", + "save": "Salvar" + }, + "app_lock": { + "section_label": "SEGURANÇA", + "app_lock": "Bloqueio do app", + "settings_title": "Bloqueio do app", + "setup_title": "Configurar PIN", + "enable": "Ativar o bloqueio do app", + "state_on": "Ligado", + "state_off": "Desligado", + "choose_length_title": "Tamanho do PIN", + "choose_length_subtitle": "Escolha quantos dígitos seu PIN terá.", + "length_n_digits": "PIN de {} dígitos", + "create_pin_title": "Crie um novo PIN", + "create_pin_subtitle": "Escolha algo que você não vá compartilhar com ninguém.", + "confirm_pin_title": "Confirme seu PIN", + "confirm_pin_subtitle": "Digite seu novo PIN mais uma vez.", + "verify_title": "App bloqueado", + "verify_subtitle": "Digite seu PIN para continuar.", + "verify_current_pin": "Digite seu PIN atual", + "change_pin": "Mudar o PIN", + "biometric": "Desbloquear com biometria", + "biometric_unavailable": "A biometria não está disponível neste aparelho", + "biometric_enable_reason": "Autentique-se para ativar o desbloqueio biométrico", + "biometric_unlock_reason": "Autentique-se para abrir o app", + "pin_mismatch": "Os PINs não coincidem", + "pin_wrong": "PIN incorreto", + "disable_title": "Desativar o bloqueio do app?", + "disable_subtitle": "Seu PIN e sua preferência de biometria serão removidos.", + "enabled_hint": "Você vai precisar do PIN sempre que o app abrir.", + "disabled_hint": "O bloqueio está desligado. Ligue para proteger o que você assiste.", + "private_list": "Lista privada", + "move_to_private": "Mover para privada", + "moved_to_private": "Movido para a lista privada", + "removed_from_private": "Removido da privada", + "move_to_my_list": "Voltar para Minha lista", + "private_empty": "Ainda nada privado", + "private_setup_required": "Configure um PIN antes de usar a lista privada", + "private_long_press_hint": "Segure para mover para a sua lista privada — fica atrás do PIN e o histórico não é salvo.", + "private_always_ask": "Sempre pedir o PIN para a lista privada", + "private_always_ask_hint": "Digite seu PIN toda vez, não só uma vez por sessão", + "reset_title": "Redefinir o bloqueio do app?", + "reset_body": "Isso remove o PIN e desbloqueia o Sozo neste computador. Sua biblioteca e seu histórico continuam. Você pode definir um novo PIN depois em Perfil → Bloqueio do app.", + "reset": "Redefinir", + "forgot_pin": "Esqueceu o PIN?" + }, + "my_list": { + "saved_account": "Salvo na conta", + "saved_local": "Neste dispositivo", + "untitled": "Sem título", + "empty_title": "Sua lista está vazia", + "empty_subtitle": "Toque no botão + em qualquer título para salvá-lo aqui.", + "unauth_title": "Entre para ver sua lista", + "unauth_subtitle": "Seus títulos salvos aparecerão aqui assim que você entrar.", + "error_title": "Não foi possível carregar a lista" + }, + "deeplink": { + "opt_in_title": "Abrir links do Sozo neste app?", + "opt_in_body_android": "Links compartilhados de sozo.azamov.me abrirão direto no Sozo. Ative “Abrir links compatíveis” nas configurações do sistema.", + "opt_in_body_ios": "Links do Sozo abrem no app automaticamente. Se um link abrir no Safari, segure-o e escolha “Abrir no Sozo”.", + "opt_in_enable": "Ativar", + "opt_in_later": "Depois" + }, + "streak": { + "section_label": "SEQUÊNCIA", + "empty_title": "Comece uma sequência", + "empty_subtitle": "Assista a um episódio hoje para começar sua sequência diária.", + "current_n_days": "{} dias", + "locked_today": "O dia de hoje está garantido. Volte amanhã!", + "keep_going": "Mantenha sua sequência viva — não esqueça de assistir hoje.", + "risk_subtitle": "Sua sequência zera à meia-noite. Assista a algo agora para salvá-la.", + "record_label": "Recorde", + "next_milestone": "Próxima meta", + "weekday_mon": "Seg", + "weekday_tue": "Ter", + "weekday_wed": "Qua", + "weekday_thu": "Qui", + "weekday_fri": "Sex", + "weekday_sat": "Sáb", + "weekday_sun": "Dom", + "milestone_title": "Sequência de {} dias!", + "milestone_subtitle": "Constância impressionante. Continue aparecendo e bata seu recorde pessoal.", + "share_text": "Cheguei a uma sequência de {} dias no Sozo! 🔥", + "cta_continue": "Continuar", + "cta_share": "Compartilhar", + "freeze_label": "Congelamentos", + "freeze_count": "{} / {}", + "total_days_label": "Total de dias", + "this_week_label": "Esta semana", + "calendar_label": "Últimos 35 dias", + "days_to_milestone": "{} dias até o próximo marco", + "freeze_saved_title": "Sequência salva!", + "freeze_saved_subtitle": "Um congelamento cobriu o dia que você perdeu — sua sequência continua.", + "freeze_awarded_title": "Você ganhou um congelamento!", + "freeze_awarded_subtitle": "Os congelamentos protegem sua sequência quando você perde um único dia.", + "freeze_explainer": "Um congelamento salva sua sequência automaticamente num dia que você perde. Você ganha novos assistindo com regularidade." + }, + "watch_party": { + "title": "Watch Party", + "entry_subtitle": "Assista junto com os amigos — um dá play, todos acompanham.", + "entry_create": "Criar uma sala", + "entry_create_hint": "Abra uma sala e convide os amigos com um código", + "entry_join": "Entrar com um código", + "entry_join_hint": "Digite o código que um amigo te mandou", + "not_available": "Assistir junto ainda não está disponível para este título", + "create": "Criar sala", + "join": "Entrar na sala", + "join_hint": "Digite o código da sala", + "invalid_code": "Código inválido", + "code_label": "Código da sala", + "copy_code": "Copiar o código", + "copied": "Copiado", + "share_invite": "Compartilhar o convite", + "invite": "Convidar", + "lobby_waiting_host": "Esperando o anfitrião começar…", + "host_label": "Anfitrião", + "members": "Participantes", + "member_count": "{} / {}", + "you": "Você", + "start_watching": "Começar a assistir", + "close_party": "Fechar a sala", + "leave_party": "Sair da sala", + "leave_confirm": "Sair desta Watch Party?", + "only_host_controls": "Só o anfitrião controla a reprodução", + "host_changed": "O anfitrião mudou", + "room_closed": "A sala foi fechada", + "room_closed_empty": "A sala fechou porque todos saíram", + "content_none": "Nenhum título escolhido ainda", + "resolving": "Preparando o vídeo…", + "resolve_failed": "Não foi possível carregar este título no seu aparelho", + "plugin_required_title": "Plugin necessário", + "plugin_required_body": "Instale o plugin da fonte para assistir a isto junto.", + "install_plugin": "Instalar plugin", + "chat_hint": "Diga alguma coisa…", + "chat_empty": "Ainda sem mensagens", + "reactions": "Reações", + "error_not_found": "Sala não encontrada ou já fechada", + "error_full": "Esta sala está lotada", + "error_forbidden": "Você não tem acesso a esta sala", + "error_rate": "Vá com calma", + "error_unauthorized": "Entre para participar de uma Watch Party", + "error_generic": "Algo deu errado", + "reconnecting": "Reconectando…", + "connecting": "Conectando…", + "connect_failed_title": "Não foi possível conectar", + "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo." + }, + "notifications": { + "title": "Notificações", + "mark_all_read": "Marcar tudo como lido", + "empty_title": "Ainda sem notificações", + "empty_subtitle": "Quando algo novo chegar, você verá aqui.", + "time_now": "agora", + "time_minutes": "{} min", + "time_hours": "{} h", + "time_days": "{} d" + }, + "manga": { + "sources_title": "Fontes de mangá", + "sources_desc": "Adicione repositórios de extensões de mangá para instalar mais provedores de mangá, manhwa e webtoon. Eles rodam no seu aparelho (só Android).", + "repo_url": "URL do repo", + "add_source": "Adicionar fonte", + "installing": "Instalando…", + "installing_long": "Instalando extensões… em repos grandes isso pode demorar um pouco.", + "installing_progress": "Instalando {} / {} extensões…", + "no_extensions": "Nenhuma extensão encontrada nessa URL.", + "added_sources": "{} fonte(s) · {} provedor(es) adicionados.", + "source_removed": "Fonte removida.", + "recommended": "RECOMENDADO", + "installed_sources": "FONTES INSTALADAS", + "source_settings": "CONFIGURAÇÕES DA FONTE", + "no_sources": "Ainda sem fontes", + "show_nsfw": "Fontes adultas", + "show_nsfw_hint": "Mostra as fontes marcadas como 18+ nesta lista e no seletor de fontes. Desligado por padrão.", + "show": "Mostrar", + "hide": "Ocultar", + "installed": "Instalada", + "android_only": "Este recurso só existe no Android", + "error_prefix": "Erro: {}", + "no_preferences": "Esta fonte não tem opções configuráveis", + "not_selected": "Não selecionado", + "n_selected": "{} selecionados", + "reading_mode": "Modo de leitura", + "mode_continuous": "Contínuo", + "mode_paged": "Por página", + "direction": "Direção", + "dir_ltr": "Da esquerda para a direita (LTR)", + "dir_rtl": "Da direita para a esquerda (RTL)", + "background": "Fundo", + "bg_black": "Preto", + "bg_gray": "Cinza", + "bg_white": "Branco", + "chapters": "Capítulos", + "chapter_ended": "Fim do capítulo", + "next_chapter": "Próximo capítulo", + "first_chapter": "Primeiro capítulo", + "last_chapter": "Último capítulo", + "no_pages": "Nenhuma página encontrada", + "tap_to_reload": "Toque para recarregar", + "download_started": "Download iniciado", + "downloaded_pages": "{} / {} páginas", + "check_updates": "Procurar atualizações", + "checking_updates": "Procurando atualizações de extensões…", + "up_to_date": "Todas as extensões estão em dia.", + "updated_n": "{} extensão(ões) atualizada(s).", + "spread": "Duas páginas", + "spread_desc": "Lado a lado na horizontal, como o livro foi desenhado" + }, + "cloudflare": { + "title": "Confirme que você é humano", + "hint": "Conclua a verificação abaixo — isto fecha sozinho ao terminar.", + "solve": "Resolver Cloudflare", + "done": "Pronto" + }, + "desktop": { + "saved_refresh_on_pc": "Salvo — toque em “Atualizar fontes” no PC para aplicar.", + "sources_turned_off": "Fontes do desktop desligadas.", + "saved_loading_sources": "Salvo — carregando as fontes do seu celular…", + "refreshing_sources": "Atualizando as fontes do seu celular…", + "share_title": "Compartilhar fontes com o desktop", + "sources_title": "Fontes do desktop", + "share_with_desktop": "Compartilhar fontes com o desktop", + "let_pc_use_sources": "Deixe o app do Sozo para PC usar as fontes deste celular.", + "your_link": "Seu link", + "link_copied": "Link copiado", + "no_wifi_address": "Ligado, mas nenhum endereço de Wi-Fi foi encontrado. Conecte este celular ao Wi-Fi (não ao dado móvel) e abra esta tela de novo.", + "step_host_same_wifi": "Mantenha este celular e o seu PC na mesma rede Wi-Fi.", + "step_host_turn_on": "Ligue a chave acima.", + "step_host_pick_sources": "Escolha quais fontes compartilhar (ou deixe “Compartilhar todas” ligado).", + "step_host_paste_on_pc": "No PC, abra Sozo → Perfil → Fontes do desktop e cole o link.", + "step_host_keep_open": "Deixe o Sozo aberto neste celular enquanto navega no PC.", + "sources_to_share": "Fontes a compartilhar", + "reload_from_phone": "Recarregar do celular", + "share_all_sources": "Compartilhar todas as fontes", + "every_source_shared": "Todas as fontes deste celular são compartilhadas", + "sharing_count": "Compartilhando {} de {}", + "save_send_to_desktop": "Salvar e enviar ao desktop", + "n_selected": "{} selecionadas", + "select_all": "Selecionar tudo", + "clear": "Limpar", + "no_sources_yet": "Ainda não há fontes neste celular. Adicione algumas em Perfil → CloudStream / Aniyomi / Fontes de mangá e recarregue aqui.", + "phone_link": "Link do celular", + "refresh_sources": "Atualizar fontes", + "step_client_same_wifi": "Mantenha este PC e o seu celular na mesma rede Wi-Fi.", + "step_client_open_on_phone": "No celular, abra Sozo → Perfil → Compartilhar fontes com o desktop e ligue.", + "step_client_choose_sources": "No celular, escolha quais fontes compartilhar (ou deixe “Compartilhar todas” ligado).", + "step_client_copy_link": "Copie o link que aparece lá, cole acima e toque em Salvar.", + "step_client_refresh": "Mudou as fontes compartilhadas no celular? Toque em “Atualizar fontes” aqui.", + "step_client_keep_open": "Deixe o Sozo aberto no celular enquanto navega aqui.", + "step_client_emulator": "Usando um emulador Android? O IP dele não é acessível direto — rode “adb forward tcp:8765 tcp:8765” neste PC e cole http://127.0.0.1:8765 acima (ligue a chave no emulador mesmo assim).", + "how_it_works": "Como funciona", + "how_it_works_desc": "Use suas fontes do CloudStream, Aniyomi e mangá no app do Sozo para desktop. O celular executa as fontes; o PC apenas as exibe.", + "internet_still_unavailable": "A internet continua indisponível", + "offline_watch_downloads": "Você pode continuar assistindo aos vídeos baixados enquanto está sem conexão.", + "refresh": "Atualizar" + }, + "detail": { + "download_will_resolve": "Resolvendo…", + "download_needs_playback": "Esta fonte esconde o stream atrás de um player protegido. Reproduza o episódio uma vez e depois baixe-o pelo player.", + "download_choose_server": "Download · escolher servidor", + "download_playing_now": "Tocando agora", + "similar": "Parecidos", + "comments": "Comentários", + "screenshots": "Capturas", + "play": "Reproduzir", + "no_episodes": "Nenhum episódio disponível", + "no_playable_source": "Nenhuma fonte reproduzível", + "added_to_my_list": "Adicionado a Minha lista", + "removed_from_my_list": "Removido de Minha lista", + "failed_resolve_pages": "Não foi possível resolver as páginas", + "episodes_count": "{} episódios", + "episodes_count_of": "{} de {} episódios", + "episode_n": "Episódio {}", + "no_films": "Nenhum filme encontrado", + "filmography": "Filmografia", + "actor": "Ator", + "continue_watching": "Continuar", + "continue_ep": "Continuar · ep. {}", + "no_cast": "Elenco indisponível", + "no_recommendations": "Nenhuma recomendação disponível", + "untitled": "Sem título", + "read": "Ler", + "watch_later": "Assistir depois", + "watched": "Assistido", + "follow_series": "Seguir a série", + "find_other_sources": "Procurar outras fontes", + "anilist_track": "Acompanhar no AniList", + "anilist_tracked": "Acompanhado no AniList", + "following_on": "Seguindo", + "following_off": "Não seguindo", + "no_screenshots": "Sem capturas", + "show_more": "Ver mais", + "show_less": "Ver menos", + "time_left": "faltam {}", + "liked": "{}% curtiram", + "watched_pct": "{}% assistido", + "copy_link": "Copiar link", + "link_copied": "Link copiado", + "report_problem": "Relatar um problema", + "selected_n": "{} selecionados", + "select_all": "Selecionar tudo", + "select_none": "Limpar", + "download_n": "Baixar {}", + "download_queueing": "Colocando na fila…", + "download_action": "Baixar", + "download_already": "Já baixado", + "download_in_progress": "Download em andamento", + "download_started": "Download iniciado", + "download_needs_permission": "A permissão de notificações é necessária para baixar", + "download_resolve_failed": "Não foi possível obter um link de download para este episódio", + "download_queued_n": "{} adicionados aos downloads", + "download_none_queued": "Nada foi adicionado — talvez já estejam baixados", + "download_batch_title": "Baixar vários episódios?", + "download_batch_body": "Os episódios {range} entrarão na fila — {count} no total. No dado móvel isso pode dar vários gigabytes.", + "relations": "Relacionados", + "no_relations": "Nenhum título relacionado a este foi encontrado.", + "relation_sequel": "CONTINUAÇÃO", + "relation_prequel": "PREQUEL", + "relation_parent": "HISTÓRIA PRINCIPAL", + "relation_side_story": "HISTÓRIA PARALELA", + "relation_alternative": "VERSÃO ALTERNATIVA", + "relation_spin_off": "SPIN-OFF", + "relation_summary": "RESUMO", + "relation_adaptation": "ADAPTAÇÃO", + "relation_source": "OBRA ORIGINAL", + "relation_character": "MESMOS PERSONAGENS", + "relation_other": "OUTROS", + "trailer_action": "Trailer", + "more_options": "Mais opções", + "in_private_list": "Na sua lista privada", + "add_to_my_list_action": "Adicionar a Minha lista", + "remove_from_my_list_action": "Remover de Minha lista", + "trailer_mute": "Silenciar o trailer", + "trailer_unmute": "Ativar o som do trailer" + }, + "episodes": { + "filter_hint": "Número ou título do episódio", + "filter_none": "Nenhum episódio corresponde a “{query}” neste bloco", + "jump_to": "Ir para o episódio {n}", + "jump_loads": "Carrega {range}", + "jump_missing": "O episódio {n} não está nesta página", + "range_of": "Episódios {from}–{to} de {total}" + }, + "shorts": { + "refresh": "Atualizar", + "opening": "Abrindo…", + "content_unavailable": "Conteúdo indisponível", + "loading": "Carregando shorts…", + "empty_title": "Ainda sem shorts", + "empty_subtitle": "Clipes curtos aparecem aqui assim que estiverem disponíveis.", + "error_title": "Não foi possível carregar os shorts", + "video_unavailable": "Vídeo indisponível", + "speed_2x": "Velocidade 2x", + "report_submitted": "Denúncia enviada, obrigado", + "watch_full_movie": "Assistir ao filme completo", + "browse_movies": "Explorar filmes", + "sign_in_to_like": "Entre para curtir" + }, + "comments": { + "please_sign_in": "Entre primeiro", + "report_submitted": "Denúncia enviada, obrigado", + "edited": "· editado", + "like": "Curtir", + "reply": "Responder", + "hide": "Ocultar", + "replies_one": "1 resposta", + "replies_other": "{} respostas", + "report": "Denunciar", + "edit_hint": "Edite seu comentário…", + "reply_hint": "Escreva uma resposta…", + "comment_hint": "Escreva um comentário…", + "editing": "Editando", + "replying_to": "Respondendo a {}", + "sign_in_to_comment": "Entre para comentar", + "empty_title": "Ainda sem comentários", + "empty_subtitle": "Seja o primeiro a dizer o que achou", + "count_one": "1 comentário", + "count_other": "{} comentários", + "show_more": "Ver mais", + "delete_confirm_title": "Excluir este comentário?", + "delete_confirm_body": "Esta ação não pode ser desfeita.", + "sign_in_prompt": "Entre para deixar um comentário" + }, + "downloads": { + "delete_all_title": "Excluir todos os downloads?", + "file_not_found": "Arquivo não encontrado", + "clear_all": "Limpar tudo", + "segments_progress": "{} / {} segmentos", + "downloaded_amount": "{} baixados", + "pages_count": "{} páginas", + "downloaded": "Baixado", + "failed": "Falhou", + "pending": "Na fila", + "remove": "Remover", + "empty_title": "Ainda sem downloads", + "empty_subtitle": "Os vídeos baixados aparecerão aqui", + "paused": "Pausado", + "wifi_only": "Baixar só no Wi-Fi", + "wifi_only_desc": "Downloads na fila esperam até você estar no Wi-Fi", + "waiting_for_wifi": "Esperando o Wi-Fi — os downloads seguem sozinhos", + "export": "Salvar em Downloads", + "export_hint": "Deixa o arquivo visível para outros apps", + "export_running": "Salvando…", + "export_done": "Salvo em {}", + "export_failed": "Não foi possível salvar em Downloads", + "downloading": "Baixando", + "missing": "O arquivo sumiu — toque para baixar de novo", + "redownload_started": "Baixando de novo…", + "file_missing_retry": "Esse arquivo não está mais aí. Baixando de novo.", + "download_again": "Baixar de novo", + "pause": "Pausar", + "resume": "Retomar", + "retry_all": "Tentar de novo tudo que falhou", + "sort_by": "ORDENAR POR", + "sort_newest": "Mais recentes primeiro", + "sort_title": "Título", + "sort_size": "Maiores primeiro", + "filter_all": "Todos", + "filter_active": "Em andamento", + "filter_done": "Concluídos", + "filter_problems": "Precisa de atenção", + "filter_empty_title": "Nada corresponde a este filtro", + "filter_empty_subtitle": "Seus downloads continuam aí — só esta visão está limitada.", + "filter_clear": "Mostrar todos os downloads", + "storage_used": "{} usados pelos downloads", + "storage_free": "{} livres", + "orphans_found": "{} em restos de downloads cancelados", + "orphans_clean": "Limpar", + "group_summary": "{} de {} baixados", + "group_problems": "Precisa de atenção", + "error": { + "network": "A conexão caiu", + "refused": "A fonte recusou o download — reproduza uma vez e tente de novo", + "gone": "O arquivo não está mais na fonte", + "no_space": "Espaço insuficiente neste aparelho", + "not_media": "A fonte mandou uma página web em vez do vídeo", + "incomplete": "O download terminou incompleto", + "unknown": "O download falhou" + }, + "location": "Onde os downloads ficam", + "location_hint": "Só lugares onde o app pode gravar sem permissão. Os downloads mudam junto com o ajuste; nada é apagado até cada arquivo chegar.", + "location_internal": "Armazenamento do celular (app)", + "location_external": "Armazenamento do celular", + "location_sdcard": "Cartão SD", + "location_moved": "Downloads movidos", + "location_unchanged": "Já estavam lá", + "location_failed": "Não foi possível mover os downloads — eles continuam onde estavam" + }, + "history": { + "clear_title": "Limpar histórico", + "clear_confirm": "Tem certeza de que quer limpar seu histórico?", + "clear": "Limpar", + "clear_all": "Limpar tudo", + "remove": "Remover", + "empty_title": "Ainda sem histórico", + "empty_subtitle": "Comece a assistir e seu histórico aparecerá aqui" + }, + "main": { + "shorts_refresh_title": "Atualizar Shorts", + "shorts_refresh_body": "Toque na aba Shorts de novo. O feed atualiza e novos vídeos carregam.", + "double_tap": "Toque duas vezes", + "tap_again": "Toque de novo" + }, + "reports": { + "title": "Denunciar", + "reason_spam": "Spam", + "reason_abuse": "Assédio", + "reason_sexual": "Conteúdo sexual", + "reason_violence": "Violência", + "reason_copyright": "Direitos autorais", + "reason_other": "Outro", + "note_hint": "Observação adicional (opcional)", + "submit": "Enviar" + }, + "ios": { + "sources_title": "Fontes de extensão", + "how_it_works_desc": "Use suas fontes do CloudStream, Aniyomi e mangá no Sozo para iOS. Seu celular Android executa as fontes; este aparelho apenas as exibe.", + "step_client_same_wifi": "Mantenha este aparelho e seu celular Android na mesma rede Wi-Fi." + }, + "trivia": { + "fan_test_title": "Teste do fã", + "fan_test_subtitle": "Prove que você é o maior fã", + "hub_tagline": "Quanto você realmente sabe de cinema?", + "view_leaderboards": "Ver rankings", + "my_daily_rank": "Minha posição do dia", + "rank_value": "#{}", + "unranked": "Sem posição", + "points_value": "{} pts", + "popular_now": "Popular agora", + "results": "Resultados", + "searching": "Buscando…", + "search_cast_hint": "Buscar atores", + "no_results": "Sem resultados", + "no_results_hint": "Tente outro nome", + "something_wrong": "Algo deu errado", + "filmography": "Filmografia", + "n_titles": "{count} títulos", + "start_fan_test": "Começar o teste", + "challenge_friend": "Desafiar um amigo", + "top_fans": "Maiores fãs", + "no_fans_yet": "Ainda sem fãs", + "be_first_fan": "Seja o primeiro fã", + "fans_meta": "{rounds} rodadas · {fandom}% de fandom médio", + "you": "Você", + "you_rank": "Você · #{rank}", + "best_score": "Melhor {score}", + "fandom": "Fandom", + "challenge_share_text": "Será que você manja mais de {name} que eu? Faça o teste do fã no Sozo!", + "scope_daily": "Diário", + "scope_weekly": "Semanal", + "scope_all_time": "Geral", + "scope_friends": "Amigos", + "leaderboard": "Ranking", + "no_friends_board": "Ainda sem amigos no ranking", + "no_scores_yet": "Ainda sem pontuações", + "correct_of": "{count} certas", + "forfeit_title": "Sair do jogo?", + "forfeit_body": "Seu progresso nesta rodada será perdido.", + "keep_playing": "Continuar jogando", + "forfeit_confirm": "Sair", + "correct": "Certo", + "the_answer": "A resposta", + "watch_full": "Assistir ao filme completo", + "building_round": "Montando sua rodada…", + "round_failed": "Não foi possível começar a rodada", + "go_back": "Voltar", + "fan_test_done": "Teste do fã concluído", + "round_done": "Rodada concluída", + "score": "Pontuação", + "rank": "Posição", + "play_again": "Jogar de novo", + "share_result": "Compartilhar resultado", + "share_text": "Acabei de jogar Buff no Sozo!", + "challenge_text": "Acha que manja mais de cinema? Me desafie no Buff dentro do Sozo!", + "share_tagline": "Jogue Buff no Sozo", + "challenge_from": "Desafio de {name}", + "same_clips_head_to_head": "Os mesmos clipes, um contra o outro", + "score_to_beat": "Pontuação a bater", + "play_challenge": "Jogar o desafio", + "challenge_not_found": "Desafio não encontrado", + "no_actors_yet": "Ainda sem atores", + "no_actors_yet_body": "O Buff está sendo abastecido. Os clipes são revisados um a um — os atores aparecem aqui assim que seus clipes forem aprovados.", + "actor_not_ready": "Este ator ainda não está pronto", + "actor_not_ready_body": "Ainda não há clipes aprovados suficientes para uma rodada. Tente outro ator.", + "round_short_note": "Esta rodada teve {count} clipes — todos os clipes aprovados deste ator até agora.", + "todays_top": "Os melhores de hoje", + "how_it_works": "Como o Buff funciona", + "how_step_1": "Escolha um ator", + "how_step_2": "Assista a um clipe curto", + "how_step_3": "Diga o nome do filme" + }, + "outage": { + "body_with_local": "Os servidores do Sozo estão inacessíveis. Suas {} fontes no aparelho continuam funcionando.", + "body_no_local": "Os servidores do Sozo estão inacessíveis. Adicione uma fonte do CloudStream, Aniyomi ou mangá para continuar navegando sem nossa API.", + "pick_source": "Escolher fonte" + }, + "link_tv": { + "title": "Conectar uma TV", + "tile_subtitle": "Escaneie o QR na sua TV, ou digite o código dela", + "subtitle": "Escaneie o QR code na sua televisão, ou digite o código de 8 caracteres que aparece abaixo dele.", + "scan_button": "Escanear o QR code", + "scan_hint": "Aponte a câmera para o QR code na tela da TV", + "manual_title": "Ou digite o código", + "confirm": "Confirmar", + "approved": "TV conectada", + "approved_named": "“{}” conectada", + "approved_hint": "A TV vai entrar na sua conta em alguns segundos.", + "linked_devices": "Aparelhos conectados", + "no_devices": "Ainda sem aparelhos conectados", + "unnamed_device": "Aparelho sem nome", + "last_seen": "Visto por último: {}", + "unlink_title": "Desconectar aparelho", + "unlink_message": "Este aparelho vai sair da conta e vai precisar de um novo QR code para conectar de novo.", + "unlink_confirm": "Desconectar", + "camera_denied": "Acesso à câmera negado. Libere nas Configurações, ou digite o código.", + "camera_error": "A câmera não pôde ser iniciada.", + "error_bad_code": "O código precisa ter 8 caracteres.", + "error_not_found": "Esse código não foi encontrado. Pegue um novo na TV.", + "error_expired": "O código expirou. Aperte Atualizar na TV.", + "error_already_used": "Esse código já foi usado.", + "error_unauthorized": "Entre primeiro.", + "error_rate_limited": "Tentativas demais. Tente daqui a pouco.", + "error_network": "Sem conexão com a internet.", + "error_generic": "Algo deu errado." + }, + "anilist": { + "connections_title": "Conexões", + "connect": "Conectar o AniList", + "disconnect": "Desconectar", + "disconnect_confirm": "Sua conta do AniList será desconectada e os títulos vinculados serão esquecidos. Sua lista continua no AniList.", + "not_connected": "Não conectado", + "connect_explainer": "Conecte o AniList e os episódios que você assiste são marcados sozinhos, com sua lista aparecendo aqui.", + "connected_explainer": "Os episódios assistidos são gravados na sua lista do AniList. A conexão fica na sua conta, então sua TV e outros aparelhos não pedirão login de novo.", + "connect_prompt": "Conecte sua conta para ver aqui sua biblioteca do AniList.", + "upcoming_connect_prompt": "Conecte o AniList para ver quando saem os próximos episódios das séries que você segue.", + "sign_in_first": "Entre primeiro na sua conta Sozo", + "browser_failed": "Não foi possível abrir o navegador", + "open_library": "Minha biblioteca do AniList", + "linked_titles": "Títulos vinculados", + "linked_titles_count": "{} títulos vinculados", + "linked_titles_none": "Nada vinculado ainda", + "unlink": "Desvincular", + "unlinked": "{} desvinculado", + "auto_matched": "Automático", + "privacy_note": "Seu token do AniList fica na sua conta Sozo. O app só grava o número do episódio que você assistiu.", + "status_label": "STATUS", + "status_current": "Assistindo", + "status_planning": "Planejo assistir", + "status_completed": "Concluído", + "status_paused": "Pausado", + "status_dropped": "Abandonado", + "status_repeating": "Revendo", + "episodes_watched": "episódios assistidos", + "empty_status": "Sua lista “{}” está vazia", + "behind_n": "{} novos", + "mark_episode_watched": "Marcar o episódio {} como assistido", + "find_in_sources": "Procurar nas fontes", + "find_in_sources_hint": "Buscar este título em todas as fontes", + "open_on_anilist": "Abrir no AniList", + "refresh": "Atualizar", + "retry": "Tentar de novo", + "upcoming_title": "Em breve", + "upcoming_empty": "Nenhuma das séries que você segue tem próximo episódio anunciado.", + "today": "Hoje", + "tomorrow": "Amanhã", + "episode_n": "Episódio {}", + "aired": "Exibido", + "in_days": "em {} dias", + "in_hours": "em {} horas", + "in_minutes": "em {} min", + "link_title": "Vincular ao AniList", + "link_hint": "Escolha a qual anime do AniList este título corresponde.", + "link_search_hint": "Nome do anime", + "link_no_results": "Nada encontrado", + "linked_to": "Vinculado a {}", + "tracking_on": "Acompanhado no AniList", + "track_on_anilist": "Acompanhar no AniList", + "n_episodes_short": "{} ep.", + "open_anilist": "AniList", + "connect_tagline": "Registre automaticamente o que você assiste", + "connect_short": "Conectar", + "calendar_title": "Calendário de exibição", + "calendar_all": "Tudo", + "calendar_mine": "Minha lista", + "calendar_episode": "Episódio {episode}", + "calendar_empty": "Nada é exibido neste dia.", + "calendar_empty_mine": "Nada da sua lista é exibido neste dia.", + "calendar_open": "Calendário de exibição", + "calendar_error": "Não foi possível carregar a grade de exibição.", + "calendar_rate_limited": "O AniList está sobrecarregado agora. Tente daqui a pouco.", + "calendar_list_error": "Não foi possível carregar sua lista do AniList.", + "calendar_now": "agora", + "calendar_count": "{count} exibições", + "calendar_count_mine": "{mine} de {total} são suas", + "calendar_episode_range": "Episódios {from}-{to}", + "calendar_add_planning": "Adicionar a Planejo assistir", + "calendar_added_planning": "Adicionado à sua lista Planejo assistir", + "calendar_add_failed": "Não foi possível adicionar isto à sua lista", + "calendar_already_on_list": "Já está na sua lista", + "browse_title": "Descobrir", + "browse_trending": "Em alta", + "browse_season": "Nesta temporada", + "browse_popular": "Populares de todos os tempos", + "browse_upcoming": "Em breve", + "browse_empty": "Não há nada para mostrar aqui.", + "browse_failed": "Não foi possível acessar o AniList.", + "episodes_count": "{count} ep.", + "reminder_body": "O episódio {episode} sai em 10 minutos", + "reminders_on": "Lembretes ativados — antes e quando um episódio sai", + "reminders_off": "Os lembretes de episódio estão desligados", + "connect_first": "Conecte o AniList antes", + "save_failed": "Não foi possível salvar no AniList", + "search_in_source": "BUSCAR EM UMA FONTE", + "no_sources": "Nenhuma fonte instalada ainda", + "released_body": "O episódio {episode} saiu", + "remove_entry": "Remover da minha lista", + "remove_confirm": "Remover “{}” do seu AniList? Seu progresso nele também é apagado.", + "removed": "Removido do AniList" + }, + "mal": { + "connect": "Conectar o MyAnimeList", + "disconnect": "Desconectar o MyAnimeList", + "disconnect_confirm": "Sua conta do MyAnimeList será desconectada e os títulos associados serão esquecidos. Sua lista continua no MyAnimeList.", + "connect_explainer": "Conecte o MyAnimeList e os episódios que você assiste são marcados sozinhos.", + "connected_explainer": "Os episódios assistidos são gravados no seu MyAnimeList. A conexão fica na sua conta, então sua TV e outros aparelhos não pedirão login de novo.", + "not_configured": "O MyAnimeList ainda não está configurado no servidor", + "matching_title": "Como os títulos são associados", + "matching_note": "A associação passa pelo AniList, que carrega o id do MyAnimeList da mesma entrada. Vincule um título uma vez e os dois rastreadores acompanham.", + "privacy_note": "Seus tokens do AniList e do MyAnimeList ficam na sua conta Sozo. O app só grava o número do episódio que você assistiu.", + "tracking_on": "Acompanhado no MyAnimeList", + "save_failed": "Não foi possível salvar no MyAnimeList", + "open_library": "Meu MyAnimeList", + "linked_titles": "Títulos vinculados", + "connect_prompt": "Conecte sua conta para ver aqui o seu MyAnimeList.", + "connect_first": "Conecte o MyAnimeList antes", + "search_failed": "Não foi possível acessar o MyAnimeList.", + "link_title": "Vincular ao MyAnimeList", + "link_hint": "Escolha a qual anime do MyAnimeList este título corresponde.", + "linked_to": "Vinculado a {}", + "track": "Acompanhar no MyAnimeList", + "tracked": "Acompanhado no MyAnimeList", + "score_label": "NOTA", + "remove_entry": "Remover da minha lista", + "remove_confirm": "Remover “{}” do seu MyAnimeList? Seu progresso nele também é apagado.", + "removed": "Removido do MyAnimeList", + "rewatching_note": "Você está revendo isto. O Sozo não mexe no status para não encerrar a revisão." + }, + "tracker": { + "title": "Acompanhamento", + "tab_following": "Seguindo", + "tab_upcoming": "Em breve", + "following_empty": "Siga uma série para ser avisado quando saírem episódios novos.", + "new_episodes_found": "{} títulos têm episódios novos", + "no_new_episodes": "Nenhum episódio novo", + "n_episodes": "{} episódios", + "not_checked": "Ainda não verificado", + "unfollow": "Deixar de seguir", + "unfollowed": "Você deixou de seguir {}", + "undo": "Desfazer" + }, + "remote": { + "title": "Controle da TV", + "no_devices": "Ainda sem TVs conectadas. Pareie uma pelo app da TV primeiro.", + "load_failed": "Não foi possível carregar suas TVs.", + "tv_offline": "A TV está off-line", + "idle": "Nada tocando", + "command_failed": "Isso não chegou à TV", + "search_on_tv": "BUSCAR NA TV", + "search_hint": "Digite aqui; a busca roda na TV", + "back": "Voltar", + "home": "Início", + "open_on_tv": "Reproduzir na TV", + "sent_to_tv": "Enviado para {device}", + "connected_to": "Conectado a {device}", + "not_connected_to": "{device} não está conectada", + "turn_tv_on": "Abra o Sozo na TV para controlá-la", + "retry": "Tentar de novo", + "navigation": "NAVEGAÇÃO", + "playback": "REPRODUÇÃO", + "pick_device": "Qual TV?", + "device_online": "On-line", + "device_offline": "Off-line — abra o Sozo nela" + }, + "live_tv": { + "title": "TV ao vivo", + "favourites": "Favoritos", + "recent": "Vistos recentemente", + "categories": "Categorias", + "countries": "Países", + "results": "Resultados da busca", + "search_hint": "Nome do canal…", + "search_in": "Buscar em {folder}…", + "no_match": "Nenhum canal corresponde a isso.", + "empty": "Ainda sem canais.", + "load_failed": "Não foi possível carregar os canais.", + "retry": "Tentar de novo", + "now": "Agora", + "next": "A seguir", + "guide": "Guia de TV", + "no_guide": "Sem guia para este canal.", + "watch": "Assistir", + "favourite_add": "Adicionar aos favoritos", + "favourite_remove": "Remover dos favoritos", + "hint_long_press": "Segure um canal para ver o guia e os favoritos.", + "channel_count": { + "one": "{} canal", + "other": "{} canais" + }, + "up_next": "A SEGUIR", + "minutes_left": "faltam {} min" + }, + "time": { + "now": "agora mesmo", + "minutes": "há {} min", + "hours": "há {} h", + "days": "há {} d" + }, + "user_lists": { + "title": "Minhas listas", + "remove": "Remover", + "empty_title": "Nada salvo ainda", + "empty_subtitle": "Os títulos que você salvar aparecem aqui." + }, + "torrent": { + "title": "Torrents", + "search_hint": "Buscar torrents…", + "empty_hint": "Busque no Nyaa, no Tokyo Toshokan e no nekoBT de uma vez.", + "searching": "Buscando nos trackers…", + "no_results": "Nada encontrado nos trackers ativos", + "no_results_hint": "Tente um título mais curto, ou afrouxe os filtros.", + "results_n": "{} resultados", + "seeders": "seeders", + "leechers": "leechers", + "unknown_swarm": "Tamanho do enxame desconhecido", + "dead": "Sem seeders", + "dead_warning": "Este torrent não tem seeders e não pode ser reproduzido.", + "trusted": "Confiável", + "remake": "Reenvio", + "filters": "Filtros", + "category": "Categoria", + "cat_all": "Todas", + "cat_anime": "Anime", + "cat_english": "Legendas em inglês", + "cat_non_english": "Outros idiomas", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Live action", + "sort": "Ordenar por", + "sort_seeders": "Seeders", + "sort_date": "Mais recentes", + "sort_size": "Tamanho", + "sort_downloads": "Downloads", + "min_seeders": "Mínimo de seeders", + "quality": "Qualidade", + "any_quality": "Qualquer", + "trusted_only": "Só uploaders confiáveis", + "exclude_hardsubs": "Ocultar versões com legenda queimada", + "exclude_mini": "Ocultar mini-encodes", + "exclude_remakes": "Ocultar reenvios", + "require_dual_audio": "Só áudio duplo", + "exclude_batches": "Ocultar pacotes de temporada", + "trackers": "Trackers", + "reset_filters": "Redefinir", + "consent_title": "Reproduzir via BitTorrent?", + "consent_body": "Torrents são ponto a ponto. Enquanto um está tocando, outras pessoas no enxame podem ver seu endereço IP e o que você está baixando, e seu aparelho também envia dados para elas.\n\nUse uma VPN se isso importar onde você está.", + "consent_accept": "Entendi", + "consent_remember": "Não perguntar de novo", + "preparing": "Iniciando o motor de torrents…", + "fetching_metadata": "Buscando a lista de arquivos no enxame…", + "metadata_timeout": "O enxame não respondeu. O torrent pode não ter mais seeders.", + "engine_failed": "Não foi possível iniciar o motor de torrents.", + "unsupported": "A reprodução por torrent só existe no Android.", + "pick_file": "Escolha um arquivo", + "pick_file_hint": "Este torrent tem {} vídeos.", + "play": "Reproduzir", + "copy_magnet": "Copiar link magnet", + "magnet_copied": "Link magnet copiado", + "open_external": "Abrir em um app de torrent", + "open_page": "Abrir no tracker", + "stats_peers": "{} peers", + "buffering": "Carregando {}%", + "still_searching": "{} trackers ainda respondendo", + "buffering_title": "Carregando…", + "no_video_in_torrent": "Este torrent não tem vídeo reproduzível.", + "tracker_failed": "Não foi possível buscar em {} — os resultados podem estar incompletos." + }, + "backup": { + "title": "Backup e restauração", + "subtitle": "Histórico, listas, fontes e configurações. Os logins não entram.", + "export": "Criar backup", + "export_desc": "Salve um arquivo para guardar ou enviar para você mesmo", + "restore": "Restaurar", + "restore_desc": "Ler um arquivo de backup de volta no app", + "restore_warning": "Os valores do backup vão sobrescrever os deste aparelho. O que não estiver no arquivo fica como está.", + "restored": "{} valores restaurados", + "not_a_backup": "Esse arquivo não é um backup do Sozo", + "too_new": "Esse backup foi feito por uma versão mais nova do app", + "export_failed": "Não foi possível criar o backup", + "restore_failed": "Não foi possível ler esse backup", + "file_subject": "Backup do Sozo", + "last_export": "Último backup: {}", + "never_exported": "Nenhum backup foi feito neste aparelho ainda." + }, + "provider_test": { + "title": "Testar fontes", + "intro": "Faz uma busca real, abre um título e pede os primeiros bytes ao CDN. Os resultados valem para este aparelho e esta rede, agora.", + "summary": "{} de {} reproduziram", + "run_all": "Testar todas", + "run_one": "Testar", + "stop": "Parar", + "stage_catalogue": "Catálogo", + "stage_detail": "Título", + "stage_stream": "Link", + "stage_playback": "Reprodução", + "entry": "Testar fontes" + }, + "catalog": { + "title": "Fontes por idioma", + "subtitle": "Explore todas as fontes de extensão dos quatro ecossistemas", + "type_all": "Todas", + "type_anime": "Anime", + "type_video": "Filmes e séries", + "type_manga": "Mangá", + "type_novel": "Novels", + "add_repo": "Adicionar repo", + "installed": "{} fonte(s) adicionada(s) de {}", + "install_empty": "Esse repo não trouxe nada instalável.", + "install_failed": "Falha na instalação: {}", + "needs_js": "Fonte em Dart — não roda aqui", + "android_only": "Só Android", + "empty": "Ainda não há nada aqui. Tente outro idioma ou limpe os filtros.", + "unreachable": "Não foi possível acessar o catálogo de fontes." + }, + "mode": { + "video": "Assistir", + "manga": "Mangá", + "novel": "Novels", + "none_installed": "Nenhuma fonte de {} instalada ainda." + }, + "home_rails": { + "title": "Layout do início", + "entry_title": "Personalizar início", + "entry_subtitle": "Escolha quais linhas aparecem e em que ordem", + "hint": "Arraste para reordenar. Toque no olho para ocultar uma linha — ela mantém o lugar, então reativá-la a devolve onde estava.", + "hero": "Banner em destaque", + "resume": "Continuar assistindo", + "genres": "Gêneros", + "live_tv": "TV ao vivo", + "catalogue": "Linhas do catálogo" + }, + "stats": { + "title": "Estatísticas", + "entry_subtitle": "Tempo assistido, sequência, para onde foi", + "watched": "Assistido", + "hours_short": "h", + "minutes_short": "min", + "since": "Contando desde {}", + "completed": "Concluídos", + "streak": "Sequência de dias", + "last_days": "Últimos {} dias", + "by_source": "Por fonte", + "empty_title": "Nada contado ainda", + "empty_subtitle": "Assista a algo e os totais começam aqui. O que você vê no modo anônimo não conta." + }, + "discord": { + "sign_in": "Entrar com o Discord", + "sign_in_desc": "Entre na própria página de login do Discord. O token é capturado automaticamente e guardado só neste aparelho.", + "or_paste": "ou cole um token você mesmo", + "web_login_title": "Conectar o Discord", + "web_login_paste": "Colar token", + "web_login_paste_title": "Colar o token do Discord", + "web_login_paste_hint": "Seu token de usuário do Discord", + "title": "Discord", + "what_it_does": "Mostra no seu perfil do Discord o que você está assistindo — o título, o episódio e em que ponto você está. Seus amigos veem na lista de amigos deles.", + "enable": "Mostrar no Discord", + "enable_desktop_desc": "Precisa do app do Discord aberto neste computador", + "enable_mobile_desc": "Precisa do seu token do Discord — leia o aviso abaixo antes", + "connected": "Conectado", + "not_connected": "Não conectado", + "risk_title": "Leia isto antes de continuar", + "risk_body": "No celular não existe um app do Discord ao qual se conectar, então o Sozo precisa entrar como você usando o seu próprio token. O Discord não permite isso: sua conta pode ser suspensa. O token dá acesso total à sua conta do Discord, e o Sozo o guarda apenas neste aparelho — nunca em um servidor. Remova quando quiser com Esquecer.", + "token_label": "Token do Discord", + "token_hint": "Cole seu token", + "token_saved": "Há um token salvo — cole um novo para substituí-lo", + "token_save": "Salvar", + "token_forget": "Esquecer", + "brand_title": "Rich Presence", + "brand_subtitle": "Seus amigos veem ao vivo no seu perfil o que você está assistindo", + "preview_heading": "ASSISTINDO", + "preview_title": "O título aparece aqui", + "preview_subtitle": "Temporada e episódio", + "preview_elapsed": "{} decorridos", + "preview_elapsed_placeholder": "Tempo decorrido", + "row_off": "Desligado", + "row_live": "Aparecendo no seu perfil", + "row_waiting": "Ligado — esperando o Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — a imagem pode ficar verde ou roxa neste aparelho", + "atmos": "Áudio Atmos — este aparelho pode reproduzir sem som", + "indirect_host": "Este espelho abre uma página em vez do vídeo", + "uhd": "4K — mais pesado para decodificar e baixar", + "large_file": "Arquivo grande — demora a iniciar no dado móvel", + "unreachable": "Este link não respondeu na verificação" + }, + "update": { + "available_title": "Nova versão disponível (v{})", + "available_body": "Está disponível uma nova versão da aplicação.", + "later": "Mais tarde", + "now": "Atualizar", + "open_store": "Abrir na App Store", + "required_title": "Atualização necessária", + "required_body": "Instala a nova versão (v{}) para continuares a usar a aplicação.", + "exit_app": "Sair da aplicação", + "downloading": "A transferir a atualização…" + }, + "ext": { + "cloudstream_title": "Fontes CloudStream", + "aniyomi_title": "Fontes Aniyomi", + "mangayomi_title": "Fontes Mangayomi", + "android_only": "Esta funcionalidade só está disponível no Android", + "needs_webview": "As extensões JavaScript precisam de um WebView, que não existe nesta plataforma.", + "check_updates": "Procurar atualizações", + "check_extension_updates": "Procurar atualizações de extensões", + "installed_sources": "FONTES INSTALADAS", + "installed_repos": "REPOSITÓRIOS INSTALADOS", + "sources_heading": "FONTES", + "recommended": "RECOMENDADAS", + "repo_url": "URL do repositório", + "index_url": "URL do índice", + "browse_plugins": "Ver plug-ins", + "no_sources_yet": "Ainda não há fontes", + "no_repos_yet": "Ainda não há repositórios", + "add_source": "Adicionar fonte", + "add_repo": "Adicionar repositório", + "installing": "A instalar…", + "installing_extensions": "A instalar extensões…", + "installing_plugins": "A instalar plug-ins…", + "installing_progress": "A instalar {} / {}", + "installing_plugins_progress": "A instalar {} / {} plug-ins…", + "installing_extensions_progress": "A instalar {} / {} extensões…", + "installing_long": "A instalar extensões… em repositórios grandes pode demorar um pouco.", + "checking_updates": "A procurar atualizações…", + "checking_extension_updates": "A procurar atualizações de extensões…", + "all_up_to_date": "Todas as extensões estão atualizadas.", + "updated_count": "{} extensão(ões) atualizada(s).", + "updated_list": "Atualizadas {}: {}", + "update_check_failed": "Falha ao procurar atualizações: {}", + "source_removed": "Fonte removida.", + "repo_removed": "Repositório removido.", + "none_found": "Não foram encontradas extensões nesse URL.", + "none_found_js": "Não foram encontradas extensões JavaScript nesse URL.", + "added_sources": "Foram adicionadas {} fonte(s).", + "added_sources_providers": "Foram adicionadas {} fonte(s) · {} fornecedor(es).", + "installed_from_file": "Foram instaladas {} fonte(s).", + "could_not_install": "Não foi possível instalar: {}", + "could_not_install_plugin": "Não foi possível instalar o plug-in", + "could_not_remove_plugin": "Não foi possível remover o plug-in", + "error": "Erro: {}", + "cloudstream_desc": "Adiciona um repositório CloudStream e instala apenas os plug-ins que quiseres — só esses aparecem como fornecedores. Correm no teu dispositivo (só Android).", + "aniyomi_desc": "Adiciona repositórios de extensões Aniyomi para instalar mais fornecedores de anime. Correm no teu dispositivo (só Android).", + "mangayomi_desc": "As extensões Mangayomi são JavaScript, não aplicações Android — por isso, ao contrário das fontes CloudStream, Aniyomi e Manga, também funcionam no iOS, macOS e Windows.", + "plugins_summary": "{} plug-ins · {} instalados", + "install_all": "Instalar tudo", + "repo_load_failed": "Não foi possível carregar este repositório.", + "no_plugins": "Não foram encontrados plug-ins neste repositório", + "search_plugins": "Procurar plug-ins…", + "no_matches": "Sem resultados", + "import_title": "Instalar o repositório de extensões?", + "import_pick_library": "Os repositórios de anime e manga usam o mesmo formato de ficheiro, por isso escolhe a que biblioteca este pertence.", + "import_js_only": "Nesta plataforma só é possível instalar extensões Mangayomi (JavaScript) — os repositórios CloudStream, Aniyomi e Mihon trazem pacotes de aplicações Android.", + "import_none_found": "Não foram encontradas extensões neste ficheiro. Pode pertencer a outra aplicação ou o tipo escolhido estar errado.", + "mangayomi_url_only": "Os repositórios Mangayomi instalam-se a partir de um URL — abre a ligação do índice em vez de uma cópia transferida.", + "skipped_dart": "Foram ignoradas {} fonte(s) só de Dart — estão integradas no Mangayomi e não funcionam aqui.", + "type_manga": "Mangá", + "type_anime": "Anime", + "type_novel": "Novela" + }, + "ux": { + "quick_search": "Busca rápida ({})", + "quick_search_note": "A busca rápida pesquisa {} de {} fontes. Escolha fontes para uma busca específica.", + "torrents": "Torrents", + "filters": "Filtros", + "change_source": "Trocar fonte", + "manage_sources": "Gerenciar fontes", + "no_source_match": "Nenhuma fonte corresponde à busca.", + "download_options": "Opções de download", + "download_options_note": "Escolha uma fonte e uma qualidade disponíveis. O áudio depende da fonte escolhida.", + "source_number": "Fonte {}", + "checking_quality": "Verificando a qualidade disponível…", + "quality_unavailable": "Não foi possível verificar a qualidade. Tente novamente ou baixe a transmissão original sem garantia de qualidade.", + "quality_unknown": "Qualidade desconhecida", + "size_unknown": "A fonte não informou o tamanho", + "interface_language_note": "Apenas a interface do aplicativo muda. Títulos de filmes, descrições e outros conteúdos das fontes permanecem no idioma original.", + "continue": "Continuar" + } +} diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 55afec7e..eb55a3a8 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -26,7 +26,15 @@ "off": "Выкл", "on": "Вкл", "new_badge": "НОВОЕ", - "reset": "Сбросить" + "reset": "Сбросить", + "clear": "Очистить", + "copy": "Копировать", + "share": "Поделиться", + "install": "Установить", + "remove": "Удалить", + "show": "Показать", + "hide": "Скрыть", + "installed": "Установлено" }, "auth": { "login": "Войти", @@ -78,7 +86,8 @@ "strong": "Надёжный", "code_resent": "Код отправлен повторно. Проверьте почту.", "verify": "Подтвердить", - "avatar_bad_format": "Формат изображения не поддерживается (jpg, png, webp)" + "avatar_bad_format": "Формат изображения не поддерживается (jpg, png, webp)", + "verification_code": "Код подтверждения" }, "onboarding": { "skip": "Пропустить", @@ -348,7 +357,10 @@ "fit": "Вписать", "pip": "Картинка в картинке", "layout_pinned_badge": "Закреплено", - "info_fields_title": "Строки инфо-панели" + "info_fields_title": "Строки инфо-панели", + "logs_title": "Логи плеера ({})", + "logs_copied": "Логи скопированы", + "logs_empty": "Логов пока нет" }, "profile": { "title": "Профиль", @@ -369,7 +381,7 @@ "guest_subtitle": "Войдите, чтобы сохранять избранное и историю просмотров.", "auth_provider": "Тип входа", "joined": "Дата регистрации", - "providers": "Providers", + "providers": "Источники", "providers_error": "Не удалось загрузить провайдеров.", "offline_title": "Сервер недоступен", "offline_local_available": "Источников на этом устройстве, которые продолжают работать: {}. Облачные источники недоступны, пока сервер не вернётся.", @@ -382,7 +394,7 @@ "connections": "Подключения", "coming_soon": "Скоро", "favorites": "Избранное", - "all_providers": "Все провайдеры", + "all_providers": "Все источники", "add_favorite": "Добавить в избранное", "cloudstream_sources": "Источники CloudStream", "aniyomi_sources": "Источники Aniyomi", @@ -391,12 +403,12 @@ "sign_in": "Войти", "sign_out": "Выйти", "sign_out_confirm": "Вы уверены, что хотите выйти?", - "section_providers": "ИСТОЧНИКИ", + "section_providers": "Источники", "section_content": "КОНТЕНТ", "sources_row": "Источники расширений", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Источник", - "choose_provider": "Выберите источник", + "choose_provider": "Сменить источник", "search_providers_hint": "Поиск источников…", "count_of_total_shown": "Показано {} из {}", "no_providers_in_category": "В этой категории нет источников", @@ -513,7 +525,9 @@ "activity_subtitle": "История, загрузки, отслеживание, статистика", "connections_subtitle": "Трекеры, Discord, ТВ, десктоп", "trackers": "Трекеры", - "stat_days": "дней просмотра" + "stat_days": "дней просмотра", + "mangayomi_sources": "Источники Mangayomi", + "view_only": "ТОЛЬКО ПРОСМОТР" }, "appearance": { "title": "Внешний вид", @@ -632,7 +646,8 @@ "voice_listening": "Слушаю…", "voice_done": "Прослушивание остановлено", "voice_hint": "Назовите фильм или сериал", - "try_torrents": "Искать в торрентах" + "try_torrents": "Искать в торрентах", + "capped_note": "Искали в {} самых надёжных из {} источников — сузьте область, чтобы дойти до конкретного." }, "movie": { "rating": "Рейтинг", @@ -1695,5 +1710,96 @@ "uhd": "4K — тяжелее для декодирования и загрузки", "large_file": "Большой файл — медленно стартует на мобильном интернете", "unreachable": "Эта ссылка не ответила при проверке" + }, + "update": { + "available_title": "Доступна новая версия (v{})", + "available_body": "Доступна новая версия приложения.", + "later": "Позже", + "now": "Обновить", + "open_store": "Открыть в App Store", + "required_title": "Требуется обновление", + "required_body": "Установите новую версию (v{}), чтобы продолжить пользоваться приложением.", + "exit_app": "Выйти из приложения", + "downloading": "Загрузка обновления…" + }, + "ext": { + "cloudstream_title": "Источники CloudStream", + "aniyomi_title": "Источники Aniyomi", + "mangayomi_title": "Источники Mangayomi", + "android_only": "Эта функция доступна только на Android", + "needs_webview": "Расширениям на JavaScript нужен WebView, а на этой платформе его нет.", + "check_updates": "Проверить обновления", + "check_extension_updates": "Проверить обновления расширений", + "installed_sources": "УСТАНОВЛЕННЫЕ ИСТОЧНИКИ", + "installed_repos": "УСТАНОВЛЕННЫЕ РЕПОЗИТОРИИ", + "sources_heading": "ИСТОЧНИКИ", + "recommended": "РЕКОМЕНДУЕМЫЕ", + "repo_url": "Ссылка на репозиторий", + "index_url": "Ссылка на индекс", + "browse_plugins": "Обзор плагинов", + "no_sources_yet": "Пока нет источников", + "no_repos_yet": "Пока нет репозиториев", + "add_source": "Добавить источник", + "add_repo": "Добавить репозиторий", + "installing": "Установка…", + "installing_extensions": "Установка расширений…", + "installing_plugins": "Установка плагинов…", + "installing_progress": "Установка {} / {}", + "installing_plugins_progress": "Установка плагинов {} / {}…", + "installing_extensions_progress": "Установка расширений {} / {}…", + "installing_long": "Установка расширений… для больших репозиториев это может занять время.", + "checking_updates": "Проверка обновлений…", + "checking_extension_updates": "Проверка обновлений расширений…", + "all_up_to_date": "Все расширения обновлены.", + "updated_count": "Обновлено расширений: {}.", + "updated_list": "Обновлено {}: {}", + "update_check_failed": "Не удалось проверить обновления: {}", + "source_removed": "Источник удалён.", + "repo_removed": "Репозиторий удалён.", + "none_found": "По этой ссылке расширений не найдено.", + "none_found_js": "По этой ссылке не найдено расширений на JavaScript.", + "added_sources": "Добавлено источников: {}.", + "added_sources_providers": "Добавлено источников: {} · провайдеров: {}.", + "installed_from_file": "Установлено источников: {}.", + "could_not_install": "Не удалось установить: {}", + "could_not_install_plugin": "Не удалось установить плагин", + "could_not_remove_plugin": "Не удалось удалить плагин", + "error": "Ошибка: {}", + "cloudstream_desc": "Добавьте репозиторий CloudStream и установите только нужные плагины — провайдерами станут только они. Они работают на вашем устройстве (только Android).", + "aniyomi_desc": "Добавьте репозитории расширений Aniyomi, чтобы установить дополнительные аниме-провайдеры. Они работают на вашем устройстве (только Android).", + "mangayomi_desc": "Расширения Mangayomi написаны на JavaScript, а не являются Android-приложениями — поэтому, в отличие от источников CloudStream, Aniyomi и Manga, они работают также на iOS, macOS и Windows.", + "plugins_summary": "Плагинов: {} · установлено: {}", + "install_all": "Установить все", + "repo_load_failed": "Не удалось загрузить репозиторий.", + "no_plugins": "В этом репозитории нет плагинов", + "search_plugins": "Поиск плагинов…", + "no_matches": "Ничего не найдено", + "import_title": "Установить репозиторий расширений?", + "import_pick_library": "Репозитории аниме и манги используют один формат файла, поэтому выберите, к какой библиотеке относится этот.", + "import_js_only": "На этой платформе можно установить только расширения Mangayomi (JavaScript) — репозитории CloudStream, Aniyomi и Mihon содержат пакеты Android-приложений.", + "import_none_found": "В этом файле расширений не найдено. Возможно, он от другого приложения или выбран не тот тип.", + "mangayomi_url_only": "Репозитории Mangayomi устанавливаются по ссылке — откройте ссылку на индекс, а не скачанную копию.", + "skipped_dart": "Пропущено источников только для Dart: {} — они встроены в Mangayomi и здесь не работают.", + "type_manga": "Манга", + "type_anime": "Аниме", + "type_novel": "Новелла" + }, + "ux": { + "quick_search": "Быстрый поиск ({})", + "quick_search_note": "Быстрый поиск охватывает {} из {} источников. Выберите источники для точного поиска.", + "torrents": "Торренты", + "filters": "Фильтры", + "change_source": "Сменить источник", + "manage_sources": "Управление источниками", + "no_source_match": "Нет источников по вашему запросу.", + "download_options": "Параметры загрузки", + "download_options_note": "Выберите доступный источник и качество. Аудио зависит от выбранного источника.", + "source_number": "Источник {}", + "checking_quality": "Проверка доступного качества…", + "quality_unavailable": "Не удалось проверить качество. Повторите попытку или скачайте исходный поток без гарантии качества.", + "quality_unknown": "Качество неизвестно", + "size_unknown": "Источник не указал размер", + "interface_language_note": "Меняется только интерфейс приложения. Названия фильмов, описания и другой контент источников остаются на исходном языке.", + "continue": "Продолжить" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json new file mode 100644 index 00000000..6035e319 --- /dev/null +++ b/assets/translations/tr.json @@ -0,0 +1,1804 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Yükleniyor…", + "error": "Bir şeyler ters gitti", + "retry": "Yeniden dene", + "cancel": "İptal", + "confirm": "Onayla", + "ok": "Tamam", + "yes": "Evet", + "no": "Hayır", + "back": "Geri", + "next": "İleri", + "done": "Bitti", + "save": "Kaydet", + "delete": "Sil", + "edit": "Düzenle", + "search": "Ara", + "close": "Kapat", + "settings": "Ayarlar", + "logout": "Çıkış yap", + "no_internet": "İnternet bağlantısı yok", + "try_again": "Tekrar dene", + "see_all": "Tümünü gör", + "unknown": "Bilinmiyor", + "off": "Kapalı", + "on": "Açık", + "new_badge": "YENİ", + "reset": "Sıfırla", + "clear": "Temizle", + "copy": "Kopyala", + "share": "Paylaş", + "install": "Yükle", + "remove": "Kaldır", + "show": "Göster", + "hide": "Gizle", + "installed": "Yüklendi" + }, + "auth": { + "login": "Giriş yap", + "register": "Kayıt ol", + "email": "E-posta", + "password": "Parola", + "username": "Kullanıcı adı", + "email_hint": "E-postanı gir", + "password_hint": "Parolanı gir", + "username_hint": "Kullanıcı adını gir", + "forgot_password": "Parolanı mı unuttun?", + "dont_have_account": "Hesabın yok mu?", + "already_have_account": "Zaten hesabın var mı?", + "sign_up": "Kayıt ol", + "sign_in": "Giriş yap", + "login_success": "Giriş başarılı", + "register_success": "Kayıt başarılı", + "logout_success": "Çıkış yapıldı", + "invalid_email": "Geçersiz e-posta adresi", + "invalid_password": "Parola en az 6 karakter olmalı", + "invalid_username": "Kullanıcı adı en az 3 karakter olmalı", + "confirm_password": "Parolayı onayla", + "passwords_not_match": "Parolalar eşleşmiyor", + "terms_agree": "Koşulları ve Gizlilik Politikasını kabul ediyorum", + "or_continue_with": "Ya da şununla devam et", + "welcome_back": "Tekrar hoş geldin", + "create_account": "Hesap oluştur", + "login_subtitle": "Profilini ve kayıtlı verilerini kullanmak için giriş yap.", + "register_subtitle": "E-posta, kullanıcı adı ve parolayla bir hesap oluştur.", + "forgot_password_title": "Parolanı sıfırla", + "forgot_password_subtitle": "Hesabının e-postasını gir, sana 6 haneli bir kod gönderelim.", + "reset_password_subtitle": "{email} adresine bir kod gönderdik. Onu yeni parolanla birlikte gir.", + "send_reset_code": "Kod gönder", + "reset_code_sent": "Bu e-posta kayıtlıysa kod yolda", + "reset_code_hint": "6 haneli kod", + "invalid_reset_code": "6 haneli kodu gir", + "new_password_hint": "Yeni parola", + "confirm_password_hint": "Yeni parolayı tekrar gir", + "reset_password": "Parolayı sıfırla", + "resend_code": "Yeni bir kod gönder", + "resend_in": "{seconds} sn sonra tekrar gönder", + "identifier_hint": "E-posta veya kullanıcı adı", + "identifier_required": "E-posta veya kullanıcı adı gerekli", + "continue_with_google": "Google ile devam et", + "google_unavailable": "Google ile giriş bu cihazda kullanılamıyor", + "google_failed": "Google ile giriş yapılamadı. Lütfen tekrar dene", + "weak": "Zayıf", + "medium": "Orta", + "strong": "Güçlü", + "code_resent": "Kod yeniden gönderildi. E-postanı kontrol et.", + "verify": "Doğrula", + "avatar_bad_format": "Görsel biçimi desteklenmiyor (jpg, png, webp)", + "verification_code": "Doğrulama kodu" + }, + "onboarding": { + "skip": "Atla", + "continue_with_email": "E-posta ile devam et", + "slide_1_title": "Binlerce film ve dizi", + "slide_1_body": "Yeni çıkanlar, dünya sineması ve animasyon — hepsi tek uygulamada.", + "slide_2_title": "Anime, kocaman bir kütüphane", + "slide_2_body": "Sezon yapımları, klasikler ve altyazılar. AniList’i bağla, listen kendi kendini güncellesin.", + "slide_3_title": "Büyük ekranda da", + "slide_3_body": "Canlı TV kanalları, telefonu televizyonla eşleştirme ve birlikte izleme — Sozo TV’de de çalışır." + }, + "home": { + "title": "Ana sayfa", + "continue_watching": "İzlemeye devam et", + "trending": "Şu an yükselişte", + "new_releases": "Yeni çıkanlar", + "top_rated": "En yüksek puanlılar", + "for_you": "Sana özel", + "all_categories": "Tüm kategoriler", + "genres": "Türler", + "get_all": "Hepsini al", + "popular": "Popüler", + "watch_now": "Şimdi izle", + "more_info": "Daha fazla bilgi", + "add_to_list": "Listeye ekle", + "my_list": "Listem", + "featured": "Öne çıkan", + "action": "Aksiyon", + "comedy": "Komedi", + "drama": "Dram", + "thriller": "Gerilim", + "horror": "Korku", + "romance": "Romantik", + "sci_fi": "Bilim kurgu", + "documentary": "Belgesel", + "animation": "Animasyon", + "telegram_promo_title": "Telegram’da Sozo’ya katıl", + "telegram_promo_subtitle": "Güncellemeleri, yeni özellikleri ve içerik bildirimlerini al.", + "telegram_join_channel": "Kanala katıl", + "dont_show_again": "Bir daha gösterme", + "downloaded": "İndirildi", + "view_all": "Tümünü gör", + "remove_from_continue": "İzlemeye devam et’ten kaldır", + "ep_number": "BÖL {}", + "untitled": "Başlıksız", + "empty_title": "Burada henüz bir şey yok", + "empty_subtitle": "Başka bir kaynak seç ya da yenilemek için aşağı çek.", + "list_empty_title": "Bu liste boş", + "list_empty_subtitle": "Şu an bu bölümde gösterilecek bir şey yok." + }, + "player": { + "needs_media_kit": "media_kit motorunu gerektirir", + "auto_quality_desc": "Oynatıcı, bağlantının kaldırabileceği en iyisini seçer", + "on": "Açık", + "info_title": "Oynatıcı bilgisi", + "info_resolution": "Çözünürlük", + "info_engine": "Motor", + "info_provider": "Kaynak", + "info_server": "Sunucu", + "info_quality": "Kalite", + "info_codec": "Kodek", + "info_hdr": "HDR", + "info_audio": "Ses", + "info_container": "Kapsayıcı", + "info_size": "Dosya boyutu", + "info_host": "Sunucu adresi", + "info_position": "Konum", + "info_buffer": "Ön belleklenen", + "info_speed": "Hız", + "info_state": "Durum", + "switching_to": "{} kaynağına geçiliyor…", + "fault": { + "decoder": "Bu cihaz videoyu çözemedi. Başka bir kalite dene ya da tekrar dene.", + "unsupported_format": "Bu video biçimi cihazında desteklenmiyor. Tarayıcında oynatmayı deneyebilirsin.", + "source_blocked": "Video kaynağı açılamadı — sunucu engellemiş olabilir. Başka bir kalite dene.", + "network": "Ağ hatası — bağlantını kontrol et.", + "no_source": "Bu bölüm için kaynak yok.", + "empty_url": "Bu kaynak hiçbir video adresi döndürmedi.", + "engine_unavailable": "Oynatıcı başlamadı. Lütfen uygulamayı tamamen yeniden başlat.", + "unknown": "Video yüklenemedi." + }, + "play": "Oynat", + "pause": "Duraklat", + "resume": "Devam et", + "forward": "10 sn ileri", + "backward": "10 sn geri", + "fullscreen": "Tam ekran", + "exit_fullscreen": "Tam ekrandan çık", + "quality": "Kalite", + "subtitles": "Altyazı", + "audio": "Ses", + "speed": "Oynatma hızı", + "auto": "Otomatik", + "off": "Kapalı", + "episodes": "Bölümler", + "next_episode": "Sonraki bölüm", + "season": "{n}. sezon", + "fit_original": "Orijinal", + "fit_fill": "Doldur", + "fit_stretch": "Uzat", + "play_in_browser": "Tarayıcıda oynat", + "view_logs": "Günlükleri gör", + "speed_2x": "2x hız", + "tap_to_unlock": "Kilidi açmak için dokun", + "previous": "Önceki", + "live": "CANLI", + "go_live": "Canlıya dön", + "lang_sub": "Altyazılı (SUB)", + "lang_dub": "Dublajlı (DUB)", + "lang_softsub": "Yumuşak altyazı", + "audio_language": "Ses dili", + "speed_short": "Hız", + "aspect": "Oran", + "aspect_ratio": "En boy oranı", + "subtitle_style": "Altyazı stili", + "audio_track": "Ses parçası", + "diagnostics_logs": "Tanılama / günlükler", + "extracting_media": "Medya çıkarılıyor…", + "loading_video": "Video yükleniyor…", + "fetching_link": "Kaynaktan oynatma bağlantısı alınıyor", + "preparing_stream": "Video akışı hazırlanıyor", + "episode_n": "{}. bölüm", + "search_online": "Çevrimiçi ara", + "subtitle_sync": "Altyazı senkronu", + "subtitle_sync_desc": "Sesle uyuşması için zamanlamayı kaydır", + "subtitle_sync_help": "Altyazı erken mi geliyor? Artır (+). Geç mi? Azalt (−).", + "search_subtitles": "Altyazı ara", + "subtitle_search_hint": "Başlık / film adı", + "searching_subtitles": "Altyazı aranıyor…", + "no_subtitles_found": "Altyazı bulunamadı", + "search_failed": "Arama başarısız: {}", + "online_subtitles_count": "Çevrimiçi altyazı · {}", + "loading_subtitle": "Altyazı yükleniyor…", + "subtitle_loaded": "Altyazı yüklendi", + "subtitle_failed_download": "Bu altyazı indirilemedi", + "subtitle_failed_empty": "Bu altyazı dosyasında hiç metin yok", + "subtitle_failed_archive": "Arşivlenmiş altyazılar (.zip / .rar) desteklenmiyor", + "subtitle_failed_html": "Sunucu altyazı değil, bir web sayfası döndürdü", + "subtitle_failed_format": "Desteklenmeyen altyazı biçimi", + "subtitle_rate": "Kare hızı dönüşümü", + "subtitle_rate_help": "Zamanla giderek mi kayıyor? Altyazı başka bir kare hızına göre zamanlanmış — dönüşümü seç.", + "subtitle_rate_off": "Kapalı", + "reset": "Sıfırla", + "font_size": "Yazı boyutu", + "text_color": "Yazı rengi", + "background": "Arka plan", + "none": "Yok", + "solid": "Düz", + "edge": "Kenar", + "shadow": "Gölge", + "outline": "Dış çizgi", + "position": "Konum", + "lower": "Daha aşağı", + "default": "Varsayılan", + "higher": "Daha yukarı", + "bold": "Kalın", + "audio_track_engine_only": "yalnızca media_kit", + "audio_track_single": "Tek parça var", + "external_player": "Harici oynatıcıda oynat", + "external_player_gated": "Bu kaynak, VLC ve MX Player’ın gönderemediği özel istek başlıklarına ihtiyaç duyuyor. Büyük ihtimalle yüklenmeyecek. Yine de denensin mi?", + "external_player_try": "Yine de dene", + "external_player_none": "Harici video oynatıcı bulunamadı", + "audio_track_numbered": "Ses {}", + "engine_sheet_title": "Bu nasıl oynatılsın?", + "engine_sheet_subtitle": "Bu video için motoru seç. İstediğin zaman Ayarlar → Oynatıcı’dan değiştirebilirsin.", + "engine_sheet_dont_ask": "Bir daha sorma", + "engine_sheet_play": "Oynat", + "subtitle_preview_text": "Pijamalı hasta yağız şoföre çabucak güvendi", + "server": "Sunucu", + "stream_not_found": "Bu kaynak akışını korumalı bir oynatıcının arkasına saklıyor. Başka bir kaynak dene ya da tarayıcında aç.", + "subtitle_translation": "Altyazı çevirisi", + "auto_translate": "Otomatik çevir", + "auto_translate_desc": "Kaynakta senin dilinde altyazı yoksa yapay zekâ çevirir", + "translate_to": "Şu dile çevir", + "translate_note": "Çeviri makine üretimidir ve hatalı olabilir. Günlük bir sınır uygulanır.", + "translate_ai": "Yapay zekâ ile çevir", + "translating_subtitle": "Çevriliyor…", + "translate_failed": "Çevrilemedi", + "ai_translate_title": "Yapay zekâ ile {} diline çevir", + "ai_translate_hint": "Listeden bir altyazı seç ve YZ’ye dokun", + "ai_translate_remaining": "Bugün {}/{} çeviri hakkın kaldı", + "ai_translate_used_up": "Bugünkü çeviri sınırını doldurdun", + "ai_translate_menu": "Yapay zekâ ile çevir", + "ai_translate_menu_desc": "Bir altyazıyı {} diline çevirir", + "ready_translations": "Hazır çeviriler", + "lines": "satır", + "sleep_timer": "Uyku zamanlayıcısı", + "sleep_minutes": "{} dakika", + "sleep_hours": "{} saat", + "sleep_minutes_short": "{} dk", + "sleep_episode_end": "Bölüm sonu", + "sleep_fired": "Uyku zamanlayıcısı — oynatma duraklatıldı", + "skip_opening": "Jeneriği atla", + "skip_ending": "Kapanışı atla", + "alt_sources": "Başka kaynak dene", + "alt_searching": "Diğer kaynaklar aranıyor…", + "alt_none": "Başka hiçbir kaynakta bu yapım yok", + "alt_failed": "Bu kaynak açılamadı", + "alt_no_episode": "Bu kaynakta {}. bölüm yok", + "alt_search_all": "Tüm kaynaklarda ara", + "guide": "Rehber", + "change_source": "Kaynağı değiştir", + "cast": "TV’ye yayınla", + "cast_to": "Şuraya yayınla", + "cast_searching": "Bu Wi‑Fi’daki cihazlar aranıyor…", + "cast_none": "Cihaz bulunamadı. TV, bu telefonla aynı Wi‑Fi’da olmalı.", + "cast_failed": "{} cihazına bağlanılamadı", + "cast_unsupported": "Bu akış yayınlanamıyor", + "cast_active": "{} üzerinde oynatılıyor", + "cast_stop": "Yayını durdur", + "color_profile": "Görüntü", + "color_natural": "Doğal", + "color_cinema": "Sinema", + "color_night": "Gece", + "color_anime": "Anime", + "color_restore": "Geri yükle", + "shaders": "İyileştir (Anime4K)", + "shaders_note": "Animasyon için GPU ölçekleme. Shader dosyaları ilk kullanımda bir kez indirilir.", + "shader_off": "Kapalı", + "shader_off_desc": "İyileştirme yok", + "shader_sharpen": "Keskinleştir", + "shader_sharpen_desc": "Ayrıntıyı geri getirir — temiz kaynaklar için en iyisi", + "shader_deblur": "Bulanıklığı gider", + "shader_deblur_desc": "Daha yumuşak onarım — bulanık kaynaklar için", + "shader_denoise": "Gürültüyü azalt", + "shader_denoise_desc": "Kumlanmayı temizler — çok sıkıştırılmış kaynaklar için", + "shader_tier": "GPU BÜTÇESİ", + "shader_tier_mid": "Dengeli", + "shader_tier_mid_desc": "Çoğu telefonda akıcı", + "shader_tier_high": "En yüksek", + "shader_tier_high_desc": "En keskini, güçlü bir GPU ister", + "font_family": "Yazı tipi", + "font_system": "Varsayılan", + "font_sans": "Tırnaksız", + "font_serif": "Tırnaklı", + "font_condensed": "Dar", + "font_mono": "Eş aralıklı", + "second_track": "İkinci parça", + "second_track_on": "{} ikinci parça olarak gösteriliyor", + "second_track_same": "O parça zaten ana parça", + "second_track_hint": "Bir parçayı ilkinin yanında göstermek için uzun bas", + "share_frame": "Bu kareyi paylaş", + "share_frame_failed": "Kare yakalanamadı", + "save_frame": "Kareyi kaydet", + "save_frame_done": "Galeriye kaydedildi", + "save_frame_failed": "Kare kaydedilemedi", + "save_frame_denied": "Kareyi kaydetmek için galeri erişimi gerekiyor", + "switching_server": "Sunucu değiştiriliyor…", + "recommended": "Önerilen", + "episode_page_failed": "Dizinin sonraki bölümü yüklenemedi", + "info_fields_desc": "Videonun üzerinde ne görüneceğini seç. Kontrollerle birlikte belirir.", + "layout_title": "Oynatıcı kontrolleri", + "layout_desc": "Sıralamak için sürükle ya da bir kontrolü çubuğun başka bir bölümüne taşı.", + "layout_top_bar": "Üst çubuk", + "layout_bottom_left": "Sol alt", + "layout_bottom_right": "Sağ alt", + "layout_hidden": "Gizli", + "layout_hidden_desc": "Oynatıcı içindeki Ayarlar’dan hâlâ erişilebilir.", + "layout_pinned": "Ayarlar yerinde kalır — diğer her şeye dönüş yolu odur.", + "layout_top_bar_full": "Üst çubuk dolu. Önce oradan bir şey çıkar.", + "layout_unknown_control": "Bu kontrol artık kullanılamıyor.", + "layout_move_to": "Taşı", + "layout_empty": "Burada bir şey yok.", + "rotate": "Döndür", + "lock": "Kilitle", + "enhance": "İyileştir", + "fit": "Sığdır", + "pip": "Görüntü içinde görüntü", + "layout_pinned_badge": "Sabit", + "info_fields_title": "Bilgi katmanı satırları", + "logs_title": "Oynatıcı günlükleri ({})", + "logs_copied": "Günlükler kopyalandı", + "logs_empty": "Henüz günlük yok" + }, + "profile": { + "title": "Profil", + "my_account": "Hesabım", + "notifications": "Bildirimler", + "language": "Dil", + "theme": "Tema", + "privacy": "Gizlilik Politikası", + "help": "Yardım ve destek", + "about": "Hakkında", + "version": "Sürüm", + "subscription": "Abonelik", + "downloads": "İndirilenler", + "watch_history": "İzleme geçmişi", + "manage_profiles": "Profilleri yönet", + "parental_controls": "Ebeveyn denetimi", + "guest_title": "Profiline giriş yap", + "guest_subtitle": "Favorilerin ve geçmişin eşitlensin diye giriş yap.", + "auth_provider": "Giriş yöntemi", + "joined": "Katıldı", + "providers": "Kaynaklar", + "providers_error": "Kaynaklar yüklenemedi.", + "offline_title": "Sunucuya ulaşılamıyor", + "offline_local_available": "Bu cihazda kurulu {} kaynak hâlâ çalışıyor. Bulut kaynakları sunucu dönene kadar kullanılamaz.", + "offline_no_local": "Sunucu yanıt vermiyor ve bu cihazda kurulu kaynak yok. Çevrimdışı izlemeye devam etmek için bir CloudStream eklentisi kur.", + "offline_cached_at": "{} tarihli bulut listesi", + "offline_install_plugins": "Eklenti kur", + "offline_badge": "ÇEVRİMDIŞI", + "provider_needs_server": "Bu kaynak sunucuya ihtiyaç duyuyor, sunucuya şu an ulaşılamıyor. Bu cihazda kurulu bir kaynak seç.", + "logout_confirm": "Hesabından çıkmak istiyor musun?", + "connections": "Bağlantılar", + "coming_soon": "Yakında", + "favorites": "Favoriler", + "all_providers": "Tüm kaynaklar", + "add_favorite": "Favorilere ekle", + "cloudstream_sources": "CloudStream kaynakları", + "aniyomi_sources": "Aniyomi kaynakları", + "signin_account_title": "Hesabına giriş yap", + "signin_account_subtitle": "Favorilerini ve izleme geçmişini kaydetmek için giriş yap.", + "sign_in": "Giriş yap", + "sign_out": "Çıkış yap", + "sign_out_confirm": "Çıkmak istediğine emin misin?", + "section_providers": "Kaynaklar", + "section_content": "İÇERİK", + "sources_row": "Eklenti kaynakları", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", + "provider": "Kaynak", + "choose_provider": "Kaynağı değiştir", + "search_providers_hint": "Kaynaklarda ara…", + "count_of_total_shown": "{} / {} gösteriliyor", + "no_providers_in_category": "Bu kategoride kaynak yok", + "try_select_all": "Her kaynağı görmek için “Tümü”nü seç", + "section_activity": "ETKİNLİK", + "share_sources_desktop": "Kaynakları masaüstüyle paylaş", + "desktop_sources": "Masaüstü kaynakları", + "developer_role": "Mobil geliştirici", + "section_about": "HAKKINDA", + "developer": "Geliştirici", + "website": "Web sitesi", + "server": "Sunucu", + "expired": "Süresi doldu", + "support_title": "Sozo’yu ayakta tut", + "server_expired": "Sunucunun süresi doldu", + "days": "Gün", + "hours": "Saat", + "min": "Dk", + "sec": "Sn", + "support_body_expired": "Sunucunun süresi doldu ve içerik artık yüklenemiyor. Desteğin onu yeniden çevrimiçi yapabilir.", + "support_body": "Sozo, düzenli olarak yenilenmesi gereken bir sunucuya dayanıyor. Uygulamayı seviyorsan desteğin her şeyin sorunsuz sürmesine yardım eder.", + "support_developer": "Geliştiriciyi destekle", + "section_appearance": "GÖRÜNÜM", + "native_window_bar": "Yerel pencere çubuğu", + "native_window_bar_subtitle": "Uygulamanın kendi çubuğu yerine Windows’un standart başlık çubuğunu kullan.", + "liquid_glass_nav": "Liquid glass gezinme", + "liquid_glass_nav_subtitle": "Buzlu cam görünümlü yüzen sekme çubuğu (iOS 26). Düz bir çubuk için kapat.", + "nav_style": "Gezinme çubuğu", + "nav_style_glass": "Liquid glass", + "nav_style_solid": "Düz", + "nav_style_classic": "Klasik", + "section_player": "Oynatıcı", + "player_engine_native": "Sistem oynatıcısı", + "player_engine_native_desc": "Platformun kendi oynatıcısı. En iyi pil ömrü ve donanım çözme. Ses parçası değiştiremez.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Daha çok biçim oynatır, ses ve altyazı parçalarını değiştirmene izin verir. Biraz daha çok pil harcar.", + "player_engine_external": "Harici oynatıcı", + "player_engine_external_desc": "Videoyu burada oynatmak yerine VLC ya da MX Player’a gönder.", + "player_engine_external_note": "Bazı kaynaklar özel istek başlıkları gerektirdiği için yalnızca Sozo içinde çalışır. Onlar harici bir uygulamada oynamaz.", + "player_engine_badge_default": "VARSAYILAN", + "player_engine_section": "OYNATMA MOTORU", + "ask_engine_on_play": "Her seferinde sor", + "ask_engine_on_play_desc": "Her video başlarken motoru seç", + "playback_section": "OYNATMA", + "default_speed": "Varsayılan hız", + "default_speed_desc": "Her videonun başlayacağı hız", + "default_fit": "Varsayılan doldurma modu", + "default_fit_desc": "Videonun ekrana nasıl sığacağı", + "auto_next_episode": "Sonraki bölümü otomatik oynat", + "auto_next_episode_desc": "Bir bölüm bitince sonrakini başlat", + "keep_screen_on": "Ekranı açık tut", + "keep_screen_on_desc": "İzlerken ekranın kapanmasını engeller", + "gestures_section": "HAREKETLER", + "seek_step": "Çift dokunma atlaması", + "seek_step_desc": "Çift dokunmanın ne kadar atlayacağı", + "long_press_boost": "Basılı tutma hızı", + "long_press_boost_desc": "Videoya basılı tuttuğun sürece geçerli hız", + "brightness_gesture": "Parlaklık kaydırma", + "brightness_gesture_desc": "Sol tarafta yukarı ve aşağı kaydır", + "volume_gesture": "Ses kaydırma", + "volume_gesture_desc": "Sağ tarafta yukarı ve aşağı kaydır", + "seconds_short": "{} sn", + "subtitle_style_note": "Bunlar her video için geçerlidir. Altyazı zamanlaması bölüm başına kalır ve oynatıcı içinde ayarlanır.", + "searching_all_sources": "tüm kaynaklarda aranıyor", + "section_connections": "BAĞLANTILAR", + "section_extensions": "KAYNAKLAR", + "section_settings": "AYARLAR", + "remove_favorite": "Favorilerden çıkar", + "edit_profile": "Profili düzenle", + "display_name_hint": "Adın", + "display_name_too_long": "Ad en fazla 60 karakter olmalı", + "pick_from_gallery": "Galeriden seç", + "take_photo": "Fotoğraf çek", + "remove_photo": "Fotoğrafı kaldır", + "photo_pick_failed": "Fotoğraf seçilemedi", + "incognito": "Gizli mod", + "incognito_desc": "Geçmişe ya da takipçilere eklenmeden izle", + "incognito_off": "Gizli mod kapatıldı", + "auto_skip_intro": "Jenerikleri otomatik atla", + "auto_skip_intro_desc": "Anime açılış ve kapanışlarını sormadan atlar", + "incognito_active": "Gizli mod açık. İzlediğin hiçbir şey geçmişe kaydedilmiyor, AniList ve MAL’a gönderilmiyor.", + "incognito_turn_off": "Kapat", + "all_languages": "Tüm diller", + "section_library": "KİTAPLIĞIN", + "section_watch": "İZLE", + "hero_trailer": "Fragmanı başlıkta oynat", + "hero_trailer_desc": "Bir yapım açıldıktan kısa süre sonra afiş, sessiz fragmana yerini bırakır", + "section_overview": "GENEL BAKIŞ", + "section_app": "UYGULAMA", + "library": "Kitaplık", + "library_subtitle": "Favoriler, sonra izle, özel liste", + "sources_title": "Kaynaklar", + "section_active_source": "ETKİN KAYNAK", + "active_source_footnote": "Sozo’nun arama yaptığı ve oynattığı bulut kaynağı. Aşağıdaki eklentiler bu cihazda çalışan kaynaklar ekler.", + "settings_title": "Ayarlar", + "settings_subtitle": "Dil, görünüm, oynatıcı, güvenlik", + "section_general": "GENEL", + "section_data": "VERİ", + "section_lists": "LİSTELER", + "section_accounts": "HESAPLAR", + "section_devices": "CİHAZLAR", + "language_desc": "Arayüz dili", + "not_connected": "Bağlı değil", + "joined_on": "{} tarihinde katıldı", + "watch_later": "Sonra izle", + "watched": "İzlendi", + "stat_hours": "saat izlendi", + "stat_completed": "tamamlandı", + "stat_streak": "günlük seri", + "guest_benefit_sync": "Favoriler ve geçmiş tüm cihazlarında eşitlenir", + "guest_benefit_streak": "Seri, istatistikler ve bilgi yarışması sıralaması", + "guest_benefit_trackers": "AniList, MyAnimeList ve Watch Party", + "activity": "Etkinlik", + "activity_subtitle": "Geçmiş, indirilenler, takip, istatistikler", + "connections_subtitle": "Takipçiler, Discord, TV, masaüstü", + "trackers": "Takipçiler", + "stat_days": "gün izlendi", + "mangayomi_sources": "Mangayomi kaynakları", + "view_only": "SALT İZLEME" + }, + "appearance": { + "title": "Görünüm", + "entry_subtitle": "Vurgu rengi, saf siyah", + "section_preview": "ÖNİZLEME", + "section_accent": "VURGU RENGİ", + "accent_footnote": "Vurgu rengi düğmeleri, seçimleri, ilerleme çubuklarını ve etkin sekmeyi boyar — uygulamanın her yerinde.", + "accent_custom": "Özel", + "accent_red": "Sozo kırmızısı", + "accent_ember": "Kor", + "accent_amber": "Kehribar", + "accent_emerald": "Zümrüt", + "accent_teal": "Deniz mavisi", + "accent_sky": "Gök", + "accent_ocean": "Okyanus", + "accent_indigo": "Çivit", + "accent_violet": "Menekşe", + "accent_magenta": "Macenta", + "accent_rose": "Gül", + "accent_slate": "Arduvaz", + "section_darkness": "ARKA PLAN", + "darkness_dark": "Koyu", + "darkness_dark_desc": "Alışıldık koyu griler", + "darkness_black": "Saf siyah", + "darkness_black_desc": "Gerçek siyah arka planlar", + "amoled_footnote": "OLED ekranda siyah piksel kapalıdır, bu yüzden saf siyah daha derin kontrast verir ve daha az pil harcar.", + "reset": "Varsayılana döndür", + "reset_tooltip": "Varsayılan görünüme dön", + "custom_title": "Özel renk", + "custom_hue": "TON", + "custom_saturation": "DOYGUNLUK", + "custom_lightness": "AÇIKLIK", + "custom_apply": "Bu rengi kullan", + "custom_adjusted": "Üzerindeki beyaz yazı okunur kalsın diye biraz koyulaştırıldı.", + "section_library": "KİTAPLIĞINDAN", + "library_footnote": "İzlediklerinin afişlerinden alınan renkler.", + "section_extras": "DAHA FAZLASI", + "tint_nav": "Sekme çubuğunu renklendir", + "tint_nav_desc": "Seçili sekmeyi beyaz yerine vurgu renginle boyar", + "shuffle": "Sıradaki rengi dene", + "shuffle_desc": "Paleti tek tek gez" + }, + "search": { + "title": "Arama", + "hint": "Film, dizi ara…", + "no_results": "Sonuç bulunamadı", + "no_results_for": "“{query}” için sonuç yok", + "recent": "Son aramalar", + "popular_searches": "Popüler aramalar", + "categories": "Türler", + "all": "Tümü", + "movies": "Filmler", + "series": "Diziler", + "filter": "Filtrele", + "sort": "Sırala", + "sort_newest": "En yeni", + "sort_oldest": "En eski", + "sort_rating": "Puan", + "type": "Tür", + "apply": "Uygula", + "clear_filter": "Temizle", + "sources": "Kaynaklar", + "sources_n": "{} kaynak", + "all_sources": "Tümünü seç", + "this_server": "Bu sunucu", + "local_group": "Yerel", + "search_in": "Şurada ara", + "sources_hint": "Birden çok kaynakta aynı anda ara. Mevcut kaynağını kullanmak için boş bırak.", + "searching": "Aranıyor…", + "all_source_search": "Tüm kaynaklarda arama", + "cross_hint": "Kaynaklarında ara…", + "pick_sources": "Hangi kaynaklarda aranacağını seç.", + "choose_sources": "Kaynak seç", + "no_sources_selected": "Kaynak seçilmedi — seçmek için dokun", + "sources_selected": "{} seçildi — değiştirmek için dokun", + "type_to_search_n": "{} kaynakta aynı anda aramak için yaz.", + "found_in": "{} / {} kaynakta bulundu", + "results_n": "{} sonuç", + "waiting_for": "{} aranıyor…", + "no_results_any": "Seçtiğin hiçbir kaynakta sonuç yok.", + "offline_note": "Çevrimdışı — yalnızca cihazdaki kaynaklarda arama yapılabilir.", + "group_by_source": "Kaynağa göre grupla", + "merge_titles": "Yapımları birleştir", + "open_from": "Şuradan aç", + "load_more": "Daha fazla yükle", + "search_failed": "Arama başarısız", + "source_failed": "Bu kaynak şu an kullanılamıyor", + "try_all_sources": "Tüm kaynakları dene", + "weak_results": "Buradaki hiçbir şey “{query}” gibi görünmüyor — bu kaynak başka bir şeyle eşleşti. Diğer kaynaklarda olabilir.", + "did_you_mean": "Şunu mu demek istedin", + "search_sources": "Kaynaklarda ara", + "filter_providers": "Kaynakları filtrele…", + "selected_n": "{} seçildi", + "apply_n": "Uygula ({})", + "many_sources_warning": "{} kaynakta aramak yavaş olabilir. Uygulama takılmaz ama daha az kaynak daha hızlıdır.", + "all_sources_n": "Tüm kaynaklar ({})", + "more_sources": "Daha fazla…", + "select_all_sources": "Tümünü seç", + "search_all_sources": "Tüm kaynaklarda ara", + "apply_all": "Tüm kaynaklarda ara", + "selected_n_of_m": "{} / {} kaynak", + "loading_sources": "Kaynakların yükleniyor…", + "no_sources_available": "Kullanılabilir kaynak yok. Arama yapmak için bir kaynak ekle ya da etkinleştir.", + "min_query_n": "En az {} karakter yaz.", + "retry_failed": "Başarısızları yeniden dene", + "status_searching": "Aranıyor…", + "status_searching_n": "{} aranıyor", + "status_with_results_n": "{} sonuç verdi", + "status_no_match_n": "{} eşleşme yok", + "status_timed_out_n": "{} zaman aşımına uğradı", + "status_failed_n": "{} başarısız", + "all_sources_failed": "{} kaynağın hiçbiri yanıt vermedi. Bu boş bir sonuç değil — tekrar dene.", + "no_results_with_failures": "Yanıt veren kaynaklarda eşleşme yok ({}). {} kaynağa ulaşılamadı.", + "all_sources_failed_one": "Aradığın kaynak yanıt vermedi. Bu boş bir sonuç değil — tekrar dene.", + "voice": "Sesle ara", + "voice_listening": "Dinleniyor…", + "voice_done": "Dinleme durduruldu", + "voice_hint": "Bir film ya da dizi adı söyle", + "try_torrents": "Torrent ara", + "capped_note": "{} kaynaktan en güvenilir {} tanesinde arandı — belirli birine ulaşmak için kapsamı daralt." + }, + "movie": { + "rating": "Puan", + "year": "Yıl", + "quality": "Kalite", + "duration": "Süre", + "genre": "Tür", + "director": "Yönetmen", + "cast": "Oyuncular", + "description": "Açıklama", + "similar": "Benzer yapımlar", + "trailer": "Fragman", + "share": "Paylaş", + "download": "İndir", + "rate": "Puan ver", + "episodes": "Bölümler", + "seasons": "Sezonlar" + }, + "errors": { + "network": "Ağ hatası. Bağlantını kontrol et.", + "server": "Sunucu hatası. Lütfen tekrar dene.", + "unauthorized": "Devam etmek için giriş yap.", + "not_found": "İçerik bulunamadı.", + "unknown": "Bilinmeyen bir hata oluştu.", + "timeout": "İstek zaman aşımına uğradı. Lütfen tekrar dene.", + "empty": "Kullanılabilir içerik yok.", + "source_http": "Kaynağın sunucusu bir hatayla yanıt verdi ({})." + }, + "navigation": { + "home": "Ana sayfa", + "search": "Ara", + "shorts": "Shorts", + "downloads": "İndirilenler", + "profile": "Profil", + "my_list": "Listem", + "history": "Geçmiş", + "following": "Takip", + "premiere": "Prömiyer", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "Canlı TV" + }, + "premiere": { + "tab_title": "Prömiyerler", + "live_now": "Şu an canlı", + "upcoming": "Yakında", + "live": "CANLI", + "catching_up": "Canlıya yetişiliyor", + "starts_in": "Başlamasına", + "starts_at": "{when} başlıyor", + "days": "GÜN", + "hours": "SA", + "minutes": "DK", + "seconds": "SN", + "waiting": "{count} kişi bekliyor", + "watching": "{count} kişi izliyor", + "join": "Prömiyere katıl", + "join_now": "Şimdi katıl", + "set_reminder": "Hatırlatıcı kur", + "reminder_set": "Hatırlatıcı kuruldu", + "notify_me": "Canlıya geçince haber ver", + "guest_watch_hint": "Misafir olarak izleyebilirsin.", + "login_to_chat_react": "Sohbet etmek ve tepki vermek için giriş yap", + "login_to_chat": "Sohbet etmek için giriş yap", + "chat_hint": "Selam ver — sohbet açık", + "chat_empty": "Selam ver 👋 Sohbet, prömiyerle birlikte başlar", + "chat_placeholder": "Mesaj…", + "slow_mode": "Yavaş mod: {sec} sn", + "react": "Tepki ver", + "paused_by_host": "Sunucu duraklattı", + "buffering": "Arabelleğe alınıyor…", + "reconnecting": "Yeniden bağlanılıyor…", + "connecting": "Prömiyere bağlanılıyor…", + "ended_title": "Prömiyer bitti", + "ended_watch_vod": "İstediğin zaman izle", + "canceled": "Bu prömiyer iptal edildi", + "empty_title": "Henüz prömiyer yok", + "empty_subtitle": "Canlı etkinlikler burada görünecek", + "error_generic": "Bir şeyler ters gitti", + "error_full": "Bu prömiyer dolu", + "error_not_found": "Prömiyer bulunamadı", + "retry": "Yeniden dene", + "share": "Paylaş", + "back": "Geri", + "resolve_failed": "Bu cihazda oynatma başlatılamadı" + }, + "nav_customize": { + "entry_title": "Sekmeleri özelleştir", + "entry_subtitle": "Hangi sekmelerin görüneceğini ve sırasını seç", + "title": "Alt çubuk", + "shown": "Görünen", + "available": "Kullanılabilir", + "min_hint": "En az {count} sekme bırak", + "max_hint": "En fazla {count} sekme", + "pick_range": "{min} ile {max} arasında sekme seç", + "reset": "Sıfırla", + "cancel": "İptal", + "save": "Kaydet" + }, + "app_lock": { + "section_label": "GÜVENLİK", + "app_lock": "Uygulama kilidi", + "settings_title": "Uygulama kilidi", + "setup_title": "PIN oluştur", + "enable": "Uygulama kilidini aç", + "state_on": "Açık", + "state_off": "Kapalı", + "choose_length_title": "PIN uzunluğu", + "choose_length_subtitle": "PIN’inin kaç haneli olacağını seç.", + "length_n_digits": "{} haneli PIN", + "create_pin_title": "Yeni bir PIN oluştur", + "create_pin_subtitle": "Kimseyle paylaşmayacağın bir şey seç.", + "confirm_pin_title": "PIN’ini doğrula", + "confirm_pin_subtitle": "Yeni PIN’ini bir kez daha gir.", + "verify_title": "Uygulama kilitli", + "verify_subtitle": "Devam etmek için PIN’ini gir.", + "verify_current_pin": "Mevcut PIN’ini gir", + "change_pin": "PIN’i değiştir", + "biometric": "Biyometriyle aç", + "biometric_unavailable": "Bu cihazda biyometri kullanılamıyor", + "biometric_enable_reason": "Biyometrik kilit açmayı etkinleştirmek için kimliğini doğrula", + "biometric_unlock_reason": "Uygulamayı açmak için kimliğini doğrula", + "pin_mismatch": "PIN’ler eşleşmiyor", + "pin_wrong": "Yanlış PIN", + "disable_title": "Uygulama kilidi kapatılsın mı?", + "disable_subtitle": "PIN’in ve biyometri tercihin kaldırılacak.", + "enabled_hint": "Uygulama açılırken PIN’in sorulacak.", + "disabled_hint": "Uygulama kilidi kapalı. İzlediklerini korumak için aç.", + "private_list": "Özel liste", + "move_to_private": "Özele taşı", + "moved_to_private": "Özel listeye taşındı", + "removed_from_private": "Özelden çıkarıldı", + "move_to_my_list": "Listeme geri taşı", + "private_empty": "Henüz özel bir şey yok", + "private_setup_required": "Özel listeyi kullanmak için önce bir PIN oluştur", + "private_long_press_hint": "Özel listene taşımak için uzun bas — PIN’inin arkasında durur ve izleme geçmişi kaydedilmez.", + "private_always_ask": "Özel liste için PIN’i her zaman sor", + "private_always_ask_hint": "Oturum başına bir kez değil, her seferinde PIN’ini gir", + "reset_title": "Uygulama kilidi sıfırlansın mı?", + "reset_body": "Bu, PIN’i kaldırır ve Sozo’yu bu bilgisayarda açar. Kitaplığın ve geçmişin kalır. Daha sonra Profil → Uygulama kilidi bölümünden yeni bir PIN belirleyebilirsin.", + "reset": "Sıfırla", + "forgot_pin": "PIN’ini mi unuttun?" + }, + "my_list": { + "saved_account": "Hesaba kaydedildi", + "saved_local": "Bu cihazda", + "untitled": "Başlıksız", + "empty_title": "Listen boş", + "empty_subtitle": "Buraya kaydetmek için herhangi bir yapımdaki + düğmesine dokun.", + "unauth_title": "Listeni görmek için giriş yap", + "unauth_subtitle": "Kaydettiğin yapımlar giriş yaptığında burada görünecek.", + "error_title": "Liste yüklenemedi" + }, + "deeplink": { + "opt_in_title": "Sozo bağlantıları bu uygulamada açılsın mı?", + "opt_in_body_android": "Paylaşılan sozo.azamov.me bağlantıları doğrudan Sozo’da açılır. Sistem ayarlarından “Desteklenen bağlantıları aç” seçeneğini etkinleştir.", + "opt_in_body_ios": "Sozo bağlantıları uygulamada otomatik açılır. Bir bağlantı Safari’de açılırsa uzun bas ve “Sozo’da aç” seçeneğini seç.", + "opt_in_enable": "Etkinleştir", + "opt_in_later": "Sonra" + }, + "streak": { + "section_label": "SERİ", + "empty_title": "Bir seri başlat", + "empty_subtitle": "Günlük serini başlatmak için bugün bir bölüm izle.", + "current_n_days": "{} gün", + "locked_today": "Bugün garanti. Yarın yine gel!", + "keep_going": "Serini canlı tut — bugün izlemeyi unutma.", + "risk_subtitle": "Serin gece yarısı sıfırlanıyor. Kurtarmak için şimdi bir şey izle.", + "record_label": "Rekor", + "next_milestone": "Sıradaki hedef", + "weekday_mon": "Pzt", + "weekday_tue": "Sal", + "weekday_wed": "Çar", + "weekday_thu": "Per", + "weekday_fri": "Cum", + "weekday_sat": "Cmt", + "weekday_sun": "Paz", + "milestone_title": "{} günlük seri!", + "milestone_subtitle": "İnanılmaz bir istikrar. Devam et ve yeni bir kişisel rekor kır.", + "share_text": "Sozo’da {} günlük seriye ulaştım! 🔥", + "cta_continue": "Devam et", + "cta_share": "Paylaş", + "freeze_label": "Dondurma hakkı", + "freeze_count": "{} / {}", + "total_days_label": "Toplam gün", + "this_week_label": "Bu hafta", + "calendar_label": "Son 35 gün", + "days_to_milestone": "Sıradaki eşiğe {} gün", + "freeze_saved_title": "Seri kurtarıldı!", + "freeze_saved_subtitle": "Bir dondurma hakkı kaçırdığın günü kapattı — serin devam ediyor.", + "freeze_awarded_title": "Bir dondurma hakkı kazandın!", + "freeze_awarded_subtitle": "Dondurma hakları, tek bir günü kaçırdığında serini korur.", + "freeze_explainer": "Dondurma hakkı, kaçırdığın bir günde serini otomatik olarak kurtarır. Düzenli izleyerek yenilerini kazanırsın." + }, + "watch_party": { + "title": "Watch Party", + "entry_subtitle": "Arkadaşlarınla aynı anda izle — biri oynatır, herkes takip eder.", + "entry_create": "Oda oluştur", + "entry_create_hint": "Bir oda aç ve arkadaşlarını kodla davet et", + "entry_join": "Kodla katıl", + "entry_join_hint": "Bir arkadaşının paylaştığı kodu gir", + "not_available": "Bu yapım için birlikte izleme henüz yok", + "create": "Oda oluştur", + "join": "Odaya katıl", + "join_hint": "Oda kodunu gir", + "invalid_code": "Geçersiz kod", + "code_label": "Oda kodu", + "copy_code": "Kodu kopyala", + "copied": "Kopyalandı", + "share_invite": "Daveti paylaş", + "invite": "Davet et", + "lobby_waiting_host": "Sunucunun başlatması bekleniyor…", + "host_label": "Sunucu", + "members": "Katılımcılar", + "member_count": "{} / {}", + "you": "Sen", + "start_watching": "İzlemeye başla", + "close_party": "Odayı kapat", + "leave_party": "Odadan ayrıl", + "leave_confirm": "Bu Watch Party’den ayrılasın mı?", + "only_host_controls": "Oynatmayı yalnızca sunucu kontrol edebilir", + "host_changed": "Sunucu değişti", + "room_closed": "Oda kapatıldı", + "room_closed_empty": "Herkes ayrıldığı için oda kapandı", + "content_none": "Henüz bir yapım seçilmedi", + "resolving": "Video hazırlanıyor…", + "resolve_failed": "Bu yapım cihazında yüklenemedi", + "plugin_required_title": "Eklenti gerekli", + "plugin_required_body": "Bunu birlikte izlemek için gerekli kaynak eklentisini kur.", + "install_plugin": "Eklentiyi kur", + "chat_hint": "Bir şeyler yaz…", + "chat_empty": "Henüz mesaj yok", + "reactions": "Tepkiler", + "error_not_found": "Oda bulunamadı ya da çoktan kapandı", + "error_full": "Bu oda dolu", + "error_forbidden": "Bu odaya erişimin yok", + "error_rate": "Biraz yavaşla", + "error_unauthorized": "Bir Watch Party’ye katılmak için giriş yap", + "error_generic": "Bir şeyler ters gitti", + "reconnecting": "Yeniden bağlanılıyor…", + "connecting": "Bağlanılıyor…", + "connect_failed_title": "Bağlanılamadı", + "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene." + }, + "notifications": { + "title": "Bildirimler", + "mark_all_read": "Tümünü okundu işaretle", + "empty_title": "Henüz bildirim yok", + "empty_subtitle": "Yeni bir şey geldiğinde burada göreceksin.", + "time_now": "şimdi", + "time_minutes": "{} dk", + "time_hours": "{} sa", + "time_days": "{} g" + }, + "manga": { + "sources_title": "Manga kaynakları", + "sources_desc": "Ek manga, manhwa ve webtoon sağlayıcıları kurmak için manga eklenti depoları ekle. Bunlar cihazında çalışır (yalnızca Android).", + "repo_url": "Depo URL’si", + "add_source": "Kaynak ekle", + "installing": "Kuruluyor…", + "installing_long": "Eklentiler kuruluyor… büyük depolarda bu biraz sürebilir.", + "installing_progress": "{} / {} eklenti kuruluyor…", + "no_extensions": "Bu URL’de eklenti bulunamadı.", + "added_sources": "{} kaynak · {} sağlayıcı eklendi.", + "source_removed": "Kaynak kaldırıldı.", + "recommended": "ÖNERİLEN", + "installed_sources": "KURULU KAYNAKLAR", + "source_settings": "KAYNAK AYARLARI", + "no_sources": "Henüz kaynak yok", + "show_nsfw": "Yetişkin kaynakları", + "show_nsfw_hint": "18+ olarak işaretli kaynakları bu listede ve kaynak seçicide gösterir. Varsayılan olarak kapalıdır.", + "show": "Göster", + "hide": "Gizle", + "installed": "Kurulu", + "android_only": "Bu özellik yalnızca Android’de var", + "error_prefix": "Hata: {}", + "no_preferences": "Bu kaynağın ayarlanabilir seçeneği yok", + "not_selected": "Seçilmedi", + "n_selected": "{} seçildi", + "reading_mode": "Okuma modu", + "mode_continuous": "Sürekli", + "mode_paged": "Sayfa sayfa", + "direction": "Yön", + "dir_ltr": "Soldan sağa (LTR)", + "dir_rtl": "Sağdan sola (RTL)", + "background": "Arka plan", + "bg_black": "Siyah", + "bg_gray": "Gri", + "bg_white": "Beyaz", + "chapters": "Bölümler", + "chapter_ended": "Bölüm bitti", + "next_chapter": "Sonraki bölüm", + "first_chapter": "İlk bölüm", + "last_chapter": "Son bölüm", + "no_pages": "Sayfa bulunamadı", + "tap_to_reload": "Yeniden yüklemek için dokun", + "download_started": "İndirme başladı", + "downloaded_pages": "{} / {} sayfa", + "check_updates": "Güncelleme ara", + "checking_updates": "Eklenti güncellemeleri aranıyor…", + "up_to_date": "Tüm eklentiler güncel.", + "updated_n": "{} eklenti güncellendi.", + "spread": "Çift sayfa", + "spread_desc": "Yatay konumda yan yana, kitabın çizildiği gibi" + }, + "cloudflare": { + "title": "İnsan olduğunu doğrula", + "hint": "Aşağıdaki kontrolü tamamla — bittiğinde bu pencere kendiliğinden kapanır.", + "solve": "Cloudflare’i çöz", + "done": "Tamam" + }, + "desktop": { + "saved_refresh_on_pc": "Kaydedildi — uygulamak için bilgisayarda “Kaynakları yenile”ye dokun.", + "sources_turned_off": "Masaüstü kaynakları kapatıldı.", + "saved_loading_sources": "Kaydedildi — kaynaklar telefonundan yükleniyor…", + "refreshing_sources": "Kaynaklar telefonundan yenileniyor…", + "share_title": "Kaynakları masaüstüyle paylaş", + "sources_title": "Masaüstü kaynakları", + "share_with_desktop": "Kaynakları masaüstüyle paylaş", + "let_pc_use_sources": "Sozo PC uygulamasının bu telefonun kaynaklarını kullanmasına izin ver.", + "your_link": "Bağlantın", + "link_copied": "Bağlantı kopyalandı", + "no_wifi_address": "Açık, ama Wi‑Fi adresi bulunamadı. Bu telefonu Wi‑Fi’ya bağla (mobil veriye değil), sonra bu ekranı yeniden aç.", + "step_host_same_wifi": "Bu telefonu ve bilgisayarını aynı Wi‑Fi ağında tut.", + "step_host_turn_on": "Yukarıdaki anahtarı aç.", + "step_host_pick_sources": "Hangi kaynakların paylaşılacağını seç (ya da “Tümünü paylaş”ı açık bırak).", + "step_host_paste_on_pc": "Bilgisayarda Sozo → Profil → Masaüstü kaynakları’nı aç ve bağlantıyı yapıştır.", + "step_host_keep_open": "Bilgisayarda gezinirken Sozo’yu bu telefonda açık tut.", + "sources_to_share": "Paylaşılacak kaynaklar", + "reload_from_phone": "Telefondan yeniden yükle", + "share_all_sources": "Tüm kaynakları paylaş", + "every_source_shared": "Bu telefondaki her kaynak paylaşılıyor", + "sharing_count": "{} / {} paylaşılıyor", + "save_send_to_desktop": "Kaydet ve masaüstüne gönder", + "n_selected": "{} seçildi", + "select_all": "Tümünü seç", + "clear": "Temizle", + "no_sources_yet": "Bu telefonda henüz kaynak yok. Profil → CloudStream / Aniyomi / Manga kaynakları bölümünden ekle, sonra burayı yeniden yükle.", + "phone_link": "Telefon bağlantısı", + "refresh_sources": "Kaynakları yenile", + "step_client_same_wifi": "Bu bilgisayarı ve telefonunu aynı Wi‑Fi ağında tut.", + "step_client_open_on_phone": "Telefonda Sozo → Profil → Kaynakları masaüstüyle paylaş’ı aç ve etkinleştir.", + "step_client_choose_sources": "Telefonda hangi kaynakların paylaşılacağını seç (ya da “Tümünü paylaş”ı açık bırak).", + "step_client_copy_link": "Orada görünen bağlantıyı kopyala, yukarıya yapıştır ve Kaydet’e dokun.", + "step_client_refresh": "Telefondaki paylaşılan kaynakları mı değiştirdin? Buradan “Kaynakları yenile”ye dokun.", + "step_client_keep_open": "Burada gezinirken Sozo’yu telefonda açık tut.", + "step_client_emulator": "Android emülatörü mü kullanıyorsun? IP’sine doğrudan erişilemez — bu bilgisayarda “adb forward tcp:8765 tcp:8765” komutunu çalıştır, sonra yukarıya http://127.0.0.1:8765 yapıştır (emülatördeki anahtarı yine de aç).", + "how_it_works": "Nasıl çalışır", + "how_it_works_desc": "CloudStream, Aniyomi ve manga kaynaklarını Sozo masaüstü uygulamasında kullan. Kaynakları telefon çalıştırır; bilgisayar yalnızca görüntüler.", + "internet_still_unavailable": "İnternet hâlâ yok", + "offline_watch_downloads": "Bağlantı yokken indirdiğin videoları izlemeye devam edebilirsin.", + "refresh": "Yenile" + }, + "detail": { + "download_will_resolve": "Çözümleniyor…", + "download_needs_playback": "Bu kaynak akışını korumalı bir oynatıcının arkasına saklıyor. Bölümü bir kez oynat, sonra oynatıcıdan indir.", + "download_choose_server": "İndirme · sunucu seç", + "download_playing_now": "Şu an oynuyor", + "similar": "Benzerler", + "comments": "Yorumlar", + "screenshots": "Ekran görüntüleri", + "play": "Oynat", + "no_episodes": "Bölüm yok", + "no_playable_source": "Oynatılabilir kaynak yok", + "added_to_my_list": "Listeme eklendi", + "removed_from_my_list": "Listemden çıkarıldı", + "failed_resolve_pages": "Sayfalar çözümlenemedi", + "episodes_count": "{} bölüm", + "episodes_count_of": "{} / {} bölüm", + "episode_n": "{}. bölüm", + "no_films": "Film bulunamadı", + "filmography": "Filmografi", + "actor": "Oyuncu", + "continue_watching": "Devam et", + "continue_ep": "Devam et · {}. bölüm", + "no_cast": "Oyuncu bilgisi yok", + "no_recommendations": "Öneri yok", + "untitled": "Başlıksız", + "read": "Oku", + "watch_later": "Sonra izle", + "watched": "İzlendi", + "follow_series": "Diziyi takip et", + "find_other_sources": "Başka kaynak bul", + "anilist_track": "AniList’te takip et", + "anilist_tracked": "AniList’te takip ediliyor", + "following_on": "Takip ediliyor", + "following_off": "Takip edilmiyor", + "no_screenshots": "Ekran görüntüsü yok", + "show_more": "Daha fazla göster", + "show_less": "Daha az göster", + "time_left": "{} kaldı", + "liked": "%{} beğendi", + "watched_pct": "%{} izlendi", + "copy_link": "Bağlantıyı kopyala", + "link_copied": "Bağlantı kopyalandı", + "report_problem": "Sorun bildir", + "selected_n": "{} seçildi", + "select_all": "Tümünü seç", + "select_none": "Temizle", + "download_n": "{} indir", + "download_queueing": "Sıraya alınıyor…", + "download_action": "İndir", + "download_already": "Zaten indirildi", + "download_in_progress": "İndirme sürüyor", + "download_started": "İndirme başladı", + "download_needs_permission": "İndirmeler için bildirim izni gerekiyor", + "download_resolve_failed": "Bu bölüm için indirme bağlantısı alınamadı", + "download_queued_n": "{} indirmelere eklendi", + "download_none_queued": "Hiçbir şey eklenmedi — muhtemelen zaten indirilmiş", + "download_batch_title": "Birkaç bölüm indirilsin mi?", + "download_batch_body": "{range} arası bölümler sıraya alınacak — toplam {count}. Mobil veride bu birkaç gigabayt olabilir.", + "relations": "İlgili yapımlar", + "no_relations": "Buna ilgili bir yapım bulunamadı.", + "relation_sequel": "DEVAM", + "relation_prequel": "ÖNCESİ", + "relation_parent": "ANA HİKÂYE", + "relation_side_story": "YAN HİKÂYE", + "relation_alternative": "ALTERNATİF SÜRÜM", + "relation_spin_off": "SPIN-OFF", + "relation_summary": "ÖZET", + "relation_adaptation": "UYARLAMA", + "relation_source": "KAYNAK ESER", + "relation_character": "ORTAK KARAKTERLER", + "relation_other": "DİĞER", + "trailer_action": "Fragman", + "more_options": "Diğer seçenekler", + "in_private_list": "Özel listende", + "add_to_my_list_action": "Listeme ekle", + "remove_from_my_list_action": "Listemden çıkar", + "trailer_mute": "Fragmanı sessize al", + "trailer_unmute": "Fragmanın sesini aç" + }, + "episodes": { + "filter_hint": "Bölüm numarası veya adı", + "filter_none": "Bu blokta “{query}” ile eşleşen bölüm yok", + "jump_to": "{n}. bölüme git", + "jump_loads": "{range} yüklenir", + "jump_missing": "{n}. bölüm bu sayfada değil", + "range_of": "{total} bölümden {from}–{to}" + }, + "shorts": { + "refresh": "Yenile", + "opening": "Açılıyor…", + "content_unavailable": "İçerik kullanılamıyor", + "loading": "Shorts yükleniyor…", + "empty_title": "Henüz Shorts yok", + "empty_subtitle": "Kısa klipler hazır olduğunda burada görünecek.", + "error_title": "Shorts yüklenemedi", + "video_unavailable": "Video kullanılamıyor", + "speed_2x": "2x hız", + "report_submitted": "Şikâyet gönderildi, teşekkürler", + "watch_full_movie": "Filmin tamamını izle", + "browse_movies": "Filmlere göz at", + "sign_in_to_like": "Beğenmek için giriş yap" + }, + "comments": { + "please_sign_in": "Önce giriş yap", + "report_submitted": "Şikâyet gönderildi, teşekkürler", + "edited": "· düzenlendi", + "like": "Beğen", + "reply": "Yanıtla", + "hide": "Gizle", + "replies_one": "1 yanıt", + "replies_other": "{} yanıt", + "report": "Şikâyet et", + "edit_hint": "Yorumunu düzenle…", + "reply_hint": "Bir yanıt yaz…", + "comment_hint": "Bir yorum yaz…", + "editing": "Düzenleniyor", + "replying_to": "{} kişisine yanıt", + "sign_in_to_comment": "Yorum yapmak için giriş yap", + "empty_title": "Henüz yorum yok", + "empty_subtitle": "Ne düşündüğünü ilk sen söyle", + "count_one": "1 yorum", + "count_other": "{} yorum", + "show_more": "Daha fazla göster", + "delete_confirm_title": "Bu yorum silinsin mi?", + "delete_confirm_body": "Bu işlem geri alınamaz.", + "sign_in_prompt": "Yorum bırakmak için giriş yap" + }, + "downloads": { + "delete_all_title": "Tüm indirmeler silinsin mi?", + "file_not_found": "Dosya bulunamadı", + "clear_all": "Tümünü temizle", + "segments_progress": "{} / {} parça", + "downloaded_amount": "{} indirildi", + "pages_count": "{} sayfa", + "downloaded": "İndirildi", + "failed": "Başarısız", + "pending": "Bekliyor", + "remove": "Kaldır", + "empty_title": "Henüz indirme yok", + "empty_subtitle": "İndirilen videolar burada görünecek", + "paused": "Duraklatıldı", + "wifi_only": "Yalnızca Wi‑Fi’da indir", + "wifi_only_desc": "Sıradaki indirmeler sen Wi‑Fi’ya bağlanana kadar bekler", + "waiting_for_wifi": "Wi‑Fi bekleniyor — indirmeler kendiliğinden devam eder", + "export": "İndirilenler’e kaydet", + "export_hint": "Dosyayı diğer uygulamalara görünür yapar", + "export_running": "Kaydediliyor…", + "export_done": "{} konumuna kaydedildi", + "export_failed": "İndirilenler’e kaydedilemedi", + "downloading": "İndiriliyor", + "missing": "Dosya kayıp — yeniden indirmek için dokun", + "redownload_started": "Yeniden indiriliyor…", + "file_missing_retry": "O dosya kayboldu. Yeniden indiriliyor.", + "download_again": "Yeniden indir", + "pause": "Duraklat", + "resume": "Devam et", + "retry_all": "Başarısız olan her şeyi yeniden dene", + "sort_by": "SIRALAMA", + "sort_newest": "Önce en yeni", + "sort_title": "Başlık", + "sort_size": "Önce en büyük", + "filter_all": "Tümü", + "filter_active": "Sürüyor", + "filter_done": "Bitti", + "filter_problems": "İlgilenmen gerekiyor", + "filter_empty_title": "Bu filtreye uyan bir şey yok", + "filter_empty_subtitle": "İndirmelerin hâlâ burada — sadece bu görünüm daraltıldı.", + "filter_clear": "Tüm indirmeleri göster", + "storage_used": "İndirmeler {} kullanıyor", + "storage_free": "{} boş", + "orphans_found": "İptal edilen indirmelerin artıklarında {}", + "orphans_clean": "Temizle", + "group_summary": "{} / {} indirildi", + "group_problems": "İlgilenmen gerekiyor", + "error": { + "network": "Bağlantı koptu", + "refused": "Kaynak indirmeyi reddetti — bir kez oynat, sonra tekrar dene", + "gone": "Dosya artık kaynakta yok", + "no_space": "Bu cihazda yeterli alan yok", + "not_media": "Kaynak video yerine bir web sayfası gönderdi", + "incomplete": "İndirme eksik bitti", + "unknown": "İndirme başarısız oldu" + }, + "location": "İndirmelerin tutulduğu yer", + "location_hint": "Yalnızca uygulamanın izinsiz yazabildiği yerler. İndirmeler ayarla birlikte taşınır; her dosya varmadan hiçbir şey silinmez.", + "location_internal": "Telefon deposu (uygulama)", + "location_external": "Telefon deposu", + "location_sdcard": "SD kart", + "location_moved": "İndirmeler taşındı", + "location_unchanged": "Zaten orada", + "location_failed": "İndirmeler taşınamadı — oldukları yerde duruyorlar" + }, + "history": { + "clear_title": "Geçmişi temizle", + "clear_confirm": "İzleme geçmişini temizlemek istediğine emin misin?", + "clear": "Temizle", + "clear_all": "Tümünü temizle", + "remove": "Kaldır", + "empty_title": "Henüz izleme geçmişi yok", + "empty_subtitle": "İzlemeye başla, geçmişin burada görünsün" + }, + "main": { + "shorts_refresh_title": "Shorts’u yenile", + "shorts_refresh_body": "Shorts sekmesine tekrar dokun. Akış yenilenir ve yeni videolar yüklenir.", + "double_tap": "Çift dokun", + "tap_again": "Tekrar dokun" + }, + "reports": { + "title": "Şikâyet et", + "reason_spam": "Spam", + "reason_abuse": "Taciz", + "reason_sexual": "Cinsel içerik", + "reason_violence": "Şiddet", + "reason_copyright": "Telif hakkı", + "reason_other": "Diğer", + "note_hint": "Ek not (isteğe bağlı)", + "submit": "Gönder" + }, + "ios": { + "sources_title": "Eklenti kaynakları", + "how_it_works_desc": "CloudStream, Aniyomi ve manga kaynaklarını iOS için Sozo’da kullan. Kaynakları Android telefonun çalıştırır; bu cihaz yalnızca görüntüler.", + "step_client_same_wifi": "Bu cihazı ve Android telefonunu aynı Wi‑Fi ağında tut." + }, + "trivia": { + "fan_test_title": "Hayran testi", + "fan_test_subtitle": "En büyük hayran olduğunu kanıtla", + "hub_tagline": "Sinemayı gerçekten ne kadar biliyorsun?", + "view_leaderboards": "Sıralamaları gör", + "my_daily_rank": "Günlük sıram", + "rank_value": "#{}", + "unranked": "Sıralama dışı", + "points_value": "{} puan", + "popular_now": "Şimdi popüler", + "results": "Sonuçlar", + "searching": "Aranıyor…", + "search_cast_hint": "Oyuncu ara", + "no_results": "Sonuç yok", + "no_results_hint": "Başka bir isim dene", + "something_wrong": "Bir şeyler ters gitti", + "filmography": "Filmografi", + "n_titles": "{count} yapım", + "start_fan_test": "Hayran testini başlat", + "challenge_friend": "Bir arkadaşına meydan oku", + "top_fans": "En iyi hayranlar", + "no_fans_yet": "Henüz hayran yok", + "be_first_fan": "İlk hayran sen ol", + "fans_meta": "{rounds} tur · ortalama %{fandom} hayranlık", + "you": "Sen", + "you_rank": "Sen · #{rank}", + "best_score": "En iyi {score}", + "fandom": "Hayranlık", + "challenge_share_text": "{name} konusunda beni geçebilir misin? Sozo’da hayran testine gir!", + "scope_daily": "Günlük", + "scope_weekly": "Haftalık", + "scope_all_time": "Tüm zamanlar", + "scope_friends": "Arkadaşlar", + "leaderboard": "Sıralama", + "no_friends_board": "Sıralamada henüz arkadaş yok", + "no_scores_yet": "Henüz puan yok", + "correct_of": "{count} doğru", + "forfeit_title": "Oyundan çıkılsın mı?", + "forfeit_body": "Bu turdaki ilerlemen kaybolacak.", + "keep_playing": "Oynamaya devam et", + "forfeit_confirm": "Çık", + "correct": "Doğru", + "the_answer": "Cevap", + "watch_full": "Filmin tamamını izle", + "building_round": "Turun hazırlanıyor…", + "round_failed": "Tur başlatılamadı", + "go_back": "Geri dön", + "fan_test_done": "Hayran testi tamamlandı", + "round_done": "Tur tamamlandı", + "score": "Puan", + "rank": "Sıra", + "play_again": "Tekrar oyna", + "share_result": "Sonucu paylaş", + "share_text": "Az önce Sozo’da Buff oynadım!", + "challenge_text": "Filmleri daha iyi bildiğini mi sanıyorsun? Sozo’daki Buff’ta bana meydan oku!", + "share_tagline": "Sozo’da Buff oyna", + "challenge_from": "{name} kişisinden meydan okuma", + "same_clips_head_to_head": "Aynı klipler, kafa kafaya", + "score_to_beat": "Geçilecek puan", + "play_challenge": "Meydan okumayı oyna", + "challenge_not_found": "Meydan okuma bulunamadı", + "no_actors_yet": "Henüz oyuncu yok", + "no_actors_yet_body": "Buff dolduruluyor. Klipler tek tek inceleniyor — oyuncular klipleri onaylandıkça burada görünür.", + "actor_not_ready": "Bu oyuncu henüz hazır değil", + "actor_not_ready_body": "Bir tur için yeterli onaylı klip yok. Başka bir oyuncu dene.", + "round_short_note": "Bu turda {count} klip vardı — bu oyuncu için şu ana kadar onaylanan tüm klipler.", + "todays_top": "Bugünün zirvesi", + "how_it_works": "Buff nasıl çalışır", + "how_step_1": "Bir oyuncu seç", + "how_step_2": "Kısa bir klip izle", + "how_step_3": "Filmin adını söyle" + }, + "outage": { + "body_with_local": "Sozo sunucularına ulaşılamıyor. Cihazındaki {} kaynak çalışmaya devam ediyor.", + "body_no_local": "Sozo sunucularına ulaşılamıyor. API’mize bağlı olmadan gezinmeye devam etmek için bir CloudStream, Aniyomi veya manga kaynağı ekle.", + "pick_source": "Kaynak seç" + }, + "link_tv": { + "title": "TV bağla", + "tile_subtitle": "TV’ndeki QR’ı tara ya da kodunu yaz", + "subtitle": "Televizyonundaki QR kodu tara ya da altında görünen 8 karakterlik kodu yaz.", + "scan_button": "QR kodu tara", + "scan_hint": "Kamerayı TV ekranındaki QR koda doğrult", + "manual_title": "Ya da kodu gir", + "confirm": "Onayla", + "approved": "TV bağlandı", + "approved_named": "“{}” bağlandı", + "approved_hint": "TV birkaç saniye içinde hesabına giriş yapacak.", + "linked_devices": "Bağlı cihazlar", + "no_devices": "Henüz bağlı cihaz yok", + "unnamed_device": "İsimsiz cihaz", + "last_seen": "Son görülme: {}", + "unlink_title": "Cihaz bağlantısını kes", + "unlink_message": "Bu cihazın oturumu kapanacak ve yeniden bağlamak için yeni bir QR kod gerekecek.", + "unlink_confirm": "Bağlantıyı kes", + "camera_denied": "Kamera erişimi reddedildi. Ayarlar’dan izin ver ya da kodu yaz.", + "camera_error": "Kamera başlatılamadı.", + "error_bad_code": "Kod 8 karakter olmalı.", + "error_not_found": "Bu kod bulunamadı. TV’den yenisini al.", + "error_expired": "Kodun süresi doldu. TV’de Yenile’ye bas.", + "error_already_used": "Bu kod zaten kullanılmış.", + "error_unauthorized": "Lütfen önce giriş yap.", + "error_rate_limited": "Çok fazla deneme. Birazdan tekrar dene.", + "error_network": "İnternet bağlantısı yok.", + "error_generic": "Bir şeyler ters gitti." + }, + "anilist": { + "connections_title": "Bağlantılar", + "connect": "AniList’i bağla", + "disconnect": "Bağlantıyı kes", + "disconnect_confirm": "AniList hesabının bağlantısı kesilecek ve eşleştirilen yapımlar unutulacak. Listen AniList’te kalır.", + "not_connected": "Bağlı değil", + "connect_explainer": "AniList’i bağla, izlediğin bölümler otomatik işaretlensin ve listen burada görünsün.", + "connected_explainer": "İzlenen bölümler AniList listene yazılır. Bağlantı hesabında saklanır, bu yüzden TV’n ve diğer cihazların yeniden giriş yapmanı istemez.", + "connect_prompt": "AniList kitaplığını burada görmek için hesabını bağla.", + "upcoming_connect_prompt": "Takip ettiğin dizilerin bir sonraki bölümlerinin ne zaman yayınlanacağını görmek için AniList’i bağla.", + "sign_in_first": "Önce Sozo hesabına giriş yap", + "browser_failed": "Tarayıcı açılamadı", + "open_library": "AniList kitaplığım", + "linked_titles": "Eşleştirilen yapımlar", + "linked_titles_count": "{} yapım eşleştirildi", + "linked_titles_none": "Henüz hiçbir şey eşleştirilmedi", + "unlink": "Eşleşmeyi kaldır", + "unlinked": "{} eşleşmesi kaldırıldı", + "auto_matched": "Otomatik", + "privacy_note": "AniList jetonun Sozo hesabında saklanır. Uygulama yalnızca izlediğin bölümün numarasını yazar.", + "status_label": "DURUM", + "status_current": "İzliyorum", + "status_planning": "Planlıyorum", + "status_completed": "Tamamlandı", + "status_paused": "Duraklatıldı", + "status_dropped": "Bırakıldı", + "status_repeating": "Yeniden izliyorum", + "episodes_watched": "bölüm izlendi", + "empty_status": "“{}” listen boş", + "behind_n": "{} yeni", + "mark_episode_watched": "{}. bölümü izlendi olarak işaretle", + "find_in_sources": "Kaynaklarda bul", + "find_in_sources_hint": "Bu yapımı tüm kaynaklarda ara", + "open_on_anilist": "AniList’te aç", + "refresh": "Yenile", + "retry": "Tekrar dene", + "upcoming_title": "Yakında", + "upcoming_empty": "Takip ettiğin dizilerin hiçbirinin duyurulmuş yeni bölümü yok.", + "today": "Bugün", + "tomorrow": "Yarın", + "episode_n": "{}. bölüm", + "aired": "Yayınlandı", + "in_days": "{} gün sonra", + "in_hours": "{} saat sonra", + "in_minutes": "{} dk sonra", + "link_title": "AniList’e bağla", + "link_hint": "Bu yapımın hangi AniList animesi olduğunu seç.", + "link_search_hint": "Anime adı", + "link_no_results": "Bir şey bulunamadı", + "linked_to": "{} ile eşleştirildi", + "tracking_on": "AniList’te takip ediliyor", + "track_on_anilist": "AniList’te takip et", + "n_episodes_short": "{} bl.", + "open_anilist": "AniList", + "connect_tagline": "İzlediklerini otomatik olarak takip et", + "connect_short": "Bağla", + "calendar_title": "Yayın takvimi", + "calendar_all": "Tümü", + "calendar_mine": "Listem", + "calendar_episode": "{episode}. bölüm", + "calendar_empty": "Bu gün hiçbir şey yayınlanmıyor.", + "calendar_empty_mine": "Bu gün listenden hiçbir şey yayınlanmıyor.", + "calendar_open": "Yayın takvimi", + "calendar_error": "Yayın takvimi yüklenemedi.", + "calendar_rate_limited": "AniList şu an yoğun. Birazdan tekrar dene.", + "calendar_list_error": "AniList listen yüklenemedi.", + "calendar_now": "şimdi", + "calendar_count": "{count} yayın", + "calendar_count_mine": "{total} yayından {mine} tanesi senin", + "calendar_episode_range": "{from}-{to} arası bölümler", + "calendar_add_planning": "Planlıyorum listesine ekle", + "calendar_added_planning": "Planlıyorum listene eklendi", + "calendar_add_failed": "Bu, listene eklenemedi", + "calendar_already_on_list": "Zaten listende", + "browse_title": "Keşfet", + "browse_trending": "Yükselişte", + "browse_season": "Bu sezon", + "browse_popular": "Tüm zamanların popülerleri", + "browse_upcoming": "Yakında", + "browse_empty": "Burada gösterilecek bir şey yok.", + "browse_failed": "AniList’e ulaşılamadı.", + "episodes_count": "{count} bl.", + "reminder_body": "{episode}. bölüm 10 dakika içinde yayınlanıyor", + "reminders_on": "Hatırlatmalar açık — bölüm çıkmadan önce ve çıktığında", + "reminders_off": "Bölüm hatırlatmaları kapalı", + "connect_first": "Önce AniList’i bağla", + "save_failed": "AniList’e kaydedilemedi", + "search_in_source": "TEK BİR KAYNAKTA ARA", + "no_sources": "Henüz kaynak kurulmadı", + "released_body": "{episode}. bölüm çıktı", + "remove_entry": "Listemden çıkar", + "remove_confirm": "“{}” AniList’ten çıkarılsın mı? Ona ait ilerlemen de silinir.", + "removed": "AniList’ten çıkarıldı" + }, + "mal": { + "connect": "MyAnimeList’i bağla", + "disconnect": "MyAnimeList bağlantısını kes", + "disconnect_confirm": "MyAnimeList hesabının bağlantısı kesilecek ve eşleştirilen yapımlar unutulacak. Listen MyAnimeList’te kalır.", + "connect_explainer": "MyAnimeList’i bağla, izlediğin bölümler otomatik işaretlensin.", + "connected_explainer": "İzlenen bölümler MyAnimeList’ine yazılır. Bağlantı hesabında saklanır, bu yüzden TV’n ve diğer cihazların yeniden giriş yapmanı istemez.", + "not_configured": "MyAnimeList sunucuda henüz yapılandırılmadı", + "matching_title": "Yapımlar nasıl eşleştirilir", + "matching_note": "Eşleştirme, aynı kaydın MyAnimeList kimliğini taşıyan AniList üzerinden yapılır. Bir yapımı bir kez bağla, iki takipçi de onu izler.", + "privacy_note": "AniList ve MyAnimeList jetonların Sozo hesabında saklanır. Uygulama yalnızca izlediğin bölümün numarasını yazar.", + "tracking_on": "MyAnimeList’te takip ediliyor", + "save_failed": "MyAnimeList’e kaydedilemedi", + "open_library": "MyAnimeList’im", + "linked_titles": "Eşleştirilen yapımlar", + "connect_prompt": "MyAnimeList’ini burada görmek için hesabını bağla.", + "connect_first": "Önce MyAnimeList’i bağla", + "search_failed": "MyAnimeList’e ulaşılamadı.", + "link_title": "MyAnimeList’e bağla", + "link_hint": "Bu yapımın hangi MyAnimeList animesi olduğunu seç.", + "linked_to": "{} ile eşleştirildi", + "track": "MyAnimeList’te takip et", + "tracked": "MyAnimeList’te takip ediliyor", + "score_label": "PUAN", + "remove_entry": "Listemden çıkar", + "remove_confirm": "“{}” MyAnimeList’inden çıkarılsın mı? Ona ait ilerlemen de silinir.", + "removed": "MyAnimeList’ten çıkarıldı", + "rewatching_note": "Bunu yeniden izliyorsun. Yeniden izleme bitmesin diye Sozo duruma dokunmuyor." + }, + "tracker": { + "title": "Takip", + "tab_following": "Takip edilen", + "tab_upcoming": "Yakında", + "following_empty": "Yeni bölümler çıktığında haber almak için bir diziyi takip et.", + "new_episodes_found": "{} yapımın yeni bölümü var", + "no_new_episodes": "Yeni bölüm yok", + "n_episodes": "{} bölüm", + "not_checked": "Henüz kontrol edilmedi", + "unfollow": "Takibi bırak", + "unfollowed": "{} takipten çıkarıldı", + "undo": "Geri al" + }, + "remote": { + "title": "TV kumandası", + "no_devices": "Henüz bağlı TV yok. Önce TV uygulamasından birini eşleştir.", + "load_failed": "TV’lerin yüklenemedi.", + "tv_offline": "TV çevrimdışı", + "idle": "Hiçbir şey oynamıyor", + "command_failed": "Bu TV’ye ulaşmadı", + "search_on_tv": "TV’DE ARA", + "search_hint": "Buraya yaz, arama TV’de çalışır", + "back": "Geri", + "home": "Ana sayfa", + "open_on_tv": "TV’de oynat", + "sent_to_tv": "{device} cihazına gönderildi", + "connected_to": "{device} cihazına bağlı", + "not_connected_to": "{device} bağlı değil", + "turn_tv_on": "Kontrol etmek için TV’de Sozo’yu aç", + "retry": "Tekrar dene", + "navigation": "GEZİNME", + "playback": "OYNATMA", + "pick_device": "Hangi TV?", + "device_online": "Çevrimiçi", + "device_offline": "Çevrimdışı — üzerinde Sozo’yu aç" + }, + "live_tv": { + "title": "Canlı TV", + "favourites": "Favoriler", + "recent": "Son izlenenler", + "categories": "Kategoriler", + "countries": "Ülkeler", + "results": "Arama sonuçları", + "search_hint": "Kanal adı…", + "search_in": "{folder} içinde ara…", + "no_match": "Bununla eşleşen kanal yok.", + "empty": "Henüz kanal yok.", + "load_failed": "Kanallar yüklenemedi.", + "retry": "Tekrar dene", + "now": "Şimdi", + "next": "Sırada", + "guide": "TV rehberi", + "no_guide": "Bu kanal için rehber yok.", + "watch": "İzle", + "favourite_add": "Favorilere ekle", + "favourite_remove": "Favorilerden çıkar", + "hint_long_press": "Rehber ve favoriler için bir kanala uzun bas.", + "channel_count": { + "one": "{} kanal", + "other": "{} kanal" + }, + "up_next": "SIRADA", + "minutes_left": "{} dk kaldı" + }, + "time": { + "now": "az önce", + "minutes": "{} dk önce", + "hours": "{} sa önce", + "days": "{} g önce" + }, + "user_lists": { + "title": "Listelerim", + "remove": "Kaldır", + "empty_title": "Henüz bir şey kaydedilmedi", + "empty_subtitle": "Kaydettiğin yapımlar burada görünür." + }, + "torrent": { + "title": "Torrentler", + "search_hint": "Torrent ara…", + "empty_hint": "Nyaa, Tokyo Toshokan ve nekoBT’de aynı anda ara.", + "searching": "İzleyicilerde aranıyor…", + "no_results": "Etkin izleyicilerde bir şey bulunamadı", + "no_results_hint": "Daha kısa bir başlık dene ya da filtreleri gevşet.", + "results_n": "{} sonuç", + "seeders": "gönderen", + "leechers": "indiren", + "unknown_swarm": "Sürü büyüklüğü bilinmiyor", + "dead": "Gönderen yok", + "dead_warning": "Bu torrentte gönderen yok, akış olarak izlenemez.", + "trusted": "Güvenilir", + "remake": "Yeniden yükleme", + "filters": "Filtreler", + "category": "Kategori", + "cat_all": "Tümü", + "cat_anime": "Anime", + "cat_english": "İngilizce altyazı", + "cat_non_english": "Diğer diller", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Gerçek çekim", + "sort": "Sırala", + "sort_seeders": "Gönderen sayısı", + "sort_date": "En yeni", + "sort_size": "Boyut", + "sort_downloads": "İndirme sayısı", + "min_seeders": "En az gönderen", + "quality": "Kalite", + "any_quality": "Fark etmez", + "trusted_only": "Yalnızca güvenilir yükleyiciler", + "exclude_hardsubs": "Gömülü altyazılı sürümleri gizle", + "exclude_mini": "Mini kodlamaları gizle", + "exclude_remakes": "Yeniden yüklemeleri gizle", + "require_dual_audio": "Yalnızca çift ses", + "exclude_batches": "Sezon paketlerini gizle", + "trackers": "İzleyiciler", + "reset_filters": "Sıfırla", + "consent_title": "BitTorrent üzerinden izlensin mi?", + "consent_body": "Torrentler eşler arasıdır. Biri oynarken sürüdeki diğer kişiler IP adresini ve ne indirdiğini görebilir, cihazın da onlara veri yükler.\n\nBulunduğun yerde bu önemliyse VPN kullan.", + "consent_accept": "Anladım", + "consent_remember": "Bir daha sorma", + "preparing": "Torrent motoru başlatılıyor…", + "fetching_metadata": "Sürüden dosya listesi alınıyor…", + "metadata_timeout": "Sürüden yanıt yok. Torrentte hiç gönderen kalmamış olabilir.", + "engine_failed": "Torrent motoru başlatılamadı.", + "unsupported": "Torrent akışı yalnızca Android’de var.", + "pick_file": "Bir dosya seç", + "pick_file_hint": "Bu torrentte {} video var.", + "play": "Oynat", + "copy_magnet": "Magnet bağlantısını kopyala", + "magnet_copied": "Magnet bağlantısı kopyalandı", + "open_external": "Torrent uygulamasında aç", + "open_page": "İzleyicide aç", + "stats_peers": "{} eş", + "buffering": "Arabelleğe alınıyor %{}", + "still_searching": "{} izleyici hâlâ yanıt veriyor", + "buffering_title": "Arabelleğe alınıyor…", + "no_video_in_torrent": "Bu torrentte oynatılabilir video yok.", + "tracker_failed": "{} aranamadı — sonuçlar eksik olabilir." + }, + "backup": { + "title": "Yedekle ve geri yükle", + "subtitle": "İzleme geçmişi, listeler, kaynaklar ve ayarlar. Oturumlar dâhil değildir.", + "export": "Yedek oluştur", + "export_desc": "Saklayabileceğin ya da kendine gönderebileceğin bir dosya kaydet", + "restore": "Geri yükle", + "restore_desc": "Bir yedek dosyasını uygulamaya geri oku", + "restore_warning": "Yedekteki değerler bu cihazdakilerin üzerine yazılır. Dosyada olmayan hiçbir şeye dokunulmaz.", + "restored": "{} değer geri yüklendi", + "not_a_backup": "Bu dosya bir Sozo yedeği değil", + "too_new": "Bu yedek uygulamanın daha yeni bir sürümüyle oluşturulmuş", + "export_failed": "Yedek oluşturulamadı", + "restore_failed": "Bu yedek okunamadı", + "file_subject": "Sozo yedeği", + "last_export": "Son yedek: {}", + "never_exported": "Bu cihazda henüz yedek alınmadı." + }, + "provider_test": { + "title": "Kaynakları test et", + "intro": "Gerçek bir arama yapar, bir yapımı açar ve CDN’den ilk baytları ister. Sonuçlar bu cihaz ve bu ağ için, şu an geçerlidir.", + "summary": "{} / {} oynatıldı", + "run_all": "Hepsini test et", + "run_one": "Test et", + "stop": "Durdur", + "stage_catalogue": "Katalog", + "stage_detail": "Yapım", + "stage_stream": "Bağlantı", + "stage_playback": "Oynatma", + "entry": "Kaynakları test et" + }, + "catalog": { + "title": "Dile göre kaynaklar", + "subtitle": "Dört ekosistemdeki tüm eklenti kaynaklarına göz at", + "type_all": "Tümü", + "type_anime": "Anime", + "type_video": "Film ve dizi", + "type_manga": "Manga", + "type_novel": "Roman", + "add_repo": "Depo ekle", + "installed": "{} kaynak {} deposundan eklendi", + "install_empty": "Bu depo kurulabilir bir şey döndürmedi.", + "install_failed": "Kurulum başarısız: {}", + "needs_js": "Dart kaynağı — burada çalıştırılamaz", + "android_only": "Yalnızca Android", + "empty": "Burada henüz bir şey yok. Başka bir dil dene ya da filtreleri temizle.", + "unreachable": "Kaynak kataloğuna ulaşılamadı." + }, + "mode": { + "video": "İzle", + "manga": "Manga", + "novel": "Roman", + "none_installed": "Henüz {} kaynağı yüklenmedi." + }, + "home_rails": { + "title": "Ana sayfa düzeni", + "entry_title": "Ana sayfayı özelleştir", + "entry_subtitle": "Hangi satırların görüneceğini ve sırasını seç", + "hint": "Sıralamak için sürükle. Bir satırı gizlemek için göze dokun — yeri korunur, tekrar açtığında eski yerine döner.", + "hero": "Öne çıkan afiş", + "resume": "İzlemeye devam et", + "genres": "Türler", + "live_tv": "Canlı TV", + "catalogue": "Katalog satırları" + }, + "stats": { + "title": "İstatistikler", + "entry_subtitle": "İzlenen süre, seri, nereye gittiği", + "watched": "İzlendi", + "hours_short": "sa", + "minutes_short": "dk", + "since": "{} tarihinden beri sayılıyor", + "completed": "Tamamlandı", + "streak": "Gün serisi", + "last_days": "Son {} gün", + "by_source": "Kaynağa göre", + "empty_title": "Henüz bir şey sayılmadı", + "empty_subtitle": "Bir şey izle, toplamlar buradan başlasın. Gizli modda izlenenler sayılmaz." + }, + "discord": { + "sign_in": "Discord ile giriş yap", + "sign_in_desc": "Discord’un kendi giriş sayfasında oturum aç. Jeton otomatik alınır ve yalnızca bu cihazda saklanır.", + "or_paste": "ya da jetonu kendin yapıştır", + "web_login_title": "Discord’u bağla", + "web_login_paste": "Jeton yapıştır", + "web_login_paste_title": "Discord jetonunu yapıştır", + "web_login_paste_hint": "Discord kullanıcı jetonun", + "title": "Discord", + "what_it_does": "Discord profilinde ne izlediğini gösterir — yapımı, bölümü ve nerede olduğunu. Arkadaşların bunu arkadaş listelerinde görür.", + "enable": "Discord’da göster", + "enable_desktop_desc": "Bu bilgisayarda Discord uygulamasının açık olması gerekir", + "enable_mobile_desc": "Discord jetonun gerekir — önce aşağıdaki uyarıyı oku", + "connected": "Bağlı", + "not_connected": "Bağlı değil", + "risk_title": "Devam etmeden önce bunu oku", + "risk_body": "Telefonda bağlanılacak bir Discord uygulaması yok, bu yüzden Sozo kendi jetonunla senin adına giriş yapmak zorunda. Discord buna izin vermiyor: hesabın askıya alınabilir. Jeton Discord hesabına tam erişim verir ve Sozo onu yalnızca bu cihazda saklar — asla bir sunucuda değil. İstediğin an Unut ile kaldırabilirsin.", + "token_label": "Discord jetonu", + "token_hint": "Jetonunu yapıştır", + "token_saved": "Kayıtlı bir jeton var — değiştirmek için yenisini yapıştır", + "token_save": "Kaydet", + "token_forget": "Unut", + "brand_title": "Rich Presence", + "brand_subtitle": "Arkadaşların ne izlediğini profilinde canlı olarak görür", + "preview_heading": "İZLİYOR", + "preview_title": "Yapım adı burada görünür", + "preview_subtitle": "Sezon ve bölüm", + "preview_elapsed": "{} geçti", + "preview_elapsed_placeholder": "Geçen süre", + "row_off": "Kapalı", + "row_live": "Profilinde gösteriliyor", + "row_waiting": "Açık — Discord bekleniyor" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — görüntü bu cihazda yeşil veya mor görünebilir", + "atmos": "Atmos ses — bu cihaz sessiz oynatabilir", + "indirect_host": "Bu yansı, video yerine bir sayfa açar", + "uhd": "4K — çözmesi ve indirmesi daha ağır", + "large_file": "Büyük dosya — mobil veride yavaş başlar", + "unreachable": "Bu bağlantı kontrol edildiğinde yanıt vermedi" + }, + "update": { + "available_title": "Yeni sürüm mevcut (v{})", + "available_body": "Uygulamanın yeni bir sürümü mevcut.", + "later": "Daha sonra", + "now": "Güncelle", + "open_store": "App Store’da aç", + "required_title": "Güncelleme gerekli", + "required_body": "Uygulamayı kullanmaya devam etmek için yeni sürümü (v{}) yükle.", + "exit_app": "Uygulamadan çık", + "downloading": "Güncelleme indiriliyor…" + }, + "ext": { + "cloudstream_title": "CloudStream kaynakları", + "aniyomi_title": "Aniyomi kaynakları", + "mangayomi_title": "Mangayomi kaynakları", + "android_only": "Bu özellik yalnızca Android’de kullanılabilir", + "needs_webview": "JavaScript uzantıları WebView gerektirir, bu platformda ise WebView yok.", + "check_updates": "Güncellemeleri denetle", + "check_extension_updates": "Uzantı güncellemelerini denetle", + "installed_sources": "YÜKLÜ KAYNAKLAR", + "installed_repos": "YÜKLÜ DEPOLAR", + "sources_heading": "KAYNAKLAR", + "recommended": "ÖNERİLEN", + "repo_url": "Depo adresi", + "index_url": "Dizin adresi", + "browse_plugins": "Eklentilere göz at", + "no_sources_yet": "Henüz kaynak yok", + "no_repos_yet": "Henüz depo yok", + "add_source": "Kaynak ekle", + "add_repo": "Depo ekle", + "installing": "Yükleniyor…", + "installing_extensions": "Uzantılar yükleniyor…", + "installing_plugins": "Eklentiler yükleniyor…", + "installing_progress": "Yükleniyor {} / {}", + "installing_plugins_progress": "{} / {} eklenti yükleniyor…", + "installing_extensions_progress": "{} / {} uzantı yükleniyor…", + "installing_long": "Uzantılar yükleniyor… büyük depolarda bu biraz sürebilir.", + "checking_updates": "Güncellemeler denetleniyor…", + "checking_extension_updates": "Uzantı güncellemeleri denetleniyor…", + "all_up_to_date": "Tüm uzantılar güncel.", + "updated_count": "{} uzantı güncellendi.", + "updated_list": "{} güncellendi: {}", + "update_check_failed": "Güncelleme denetimi başarısız: {}", + "source_removed": "Kaynak kaldırıldı.", + "repo_removed": "Depo kaldırıldı.", + "none_found": "Bu adreste uzantı bulunamadı.", + "none_found_js": "Bu adreste JavaScript uzantısı bulunamadı.", + "added_sources": "{} kaynak eklendi.", + "added_sources_providers": "{} kaynak · {} sağlayıcı eklendi.", + "installed_from_file": "{} kaynak yüklendi.", + "could_not_install": "Yüklenemedi: {}", + "could_not_install_plugin": "Eklenti yüklenemedi", + "could_not_remove_plugin": "Eklenti kaldırılamadı", + "error": "Hata: {}", + "cloudstream_desc": "Bir CloudStream deposu ekle, sonra yalnızca istediğin eklentileri yükle — sağlayıcı olarak yalnızca onlar görünür. Cihazında çalışırlar (yalnızca Android).", + "aniyomi_desc": "Daha fazla anime sağlayıcısı yüklemek için Aniyomi uzantı depoları ekle. Cihazında çalışırlar (yalnızca Android).", + "mangayomi_desc": "Mangayomi uzantıları Android uygulaması değil, JavaScript’tir — bu yüzden CloudStream, Aniyomi ve Manga kaynaklarının aksine iOS, macOS ve Windows’ta da çalışır.", + "plugins_summary": "{} eklenti · {} yüklü", + "install_all": "Tümünü yükle", + "repo_load_failed": "Bu depo yüklenemedi.", + "no_plugins": "Bu depoda eklenti bulunamadı", + "search_plugins": "Eklenti ara…", + "no_matches": "Eşleşme yok", + "import_title": "Uzantı deposu yüklensin mi?", + "import_pick_library": "Anime ve manga depoları aynı dosya biçimini kullanır, bu yüzden bunun hangi kitaplığa ait olduğunu seç.", + "import_js_only": "Bu platformda yalnızca Mangayomi (JavaScript) uzantıları yüklenebilir — CloudStream, Aniyomi ve Mihon depoları Android uygulama paketleri içerir.", + "import_none_found": "Bu dosyada uzantı bulunamadı. Başka bir uygulamaya ait olabilir ya da yanlış tür seçilmiş olabilir.", + "mangayomi_url_only": "Mangayomi depoları bir adresten yüklenir — indirilmiş bir kopya yerine dizin bağlantısını aç.", + "skipped_dart": "Yalnızca Dart olan {} kaynak atlandı — bunlar Mangayomi’ye gömülüdür ve burada çalışmaz.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Roman" + }, + "ux": { + "quick_search": "Hızlı arama ({})", + "quick_search_note": "Hızlı arama {} kaynağı tarar; toplam {} kaynak vardır. Belirli bir arama için kaynakları seçin.", + "torrents": "Torrentler", + "filters": "Filtreler", + "change_source": "Kaynağı değiştir", + "manage_sources": "Kaynakları yönet", + "no_source_match": "Aramanızla eşleşen kaynak yok.", + "download_options": "İndirme seçenekleri", + "download_options_note": "Mevcut bir kaynak ve kalite seçin. Ses, seçilen kaynağa bağlıdır.", + "source_number": "Kaynak {}", + "checking_quality": "Mevcut kalite kontrol ediliyor…", + "quality_unavailable": "Kalite kontrol edilemedi. Yeniden deneyin veya orijinal yayını kalite garantisi olmadan indirin.", + "quality_unknown": "Kalite bilinmiyor", + "size_unknown": "Kaynak dosya boyutunu belirtmedi", + "interface_language_note": "Yalnızca uygulama arayüzü değişir. Film adları, açıklamalar ve diğer kaynak içerikleri orijinal dilinde kalır.", + "continue": "Devam et" + } +} diff --git a/assets/translations/uz.json b/assets/translations/uz.json index d31c6a7d..a9cfa0ba 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -26,7 +26,15 @@ "off": "O‘chiq", "on": "Yoniq", "new_badge": "YANGI", - "reset": "Tiklash" + "reset": "Tiklash", + "clear": "Tozalash", + "copy": "Nusxalash", + "share": "Ulashish", + "install": "O‘rnatish", + "remove": "Olib tashlash", + "show": "Ko‘rsatish", + "hide": "Yashirish", + "installed": "O‘rnatilgan" }, "auth": { "login": "Kirish", @@ -78,7 +86,8 @@ "strong": "Kuchli", "code_resent": "Kod qayta yuborildi. Emailingizni tekshiring.", "verify": "Tasdiqlash", - "avatar_bad_format": "Rasm formati qabul qilinmaydi (jpg, png, webp)" + "avatar_bad_format": "Rasm formati qabul qilinmaydi (jpg, png, webp)", + "verification_code": "Tasdiqlash kodi" }, "onboarding": { "skip": "O'tkazib yuborish", @@ -348,7 +357,10 @@ "fit": "Moslash", "pip": "Kadr ichida kadr", "layout_pinned_badge": "Qotirilgan", - "info_fields_title": "Info qatorlari" + "info_fields_title": "Info qatorlari", + "logs_title": "Pleyer loglari ({})", + "logs_copied": "Loglar nusxalandi", + "logs_empty": "Hozircha log yo‘q" }, "profile": { "title": "Profil", @@ -369,7 +381,7 @@ "guest_subtitle": "Sevimlilar va tarixni saqlash uchun akkauntingizga kiring.", "auth_provider": "Login turi", "joined": "Qo'shilgan sana", - "providers": "Providers", + "providers": "Manbalar", "providers_error": "Providerlarni yuklab bo'lmadi.", "offline_title": "Server ishlamayapti", "offline_local_available": "Shu qurilmadagi {} ta manba hali ham ishlaydi. Bulutli manbalar server qaytgunicha mavjud emas.", @@ -382,7 +394,7 @@ "connections": "Ulanishlar", "coming_soon": "Tez kunda", "favorites": "Sevimlilar", - "all_providers": "Barcha providerlar", + "all_providers": "Barcha manbalar", "add_favorite": "Sevimlilarga qo'shish", "cloudstream_sources": "CloudStream manbalari", "aniyomi_sources": "Aniyomi manbalari", @@ -391,12 +403,12 @@ "sign_in": "Kirish", "sign_out": "Chiqish", "sign_out_confirm": "Hisobdan chiqmoqchimisiz?", - "section_providers": "MANBALAR", + "section_providers": "Manbalar", "section_content": "KONTENT", "sources_row": "Kengaytma manbalari", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Manba", - "choose_provider": "Manbani tanlang", + "choose_provider": "Manbani almashtirish", "search_providers_hint": "Manbalarni qidirish…", "count_of_total_shown": "{} / {} ko'rsatilmoqda", "no_providers_in_category": "Bu turkumda manbalar yo'q", @@ -513,7 +525,9 @@ "activity_subtitle": "Tarix, yuklamalar, kuzatuv, statistika", "connections_subtitle": "Trackerlar, Discord, TV, desktop", "trackers": "Trackerlar", - "stat_days": "kun ko'rildi" + "stat_days": "kun ko'rildi", + "mangayomi_sources": "Mangayomi manbalari", + "view_only": "FAQAT KO‘RISH" }, "appearance": { "title": "Ko'rinish", @@ -632,7 +646,8 @@ "voice_listening": "Tinglanmoqda…", "voice_done": "Tinglash to‘xtadi", "voice_hint": "Film yoki serial nomini ayting", - "try_torrents": "Torrentlarda qidirish" + "try_torrents": "Torrentlarda qidirish", + "capped_note": "{} ta manbadan eng ishonchli {} tasi qidirildi — aniq bittasiga yetish uchun qamrovni toraytiring." }, "movie": { "rating": "Reyting", @@ -1694,5 +1709,96 @@ "uhd": "4K — dekodlash ham, yuklash ham og'irroq", "large_file": "Katta fayl — mobil internetda sekin boshlanadi", "unreachable": "Bu havola tekshirilganda javob bermadi" + }, + "update": { + "available_title": "Yangi versiya chiqdi (v{})", + "available_body": "Ilovaning yangi versiyasi mavjud.", + "later": "Keyinroq", + "now": "Yangilash", + "open_store": "App Store’da ochish", + "required_title": "Yangilash shart", + "required_body": "Ilovadan foydalanishni davom ettirish uchun yangi versiyani (v{}) o‘rnating.", + "exit_app": "Ilovadan chiqish", + "downloading": "Yangilanish yuklanmoqda…" + }, + "ext": { + "cloudstream_title": "CloudStream manbalari", + "aniyomi_title": "Aniyomi manbalari", + "mangayomi_title": "Mangayomi manbalari", + "android_only": "Bu imkoniyat faqat Android’da mavjud", + "needs_webview": "JavaScript kengaytmalari uchun WebView kerak, bu platformada esa u mavjud emas.", + "check_updates": "Yangilanishlarni tekshirish", + "check_extension_updates": "Kengaytma yangilanishlarini tekshirish", + "installed_sources": "O‘RNATILGAN MANBALAR", + "installed_repos": "O‘RNATILGAN REPOLAR", + "sources_heading": "MANBALAR", + "recommended": "TAVSIYA ETILADI", + "repo_url": "Repo manzili", + "index_url": "Indeks manzili", + "browse_plugins": "Plaginlarni ko‘rish", + "no_sources_yet": "Hozircha manba yo‘q", + "no_repos_yet": "Hozircha repo yo‘q", + "add_source": "Manba qo‘shish", + "add_repo": "Repo qo‘shish", + "installing": "O‘rnatilmoqda…", + "installing_extensions": "Kengaytmalar o‘rnatilmoqda…", + "installing_plugins": "Plaginlar o‘rnatilmoqda…", + "installing_progress": "O‘rnatilmoqda: {} / {}", + "installing_plugins_progress": "{} / {} plagin o‘rnatilmoqda…", + "installing_extensions_progress": "{} / {} kengaytma o‘rnatilmoqda…", + "installing_long": "Kengaytmalar o‘rnatilmoqda… katta repolarda bu biroz vaqt oladi.", + "checking_updates": "Yangilanishlar tekshirilmoqda…", + "checking_extension_updates": "Kengaytma yangilanishlari tekshirilmoqda…", + "all_up_to_date": "Barcha kengaytmalar eng so‘nggi holatda.", + "updated_count": "{} ta kengaytma yangilandi.", + "updated_list": "{} ta yangilandi: {}", + "update_check_failed": "Yangilanishni tekshirib bo‘lmadi: {}", + "source_removed": "Manba olib tashlandi.", + "repo_removed": "Repo olib tashlandi.", + "none_found": "Bu manzilda kengaytma topilmadi.", + "none_found_js": "Bu manzilda JavaScript kengaytmalari topilmadi.", + "added_sources": "{} ta manba qo‘shildi.", + "added_sources_providers": "{} ta manba · {} ta provayder qo‘shildi.", + "installed_from_file": "{} ta manba o‘rnatildi.", + "could_not_install": "O‘rnatib bo‘lmadi: {}", + "could_not_install_plugin": "Plaginni o‘rnatib bo‘lmadi", + "could_not_remove_plugin": "Plaginni olib tashlab bo‘lmadi", + "error": "Xato: {}", + "cloudstream_desc": "CloudStream repo qo‘shing va faqat kerakli plaginlarni o‘rnating — provayder sifatida faqat o‘shalar chiqadi. Ular qurilmangizda ishlaydi (faqat Android).", + "aniyomi_desc": "Qo‘shimcha anime provayderlari uchun Aniyomi kengaytma repolarini qo‘shing. Ular qurilmangizda ishlaydi (faqat Android).", + "mangayomi_desc": "Mangayomi kengaytmalari — JavaScript, Android ilovalari emas. Shuning uchun CloudStream, Aniyomi va Manga manbalaridan farqli o‘laroq, ular iOS, macOS va Windows’da ham ishlaydi.", + "plugins_summary": "{} ta plagin · {} ta o‘rnatilgan", + "install_all": "Hammasini o‘rnatish", + "repo_load_failed": "Bu reponi yuklab bo‘lmadi.", + "no_plugins": "Bu repoda plagin topilmadi", + "search_plugins": "Plaginlarni qidirish…", + "no_matches": "Mos keladigani yo‘q", + "import_title": "Kengaytma reposi o‘rnatilsinmi?", + "import_pick_library": "Anime va manga repolari bir xil fayl formatidan foydalanadi, shuning uchun bu qaysi kutubxonaga tegishli ekanini tanlang.", + "import_js_only": "Bu platformada faqat Mangayomi (JavaScript) kengaytmalarini o‘rnatish mumkin — CloudStream, Aniyomi va Mihon repolari Android ilova paketlarini tarqatadi.", + "import_none_found": "Bu faylda kengaytma topilmadi. U boshqa ilova uchun bo‘lishi yoki turi noto‘g‘ri tanlangan bo‘lishi mumkin.", + "mangayomi_url_only": "Mangayomi repolari manzil orqali o‘rnatiladi — yuklab olingan nusxa emas, indeks havolasini oching.", + "skipped_dart": "{} ta faqat Dart manbasi o‘tkazib yuborildi — ular Mangayomi ichiga o‘rnatilgan va bu yerda ishlamaydi.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Roman" + }, + "ux": { + "quick_search": "Tezkor qidiruv ({})", + "quick_search_note": "Tezkor qidiruv {} ta manbani tekshiradi, jami {} ta. Kerakli manbani alohida tanlashingiz mumkin.", + "torrents": "Torrentlar", + "filters": "Filtrlar", + "change_source": "Manbani almashtirish", + "manage_sources": "Manbalarni boshqarish", + "no_source_match": "Qidiruvga mos manba topilmadi.", + "download_options": "Yuklash parametrlari", + "download_options_note": "Mavjud manba va sifatni tanlang. Audio tanlangan manbaga bog‘liq.", + "source_number": "Manba {}", + "checking_quality": "Mavjud sifatlar tekshirilmoqda…", + "quality_unavailable": "Sifatni tekshirib bo‘lmadi. Qayta urinib ko‘ring yoki asl oqimni sifat kafolatisiz yuklang.", + "quality_unknown": "Sifat noma’lum", + "size_unknown": "Manba fayl hajmini bermagan", + "interface_language_note": "Faqat ilova interfeysi o‘zgaradi. Film nomlari, tavsiflari va manbadagi boshqa kontent asl tilida qoladi.", + "continue": "Davom etish" } } diff --git a/lib/app.dart b/lib/app.dart index 0224d171..3dffe42b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,6 +4,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/core/localization/app_language.dart'; import 'package:soplay/core/navigation/app_tab.dart'; import 'package:soplay/core/navigation/nav_controller.dart'; import 'package:soplay/core/system/platform_utils.dart'; @@ -55,6 +56,11 @@ class _MyAppState extends State { super.initState(); _theme.addListener(_onThemeChanged); getIt().onTap = _handlePushTap; + // After the first frame, because `context.locale` reads an inherited widget + // and initState is too early to depend on one. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) AppLanguage.syncFromDevice(context); + }); // Desktop: hide the custom title-bar strip on the immersive full-bleed // routes (player / reader) by watching the router itself — reliable // regardless of a page's initState timing. diff --git a/lib/core/diagnostics/log_viewer_sheet.dart b/lib/core/diagnostics/log_viewer_sheet.dart index bcdb2c48..e7abd972 100644 --- a/lib/core/diagnostics/log_viewer_sheet.dart +++ b/lib/core/diagnostics/log_viewer_sheet.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart'; @@ -57,7 +58,10 @@ class _LogViewerSheetState extends State { await Clipboard.setData(ClipboardData(text: _log.formatForShare())); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Logs copied'), duration: Duration(seconds: 1)), + SnackBar( + content: Text('player.logs_copied'.tr()), + duration: const Duration(seconds: 1), + ), ); } @@ -98,19 +102,19 @@ class _LogViewerSheetState extends State { ), const Spacer(), IconButton( - tooltip: 'Copy', + tooltip: 'general.copy'.tr(), icon: const Icon(Icons.copy_rounded, color: Colors.white70, size: 20), onPressed: lines.isEmpty ? null : _copy, ), IconButton( - tooltip: 'Share', + tooltip: 'general.share'.tr(), icon: const Icon(Icons.ios_share_rounded, color: Colors.white70, size: 20), onPressed: lines.isEmpty ? null : _share, ), IconButton( - tooltip: 'Clear', + tooltip: 'general.clear'.tr(), icon: const Icon(Icons.delete_outline_rounded, color: Colors.white70, size: 20), onPressed: lines.isEmpty ? null : _log.clear, @@ -121,10 +125,10 @@ class _LogViewerSheetState extends State { const Divider(color: Colors.white12, height: 1), Flexible( child: lines.isEmpty - ? const Padding( - padding: EdgeInsets.symmetric(vertical: 48), - child: Text('No logs yet', - style: TextStyle(color: Colors.white38)), + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Text('player.logs_empty'.tr(), + style: const TextStyle(color: Colors.white38)), ) : Scrollbar( controller: _scroll, diff --git a/lib/core/localization/app_language.dart b/lib/core/localization/app_language.dart new file mode 100644 index 00000000..05d187ce --- /dev/null +++ b/lib/core/localization/app_language.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/notifications/data/services/notification_service.dart'; + +/// The app's UI language, and the only place it is written. +/// +/// ## Why this exists +/// +/// The language used to live in two places that never spoke to each other. +/// `context.setLocale()` persists through `easy_localization`'s own storage and +/// is what the interface reads; `HiveService.saveLanguage()` is what the +/// subtitle translator falls back to and what the backend is told for push copy. +/// Only the settings screen wrote the second one, and nothing wrote it at +/// startup — so a German phone showed a German interface while Hive still said +/// `en`, and that user's subtitles and notifications quietly stayed English. +/// +/// Both writes now go through [set], and [syncFromDevice] repairs accounts that +/// were already in the split state. +abstract final class AppLanguage { + /// Native names on purpose: a person looking for their own language finds it + /// fastest written the way they write it. + static const names = { + 'en': 'English', + 'uz': "O'zbekcha", + 'ru': 'Русский', + 'ar': 'العربية', + 'de': 'Deutsch', + 'nl': 'Nederlands', + 'es': 'Español', + 'pt': 'Português', + 'fr': 'Français', + 'tr': 'Türkçe', + 'id': 'Bahasa Indonesia', + }; + + static String labelOf(String code) => names[code] ?? code; + + static const flags = { + 'en': '🇬🇧', + 'uz': '🇺🇿', + 'ru': '🇷🇺', + 'ar': '🇸🇦', + 'de': '🇩🇪', + 'nl': '🇳🇱', + 'es': '🇪🇸', + 'pt': '🇵🇹', + 'fr': '🇫🇷', + 'tr': '🇹🇷', + 'id': '🇮🇩', + }; + + static String flagOf(String code) => flags[code] ?? '🌐'; + + /// Switches the interface, and everything downstream of it. + static Future set(BuildContext context, String code) async { + if (code != context.locale.languageCode) { + await context.setLocale(Locale(code)); + } + await getIt().saveLanguage(code); + // Push copy is written server-side, so the server has to be told. Fire and + // forget: a language change must not wait on the network, and the next + // launch re-registers anyway. + unawaited(getIt().refreshRegistration()); + } + + /// Points Hive at the locale `easy_localization` actually resolved. + /// + /// Called once at startup. On a first launch the resolved locale comes from + /// the device, not from anything the user picked, and Hive's own default is + /// the string `'en'` — which is a real answer, not an absent one, so nothing + /// downstream can tell "English" from "never asked". + static Future syncFromDevice(BuildContext context) async { + final hive = getIt(); + final active = context.locale.languageCode; + if (hive.getLanguage() == active) return; + await hive.saveLanguage(active); + } +} diff --git a/lib/core/localization/language_picker.dart b/lib/core/localization/language_picker.dart new file mode 100644 index 00000000..03cbfb7c --- /dev/null +++ b/lib/core/localization/language_picker.dart @@ -0,0 +1,218 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/localization/app_language.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// First-run choice, after the introduction and before entering the app/auth. +Future confirmIntroLanguage(BuildContext context) async { + final code = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const IntroLanguagePage())); + if (code == null || !context.mounted) return false; + await AppLanguage.set(context, code); + return true; +} + +class IntroLanguagePage extends StatefulWidget { + const IntroLanguagePage({super.key}); + + @override + State createState() => _IntroLanguagePageState(); +} + +class _IntroLanguagePageState extends State { + String? _selected; + + @override + Widget build(BuildContext context) { + final selected = _selected ?? context.locale.languageCode; + return PopScope( + canPop: true, + child: Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(8, 8, 20, 0), + child: Row( + children: [ + IconButton( + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back_rounded), + ), + const Spacer(), + Text( + 'SOZO', + style: TextStyle( + color: AppColors.primary, + fontSize: 18, + fontWeight: FontWeight.w900, + letterSpacing: 2.2, + ), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + itemCount: context.supportedLocales.length + 1, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 8, + 8, + 8, + 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'profile.language'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 26, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + 'ux.interface_language_note'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + height: 1.4, + ), + ), + ], + ), + ); + } + final locale = context.supportedLocales[index - 1]; + return _LanguageRow( + code: locale.languageCode, + selected: selected == locale.languageCode, + onTap: () => + setState(() => _selected = locale.languageCode), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: FilledButton( + onPressed: () => Navigator.pop(context, selected), + child: Text('ux.continue'.tr()), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Lists every language the app ships, in its own name. +Future showLanguageSheet(BuildContext context) { + return showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(20, 4, 20, 12), + child: Text( + 'profile.language'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + // Scrolls rather than sizing to eleven rows: on a short phone in + // landscape the full list is taller than the sheet is allowed to be. + Flexible( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 8), + children: [ + for (final locale in context.supportedLocales) + _LanguageRow( + code: locale.languageCode, + selected: + locale.languageCode == context.locale.languageCode, + // The sheet's own context is popped; the change is applied + // through the page's, which outlives it. + onTap: () async { + Navigator.of(sheetContext).pop(); + await AppLanguage.set(context, locale.languageCode); + }, + ), + ], + ), + ), + ], + ), + ), + ); +} + +class _LanguageRow extends StatelessWidget { + const _LanguageRow({ + required this.code, + required this.selected, + required this.onTap, + }); + + final String code; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + minTileHeight: 56, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + tileColor: selected + ? AppColors.primary.withValues(alpha: 0.10) + : Colors.transparent, + leading: Text( + AppLanguage.flagOf(code), + semanticsLabel: AppLanguage.labelOf(code), + style: const TextStyle(fontSize: 26), + ), + title: Text( + AppLanguage.labelOf(code), + style: TextStyle( + color: selected ? AppColors.primary : AppColors.textPrimary, + fontSize: 15, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + trailing: selected + ? Icon(Icons.check_rounded, color: AppColors.primary, size: 20) + : null, + ); + } +} diff --git a/lib/core/navigation/app_tab.dart b/lib/core/navigation/app_tab.dart index 032ea903..78ba4263 100644 --- a/lib/core/navigation/app_tab.dart +++ b/lib/core/navigation/app_tab.dart @@ -53,7 +53,7 @@ Widget _shortsBuilder(TabBuildContext c) => ShortsPage(active: c.isActive, refreshTick: c.shortsRefreshTick); Widget _myListBuilder(TabBuildContext _) => const MyListPage(); Widget _profileBuilder(TabBuildContext _) => const ProfilePage(); -Widget _downloadsBuilder(TabBuildContext _) => const DownloadsPage(); +Widget _downloadsBuilder(TabBuildContext _) => const DownloadsPage(isTab: true); Widget _historyBuilder(TabBuildContext _) => const HistoryPage(); Widget _followingBuilder(TabBuildContext _) => const FollowingPage(); Widget _buffBuilder(TabBuildContext _) => const BuffHubPage(); diff --git a/lib/core/player/hls_variants.dart b/lib/core/player/hls_variants.dart new file mode 100644 index 00000000..c5de46db --- /dev/null +++ b/lib/core/player/hls_variants.dart @@ -0,0 +1,48 @@ +/// Reading the renditions out of an HLS master playlist. +/// +/// Shared because two places need the same answer and disagreed: the player +/// built its quality menu from the master, while the downloader took whichever +/// variant happened to be listed first. Packagers commonly list the lowest +/// bitrate first — a client is meant to start conservatively and adapt — so +/// "first" meant a film downloaded at 480p while the player was happily showing +/// 1080p of the very same stream. +library; + +/// One rendition: the height a viewer would call it, and where it lives. +typedef HlsVariant = ({int height, String url}); + +final _namedHeight = RegExp(r'[-_/]s(\d{3,4})p\b'); +final _resolution = RegExp(r'RESOLUTION=\d+x(\d+)'); + +/// Every rendition in [playlist], best first, resolved against [base]. +/// +/// Returns empty for a media playlist — one with segments rather than +/// `#EXT-X-STREAM-INF` — which is the caller's signal that the url it has is +/// already the thing to download. +List parseHlsVariants(String playlist, Uri base) { + final out = []; + final lines = playlist.split(RegExp(r'\r?\n')); + for (var i = 0; i < lines.length - 1; i++) { + final tag = lines[i].trim(); + if (!tag.startsWith('#EXT-X-STREAM-INF')) continue; + final uri = lines[i + 1].trim(); + if (uri.isEmpty || uri.startsWith('#')) continue; + + // The packager's own name for the variant beats RESOLUTION, because the two + // disagree more often than not: a 2.40:1 film encoded at 1080p carries + // RESOLUTION=1920x800, and naming it after the pixel height would offer + // `800p` for the stream every other player calls 1080p. The `s1080p` token + // in `index-s1080p-v1-a1` is what the packager called it. + final named = _namedHeight.firstMatch(uri); + final res = _resolution.firstMatch(tag); + final height = int.tryParse(named?.group(1) ?? res?.group(1) ?? '') ?? 0; + if (height <= 0) continue; + + out.add((height: height, url: base.resolve(uri).toString())); + } + // Best first. A master often carries one resolution twice at different + // bitrates; callers that show a list dedupe by height, callers that just want + // the best do not care which of the two they get. + out.sort((a, b) => b.height.compareTo(a.height)); + return out; +} diff --git a/lib/core/player/source_ladder.dart b/lib/core/player/source_ladder.dart index 5550a08f..139d75bd 100644 --- a/lib/core/player/source_ladder.dart +++ b/lib/core/player/source_ladder.dart @@ -1,4 +1,5 @@ import 'package:soplay/features/detail/data/title_prefs_store.dart'; +import 'package:soplay/features/detail/domain/video_option_groups.dart'; import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; /// Which mirror to play, and which one to try after that. @@ -74,8 +75,19 @@ class SourceLadder { /// 1. the remembered quality label — an explicit past choice outranks /// everything below it; /// 2. the source's own default; - /// 3. the order the backend returned, which is already ranked. - /// A codec being avoided sinks below all three without leaving the list. + /// 3. the higher resolution; + /// 4. the order the backend returned. + /// A codec being avoided sinks below all four without leaving the list. + /// + /// Rule 3 used not to exist, on the assumption recorded here that the backend + /// order is already ranked. It is for most providers — anilibria lists 1080p + /// first, animedia marks its highest as the default — but asilmedia and + /// animefenix mark no default at all and emit sources in page order, which on + /// asilmedia is a 360p ahead of a 720p. So a film opened from those played, + /// and downloaded, at 360p with a 720p sitting in the same list. + /// + /// Only a tiebreak: an explicit past choice and a provider's own default both + /// still win, because both are somebody's decision and this is a guess. List ordered() { final candidates = []; for (var i = 0; i < sources.length; i++) { @@ -94,10 +106,17 @@ class SourceLadder { return 2; } - // Stable: equal ranks keep the backend's order, which is the third rule. + // Zero where the label carries no resolution — "Server 1", "Auto", a host + // name. Those compare equal to each other and keep the backend's order, + // which is the only thing known about them. + int height(int i) => VideoOptionGroups.resolutionOf(sources[i].quality) ?? 0; + candidates.sort((a, b) { final byRank = rank(a).compareTo(rank(b)); - return byRank != 0 ? byRank : a.compareTo(b); + if (byRank != 0) return byRank; + final byHeight = height(b).compareTo(height(a)); + // Stable at the end: equal rank and equal resolution keep backend order. + return byHeight != 0 ? byHeight : a.compareTo(b); }); return candidates; } diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 5cf77089..9bcae002 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -73,13 +73,14 @@ class AppColors { /// flash rather than a colour choice. static const Color splashBackground = Color(0xFF000000); - static const Color textPrimary = Color(0xFFFFFFFF); + static const Color textPrimary = Color(0xFFFFFFFF); static const Color textSecondary = Color(0xFFAAAAAA); - static const Color textHint = Color(0xFF666666); + // Still secondary, but readable on the darkest AND lightest dark surfaces. + static const Color textHint = Color(0xFFA0A0A0); /// Deliberately NOT the accent. Kept at the original red so a destructive /// action still reads as destructive under a green or blue accent. - static const Color error = Color(0xFFE50914); + static const Color error = Color(0xFFE50914); /// The lifted partner to [error], for a rim or a label that has to sit on an /// error fill without collapsing into it. @@ -88,16 +89,16 @@ class AppColors { /// setting, the "wrong answer" and "failed" states borrowed `primaryLight` /// because it happened to be red; pointing them here keeps them pixel-identical /// on the default theme while stopping them following a green accent. - static const Color errorLight = Color(0xFFFF4B55); - static const Color success = Color(0xFF46D369); - static const Color rating = Color(0xFFFFD700); + static const Color errorLight = Color(0xFFFF4B55); + static const Color success = Color(0xFF46D369); + static const Color rating = Color(0xFFFFD700); // Podium medals. Gold points at the existing rating gold on purpose. - static const Color medalGold = rating; - static const Color medalSilver = Color(0xFFC0C0C0); - static const Color medalBronze = Color(0xFFCD7F32); + static const Color medalGold = rating; + static const Color medalSilver = Color(0xFFC0C0C0); + static const Color medalBronze = Color(0xFFCD7F32); - static const Color overlay = Color(0x99000000); + static const Color overlay = Color(0x99000000); static const Color gradientTransparent = Color(0x00000000); static const Color gradientBlack = Color(0xFF000000); } diff --git a/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart b/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart index 75df98ba..f24b1b36 100644 --- a/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart +++ b/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -37,8 +38,9 @@ class _AniyomiSourcesPageState extends State { if (!mounted || !_busy) return; setState(() { _status = p.total > 0 - ? 'Installing ${p.current} / ${p.total} extensions…' - : 'Installing extensions…'; + ? 'ext.installing_extensions_progress' + .tr(args: ['${p.current}', '${p.total}']) + : 'ext.installing_extensions'.tr(); _statusError = false; }); }); @@ -76,7 +78,7 @@ class _AniyomiSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'Installing extensions… this can take a moment for large repos.'; + _status = 'ext.installing_long'.tr(); }); try { final res = await AniyomiChannel.addRepo(input); @@ -92,14 +94,15 @@ class _AniyomiSourcesPageState extends State { setState(() { _statusError = count == 0; _status = count == 0 - ? 'No extensions found at that URL.' - : 'Added $count source(s) · $providers provider(s).'; + ? 'ext.none_found'.tr() + : 'ext.added_sources_providers' + .tr(args: ['$count', '$providers']); }); } catch (e) { if (!mounted) return; setState(() { _statusError = true; - _status = 'Error: $e'; + _status = 'ext.error'.tr(args: ['$e']); }); } finally { if (mounted) setState(() => _busy = false); @@ -115,7 +118,7 @@ class _AniyomiSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'Checking for extension updates…'; + _status = 'ext.checking_extension_updates'.tr(); }); try { final res = await AniyomiChannel.checkUpdates(); @@ -125,14 +128,14 @@ class _AniyomiSourcesPageState extends State { setState(() { _statusError = false; _status = count == 0 - ? 'All extensions are up to date.' - : 'Updated $count extension(s).'; + ? 'ext.all_up_to_date'.tr() + : 'ext.updated_count'.tr(args: ['$count']); }); } catch (e) { if (!mounted) return; setState(() { _statusError = true; - _status = 'Update check failed: $e'; + _status = 'ext.update_check_failed'.tr(args: ['$e']); }); } finally { if (mounted) setState(() => _busy = false); @@ -147,7 +150,7 @@ class _AniyomiSourcesPageState extends State { if (!mounted) return; setState(() { _statusError = false; - _status = 'Source removed.'; + _status = 'ext.source_removed'.tr(); }); } @@ -155,9 +158,9 @@ class _AniyomiSourcesPageState extends State { Widget build(BuildContext context) { if (!AniyomiChannel.isSupported) { return Scaffold( - appBar: AppBar(title: const Text('Aniyomi Sources')), - body: const Center( - child: Text('This feature is only available on Android'), + appBar: AppBar(title: Text('ext.aniyomi_title'.tr())), + body: Center( + child: Text('ext.android_only'.tr()), ), ); } @@ -168,10 +171,10 @@ class _AniyomiSourcesPageState extends State { surfaceTintColor: Colors.transparent, scrolledUnderElevation: 0, elevation: 0, - title: const Text('Aniyomi Sources'), + title: Text('ext.aniyomi_title'.tr()), actions: [ IconButton( - tooltip: 'Check for extension updates', + tooltip: 'ext.check_extension_updates'.tr(), icon: const Icon(Icons.system_update_alt_rounded), onPressed: (_busy || _repos.isEmpty) ? null : _checkUpdates, ), @@ -199,7 +202,7 @@ class _AniyomiSourcesPageState extends State { const SizedBox(height: 24), Row( children: [ - Text('INSTALLED SOURCES', + Text('ext.installed_sources'.tr(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppColors.textHint, letterSpacing: 1)), const Spacer(), @@ -236,11 +239,10 @@ class _AniyomiSourcesPageState extends State { children: [ _logoBox(44, radius: 11), const SizedBox(width: 12), - const Expanded( + Expanded( child: Text( - 'Add Aniyomi extension repositories to install extra anime ' - 'providers. They run on your device (Android only).', - style: TextStyle( + 'ext.aniyomi_desc'.tr(), + style: const TextStyle( color: AppColors.textHint, fontSize: 12.5, height: 1.35), ), ), @@ -263,7 +265,7 @@ class _AniyomiSourcesPageState extends State { style: const TextStyle(color: Colors.white), onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: 'Repo URL', + labelText: 'ext.repo_url'.tr(), labelStyle: const TextStyle(color: AppColors.textHint), floatingLabelStyle: const TextStyle(color: _accent), hintText: 'https://…/index.min.json', @@ -283,7 +285,7 @@ class _AniyomiSourcesPageState extends State { ? null : IconButton( icon: const Icon(Icons.clear, color: AppColors.textHint), - tooltip: 'Clear', + tooltip: 'general.clear'.tr(), onPressed: _busy ? null : () => setState(() => _controller.clear()), @@ -307,7 +309,7 @@ class _AniyomiSourcesPageState extends State { child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : const Icon(Icons.add), - label: Text(_busy ? 'Installing…' : 'Add source'), + label: Text(_busy ? 'ext.installing'.tr() : 'ext.add_source'.tr()), ), ), ], @@ -350,11 +352,13 @@ class _AniyomiSourcesPageState extends State { color: AppColors.surface, borderRadius: BorderRadius.circular(14), ), - child: const Column( + child: Column( children: [ - Icon(Icons.cloud_off_outlined, color: AppColors.textHint, size: 32), - SizedBox(height: 8), - Text('No sources yet', style: TextStyle(color: AppColors.textHint)), + const Icon(Icons.cloud_off_outlined, + color: AppColors.textHint, size: 32), + const SizedBox(height: 8), + Text('ext.no_sources_yet'.tr(), + style: const TextStyle(color: AppColors.textHint)), ], ), ); diff --git a/lib/features/app_updater/presentation/pages/force_update_page.dart b/lib/features/app_updater/presentation/pages/force_update_page.dart index 8131aa63..b4ffc6a6 100644 --- a/lib/features/app_updater/presentation/pages/force_update_page.dart +++ b/lib/features/app_updater/presentation/pages/force_update_page.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -25,7 +26,9 @@ class ForceUpdatePage extends StatelessWidget { final accent = Platform.isIOS ? _iosAccent : _androidAccent; final hasAction = Platform.isAndroid || (check.storeUrl != null && check.storeUrl!.isNotEmpty); - final actionLabel = Platform.isIOS ? 'Open in App Store' : 'Update'; + final actionLabel = Platform.isIOS + ? 'update.open_store'.tr() + : 'update.now'.tr(); return PopScope( canPop: false, @@ -51,10 +54,10 @@ class ForceUpdatePage extends StatelessWidget { ), ), const SizedBox(height: 24), - const Text( - 'Update required', + Text( + 'update.required_title'.tr(), textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w800, @@ -62,7 +65,7 @@ class ForceUpdatePage extends StatelessWidget { ), const SizedBox(height: 12), Text( - 'Please install the new version (v${check.version}) to continue using the app.', + 'update.required_body'.tr(args: ['${check.version}']), textAlign: TextAlign.center, style: const TextStyle( color: AppColors.textSecondary, @@ -109,9 +112,9 @@ class ForceUpdatePage extends StatelessWidget { const SizedBox(height: 12), TextButton( onPressed: () => SystemNavigator.pop(), - child: const Text( - 'Exit app', - style: TextStyle(color: AppColors.textHint), + child: Text( + 'update.exit_app'.tr(), + style: const TextStyle(color: AppColors.textHint), ), ), ], diff --git a/lib/features/app_updater/presentation/widgets/install_progress_dialog.dart b/lib/features/app_updater/presentation/widgets/install_progress_dialog.dart index 3503dfb8..84c656cd 100644 --- a/lib/features/app_updater/presentation/widgets/install_progress_dialog.dart +++ b/lib/features/app_updater/presentation/widgets/install_progress_dialog.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -51,9 +52,9 @@ void showInstallProgressDialog( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Downloading update...', - style: TextStyle( + Text( + 'update.downloading'.tr(), + style: const TextStyle( color: AppColors.textPrimary, fontWeight: FontWeight.w700, fontSize: 15, diff --git a/lib/features/app_updater/presentation/widgets/update_dialog.dart b/lib/features/app_updater/presentation/widgets/update_dialog.dart index fef0568f..ebc5e3a0 100644 --- a/lib/features/app_updater/presentation/widgets/update_dialog.dart +++ b/lib/features/app_updater/presentation/widgets/update_dialog.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/app_updater/domain/entities/app_version_check.dart'; @@ -37,7 +38,7 @@ class _MaterialUpdateDialog extends StatelessWidget { backgroundColor: AppColors.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), title: Text( - 'New version available (v${check.version})', + 'update.available_title'.tr(args: ['${check.version}']), style: const TextStyle( color: AppColors.textPrimary, fontWeight: FontWeight.w700, @@ -45,24 +46,27 @@ class _MaterialUpdateDialog extends StatelessWidget { ), content: SingleChildScrollView( child: (check.releaseNotes ?? '').trim().isEmpty - ? const Text( - 'A new version of the app is available.', - style: TextStyle(color: AppColors.textSecondary, height: 1.4), + ? Text( + 'update.available_body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + height: 1.4, + ), ) : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text( - 'Later', - style: TextStyle(color: AppColors.textSecondary), + child: Text( + 'update.later'.tr(), + style: const TextStyle(color: AppColors.textSecondary), ), ), FilledButton( style: FilledButton.styleFrom(backgroundColor: _androidAccent), onPressed: () => Navigator.of(context).pop(true), - child: const Text('Update'), + child: Text('update.now'.tr()), ), ], ); @@ -77,31 +81,31 @@ class _CupertinoUpdateDialog extends StatelessWidget { Widget build(BuildContext context) { final hasStore = check.storeUrl != null && check.storeUrl!.isNotEmpty; return CupertinoAlertDialog( - title: Text('New version available (v${check.version})'), + title: Text('update.available_title'.tr(args: ['${check.version}'])), content: Padding( padding: const EdgeInsets.only(top: 8), child: (check.releaseNotes ?? '').trim().isEmpty - ? const Text('A new version of the app is available.') + ? Text('update.available_body'.tr()) : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), ), actions: [ if (hasStore) CupertinoDialogAction( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Later'), + child: Text('update.later'.tr()), ), if (hasStore) CupertinoDialogAction( isDefaultAction: true, textStyle: const TextStyle(color: _iosAccent), onPressed: () => Navigator.of(context).pop(true), - child: const Text('Open in App Store'), + child: Text('update.open_store'.tr()), ) else CupertinoDialogAction( isDefaultAction: true, onPressed: () => Navigator.of(context).pop(false), - child: const Text('OK'), + child: Text('general.ok'.tr()), ), ], ); diff --git a/lib/features/auth/presentation/pages/otp_verify_page.dart b/lib/features/auth/presentation/pages/otp_verify_page.dart index 00437321..2d04216e 100644 --- a/lib/features/auth/presentation/pages/otp_verify_page.dart +++ b/lib/features/auth/presentation/pages/otp_verify_page.dart @@ -214,10 +214,10 @@ class _OtpVerifyPageState extends State ), ), const SizedBox(height: 22), - const Text( - 'Verification code', + Text( + 'auth.verification_code'.tr(), textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( color: AppColors.textPrimary, fontSize: 24, fontWeight: FontWeight.w800, diff --git a/lib/features/auth/presentation/widgets/auth_widgets.dart b/lib/features/auth/presentation/widgets/auth_widgets.dart index ee6a0aa0..384ebb4d 100644 --- a/lib/features/auth/presentation/widgets/auth_widgets.dart +++ b/lib/features/auth/presentation/widgets/auth_widgets.dart @@ -354,11 +354,8 @@ class AuthPrimaryButton extends StatelessWidget { final bool loading; @override - Widget build(BuildContext context) => AppPrimaryButton( - label: label, - onPressed: onPressed, - loading: loading, - ); + Widget build(BuildContext context) => + AppPrimaryButton(label: label, onPressed: onPressed, loading: loading); } class AuthDivider extends StatelessWidget { @@ -396,8 +393,8 @@ class GoogleAuthButton extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - height: 52, + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), child: ElevatedButton.icon( onPressed: loading ? null : onPressed, style: ElevatedButton.styleFrom( diff --git a/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart b/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart index 1cc1d889..78fe6ef5 100644 --- a/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart +++ b/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -98,7 +99,9 @@ class _CloudStreamPluginsPageState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - installed ? 'Could not remove plugin' : 'Could not install plugin', + installed + ? 'ext.could_not_remove_plugin'.tr() + : 'ext.could_not_install_plugin'.tr(), ), behavior: SnackBarBehavior.floating, ), @@ -187,7 +190,9 @@ class _CloudStreamPluginsPageState extends State { ), if (!_loading && _error == null) Text( - '${_plugins.length} plugins · $_installedCount installed', + 'ext.plugins_summary'.tr( + args: ['${_plugins.length}', '$_installedCount'], + ), style: const TextStyle(fontSize: 11, color: AppColors.textHint), ), ], @@ -202,7 +207,7 @@ class _CloudStreamPluginsPageState extends State { height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Install all'), + : Text('ext.install_all'.tr()), ), ], ), @@ -225,22 +230,22 @@ class _CloudStreamPluginsPageState extends State { color: AppColors.textHint, size: 36), const SizedBox(height: 10), Text( - 'Could not load this repo.\n$_error', + '${'ext.repo_load_failed'.tr()}\n$_error', textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textHint), ), const SizedBox(height: 14), - FilledButton(onPressed: _load, child: const Text('Retry')), + FilledButton(onPressed: _load, child: Text('general.retry'.tr())), ], ), ), ); } if (_plugins.isEmpty) { - return const Center( + return Center( child: Text( - 'No plugins found in this repo', - style: TextStyle(color: AppColors.textHint), + 'ext.no_plugins'.tr(), + style: const TextStyle(color: AppColors.textHint), ), ); } @@ -254,7 +259,7 @@ class _CloudStreamPluginsPageState extends State { onChanged: (v) => setState(() => _query = v), decoration: InputDecoration( isDense: true, - hintText: 'Search plugins…', + hintText: 'ext.search_plugins'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), prefixIcon: const Icon(Icons.search, color: AppColors.textHint, size: 20), @@ -270,9 +275,9 @@ class _CloudStreamPluginsPageState extends State { if (_installingAll) _installBanner(), Expanded( child: items.isEmpty - ? const Center( - child: Text('No matches', - style: TextStyle(color: AppColors.textHint)), + ? Center( + child: Text('ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint)), ) : ListView.builder( padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), @@ -307,8 +312,10 @@ class _CloudStreamPluginsPageState extends State { const SizedBox(width: 10), Expanded( child: Text( - 'Installing $done / $total' - '${_currentInstalling != null ? ' · $_currentInstalling' : ''}', + 'ext.installing_progress'.tr(args: ['$done', '$total']) + + (_currentInstalling != null + ? ' · $_currentInstalling' + : ''), maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, @@ -382,7 +389,7 @@ class _CloudStreamPluginsPageState extends State { ) : IconButton( onPressed: () => _toggle(p), - tooltip: installed ? 'Remove' : 'Install', + tooltip: installed ? 'general.remove'.tr() : 'general.install'.tr(), icon: Icon( installed ? Icons.check_circle diff --git a/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart b/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart index 09899a16..c7450d41 100644 --- a/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart +++ b/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -37,8 +38,9 @@ class _CloudStreamSourcesPageState extends State { if (!mounted || !_busy) return; setState(() { _status = p.total > 0 - ? 'Installing ${p.current} / ${p.total} plugins…' - : 'Installing plugins…'; + ? 'ext.installing_plugins_progress' + .tr(args: ['${p.current}', '${p.total}']) + : 'ext.installing_plugins'.tr(); _statusError = false; }); }); @@ -95,7 +97,7 @@ class _CloudStreamSourcesPageState extends State { if (!mounted) return; setState(() { _statusError = false; - _status = 'Source removed.'; + _status = 'ext.source_removed'.tr(); }); } @@ -104,7 +106,7 @@ class _CloudStreamSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'Checking for updates…'; + _status = 'ext.checking_updates'.tr(); }); try { final res = await CloudStreamChannel.checkUpdates(); @@ -118,15 +120,18 @@ class _CloudStreamSourcesPageState extends State { setState(() { _statusError = false; _status = updated.isEmpty - ? 'All extensions are up to date.' - : 'Updated ${updated.length}: ' - '${updated.take(6).join(', ')}${updated.length > 6 ? '…' : ''}'; + ? 'ext.all_up_to_date'.tr() + : 'ext.updated_list'.tr(args: [ + '${updated.length}', + '${updated.take(6).join(', ')}' + '${updated.length > 6 ? '…' : ''}', + ]); }); } catch (e) { if (!mounted) return; setState(() { _statusError = true; - _status = 'Error: $e'; + _status = 'ext.error'.tr(args: ['$e']); }); } finally { if (mounted) setState(() => _busy = false); @@ -137,9 +142,9 @@ class _CloudStreamSourcesPageState extends State { Widget build(BuildContext context) { if (!CloudStreamChannel.isSupported) { return Scaffold( - appBar: AppBar(title: const Text('CloudStream Sources')), - body: const Center( - child: Text('This feature is only available on Android'), + appBar: AppBar(title: Text('ext.cloudstream_title'.tr())), + body: Center( + child: Text('ext.android_only'.tr()), ), ); } @@ -150,7 +155,7 @@ class _CloudStreamSourcesPageState extends State { surfaceTintColor: Colors.transparent, scrolledUnderElevation: 0, elevation: 0, - title: const Text('CloudStream Sources'), + title: Text('ext.cloudstream_title'.tr()), actions: [ PopupMenuButton( enabled: !_busy && _repos.isNotEmpty, @@ -159,16 +164,16 @@ class _CloudStreamSourcesPageState extends State { onSelected: (v) { if (v == 'updates') _checkUpdates(); }, - itemBuilder: (_) => const [ + itemBuilder: (_) => [ PopupMenuItem( value: 'updates', child: Row( children: [ - Icon(Icons.system_update_alt, + const Icon(Icons.system_update_alt, size: 18, color: Colors.white70), - SizedBox(width: 10), - Text('Check for updates', - style: TextStyle(color: Colors.white)), + const SizedBox(width: 10), + Text('ext.check_updates'.tr(), + style: const TextStyle(color: Colors.white)), ], ), ), @@ -200,7 +205,7 @@ class _CloudStreamSourcesPageState extends State { const SizedBox(height: 24), Row( children: [ - Text('INSTALLED SOURCES', + Text('ext.installed_sources'.tr(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppColors.textHint, letterSpacing: 1)), const Spacer(), @@ -228,12 +233,11 @@ class _CloudStreamSourcesPageState extends State { Icon(Icons.extension_outlined, color: AppColors.primary, size: 40)), ), const SizedBox(width: 12), - const Expanded( + Expanded( child: Text( - 'Add a CloudStream repo, then install only the plugins you want — ' - 'only those show up as providers. They run on your device ' - '(Android only).', - style: TextStyle(color: AppColors.textHint, fontSize: 12.5, height: 1.35), + 'ext.cloudstream_desc'.tr(), + style: const TextStyle( + color: AppColors.textHint, fontSize: 12.5, height: 1.35), ), ), ], @@ -254,7 +258,7 @@ class _CloudStreamSourcesPageState extends State { style: const TextStyle(color: Colors.white), onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: 'Repo URL', + labelText: 'ext.repo_url'.tr(), hintText: 'https://…/repo.json', hintStyle: const TextStyle(color: AppColors.textHint), filled: true, @@ -267,7 +271,7 @@ class _CloudStreamSourcesPageState extends State { ? null : IconButton( icon: const Icon(Icons.clear, color: AppColors.textHint), - tooltip: 'Clear', + tooltip: 'general.clear'.tr(), onPressed: _busy ? null : () => setState(() => _controller.clear()), @@ -282,7 +286,7 @@ class _CloudStreamSourcesPageState extends State { style: FilledButton.styleFrom(backgroundColor: AppColors.primary), onPressed: _busy ? null : _add, icon: const Icon(Icons.travel_explore), - label: const Text('Browse plugins'), + label: Text('ext.browse_plugins'.tr()), ), ), ], @@ -325,12 +329,13 @@ class _CloudStreamSourcesPageState extends State { color: AppColors.surface, borderRadius: BorderRadius.circular(14), ), - child: const Column( + child: Column( children: [ - Icon(Icons.cloud_off_outlined, color: AppColors.textHint, size: 32), - SizedBox(height: 8), - Text('No sources yet', - style: TextStyle(color: AppColors.textHint)), + const Icon(Icons.cloud_off_outlined, + color: AppColors.textHint, size: 32), + const SizedBox(height: 8), + Text('ext.no_sources_yet'.tr(), + style: const TextStyle(color: AppColors.textHint)), ], ), ); diff --git a/lib/features/detail/data/repositories/detail_repository_impl.dart b/lib/features/detail/data/repositories/detail_repository_impl.dart index 6fb881a9..a03663b9 100644 --- a/lib/features/detail/data/repositories/detail_repository_impl.dart +++ b/lib/features/detail/data/repositories/detail_repository_impl.dart @@ -206,7 +206,7 @@ class DetailRepositoryImpl implements DetailRepository { final map = await CloudStreamChannel.loadLinks(provider.substring(3), ref); final sources = map['videoSources']; if (map.isNotEmpty && sources is List && sources.isNotEmpty) { - return _postProcess(MediaResolveModel.fromJson(map)); + return await _postProcess(MediaResolveModel.fromJson(map)); } return Failure(Exception('CloudStream: stream not found')); } catch (e) { @@ -219,7 +219,7 @@ class DetailRepositoryImpl implements DetailRepository { final map = await AniyomiChannel.loadLinks(provider.substring(3), ref); final sources = map['videoSources']; if (map.isNotEmpty && sources is List && sources.isNotEmpty) { - return _postProcess(MediaResolveModel.fromJson(map)); + return await _postProcess(MediaResolveModel.fromJson(map)); } return Failure(Exception('Aniyomi: stream not found')); } catch (e) { @@ -232,7 +232,7 @@ class DetailRepositoryImpl implements DetailRepository { final map = await mangayomi.loadLinks(provider.substring(3), ref); final sources = map['videoSources']; if (map.isNotEmpty && sources is List && sources.isNotEmpty) { - return _postProcess(MediaResolveModel.fromJson(map)); + return await _postProcess(MediaResolveModel.fromJson(map)); } return Failure(Exception('Mangayomi: stream not found')); } catch (e) { @@ -249,7 +249,7 @@ class DetailRepositoryImpl implements DetailRepository { lang: lang, ); if (map != null) { - return _postProcess(MediaResolveModel.fromJson(map)); + return await _postProcess(MediaResolveModel.fromJson(map)); } } catch (e) { if (kDebugMode) debugPrint('[resolveMedia] JS path failed: $e'); @@ -258,7 +258,7 @@ class DetailRepositoryImpl implements DetailRepository { } try { - return _postProcess( + return await _postProcess( await dataSource.resolveMedia( ref: ref, provider: provider, @@ -313,6 +313,16 @@ class DetailRepositoryImpl implements DetailRepository { return Failure(Exception('Sahifalar faqat manga manbalari uchun')); } + /// The single place a resolved media object is finished before it leaves the + /// repository. + /// + /// Trivial today. It is a hook, and the reason every caller `await`s it + /// rather than returning the future is that all five call sites sit inside a + /// `try` whose `catch` is what turns a provider failure into a `Failure` the + /// player can draw a "Try again" screen for. A future returned unawaited + /// escapes that catch, so the first time this does real work its errors would + /// surface as an unhandled async throw instead — far from here, and with no + /// error screen. Future> _postProcess( MediaResolveEntity media, ) async { diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index f85de977..87459c9f 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -1,3 +1,4 @@ +import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -5,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/player/source_ladder.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; @@ -143,10 +145,7 @@ class _DetailScaffold extends StatelessWidget { DetailInitial() || DetailLoading() => Stack( children: [ if (preview != null) - DetailPreviewSkeleton( - preview: preview, - heroTag: heroTag, - ) + DetailPreviewSkeleton(preview: preview, heroTag: heroTag) else const DetailSkeleton(), _BackOnlyBar(onBack: () => _goBack(context)), @@ -166,18 +165,17 @@ class _DetailScaffold extends StatelessWidget { // showListAction. It also removes a latent lock-up — a silent // DetailLoaded refresh that skips DetailLoading would strand // the page on the skeleton for good. - DetailLoaded(:final detail) => - Builder( - builder: (context) { - return _DetailView( - detail: detail, - provider: provider, - autoPlay: autoPlay, - resumeEpisodeIndex: resumeEpisodeIndex, - heroTag: heroTag, - ); - }, - ), + DetailLoaded(:final detail) => Builder( + builder: (context) { + return _DetailView( + detail: detail, + provider: provider, + autoPlay: autoPlay, + resumeEpisodeIndex: resumeEpisodeIndex, + heroTag: heroTag, + ); + }, + ), DetailError(:final message) => _ErrorView( message: message, onRetry: () => context.read().add( @@ -546,15 +544,16 @@ class _DetailViewState extends State<_DetailView> // blocked the very re-download that would have repaired it. var url = _pickMovieUrl(playback); var headers = playback.headers; + var sources = playback.videoSources; + var type = playback.type; // An embed page rather than a stream: the same resolve the player does on // open. Downloading the page would produce an unplayable HTML file. final ref = playback.episodes.isNotEmpty ? playback.episodes.first.mediaRef : ''; - final needsResolve = playback.type == 'webview-extract' || - url == null || - url.isEmpty; + final needsResolve = + playback.type == 'webview-extract' || url == null || url.isEmpty; if (needsResolve && ref.isNotEmpty) { final result = await getIt()( ref: ref, @@ -576,6 +575,8 @@ class _DetailViewState extends State<_DetailView> } url = result.value.videoUrl; headers = result.value.headers; + sources = result.value.videoSources; + type = result.value.type; } } @@ -584,14 +585,24 @@ class _DetailViewState extends State<_DetailView> return; } + final selection = await chooseDownload( + context, + url: url, + headers: headers, + type: type, + sources: sources, + ); + if (!mounted || selection == null) return; + final outcome = await getIt()( DownloadRequest.video( contentUrl: widget.detail.contentUrl, provider: playback.provider, title: widget.detail.title, - sourceUrl: url, + sourceUrl: selection.url, + videoHeight: selection.height, thumbnailUrl: widget.detail.thumbnail, - headers: headers, + headers: selection.headers, ), ); if (!mounted) return; @@ -888,18 +899,28 @@ class _DetailViewState extends State<_DetailView> /// The stream a movie should use: the provider's own pick, else the default /// accessible source, else any accessible one, else whatever exists. + /// Which mirror this page plays and downloads. + /// + /// Was a hand-rolled walk — default first, then anything reachable, then + /// `sources[0]` — which is the fifth place in this app that answered the same + /// question its own way, and the one place that never looked at resolution. + /// On a provider that marks no default and lists sources in page order, that + /// is how a film with a 720p in the list downloaded as 360p. + /// + /// [SourceLadder] is that decision, already written and already tested. String? _pickMovieUrl(PlaybackEntity playback) { final direct = playback.playerSrc; if (direct != null && direct.isNotEmpty) return direct; final sources = playback.videoSources; - for (final s in sources) { - if (s.isDefault && s.accessible) return s.videoUrl; - } - for (final s in sources) { - if (s.accessible) return s.videoUrl; - } - return sources.isNotEmpty ? sources.first.videoUrl : null; + if (sources.isEmpty) return null; + final pick = SourceLadder( + sources: sources, + // No resolve directive is in hand here, so an embed page cannot be + // sniffed — `isPlayable` drops iframes for exactly that reason. + hasDirective: false, + ).initialPick(); + return pick == null ? null : sources[pick].videoUrl; } Future _playMovieDirect(PlaybackEntity playback) async { @@ -1108,37 +1129,36 @@ class _DetailViewState extends State<_DetailView> // header handed to it as `child`. background: ValueListenableBuilder( valueListenable: _heroActive, - builder: (_, heroActive, _) => - ValueListenableBuilder( - valueListenable: _collapse, - child: DetailHeroBackground( - thumbnail: detail.thumbnail, - title: detail.title, - heroTag: heroActive ? widget.heroTag : null, - trailerQuery: detail.trailerQuery, - // The same threshold the hero uses: once the header is - // mostly scrolled away there is nothing to preview, and - // a video decoding under a page nobody can see is - // battery spent on nothing. - trailerActive: heroActive, - ), - builder: (_, c, child) => Stack( - fit: StackFit.expand, - children: [ - child!, - // IgnorePointer so the scrim never eats a tap meant - // for the artwork underneath it. - IgnorePointer( - child: ColoredBox( - color: AppColors.background.withValues( - alpha: c.clamp(0.0, 1.0), + builder: (_, heroActive, _) => ValueListenableBuilder( + valueListenable: _collapse, + child: DetailHeroBackground( + thumbnail: detail.thumbnail, + title: detail.title, + heroTag: heroActive ? widget.heroTag : null, + trailerQuery: detail.trailerQuery, + // The same threshold the hero uses: once the header is + // mostly scrolled away there is nothing to preview, and + // a video decoding under a page nobody can see is + // battery spent on nothing. + trailerActive: heroActive, + ), + builder: (_, c, child) => Stack( + fit: StackFit.expand, + children: [ + child!, + // IgnorePointer so the scrim never eats a tap meant + // for the artwork underneath it. + IgnorePointer( + child: ColoredBox( + color: AppColors.background.withValues( + alpha: c.clamp(0.0, 1.0), + ), ), ), - ), - ], + ], + ), ), ), - ), ), ), SliverToBoxAdapter( @@ -1587,8 +1607,7 @@ class _TabBarDelegate extends SliverPersistentHeaderDelegate { final AppTabBar tabBar; final GlobalKey stripKey; - static const double _height = - AppTabBar.stripHeight + AppTabBar.dividerHeight; + static const double _height = AppTabBar.stripHeight + AppTabBar.dividerHeight; @override Widget build( diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index dfa96e92..e64bcfb4 100644 --- a/lib/features/detail/presentation/pages/episodes_page.dart +++ b/lib/features/detail/presentation/pages/episodes_page.dart @@ -1,3 +1,4 @@ +import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'dart:async'; import 'dart:ui'; @@ -702,7 +703,8 @@ class _EpisodesPageState extends State { ); if (!mounted) return false; - if (result is! Success || result.value.videoUrl.isEmpty) { + if (result is! Success || + result.value.videoUrl.isEmpty) { if (!quiet) _toast('detail.download_resolve_failed'.tr()); return false; } @@ -721,14 +723,25 @@ class _EpisodesPageState extends State { if (!quiet) _toast('detail.download_needs_playback'.tr()); return false; } + final selection = quiet + ? DownloadSelection(url: media.videoUrl, headers: media.headers) + : await chooseDownload( + context, + url: media.videoUrl, + headers: media.headers, + type: media.type, + sources: media.videoSources, + ); + if (!mounted || selection == null) return false; final outcome = await _enqueue( DownloadRequest.video( contentUrl: widget.args.contentUrl, provider: widget.args.provider, title: widget.args.title, - sourceUrl: media.videoUrl, + sourceUrl: selection.url, + videoHeight: selection.height, thumbnailUrl: widget.args.thumbnail, - headers: media.headers, + headers: selection.headers, isSerial: true, episodeNumber: ep.episode, episodeLabel: ep.label, @@ -811,9 +824,11 @@ class _EpisodesPageState extends State { } } if (!mounted) return; - _toast(queued == 0 - ? 'detail.download_none_queued'.tr() - : 'detail.download_queued_n'.tr(args: ['$queued'])); + _toast( + queued == 0 + ? 'detail.download_none_queued'.tr() + : 'detail.download_queued_n'.tr(args: ['$queued']), + ); } /// Batches past this size ask first. Thirty episodes is several gigabytes and @@ -838,35 +853,37 @@ class _EpisodesPageState extends State { } Future _confirmLargeBatch(List indices) => showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - backgroundColor: AppColors.surface, - title: Text( - 'detail.download_batch_title'.tr(), - style: TextStyle(color: AppColors.textPrimary, fontSize: 17), - ), - content: Text( - 'detail.download_batch_body'.tr(namedArgs: { - 'range': _selectionRange(indices), - 'count': '${indices.length}', - }), - style: TextStyle(color: AppColors.textSecondary, height: 1.4), + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + 'detail.download_batch_title'.tr(), + style: TextStyle(color: AppColors.textPrimary, fontSize: 17), + ), + content: Text( + 'detail.download_batch_body'.tr( + namedArgs: { + 'range': _selectionRange(indices), + 'count': '${indices.length}', + }, + ), + style: TextStyle(color: AppColors.textSecondary, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text( + 'general.cancel'.tr(), + style: TextStyle(color: AppColors.textSecondary), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text( - 'general.cancel'.tr(), - style: TextStyle(color: AppColors.textSecondary), - ), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text('detail.download_action'.tr()), - ), - ], ), - ); + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text('detail.download_action'.tr()), + ), + ], + ), + ); /// [_downloadChapter] with its own error reporting suppressed, for batches. Future _downloadChapterQuietly(int index) async { @@ -1006,13 +1023,19 @@ class _EpisodesPageState extends State { number: jumpNumber, block: jumpBlock, busy: _jumping, - onTap: () => _jumpToEpisode(jumpNumber, jumpBlock), + onTap: () => + _jumpToEpisode(jumpNumber, jumpBlock), ), ) else if (_query.isNotEmpty && visible.isEmpty) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), + padding: const EdgeInsets.fromLTRB( + 16, + 24, + 16, + 24, + ), child: Text( _jumpMiss != null && _jumpMiss == jumpNumber ? 'episodes.jump_missing'.tr( @@ -1052,7 +1075,9 @@ class _EpisodesPageState extends State { headers: widget.args.headers, isManga: _isManga, flash: i == _flashIndex, - progress: isCurrent ? _historyItem!.progress : null, + progress: isCurrent + ? _historyItem!.progress + : null, // In selection mode a tap picks rather than plays; // opening the player from under a half-made // selection is the classic multi-select mistake. @@ -1242,7 +1267,11 @@ class _BlockStripState extends State<_BlockStrip> { child: ListView.separated( controller: _strip, scrollDirection: Axis.horizontal, - padding: const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 6), + padding: const EdgeInsetsDirectional.only( + start: 16, + end: 16, + bottom: 6, + ), itemCount: widget.blocks.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, i) { @@ -1280,7 +1309,9 @@ class _BlockChip extends StatelessWidget { button: true, selected: active, child: Material( - color: active ? AppColors.primary : Colors.white.withValues(alpha: 0.06), + color: active + ? AppColors.primary + : Colors.white.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(11), clipBehavior: Clip.antiAlias, child: InkWell( @@ -1313,7 +1344,10 @@ class _BlockChip extends StatelessWidget { /// exact match: someone reaching for 1043 types "104" and wants to see the /// neighbourhood, not one row. class _EpisodeFilterField extends StatelessWidget { - const _EpisodeFilterField({required this.controller, required this.onChanged}); + const _EpisodeFilterField({ + required this.controller, + required this.onChanged, + }); final TextEditingController controller; final ValueChanged onChanged; @@ -1502,7 +1536,10 @@ class _EpisodesAppBar extends StatelessWidget { return progress > 0.02 ? ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 18 * progress, sigmaY: 18 * progress), + filter: ImageFilter.blur( + sigmaX: 18 * progress, + sigmaY: 18 * progress, + ), child: content, ), ) @@ -1510,64 +1547,62 @@ class _EpisodesAppBar extends StatelessWidget { } Widget _selectionRow(BuildContext context) => Row( - children: [ - IconButton( - onPressed: onCancelSelection, - icon: const Icon(Icons.close_rounded, size: 22, color: Colors.white), - ), - const SizedBox(width: 2), - Expanded( - child: Text( - 'detail.selected_n'.tr(args: ['$selectedCount']), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), + children: [ + IconButton( + onPressed: onCancelSelection, + icon: const Icon(Icons.close_rounded, size: 22, color: Colors.white), + ), + const SizedBox(width: 2), + Expanded( + child: Text( + 'detail.selected_n'.tr(args: ['$selectedCount']), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + height: 1.1, ), - TextButton( - onPressed: onSelectAll, - child: Text( - allSelected - ? 'detail.select_none'.tr() - : 'detail.select_all'.tr(), - style: TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.w600, - ), - ), + ), + ), + TextButton( + onPressed: onSelectAll, + child: Text( + allSelected ? 'detail.select_none'.tr() : 'detail.select_all'.tr(), + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w600, ), - ], - ); + ), + ), + ], + ); Widget _titleRow(BuildContext context) => Row( - children: [ - IconButton( - onPressed: onBack, - icon: const Icon( - Icons.arrow_back_ios_new_rounded, - size: 20, - color: Colors.white, - ), - ), - const SizedBox(width: 2), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), + children: [ + IconButton( + onPressed: onBack, + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + size: 20, + color: Colors.white, + ), + ), + const SizedBox(width: 2), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + height: 1.1, ), - ], - ); + ), + ), + ], + ); } class _SortToggle extends StatelessWidget { @@ -1649,14 +1684,12 @@ class _CountHeader extends StatelessWidget { final to = rangeTo; final showOf = total > 0 && total > loaded; final label = from != null && to != null - ? 'episodes.range_of'.tr(namedArgs: { - 'from': '$from', - 'to': '$to', - 'total': '$total', - }) + ? 'episodes.range_of'.tr( + namedArgs: {'from': '$from', 'to': '$to', 'total': '$total'}, + ) : showOf - ? 'detail.episodes_count_of'.tr(args: ['$loaded', '$total']) - : 'detail.episodes_count'.tr(args: ['$total']); + ? 'detail.episodes_count_of'.tr(args: ['$loaded', '$total']) + : 'detail.episodes_count'.tr(args: ['$total']); return Text( label, style: const TextStyle( @@ -1827,8 +1860,8 @@ class _EpisodeRow extends StatelessWidget { color: selected ? AppColors.primary.withValues(alpha: 0.10) : flash - ? AppColors.primary.withValues(alpha: 0.18) - : Colors.transparent, + ? AppColors.primary.withValues(alpha: 0.18) + : Colors.transparent, padding: EdgeInsets.symmetric( horizontal: 16, vertical: showImage ? 8 : 14, @@ -1860,7 +1893,9 @@ class _EpisodeRow extends StatelessWidget { _episodeNumberLabel(episode.episode), maxLines: 1, style: TextStyle( - color: progress != null ? AppColors.primary : AppColors.textHint, + color: progress != null + ? AppColors.primary + : AppColors.textHint, fontSize: episode.episode >= 1000 ? 16 : 22, fontWeight: FontWeight.w900, fontFeatures: const [FontFeature.tabularFigures()], @@ -1887,8 +1922,9 @@ class _EpisodeRow extends StatelessWidget { ? AppColors.textPrimary : AppColors.textSecondary, fontSize: showImage ? 13 : 14, - fontWeight: - showImage ? FontWeight.w600 : FontWeight.w500, + fontWeight: showImage + ? FontWeight.w600 + : FontWeight.w500, height: 1.25, ), ), @@ -2014,9 +2050,7 @@ class _BatchDownloadBar extends StatelessWidget { padding: EdgeInsets.fromLTRB(16, 12, 16, bottomPad + 12), decoration: BoxDecoration( color: AppColors.navBackground, - border: Border( - top: BorderSide(color: AppColors.divider, width: 0.5), - ), + border: Border(top: BorderSide(color: AppColors.divider, width: 0.5)), ), child: SizedBox( height: 46, @@ -2076,9 +2110,9 @@ class _DownloadControl extends StatelessWidget { @override Widget build(BuildContext context) => ValueListenableBuilder( - valueListenable: downloads.revision, - builder: (context, _, _) => _control(downloads.byId(id)), - ); + valueListenable: downloads.revision, + builder: (context, _, _) => _control(downloads.byId(id)), + ); /// A fixed 48dp slot whatever the state. The states swap under a scrolling /// list, and a control that changes width takes the play button next to it @@ -2146,8 +2180,7 @@ class _DownloadControl extends StatelessWidget { size: 18, ), ); - final label = - failed ? 'general.retry'.tr() : 'detail.download_action'.tr(); + final label = failed ? 'general.retry'.tr() : 'detail.download_action'.tr(); // Android TV: the episode row itself is an InkWell (focusable for free), // but this download control sat on a bare GestureDetector, so a remote @@ -2196,24 +2229,24 @@ class _DownloadedTick extends StatelessWidget { @override Widget build(BuildContext context) => ValueListenableBuilder( - valueListenable: downloads.revision, - builder: (context, _, _) { - if (downloads.byId(id)?.status != DownloadStatus.completed) { - return const SizedBox.shrink(); - } - return Semantics( - label: 'downloads.downloaded'.tr(), - child: const Padding( - padding: EdgeInsetsDirectional.only(end: 10), - child: Icon( - Icons.download_done_rounded, - color: AppColors.success, - size: 18, - ), - ), - ); - }, + valueListenable: downloads.revision, + builder: (context, _, _) { + if (downloads.byId(id)?.status != DownloadStatus.completed) { + return const SizedBox.shrink(); + } + return Semantics( + label: 'downloads.downloaded'.tr(), + child: const Padding( + padding: EdgeInsetsDirectional.only(end: 10), + child: Icon( + Icons.download_done_rounded, + color: AppColors.success, + size: 18, + ), + ), ); + }, + ); } class _EpisodeThumb extends StatelessWidget { @@ -2270,8 +2303,7 @@ class _EpisodeThumb extends StatelessWidget { left: 4, bottom: 3, child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(4), diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index b1048de6..c15f2e3b 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1,5 +1,7 @@ +import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:soplay/core/player/hls_variants.dart'; import 'package:soplay/core/analytics/analytics.dart'; import 'package:soplay/core/player/color_profile.dart'; import 'package:soplay/core/player/shader_presets.dart'; @@ -195,6 +197,7 @@ class _PlayerPageState extends State Map _headers = const {}; bool _isNetworkVideo = false; bool _isHls = false; + /// True while the current media is a live broadcast. /// /// Seeded from what the caller SAID it is rather than guessed alone: Live TV @@ -220,6 +223,10 @@ class _PlayerPageState extends State ExtractorConfigEntity? _extractorConfig; int _currentSourceIndex = -1; + /// Master playlists already read for their renditions, so switching back to a + /// server does not refetch its manifest. + final Set _expandedMasters = {}; + /// Mirrors already attempted for what is on screen, by url. /// /// This was a single `_autoFallbackUsed` bool, which is why a five-mirror @@ -232,6 +239,7 @@ class _PlayerPageState extends State /// Set when a mirror failed to DECODE, so sibling mirrors in the same codec /// sink to the back of the ladder. Cleared wherever the ladder resets. String? _decoderAvoidCodec; + /// Pinch zoom over the video, 1.0 = untouched. /// /// Separate from [_PlayerFit], which snaps to three presets. Fit answers @@ -256,8 +264,9 @@ class _PlayerPageState extends State /// Which rows the info overlay may show. Read once at open and updated when /// the picker changes it, rather than re-read on every frame — the overlay /// rebuilds on every position tick and a Hive read there is pure waste. - Set _infoFields = - PlayerInfoFields.fromStored(getIt().getPlayerInfoFields()); + Set _infoFields = PlayerInfoFields.fromStored( + getIt().getPlayerInfoFields(), + ); /// The viewer's bar arrangement. Read once at open: it can only change from /// the editor, and returning from that re-reads it. @@ -429,7 +438,6 @@ class _PlayerPageState extends State /// player instance can cover several episodes — a plain bool would report the /// first episode and nothing after it. - bool _wasPlaying = false; bool _wasBuffering = false; bool _wasInitialized = false; @@ -464,7 +472,9 @@ class _PlayerPageState extends State // nearest enclosing scope. If that were the route's scope instead of this // node, it would no longer be in the key-event chain and the remote would go // dead until the next tap. - final FocusScopeNode _tvRootFocus = FocusScopeNode(debugLabel: 'tvPlayerRoot'); + final FocusScopeNode _tvRootFocus = FocusScopeNode( + debugLabel: 'tvPlayerRoot', + ); /// Focus scope for the TV side panel (quality / episodes). /// @@ -475,8 +485,9 @@ class _PlayerPageState extends State /// which is what "quality doesn't work on TV" actually was. Giving the panel /// its own scope lets [_openPanel] hand focus over and [_closePanel] hand it /// back deterministically. - final FocusScopeNode _tvPanelFocus = - FocusScopeNode(debugLabel: 'tvPlayerPanel'); + final FocusScopeNode _tvPanelFocus = FocusScopeNode( + debugLabel: 'tvPlayerPanel', + ); final FocusNode _tvPlayFocus = FocusNode(debugLabel: 'tvPlayerPlay'); final FocusNode _tvSeekFocus = FocusNode(debugLabel: 'tvPlayerSeek'); @@ -524,8 +535,12 @@ class _PlayerPageState extends State // the next subbed had a single setting fighting them on every episode; the // show they are actually opening now decides, and the global value is only // the default for a title nothing is remembered about. - _currentLang = widget.args.initialLang ?? - _titlePrefs.langFor(widget.args.provider, widget.args.contentUrl ?? '') ?? + _currentLang = + widget.args.initialLang ?? + _titlePrefs.langFor( + widget.args.provider, + widget.args.contentUrl ?? '', + ) ?? _hive.getPreferredMediaLang(); _restoreSubtitleSync(); _controlsAnimation = AnimationController( @@ -679,93 +694,102 @@ class _PlayerPageState extends State value: SystemUiOverlayStyle.light, child: _wrapPlayerShortcuts( Scaffold( - backgroundColor: Colors.black, - body: LayoutBuilder( - builder: (context, constraints) => _wrapHover(GestureDetector( - // ANCESTOR of the whole Stack, deliberately, and it has to stay - // that way. - // - // It was briefly moved to a Positioned.fill sibling below the - // controls, to stop its long-press recogniser competing with the - // seek bar's drag. That broke two things and fixed nothing worth - // the trade. Measured, not guessed: - // - // * The controls scrim is a full-screen DecoratedBox, and a - // RenderDecoratedBox DOES take the hit — a hit-test at the - // centre of the screen stops there and never reaches a sibling - // below it. So every gesture died the moment the controls - // became visible, which is exactly how it was reported. - // * The error card, the missing-plugin view and the loading - // overlay all live in the Stack's first child, so an opaque - // sibling above them swallowed Try again, Alternate sources - // and View logs. - // * And the conflict it was meant to fix does not cancel the - // drag at all: holding the thumb past the long-press timeout - // still delivers onChangeEnd. It only fires a spurious 2x - // speed boost, which _absorbAncestorGestures now stops at the - // seek bar itself. - // - // As an ancestor these recognisers are in the hit-test path for - // every touch, whatever is drawn on top. - behavior: HitTestBehavior.opaque, - onTap: _locked ? null : _toggleControls, - onDoubleTapDown: _locked || isDesktopPlatform - ? null - : (d) => _onDoubleTapDown(d, constraints), - onDoubleTap: _locked - ? null - : isDesktopPlatform + backgroundColor: Colors.black, + body: LayoutBuilder( + builder: (context, constraints) => _wrapHover( + GestureDetector( + // ANCESTOR of the whole Stack, deliberately, and it has to stay + // that way. + // + // It was briefly moved to a Positioned.fill sibling below the + // controls, to stop its long-press recogniser competing with the + // seek bar's drag. That broke two things and fixed nothing worth + // the trade. Measured, not guessed: + // + // * The controls scrim is a full-screen DecoratedBox, and a + // RenderDecoratedBox DOES take the hit — a hit-test at the + // centre of the screen stops there and never reaches a sibling + // below it. So every gesture died the moment the controls + // became visible, which is exactly how it was reported. + // * The error card, the missing-plugin view and the loading + // overlay all live in the Stack's first child, so an opaque + // sibling above them swallowed Try again, Alternate sources + // and View logs. + // * And the conflict it was meant to fix does not cancel the + // drag at all: holding the thumb past the long-press timeout + // still delivers onChangeEnd. It only fires a spurious 2x + // speed boost, which _absorbAncestorGestures now stops at the + // seek bar itself. + // + // As an ancestor these recognisers are in the hit-test path for + // every touch, whatever is drawn on top. + behavior: HitTestBehavior.opaque, + onTap: _locked ? null : _toggleControls, + onDoubleTapDown: _locked || isDesktopPlatform + ? null + : (d) => _onDoubleTapDown(d, constraints), + onDoubleTap: _locked + ? null + : isDesktopPlatform ? _toggleFullscreen : () {}, - // Scale, not pan: Flutter asserts if both are registered, since - // scale is a superset. One finger is routed straight back into - // the old pan handlers — see _onScaleUpdate — so brightness, - // volume and swipe-seek behave exactly as before, and two - // fingers pinch-zoom the picture. - onScaleStart: - _locked ? null : (d) => _onScaleStart(d, constraints), - onScaleUpdate: - _locked ? null : (d) => _onScaleUpdate(d, constraints), - onScaleEnd: _locked ? null : _onScaleEnd, - onLongPressStart: _locked ? null : _onLongPressStart, - onLongPressEnd: _locked ? null : _onLongPressEnd, - child: Stack( - fit: StackFit.expand, - children: [ - _buildVideoLayer(), - _buildSubtitleOverlay(), - if (!_locked) _buildSeekRipple(), - if (_locked) _buildLockOverlay() else _buildControlsOverlay(), - if (!_locked) _buildScrubOverlay(), - if (!_locked) _buildSpeedBoostBadge(), - if (!_locked) _buildSwipeIndicator(), - // Renders nothing unless the URL is a local torrent stream, - // so it is safe to hand it every playback unconditionally. - // It keeps showing while the controls are hidden if the - // pre-buffer is still filling — that is precisely when the - // picture is frozen and the user needs to know why. - TorrentStatsOverlay( - videoUrl: _videoUrl, - visible: _controlsVisible && !_locked, + // Scale, not pan: Flutter asserts if both are registered, since + // scale is a superset. One finger is routed straight back into + // the old pan handlers — see _onScaleUpdate — so brightness, + // volume and swipe-seek behave exactly as before, and two + // fingers pinch-zoom the picture. + onScaleStart: _locked + ? null + : (d) => _onScaleStart(d, constraints), + onScaleUpdate: _locked + ? null + : (d) => _onScaleUpdate(d, constraints), + onScaleEnd: _locked ? null : _onScaleEnd, + onLongPressStart: _locked ? null : _onLongPressStart, + onLongPressEnd: _locked ? null : _onLongPressEnd, + child: Stack( + fit: StackFit.expand, + children: [ + _buildVideoLayer(), + _buildSubtitleOverlay(), + if (!_locked) _buildSeekRipple(), + if (_locked) + _buildLockOverlay() + else + _buildControlsOverlay(), + if (!_locked) _buildScrubOverlay(), + if (!_locked) _buildSpeedBoostBadge(), + if (!_locked) _buildSwipeIndicator(), + // Renders nothing unless the URL is a local torrent stream, + // so it is safe to hand it every playback unconditionally. + // It keeps showing while the controls are hidden if the + // pre-buffer is still filling — that is precisely when the + // picture is frozen and the user needs to know why. + TorrentStatsOverlay( + videoUrl: _videoUrl, + visible: _controlsVisible && !_locked, + ), + // Above the controls layer so it is reachable while they are + // hidden — the offer is at its most useful to a viewer who has + // not touched the screen. Renders nothing when no interval is + // active, so it costs nothing on non-anime playback. + if (!_locked) _buildPlayerInfoOverlay(), + if (!_locked) _buildSkipButton(), + if (!_locked && _panel != _SidePanel.none) + _buildSidePanel(), + if (!_locked && _inParty) _buildPartyReactionsLayer(), + // Last, so it covers everything: while a television is + // playing this episode the phone is a remote, and leaving the + // local controls reachable underneath would let someone + // scrub a surface nobody is watching. + _buildCastOverlay(), + ], ), - // Above the controls layer so it is reachable while they are - // hidden — the offer is at its most useful to a viewer who has - // not touched the screen. Renders nothing when no interval is - // active, so it costs nothing on non-anime playback. - if (!_locked) _buildPlayerInfoOverlay(), - if (!_locked) _buildSkipButton(), - if (!_locked && _panel != _SidePanel.none) _buildSidePanel(), - if (!_locked && _inParty) _buildPartyReactionsLayer(), - // Last, so it covers everything: while a television is - // playing this episode the phone is a remote, and leaving the - // local controls reachable underneath would let someone - // scrub a surface nobody is watching. - _buildCastOverlay(), - ], + ), ), - )), + ), ), - )), + ), ), ); } @@ -811,7 +835,8 @@ class _PlayerPageState extends State final k = event.logicalKey; // Block playback-control keys (play/pause + seek) when not allowed to // control the party; consume the event so no local action happens. - final isPartyControlKey = k == LogicalKeyboardKey.space || + final isPartyControlKey = + k == LogicalKeyboardKey.space || k == LogicalKeyboardKey.mediaPlayPause || k == LogicalKeyboardKey.arrowLeft || k == LogicalKeyboardKey.arrowRight; diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 48cb5eb8..84b7f590 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -119,10 +119,7 @@ extension _PlayerHistory on _PlayerPageState { if (elapsed <= 0) return; if (_hive.isIncognito) return; unawaited( - _watchStats.record( - seconds: elapsed, - provider: widget.args.provider, - ), + _watchStats.record(seconds: elapsed, provider: widget.args.provider), ); } @@ -191,18 +188,18 @@ extension _PlayerHistory on _PlayerPageState { for (final tracker in Function()>[ if (anilist.isConnected) () => anilist.reportEpisode( - provider: widget.args.provider, - contentUrl: contentUrl, - title: widget.args.title, - episodeNumber: episodeNumber, - ), + provider: widget.args.provider, + contentUrl: contentUrl, + title: widget.args.title, + episodeNumber: episodeNumber, + ), if (mal.isConnected) () => mal.reportEpisode( - provider: widget.args.provider, - contentUrl: contentUrl, - title: widget.args.title, - episodeNumber: episodeNumber, - ), + provider: widget.args.provider, + contentUrl: contentUrl, + title: widget.args.title, + episodeNumber: episodeNumber, + ), ]) { unawaited(tracker().catchError((Object _) => null)); } @@ -274,10 +271,7 @@ extension _PlayerHistory on _PlayerPageState { widget.args.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white54, - fontSize: 12.5, - ), + style: const TextStyle(color: Colors.white54, fontSize: 12.5), ), ), const Divider(color: Colors.white12, height: 1), @@ -290,8 +284,8 @@ extension _PlayerHistory on _PlayerPageState { value: offer.isCurrent ? 'detail.download_playing_now'.tr() : offer.needsSniff && offer.detail.isEmpty - ? 'detail.download_will_resolve'.tr() - : offer.detail, + ? 'detail.download_will_resolve'.tr() + : offer.detail, onTap: () => Navigator.of(sheetContext).pop(offer), ), const SizedBox(height: 8), @@ -377,7 +371,24 @@ extension _PlayerHistory on _PlayerPageState { if (picked == null || picked.url.isEmpty) return; final choice = await _resolveChoice(picked); if (!mounted || choice == null || choice.url.isEmpty) return; - final url = choice.url; + final declared = _videoSources + .where((s) => s.videoUrl == picked.url) + .firstOrNull; + final selection = await chooseDownload( + context, + url: choice.url, + headers: choice.headers, + type: choice.isCurrent + ? _mediaType + : (declared?.type == 'iframe' ? null : declared?.type), + height: + declared?.height ?? + VideoOptionGroups.resolutionOf( + choice.isCurrent ? (_currentQuality ?? '') : picked.detail, + ), + ); + if (!mounted || selection == null) return; + final url = selection.url; EpisodeEntity? ep; if (widget.args.isSerial && @@ -400,7 +411,8 @@ extension _PlayerHistory on _PlayerPageState { thumbnailUrl: widget.args.thumbnail, // The picked mirror's own headers. Sending the playing stream's here // would 403 on every host but the one already on screen. - headers: choice.headers, + headers: selection.headers, + videoHeight: selection.height, isSerial: widget.args.isSerial, episodeNumber: widget.args.isSerial && ep != null ? ep.episode : null, episodeLabel: ep?.label, @@ -415,5 +427,4 @@ extension _PlayerHistory on _PlayerPageState { ), ); } - } diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 32143e90..3d339715 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -567,32 +567,13 @@ extension _PlayerMedia on _PlayerPageState { final base = Uri.tryParse(masterUrl); if (base == null) return; - final variants = <({int height, String url})>[]; - final lines = body.split(RegExp(r'\r?\n')); - for (var i = 0; i < lines.length - 1; i++) { - final tag = lines[i].trim(); - if (!tag.startsWith('#EXT-X-STREAM-INF')) continue; - final uri = lines[i + 1].trim(); - if (uri.isEmpty || uri.startsWith('#')) continue; - // The label comes from the variant's own name when it has one, and only - // falls back to RESOLUTION. They disagree more often than not: a 2.40:1 - // film encoded at 1080p carries RESOLUTION=1920x800, so naming the row - // after the pixel height offers the viewer `800p` for the stream every - // other player calls 1080p. The `index-s1080p-v1-a1` token in the URI is - // the name the packager gave it. - final named = RegExp(r'[-_/]s(\d{3,4})p\b').firstMatch(uri); - final res = RegExp(r'RESOLUTION=\d+x(\d+)').firstMatch(tag); - final height = - int.tryParse(named?.group(1) ?? res?.group(1) ?? '') ?? 0; - if (height <= 0) continue; - variants.add((height: height, url: base.resolve(uri).toString())); - } + // Shared with the downloader, which needs the same ranking to stop saving + // the lowest rendition of a stream the player is showing at 1080p. + final variants = parseHlsVariants(body, base); if (variants.isEmpty) return; - // Best first, and one entry per height: a master often carries the same - // resolution twice at different bitrates, which would show the sheet two - // rows both labelled `1080p`. - variants.sort((a, b) => b.height.compareTo(a.height)); + // One entry per height: a master often carries the same resolution twice at + // different bitrates, which would show the sheet two rows both `1080p`. final seen = {}; final auto = _videoSources.isNotEmpty ? _videoSources.first : null; @@ -690,6 +671,11 @@ extension _PlayerMedia on _PlayerPageState { } } + // Before playback, not after: the sheet is built from `_videoSources`, and + // a viewer who opens it during the first ten seconds should already find + // the renditions there. + await _maybeExpandQualities(effUrl, effHeaders, effType); + await _initializeResolved( url: effUrl, headers: effHeaders, @@ -699,6 +685,109 @@ extension _PlayerMedia on _PlayerPageState { ); } + /// Gives the current server real quality rows, parsed out of its own master + /// playlist. + /// + /// [_expandMasterPlaylist] already did this, but from one place only: inside + /// [_applyRewrite], for a provider carrying a rewrite rule that verifies, and + /// only when the whole source list was a single entry. Most providers have no + /// such rule. vidapi returns three HLS masters and labels them `Server 1..3`, + /// so the sheet listed three servers, the renditions inside each were never + /// surfaced, and nothing but the connection speed decided between 480p and + /// 1080p. That is the "no manual quality control" report. + /// + /// The current entry is left exactly as it is and the renditions are inserted + /// after it — it keeps its url, its headers and its proxy settings, so it + /// still resolves the way it did, and it goes on being the adaptive choice. + /// Only the server being played is fetched; the others are expanded if and + /// when they are switched to. + /// + /// Costs one GET, skipped whenever there is already something to choose + /// from. Failure is silent: this widens a menu, it does not gate playback. + Future _maybeExpandQualities( + String url, + Map headers, + String? type, + ) async { + final idx = _currentSourceIndex; + if (idx < 0 || idx >= _videoSources.length) return; + final parent = _videoSources[idx]; + + // A label that already states a resolution came from the provider, and the + // provider knows its own catalogue better than a parsed manifest does. + if (VideoOptionGroups.resolutionOf(parent.quality) != null) return; + if (url.isEmpty || !_expandedMasters.add(url)) return; + + final kind = type?.toLowerCase(); + final looksHls = + kind == 'hls' || kind == 'm3u8' || url.toLowerCase().contains('.m3u8'); + if (!looksHls) return; + + final base = Uri.tryParse(url); + if (base == null) return; + + String body; + try { + final res = await getIt().get( + url, + options: Options( + headers: headers, + responseType: ResponseType.plain, + validateStatus: (_) => true, + receiveTimeout: const Duration(seconds: 8), + extra: const {'skipAuthInterceptor': true}, + ), + ); + if (res.statusCode != 200) return; + body = res.data ?? ''; + } catch (_) { + // A master that will not load is the player's problem to report, not + // this one's — it is about to request the same url. + return; + } + if (!body.trimLeft().startsWith('#EXTM3U')) return; + + // Shared with the downloader, so the file saved matches the rendition the + // sheet offered. + final variants = parseHlsVariants(body, base); + + // One row per height: a master often carries the same resolution twice at + // different bitrates, which would list `1080p` twice. + final seen = {}; + final rows = [ + for (final v in variants) + if (v.height > 0 && seen.add(v.height)) + VideoSourceEntity( + quality: '${parent.quality} · ${v.height}p', + videoUrl: v.url, + isDefault: false, + accessible: parent.accessible, + height: v.height, + type: parent.type ?? 'hls', + headers: parent.headers.isNotEmpty ? parent.headers : headers, + useLocalProxy: parent.useLocalProxy, + localProxy: parent.localProxy, + requestTransform: parent.requestTransform, + drm: parent.drm, + ), + ]; + // One rendition beside the adaptive entry is not a choice, and a quality + // control that opens onto a single row reads as broken — the same rule the + // engine track list follows. + if (rows.length < 2) return; + + _plog('master playlist -> ${rows.length} qualities for ' + '"${parent.quality}" (${rows.map((e) => e.height).join(", ")})'); + if (!mounted) return; + setState(() { + _videoSources = [ + ..._videoSources.take(idx + 1), + ...rows, + ..._videoSources.skip(idx + 1), + ]; + }); + } + Future _initializeResolved({ required String url, required Map headers, diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index b64bdbfc..53acb469 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -175,7 +175,17 @@ extension _PlayerPanels on _PlayerPageState { String _qualityLabel(String label) { final quality = VideoOptionGroups.qualityOf(label); - return quality.isEmpty ? label : quality; + if (quality.isNotEmpty) return quality; + // Sitting in a quality list with nothing resolution-shaped in the label + // means this is the server's own stream — the adaptive entry the parsed + // renditions were hung off. Painting it "Server 1" among 1080p and 720p + // read as a stray server row. + return _hasParsedSiblings(label) ? 'player.auto'.tr() : label; + } + + bool _hasParsedSiblings(String label) { + final server = VideoOptionGroups.serverOf(label); + return VideoOptionGroups.qualitiesFor(_sourceLabels, server).isNotEmpty; } /// [_QualityRow] paints the label verbatim, and every label in a one-server diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index 8fb64975..7976c15f 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -856,6 +856,12 @@ class _PlayerInfoOverlay extends StatelessWidget { width: 96, child: Text( row.labelKey.tr(), + // A backstop. The labels were shortened to fit this + // column, but a future one that is not should lose + // its tail rather than silently add a row of height + // and knock the overlay out of alignment. + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white54, fontSize: 11), ), diff --git a/lib/features/detail/presentation/widgets/detail_info.dart b/lib/features/detail/presentation/widgets/detail_info.dart index 20c2368c..126239f4 100644 --- a/lib/features/detail/presentation/widgets/detail_info.dart +++ b/lib/features/detail/presentation/widgets/detail_info.dart @@ -84,10 +84,14 @@ class _DetailContentHeaderState extends State { const SizedBox(height: 18), if (item != null && (item.positionMs > 0 || item.episodeNumber != null)) - _ContinueWatchingCard(item: item, onTap: widget.onPrimaryAction) - else - Row( - children: [ + _ContinueWatchingCard(item: item, onTap: widget.onPrimaryAction), + if (item != null && + (item.positionMs > 0 || item.episodeNumber != null)) + const SizedBox(height: 12), + Row( + children: [ + if (item == null || + (item.positionMs <= 0 && item.episodeNumber == null)) Expanded( child: _PlayButton( key: widget.playButtonKey, @@ -99,24 +103,24 @@ class _DetailContentHeaderState extends State { reader: widget.detail.provider.opensReader, ), ), - // Offline used to be reachable only from inside the player: - // open the title, wait for a source to resolve, start playing, - // then find it in a menu. Four steps and a started stream to - // save something for the train. - if (widget.onDownload != null) ...[ - const SizedBox(width: 10), - _SquareAction( - icon: Icons.download_rounded, - tooltip: 'detail.download_action'.tr(), - onTap: widget.onDownload!, - ), - ], - // Appears on its own once a trailer has been found, and takes - // no space at all when there is none — so a title without one - // never shows a button that cannot do anything. - TrailerAction(detail: widget.detail), + // Offline used to be reachable only from inside the player: + // open the title, wait for a source to resolve, start playing, + // then find it in a menu. Four steps and a started stream to + // save something for the train. + if (widget.onDownload != null) ...[ + const SizedBox(width: 10), + _SquareAction( + icon: Icons.download_rounded, + tooltip: 'detail.download_action'.tr(), + onTap: widget.onDownload!, + ), ], - ), + // Appears on its own once a trailer has been found, and takes + // no space at all when there is none — so a title without one + // never shows a button that cannot do anything. + TrailerAction(detail: widget.detail), + ], + ), ], ), ); @@ -538,7 +542,9 @@ class _PlayButton extends StatelessWidget { backgroundColor: Colors.white, foregroundColor: Colors.black, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kButtonRadius)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kButtonRadius), + ), ), icon: Icon( reader ? Icons.menu_book_rounded : Icons.play_arrow_rounded, diff --git a/lib/features/download/data/datasources/download_transfer_data_source.dart b/lib/features/download/data/datasources/download_transfer_data_source.dart index 98ffa497..6192689f 100644 --- a/lib/features/download/data/datasources/download_transfer_data_source.dart +++ b/lib/features/download/data/datasources/download_transfer_data_source.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:soplay/core/player/hls_variants.dart'; import 'package:soplay/features/download/domain/download_layout.dart'; import 'package:soplay/features/download/domain/entities/download_failure.dart'; import 'package:soplay/features/download/domain/entities/download_kind.dart'; @@ -509,7 +510,25 @@ class DownloadTransferDataSource { } } + /// The rendition to save out of a master playlist: the best one. + /// + /// This used to return whichever variant was listed first. Packagers commonly + /// order a master lowest-bitrate-first — a client is supposed to start + /// conservatively and adapt upward — so "first" meant the file on disk was + /// the 480p rendition of a stream the player had been showing at 1080p. The + /// download completed, sat in the list, and looked blurry, with nothing on + /// screen to suggest a quality had been chosen at all. + /// + /// Nobody downloads a film in order to watch the smallest copy of it, so the + /// best rendition is the answer rather than a setting. String? _pickVariant(String playlist, String base) { + final uri = Uri.tryParse(base); + if (uri == null) return null; + final variants = parseHlsVariants(playlist, uri); + if (variants.isNotEmpty) return variants.first.url; + + // A master whose variants state neither a name nor a RESOLUTION: keep the + // old behaviour rather than refusing to download it at all. final lines = playlist.split('\n'); for (var i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#EXT-X-STREAM-INF')) continue; diff --git a/lib/features/download/data/models/download_item_model.dart b/lib/features/download/data/models/download_item_model.dart index 683c4ddd..ba13a5f1 100644 --- a/lib/features/download/data/models/download_item_model.dart +++ b/lib/features/download/data/models/download_item_model.dart @@ -16,41 +16,43 @@ import 'package:soplay/features/download/domain/entities/download_status.dart'; /// shapes and [toJson] only ever writes the new one. abstract final class DownloadItemModel { static Map toJson(DownloadItem item) => { - 'v': 2, - 'id': item.id, - 'contentUrl': item.contentUrl, - 'provider': item.provider, - 'title': item.title, - 'sourceUrl': item.sourceUrl, - 'kind': item.kind.id, - 'relativePath': item.relativePath, - 'thumbnailUrl': item.thumbnailUrl, - 'thumbnailRelativePath': item.thumbnailRelativePath, - 'headers': item.headers, - 'status': item.status.id, - 'unit': item.unit.id, - 'completedUnits': item.completedUnits, - 'totalUnits': item.totalUnits, - 'sizeBytes': item.sizeBytes, - 'createdAt': item.createdAt, - 'updatedAt': item.updatedAt, - 'isSerial': item.isSerial, - 'episodeNumber': item.episodeNumber, - 'episodeLabel': item.episodeLabel, - 'pageUrls': item.pageUrls, - 'chapterRef': item.chapterRef, - 'chapterIndex': item.chapterIndex, - 'failure': item.failure?.id, - 'failureDetail': item.failureDetail, - 'attempts': item.attempts, - }; + 'v': 2, + 'id': item.id, + 'contentUrl': item.contentUrl, + 'provider': item.provider, + 'title': item.title, + 'sourceUrl': item.sourceUrl, + 'kind': item.kind.id, + 'videoHeight': item.videoHeight, + 'relativePath': item.relativePath, + 'thumbnailUrl': item.thumbnailUrl, + 'thumbnailRelativePath': item.thumbnailRelativePath, + 'headers': item.headers, + 'status': item.status.id, + 'unit': item.unit.id, + 'completedUnits': item.completedUnits, + 'totalUnits': item.totalUnits, + 'sizeBytes': item.sizeBytes, + 'createdAt': item.createdAt, + 'updatedAt': item.updatedAt, + 'isSerial': item.isSerial, + 'episodeNumber': item.episodeNumber, + 'episodeLabel': item.episodeLabel, + 'pageUrls': item.pageUrls, + 'chapterRef': item.chapterRef, + 'chapterIndex': item.chapterIndex, + 'failure': item.failure?.id, + 'failureDetail': item.failureDetail, + 'attempts': item.attempts, + }; static DownloadItem fromJson(Map json) { final id = _string(json['id']); final legacy = json['v'] == null; - final sourceUrl = - _string(json['sourceUrl']).ifEmpty(() => _string(json['videoUrl'])); + final sourceUrl = _string( + json['sourceUrl'], + ).ifEmpty(() => _string(json['videoUrl'])); final kind = legacy ? DownloadKind.fromLegacy(_stringOrNull(json['kind']), sourceUrl) @@ -60,7 +62,8 @@ abstract final class DownloadItemModel { // the part that does not depend on the device; anything unrecognisable // falls back to the deterministic layout, and the integrity sweep repairs // the extension by looking in the folder. - final relativePath = DownloadLayout.relativeFromLegacy( + final relativePath = + DownloadLayout.relativeFromLegacy( _stringOrNull(json['relativePath']) ?? _stringOrNull(json['localPath']), ) ?? @@ -103,8 +106,10 @@ abstract final class DownloadItemModel { title: _string(json['title']), sourceUrl: sourceUrl, kind: kind, + videoHeight: _intOrNull(json['videoHeight']), relativePath: relativePath, - thumbnailUrl: _stringOrNull(json['thumbnailUrl']) ?? + thumbnailUrl: + _stringOrNull(json['thumbnailUrl']) ?? _stringOrNull(json['thumbnail']), thumbnailRelativePath: thumbnailRelative, headers: _headers(json['headers']), @@ -117,14 +122,17 @@ abstract final class DownloadItemModel { ? (legacyMultiPartDone ? 0 : legacyTotal) : _int(json['totalUnits']), sizeBytes: legacy - ? (unit == DownloadUnit.bytes || legacyMultiPartDone ? legacyTotal : 0) + ? (unit == DownloadUnit.bytes || legacyMultiPartDone + ? legacyTotal + : 0) : _int(json['sizeBytes']), createdAt: _int(json['createdAt']), updatedAt: _int(json['updatedAt']), isSerial: json['isSerial'] == true, episodeNumber: _intOrNull(json['episodeNumber']), episodeLabel: _stringOrNull(json['episodeLabel']), - pageUrls: (json['pageUrls'] as List?) + pageUrls: + (json['pageUrls'] as List?) ?.map((e) => e.toString()) .where((e) => e.isNotEmpty) .toList() ?? @@ -155,18 +163,18 @@ abstract final class DownloadItemModel { } static int _int(Object? raw) => switch (raw) { - int value => value, - num value => value.toInt(), - String value => int.tryParse(value) ?? 0, - _ => 0, - }; + int value => value, + num value => value.toInt(), + String value => int.tryParse(value) ?? 0, + _ => 0, + }; static int? _intOrNull(Object? raw) => switch (raw) { - int value => value, - num value => value.toInt(), - String value => int.tryParse(value), - _ => null, - }; + int value => value, + num value => value.toInt(), + String value => int.tryParse(value), + _ => null, + }; } extension on String { diff --git a/lib/features/download/data/repositories/download_repository_impl.dart b/lib/features/download/data/repositories/download_repository_impl.dart index d295b9fa..43104f7e 100644 --- a/lib/features/download/data/repositories/download_repository_impl.dart +++ b/lib/features/download/data/repositories/download_repository_impl.dart @@ -53,11 +53,11 @@ class DownloadRepositoryImpl implements DownloadRepository { required DownloadNativeDataSource native, required DownloadTransferDataSource transfer, required HiveService hive, - }) : _local = local, - _storage = storage, - _native = native, - _transfer = transfer, - _hive = hive; + }) : _local = local, + _storage = storage, + _native = native, + _transfer = transfer, + _hive = hive; final DownloadLocalDataSource _local; final DownloadStorage _storage; @@ -122,8 +122,7 @@ class DownloadRepositoryImpl implements DownloadRepository { // or walking back into Wi-Fi left downloads sitting there until something // else happened to pump the queue — which for most people is never. _hive.downloadWifiOnlyChanged.addListener(_pump); - _connectivity = - Connectivity().onConnectivityChanged.listen((_) => _pump()); + _connectivity = Connectivity().onConnectivityChanged.listen((_) => _pump()); if (_useNative) { _nativePoll = Timer.periodic( @@ -179,7 +178,8 @@ class DownloadRepositoryImpl implements DownloadRepository { final existing = _local.get(request.id); if (existing != null && - (existing.status.isActive || existing.status == DownloadStatus.completed) && + (existing.status.isActive || + existing.status == DownloadStatus.completed) && !_isStale(existing)) { return EnqueueOutcome.alreadyPresent; } @@ -187,8 +187,8 @@ class DownloadRepositoryImpl implements DownloadRepository { return EnqueueOutcome.alreadyPresent; } - final kind = request.kind ?? - DownloadKind.fromLegacy('video', request.sourceUrl); + final kind = + request.kind ?? DownloadKind.fromLegacy('video', request.sourceUrl); if (kind != DownloadKind.manga && request.sourceUrl.trim().isEmpty) { return EnqueueOutcome.notDownloadable; @@ -204,6 +204,7 @@ class DownloadRepositoryImpl implements DownloadRepository { contentUrl: request.contentUrl, provider: request.provider, title: request.title, + videoHeight: request.videoHeight, sourceUrl: request.sourceUrl, kind: kind, relativePath: DownloadLayout.artefactFor( @@ -215,8 +216,7 @@ class DownloadRepositoryImpl implements DownloadRepository { headers: request.headers, status: DownloadStatus.pending, unit: DownloadUnit.forKind(kind), - createdAt: existing?.createdAt ?? - DateTime.now().millisecondsSinceEpoch, + createdAt: existing?.createdAt ?? DateTime.now().millisecondsSinceEpoch, updatedAt: DateTime.now().millisecondsSinceEpoch, isSerial: request.isSerial, episodeNumber: request.episodeNumber, @@ -238,8 +238,7 @@ class DownloadRepositoryImpl implements DownloadRepository { /// A `completed` row whose file is gone is not a reason to refuse a new /// download of the same thing — it is the reason for one. bool _isStale(DownloadItem item) => - item.status == DownloadStatus.completed && - absolutePathOf(item) == null; + item.status == DownloadStatus.completed && absolutePathOf(item) == null; // --- controls ------------------------------------------------------------ @@ -274,16 +273,23 @@ class DownloadRepositoryImpl implements DownloadRepository { await _requeue(item, resetAttempts: true); } - Future _requeue(DownloadItem item, {required bool resetAttempts}) async { + Future _requeue( + DownloadItem item, { + required bool resetAttempts, + }) async { if (_running.contains(item.id) || _queue.contains(item.id)) return; // Whatever the viewer asked for a moment ago, they are asking for this now. _pendingIntent.remove(item.id); - await _local.put(_stamp(item.copyWith( - status: DownloadStatus.pending, - failure: null, - failureDetail: '', - attempts: resetAttempts ? 0 : item.attempts, - ))); + await _local.put( + _stamp( + item.copyWith( + status: DownloadStatus.pending, + failure: null, + failureDetail: '', + attempts: resetAttempts ? 0 : item.attempts, + ), + ), + ); _queue.add(item.id); _pump(); } @@ -299,13 +305,17 @@ class DownloadRepositoryImpl implements DownloadRepository { final item = _local.get(id); if (item == null) return; await _storage.deleteItem(id); - await _local.put(_stamp(item.copyWith( - status: DownloadStatus.failed, - failure: DownloadFailureKind.unknown, - failureDetail: 'cancelled', - completedUnits: 0, - sizeBytes: 0, - ))); + await _local.put( + _stamp( + item.copyWith( + status: DownloadStatus.failed, + failure: DownloadFailureKind.unknown, + failureDetail: 'cancelled', + completedUnits: 0, + sizeBytes: 0, + ), + ), + ); } @override @@ -548,11 +558,15 @@ class DownloadRepositoryImpl implements DownloadRepository { return; } - await _local.put(_stamp(verified.copyWith( - status: DownloadStatus.completed, - failure: null, - failureDetail: '', - ))); + await _local.put( + _stamp( + verified.copyWith( + status: DownloadStatus.completed, + failure: null, + failureDetail: '', + ), + ), + ); } Future _fail( @@ -560,12 +574,14 @@ class DownloadRepositoryImpl implements DownloadRepository { DownloadFailureKind kind, String detail, ) async { - final failed = _stamp(item.copyWith( - status: DownloadStatus.failed, - failure: kind, - failureDetail: detail, - attempts: item.attempts + 1, - )); + final failed = _stamp( + item.copyWith( + status: DownloadStatus.failed, + failure: kind, + failureDetail: detail, + attempts: item.attempts + 1, + ), + ); await _local.put(failed); debugPrint('[downloads] ${item.id} failed: ${kind.id} — $detail'); @@ -699,7 +715,8 @@ class DownloadRepositoryImpl implements DownloadRepository { Future _verify(DownloadItem item) async { // A transfer in flight owns its own row; checking it mid-write would // report a file that is deliberately incomplete. - if (_running.contains(item.id) || item.status == DownloadStatus.downloading) { + if (_running.contains(item.id) || + item.status == DownloadStatus.downloading) { return null; } @@ -750,8 +767,7 @@ class DownloadRepositoryImpl implements DownloadRepository { } final thumb = next.thumbnailRelativePath; - if (thumb != null && - !File(_storage.absoluteOf(thumb)).existsSync()) { + if (thumb != null && !File(_storage.absoluteOf(thumb)).existsSync()) { next = next.copyWith(thumbnailRelativePath: ''); changed = true; } @@ -770,8 +786,10 @@ class DownloadRepositoryImpl implements DownloadRepository { Future _isWhole(DownloadItem item) async { if (!item.kind.isMultiPart) return true; - final manifest = File('${_storage.dirOf(item.id)}/' - '${DownloadLayout.manifestName}'); + final manifest = File( + '${_storage.dirOf(item.id)}/' + '${DownloadLayout.manifestName}', + ); if (!await manifest.exists()) return true; try { @@ -782,8 +800,9 @@ class DownloadRepositoryImpl implements DownloadRepository { final prefix = item.kind == DownloadKind.hls ? 'seg_' : 'p_'; var found = 0; - await for (final entity - in Directory(_storage.dirOf(item.id)).list(followLinks: false)) { + await for (final entity in Directory( + _storage.dirOf(item.id), + ).list(followLinks: false)) { if (entity is! File) continue; final name = entity.uri.pathSegments.last; if (!name.startsWith(prefix)) continue; @@ -892,7 +911,8 @@ class DownloadRepositoryImpl implements DownloadRepository { // destination out of space leaves two partial libraries and no way to tell // which is which. final needed = await _storage.totalSize(); - if (location.freeBytes > 0 && location.freeBytes < needed + _headroomBytes) { + if (location.freeBytes > 0 && + location.freeBytes < needed + _headroomBytes) { return MoveLocationOutcome.noSpace; } diff --git a/lib/features/download/domain/entities/download_item.dart b/lib/features/download/domain/entities/download_item.dart index 15432b57..e3c50ae7 100644 --- a/lib/features/download/domain/entities/download_item.dart +++ b/lib/features/download/domain/entities/download_item.dart @@ -20,6 +20,7 @@ class DownloadItem { required this.relativePath, required this.createdAt, this.kind = DownloadKind.video, + this.videoHeight, this.thumbnailUrl, this.thumbnailRelativePath, this.headers = const {}, @@ -56,6 +57,9 @@ class DownloadItem { final DownloadKind kind; + /// Declared rendition height; null means unknown, including legacy rows. + final int? videoHeight; + /// The artefact, relative to the app's documents directory: /// `downloads//index.m3u8`, `downloads//video.mkv`, or for a manga /// chapter the folder `downloads/`. @@ -171,41 +175,40 @@ class DownloadItem { String? failureDetail, int? attempts, int? updatedAt, - }) => - DownloadItem( - id: id, - contentUrl: contentUrl, - provider: provider, - title: title, - sourceUrl: sourceUrl, - kind: kind ?? this.kind, - relativePath: relativePath ?? this.relativePath, - thumbnailUrl: thumbnailUrl, - thumbnailRelativePath: - thumbnailRelativePath ?? this.thumbnailRelativePath, - headers: headers ?? this.headers, - status: status ?? this.status, - unit: unit ?? this.unit, - completedUnits: completedUnits ?? this.completedUnits, - totalUnits: totalUnits ?? this.totalUnits, - sizeBytes: sizeBytes ?? this.sizeBytes, - createdAt: createdAt, - updatedAt: updatedAt ?? this.updatedAt, - isSerial: isSerial, - episodeNumber: episodeNumber, - episodeLabel: episodeLabel, - pageUrls: pageUrls ?? this.pageUrls, - chapterRef: chapterRef, - chapterIndex: chapterIndex, - // `null` is a real value here — clearing the failure is what a retry - // does — so an unset sentinel is the only way to tell "leave it" from - // "remove it". - failure: identical(failure, _unset) - ? this.failure - : failure as DownloadFailureKind?, - failureDetail: failureDetail ?? this.failureDetail, - attempts: attempts ?? this.attempts, - ); + }) => DownloadItem( + id: id, + contentUrl: contentUrl, + provider: provider, + title: title, + sourceUrl: sourceUrl, + kind: kind ?? this.kind, + videoHeight: videoHeight, + relativePath: relativePath ?? this.relativePath, + thumbnailUrl: thumbnailUrl, + thumbnailRelativePath: thumbnailRelativePath ?? this.thumbnailRelativePath, + headers: headers ?? this.headers, + status: status ?? this.status, + unit: unit ?? this.unit, + completedUnits: completedUnits ?? this.completedUnits, + totalUnits: totalUnits ?? this.totalUnits, + sizeBytes: sizeBytes ?? this.sizeBytes, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + isSerial: isSerial, + episodeNumber: episodeNumber, + episodeLabel: episodeLabel, + pageUrls: pageUrls ?? this.pageUrls, + chapterRef: chapterRef, + chapterIndex: chapterIndex, + // `null` is a real value here — clearing the failure is what a retry + // does — so an unset sentinel is the only way to tell "leave it" from + // "remove it". + failure: identical(failure, _unset) + ? this.failure + : failure as DownloadFailureKind?, + failureDetail: failureDetail ?? this.failureDetail, + attempts: attempts ?? this.attempts, + ); static const Object _unset = Object(); } diff --git a/lib/features/download/domain/entities/download_request.dart b/lib/features/download/domain/entities/download_request.dart index 745158d8..4c23eee8 100644 --- a/lib/features/download/domain/entities/download_request.dart +++ b/lib/features/download/domain/entities/download_request.dart @@ -17,6 +17,7 @@ class DownloadRequest { required this.title, required this.sourceUrl, this.kind, + this.videoHeight, this.thumbnailUrl, this.headers = const {}, this.isSerial = false, @@ -39,24 +40,25 @@ class DownloadRequest { required String title, required String sourceUrl, String? thumbnailUrl, + int? videoHeight, Map headers = const {}, bool isSerial = false, int? episodeNumber, String? episodeLabel, - }) => - DownloadRequest( - id: videoId(contentUrl: contentUrl, episodeNumber: episodeNumber), - contentUrl: contentUrl, - provider: provider, - title: title, - sourceUrl: sourceUrl, - kind: DownloadKind.fromLegacy('video', sourceUrl), - thumbnailUrl: thumbnailUrl, - headers: headers, - isSerial: isSerial, - episodeNumber: episodeNumber, - episodeLabel: episodeLabel, - ); + }) => DownloadRequest( + id: videoId(contentUrl: contentUrl, episodeNumber: episodeNumber), + contentUrl: contentUrl, + provider: provider, + title: title, + sourceUrl: sourceUrl, + kind: DownloadKind.fromLegacy('video', sourceUrl), + videoHeight: videoHeight, + thumbnailUrl: thumbnailUrl, + headers: headers, + isSerial: isSerial, + episodeNumber: episodeNumber, + episodeLabel: episodeLabel, + ); /// One manga / manhwa / novel chapter. factory DownloadRequest.mangaChapter({ @@ -70,33 +72,33 @@ class DownloadRequest { int? chapterIndex, int? episodeNumber, String? episodeLabel, - }) => - DownloadRequest( - id: mangaChapterId( - contentUrl: contentUrl, - provider: provider, - chapterRef: chapterRef, - ), - contentUrl: contentUrl, - provider: provider, - title: title, - sourceUrl: '', - kind: DownloadKind.manga, - thumbnailUrl: thumbnailUrl, - headers: headers, - isSerial: true, - episodeNumber: episodeNumber, - episodeLabel: episodeLabel, - pageUrls: pageUrls, - chapterRef: chapterRef, - chapterIndex: chapterIndex, - ); + }) => DownloadRequest( + id: mangaChapterId( + contentUrl: contentUrl, + provider: provider, + chapterRef: chapterRef, + ), + contentUrl: contentUrl, + provider: provider, + title: title, + sourceUrl: '', + kind: DownloadKind.manga, + thumbnailUrl: thumbnailUrl, + headers: headers, + isSerial: true, + episodeNumber: episodeNumber, + episodeLabel: episodeLabel, + pageUrls: pageUrls, + chapterRef: chapterRef, + chapterIndex: chapterIndex, + ); final String id; final String contentUrl; final String provider; final String title; final String sourceUrl; + final int? videoHeight; /// Null lets the repository decide from [sourceUrl]. Passed explicitly only /// where the caller genuinely knows better. @@ -127,10 +129,7 @@ class DownloadRequest { /// /// Byte-identical to what the old `DownloadService.videoId` produced, so an /// install upgrading keeps every download it already had. - static String videoId({ - required String contentUrl, - int? episodeNumber, - }) => + static String videoId({required String contentUrl, int? episodeNumber}) => _fnv( episodeNumber == null ? contentUrl : '${contentUrl}_ep$episodeNumber', ); @@ -139,6 +138,5 @@ class DownloadRequest { required String contentUrl, required String provider, required String chapterRef, - }) => - _fnv('manga|$contentUrl|$provider|$chapterRef'); + }) => _fnv('manga|$contentUrl|$provider|$chapterRef'); } diff --git a/lib/features/download/presentation/pages/downloads_page.dart b/lib/features/download/presentation/pages/downloads_page.dart index d6cc5d84..e27b338c 100644 --- a/lib/features/download/presentation/pages/downloads_page.dart +++ b/lib/features/download/presentation/pages/downloads_page.dart @@ -35,7 +35,9 @@ import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart /// answer — which is the difference between "File not found" as a dead end and /// a row that offers to fetch the file back. class DownloadsPage extends StatelessWidget { - const DownloadsPage({super.key}); + const DownloadsPage({super.key, this.isTab = false}); + + final bool isTab; @override Widget build(BuildContext context) { @@ -49,13 +51,15 @@ class DownloadsPage extends StatelessWidget { location: getIt(), hive: getIt(), )..add(const DownloadsStarted()), - child: const _DownloadsView(), + child: _DownloadsView(isTab: isTab), ); } } class _DownloadsView extends StatelessWidget { - const _DownloadsView(); + const _DownloadsView({required this.isTab}); + + final bool isTab; @override Widget build(BuildContext context) { @@ -94,12 +98,14 @@ class _DownloadsView extends StatelessWidget { titleSpacing: 16, title: Row( children: [ - _CircleBackButton( - onTap: () => context.canPop() - ? context.pop() - : context.go('/main'), - ), - const SizedBox(width: 12), + if (!isTab) ...[ + _CircleBackButton( + onTap: () => context.canPop() + ? context.pop() + : context.go('/main'), + ), + const SizedBox(width: 12), + ], Expanded( child: Text( 'navigation.downloads'.tr(), @@ -117,8 +123,7 @@ class _DownloadsView extends StatelessWidget { onRetryAll: () => bloc.add(const DownloadsRetryAllRequested()), onClearAll: () => _confirmClear(context, bloc), - onSort: (sort) => - bloc.add(DownloadsSortChanged(sort)), + onSort: (sort) => bloc.add(DownloadsSortChanged(sort)), sort: state.sort, ), ], @@ -153,8 +158,7 @@ class _DownloadsView extends StatelessWidget { ? 'downloads.waiting_for_wifi'.tr() : 'downloads.wifi_only_desc'.tr(), value: state.wifiOnly, - onChanged: (v) => - bloc.add(DownloadsWifiOnlyToggled(v)), + onChanged: (v) => bloc.add(DownloadsWifiOnlyToggled(v)), ), ), ), @@ -174,18 +178,16 @@ class _DownloadsView extends StatelessWidget { // "No downloads yet" is wrong when there are twelve of // them and the filter is hiding all twelve. filtered: state.total > 0, - onClearFilter: () => bloc - .add(const DownloadsFilterChanged(DownloadsFilter.all)), + onClearFilter: () => bloc.add( + const DownloadsFilterChanged(DownloadsFilter.all), + ), ), ) else SliverList.separated( itemCount: state.groups.length, - separatorBuilder: (_, _) => Divider( - color: AppColors.divider, - height: 1, - indent: 82, - ), + separatorBuilder: (_, _) => + Divider(color: AppColors.divider, height: 1, indent: 82), itemBuilder: (_, i) { final group = state.groups[i]; return DownloadGroupTile( @@ -414,14 +416,11 @@ class _OverflowMenu extends StatelessWidget { CheckedPopupMenuItem( value: option, checked: option == sort, - child: Text( - switch (option) { - DownloadsSort.newest => 'downloads.sort_newest'.tr(), - DownloadsSort.title => 'downloads.sort_title'.tr(), - DownloadsSort.size => 'downloads.sort_size'.tr(), - }, - style: const TextStyle(color: AppColors.textPrimary), - ), + child: Text(switch (option) { + DownloadsSort.newest => 'downloads.sort_newest'.tr(), + DownloadsSort.title => 'downloads.sort_title'.tr(), + DownloadsSort.size => 'downloads.sort_size'.tr(), + }, style: const TextStyle(color: AppColors.textPrimary)), ), const PopupMenuDivider(), PopupMenuItem( diff --git a/lib/features/download/presentation/widgets/download_choice_sheet.dart b/lib/features/download/presentation/widgets/download_choice_sheet.dart new file mode 100644 index 00000000..9b1062f4 --- /dev/null +++ b/lib/features/download/presentation/widgets/download_choice_sheet.dart @@ -0,0 +1,274 @@ +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/player/hls_variants.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/detail/domain/download_choices.dart'; +import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; +import 'package:soplay/features/detail/domain/video_option_groups.dart'; + +/// A concrete file/rendition, not the quality currently used by the player. +class DownloadSelection { + const DownloadSelection({ + required this.url, + required this.headers, + this.height, + }); + final String url; + final Map headers; + final int? height; +} + +Future chooseDownload( + BuildContext context, { + required String url, + required Map headers, + String? type, + int? height, + List sources = const [], +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => DownloadChoiceSheet( + url: url, + headers: headers, + type: type, + height: height, + sources: sources, + ), +); + +class DownloadChoiceSheet extends StatefulWidget { + const DownloadChoiceSheet({ + super.key, + required this.url, + required this.headers, + this.type, + this.height, + this.sources = const [], + this.manifestClient, + }); + final String url; + final Map headers; + final String? type; + final int? height; + final List sources; + + /// Optional transport for deterministic manifest tests; owned by this sheet. + final Dio? manifestClient; + + @override + State createState() => _DownloadChoiceSheetState(); +} + +class _DownloadChoiceSheetState extends State { + late final _dio = + widget.manifestClient ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + late final List _sources; + int _source = 0; + int _variant = 0; + List _variants = []; + bool _loading = false; + bool _failed = false; + CancelToken? _cancel; + int _revision = 0; + + @override + void initState() { + super.initState(); + final matching = widget.sources + .where((s) => s.videoUrl == widget.url) + .firstOrNull; + _sources = [ + VideoSourceEntity( + quality: matching?.quality ?? '', + videoUrl: widget.url, + isDefault: true, + accessible: true, + headers: widget.headers, + type: widget.type ?? matching?.type, + height: + widget.height ?? + matching?.height ?? + VideoOptionGroups.resolutionOf(matching?.quality ?? ''), + sizeBytes: matching?.sizeBytes, + ), + ]; + final seen = {widget.url}; + for (final s in widget.sources) { + if (s.accessible && + s.drm == null && + (s.type == 'hls' || + s.type == 'mp4' || + s.type == 'video' || + RegExp(r'\.(m3u8|mp4|mkv)(\?|$)').hasMatch(s.videoUrl)) && + seen.add(s.videoUrl)) { + _sources.add(s); + } + } + _probe(); + } + + Future _probe() async { + _cancel?.cancel(); + final revision = ++_revision; + final source = _sources[_source]; + final isHls = + source.type == 'hls' || + source.type == 'm3u8' || + Uri.tryParse(source.videoUrl)?.path.endsWith('.m3u8') == true; + setState(() { + _variants = []; + _variant = 0; + _failed = false; + _loading = isHls; + }); + if (!isHls) return; + final cancel = _cancel = CancelToken(); + try { + final result = await _dio.get( + source.videoUrl, + options: Options( + headers: source.headers, + responseType: ResponseType.plain, + ), + cancelToken: cancel, + ); + if (!mounted || revision != _revision) return; + setState(() { + _variants = parseHlsVariants(result.data ?? '', result.realUri); + _loading = false; + }); + } catch (_) { + if (!mounted || revision != _revision) return; + setState(() { + _failed = true; + _loading = false; + }); + } + } + + @override + void dispose() { + _cancel?.cancel(); + _dio.close(force: true); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final source = _sources[_source]; + final size = DownloadChoices.formatSize(source.sizeBytes); + return SafeArea( + top: false, + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.75, + child: Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + Text( + 'ux.download_options'.tr(), + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'ux.download_options_note'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + if (_sources.length > 1) ...[ + const SizedBox(height: 12), + for (var i = 0; i < _sources.length; i++) + ListTile( + title: Text( + _sources[i].quality.isEmpty + ? 'ux.source_number'.tr(args: ['${i + 1}']) + : _sources[i].quality, + ), + trailing: i == _source ? const Icon(Icons.check) : null, + selected: i == _source, + onTap: () { + _source = i; + _probe(); + }, + ), + ], + const SizedBox(height: 12), + if (_loading) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 12), + Text('ux.checking_quality'.tr()), + ] else if (_failed) ...[ + Text('ux.quality_unavailable'.tr()), + TextButton( + onPressed: _probe, + child: Text('general.retry'.tr()), + ), + ] else if (_variants.isNotEmpty) + for (var i = 0; i < _variants.length; i++) + ListTile( + title: Text('${_variants[i].height}p'), + selected: i == _variant, + trailing: i == _variant + ? const Icon(Icons.check) + : null, + onTap: () => setState(() => _variant = i), + ) + else + Text( + source.height == null + ? 'ux.quality_unknown'.tr() + : '${source.height}p', + ), + const SizedBox(height: 12), + // A master size cannot describe a selected rendition; never reuse it. + Text( + size != null && _variants.isEmpty + ? size + : 'ux.size_unknown'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton( + onPressed: _loading + ? null + : () { + final variant = _variants.isEmpty + ? null + : _variants[_variant]; + Navigator.pop( + context, + DownloadSelection( + url: variant?.url ?? source.videoUrl, + headers: source.headers, + height: _failed + ? null + : variant?.height ?? source.height, + ), + ); + }, + child: Text('detail.download_action'.tr()), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/download/presentation/widgets/download_group_tile.dart b/lib/features/download/presentation/widgets/download_group_tile.dart index 4f93ff99..9464af23 100644 --- a/lib/features/download/presentation/widgets/download_group_tile.dart +++ b/lib/features/download/presentation/widgets/download_group_tile.dart @@ -232,7 +232,8 @@ class _DownloadRow extends StatelessWidget { // `missing` is tappable on purpose: the tap re-downloads. A row that // says the file is gone and then does nothing when pressed is the state // this whole screen was rebuilt to remove. - onTap: item.status == DownloadStatus.completed || + onTap: + item.status == DownloadStatus.completed || item.status == DownloadStatus.missing ? onOpen : null, @@ -300,7 +301,21 @@ class _DownloadRow extends StatelessWidget { : 'EP $number · $label'; } - Widget _statusLine() { + Widget _statusLine() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!item.isManga) + Text( + item.videoHeight == null + ? 'ux.quality_unknown'.tr() + : '${item.videoHeight}p', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), + ), + _transferStatus(), + ], + ); + + Widget _transferStatus() { switch (item.status) { case DownloadStatus.downloading: case DownloadStatus.pending: @@ -322,7 +337,10 @@ class _DownloadRow extends StatelessWidget { const SizedBox(height: 4), Text( downloadProgressLabel(item), - style: const TextStyle(color: AppColors.textHint, fontSize: 10), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), ), ], ); @@ -582,7 +600,9 @@ class _CircleAction extends StatelessWidget { ), child: Icon(icon, size: 18, color: color), ); - if (onTap == null) return SizedBox(width: 44, height: 44, child: Center(child: disc)); + if (onTap == null) { + return SizedBox(width: 44, height: 44, child: Center(child: disc)); + } return Semantics( button: true, label: semanticLabel, diff --git a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart index 51f943f0..15125deb 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -76,7 +77,7 @@ class _MangayomiSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'Installing extensions…'; + _status = 'ext.installing_extensions'.tr(); }); var added = 0; @@ -104,12 +105,14 @@ class _MangayomiSourcesPageState extends State { _statusError = added == 0; if (added == 0) { _status = failures.isEmpty - ? 'No JavaScript extensions found at that URL.' - : 'Could not install: ${failures.first}'; + ? 'ext.none_found_js'.tr() + : 'ext.could_not_install'.tr(args: [failures.first]); } else { - _status = 'Added $added source(s).' - '${skippedDart > 0 ? ' $skippedDart Dart-only source(s) skipped — ' - 'those are built into Mangayomi and cannot run here.' : ''}'; + _status = 'ext.added_sources'.tr(args: ['$added']); + if (skippedDart > 0) { + _status = '$_status ' + '${'ext.skipped_dart'.tr(args: ['$skippedDart'])}'; + } } }); } @@ -120,7 +123,7 @@ class _MangayomiSourcesPageState extends State { _reloadProviders(); setState(() { _statusError = false; - _status = 'Repo removed.'; + _status = 'ext.repo_removed'.tr(); }); } @@ -129,7 +132,7 @@ class _MangayomiSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'Checking for extension updates…'; + _status = 'ext.checking_extension_updates'.tr(); }); try { final count = await _store.checkUpdates(); @@ -141,14 +144,14 @@ class _MangayomiSourcesPageState extends State { setState(() { _statusError = false; _status = count == 0 - ? 'All extensions are up to date.' - : 'Updated $count extension(s).'; + ? 'ext.all_up_to_date'.tr() + : 'ext.updated_count'.tr(args: ['$count']); }); } catch (e) { if (!mounted) return; setState(() { _statusError = true; - _status = 'Update check failed: $e'; + _status = 'ext.update_check_failed'.tr(args: ['$e']); }); } finally { if (mounted) setState(() => _busy = false); @@ -159,10 +162,9 @@ class _MangayomiSourcesPageState extends State { Widget build(BuildContext context) { if (!MangayomiRuntime.isSupported) { return Scaffold( - appBar: AppBar(title: const Text('Mangayomi Sources')), - body: const Center( - child: Text('JavaScript extensions need a WebView, ' - 'which is unavailable on this platform.'), + appBar: AppBar(title: Text('ext.mangayomi_title'.tr())), + body: Center( + child: Text('ext.needs_webview'.tr()), ), ); } @@ -175,10 +177,10 @@ class _MangayomiSourcesPageState extends State { surfaceTintColor: Colors.transparent, scrolledUnderElevation: 0, elevation: 0, - title: const Text('Mangayomi Sources'), + title: Text('ext.mangayomi_title'.tr()), actions: [ IconButton( - tooltip: 'Check for extension updates', + tooltip: 'ext.check_extension_updates'.tr(), icon: const Icon(Icons.system_update_alt_rounded), onPressed: (_busy || repos.isEmpty) ? null : _checkUpdates, ), @@ -207,7 +209,7 @@ class _MangayomiSourcesPageState extends State { const SizedBox(height: 24), Row( children: [ - Text('INSTALLED REPOS', + Text('ext.installed_repos'.tr(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppColors.textHint, letterSpacing: 1)), const Spacer(), @@ -224,7 +226,7 @@ class _MangayomiSourcesPageState extends State { children: [ const Icon(Icons.javascript_outlined, size: 16, color: _accent), const SizedBox(width: 6), - Text('SOURCES', + Text('ext.sources_heading'.tr(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppColors.textHint, letterSpacing: 1)), const Spacer(), @@ -257,12 +259,10 @@ class _MangayomiSourcesPageState extends State { )), ), const SizedBox(width: 12), - const Expanded( + Expanded( child: Text( - 'Mangayomi extensions are JavaScript, not Android apps — so unlike ' - 'CloudStream, Aniyomi and Manga sources, these also work on iOS, ' - 'macOS and Windows.', - style: TextStyle( + 'ext.mangayomi_desc'.tr(), + style: const TextStyle( color: AppColors.textHint, fontSize: 12.5, height: 1.35), ), ), @@ -285,7 +285,7 @@ class _MangayomiSourcesPageState extends State { style: const TextStyle(color: Colors.white), onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: 'Index URL', + labelText: 'ext.index_url'.tr(), labelStyle: const TextStyle(color: AppColors.textHint), floatingLabelStyle: const TextStyle(color: _accent), hintText: 'https://…/index.json', @@ -320,7 +320,7 @@ class _MangayomiSourcesPageState extends State { child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : const Icon(Icons.add), - label: Text(_busy ? 'Installing…' : 'Add repo'), + label: Text(_busy ? 'ext.installing'.tr() : 'ext.add_repo'.tr()), ), ), ], @@ -363,11 +363,13 @@ class _MangayomiSourcesPageState extends State { color: AppColors.surface, borderRadius: BorderRadius.circular(14), ), - child: const Column( + child: Column( children: [ - Icon(Icons.cloud_off_outlined, color: AppColors.textHint, size: 32), - SizedBox(height: 8), - Text('No repos yet', style: TextStyle(color: AppColors.textHint)), + const Icon(Icons.cloud_off_outlined, + color: AppColors.textHint, size: 32), + const SizedBox(height: 8), + Text('ext.no_repos_yet'.tr(), + style: const TextStyle(color: AppColors.textHint)), ], ), ); @@ -474,8 +476,8 @@ class _MangayomiSourcesPageState extends State { ); static String _typeLabel(MangayomiItemType t) => switch (t) { - MangayomiItemType.anime => 'Anime', - MangayomiItemType.novel => 'Novel', - MangayomiItemType.manga => 'Manga', + MangayomiItemType.anime => 'ext.type_anime'.tr(), + MangayomiItemType.novel => 'ext.type_novel'.tr(), + MangayomiItemType.manga => 'ext.type_manga'.tr(), }; } diff --git a/lib/features/extensions/presentation/repo_file_import.dart b/lib/features/extensions/presentation/repo_file_import.dart index ba327bec..9c9c498e 100644 --- a/lib/features/extensions/presentation/repo_file_import.dart +++ b/lib/features/extensions/presentation/repo_file_import.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -155,7 +156,7 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { setState(() { _busy = true; _error = false; - _result = 'Installing…'; + _result = 'ext.installing'.tr(); }); try { var count = 0; @@ -165,9 +166,7 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { // only the url form is supported here. final url = _url; if (url == null || url.isEmpty) { - throw Exception( - 'Mangayomi repos install from a URL — open the index link ' - 'instead of a downloaded copy.'); + throw Exception('ext.mangayomi_url_only'.tr()); } final res = await getIt().addRepo(url); count = res['added'] ?? 0; @@ -190,15 +189,14 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { setState(() { _error = count == 0; _result = count == 0 - ? 'No extensions found in this file. It may be for a different app, ' - 'or the wrong type was selected.' - : 'Installed $count source(s).'; + ? 'ext.import_none_found'.tr() + : 'ext.installed_from_file'.tr(args: ['$count']); }); } catch (e) { if (!mounted) return; setState(() { _error = true; - _result = 'Error: $e'; + _result = 'ext.error'.tr(args: ['$e']); }); } finally { if (mounted) setState(() => _busy = false); @@ -248,9 +246,9 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Install extension repo?', - style: TextStyle( + Text( + 'ext.import_title'.tr(), + style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w800, @@ -271,38 +269,35 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { ), const SizedBox(height: 16), if (_ambiguous && _apkHostsAvailable) - const Padding( - padding: EdgeInsets.only(bottom: 8), + Padding( + padding: const EdgeInsets.only(bottom: 8), child: Text( - 'Anime and manga repos use the same file format, so pick which ' - 'library this one belongs to.', - style: TextStyle( + 'ext.import_pick_library'.tr(), + style: const TextStyle( color: AppColors.textHint, fontSize: 12, height: 1.35), ), ), if (!_apkHostsAvailable) - const Padding( - padding: EdgeInsets.only(bottom: 8), + Padding( + padding: const EdgeInsets.only(bottom: 8), child: Text( - 'On this platform only Mangayomi (JavaScript) extensions can ' - 'be installed — CloudStream, Aniyomi and Mihon repos ship ' - 'Android app packages.', - style: TextStyle( + 'ext.import_js_only'.tr(), + style: const TextStyle( color: AppColors.textHint, fontSize: 12, height: 1.35), ), ), SegmentedButton<_Target>( segments: [ - if (_apkHostsAvailable) ...const [ + if (_apkHostsAvailable) ...[ ButtonSegment( value: _Target.manga, - label: Text('Manga'), - icon: Icon(Icons.menu_book_outlined, size: 16), + label: Text('ext.type_manga'.tr()), + icon: const Icon(Icons.menu_book_outlined, size: 16), ), ButtonSegment( value: _Target.aniyomi, - label: Text('Anime'), - icon: Icon(Icons.play_circle_outline, size: 16), + label: Text('ext.type_anime'.tr()), + icon: const Icon(Icons.play_circle_outline, size: 16), ), ], const ButtonSegment( @@ -356,18 +351,18 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { : Icon(done ? Icons.check : Icons.download_rounded), label: Text( _busy - ? 'Installing…' + ? 'ext.installing'.tr() : done - ? 'Done' - : 'Install', + ? 'general.done'.tr() + : 'general.install'.tr(), ), ), ), if (!done) TextButton( onPressed: _busy ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel', - style: TextStyle(color: AppColors.textHint)), + child: Text('general.cancel'.tr(), + style: const TextStyle(color: AppColors.textHint)), ), ], ), diff --git a/lib/features/extensions/presentation/widgets/recommended_repos_section.dart b/lib/features/extensions/presentation/widgets/recommended_repos_section.dart index e41cec36..8c3aa12a 100644 --- a/lib/features/extensions/presentation/widgets/recommended_repos_section.dart +++ b/lib/features/extensions/presentation/widgets/recommended_repos_section.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/di/injection.dart'; @@ -97,7 +98,7 @@ class _RecommendedReposSectionState extends State { padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), child: Text( - _hidden ? 'Show' : 'Hide', + _hidden ? 'general.show'.tr() : 'general.hide'.tr(), style: const TextStyle( color: AppColors.textHint, fontSize: 11.5, @@ -271,14 +272,14 @@ class _InstalledChip extends StatelessWidget { color: Colors.green.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(20), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle, size: 14, color: Colors.green), - SizedBox(width: 4), + const Icon(Icons.check_circle, size: 14, color: Colors.green), + const SizedBox(width: 4), Text( - 'Installed', - style: TextStyle( + 'general.installed'.tr(), + style: const TextStyle( color: Colors.green, fontSize: 11, fontWeight: FontWeight.w600, diff --git a/lib/features/home/presentation/widgets/home_banner.dart b/lib/features/home/presentation/widgets/home_banner.dart index 6b3fe747..2e232bb2 100644 --- a/lib/features/home/presentation/widgets/home_banner.dart +++ b/lib/features/home/presentation/widgets/home_banner.dart @@ -55,6 +55,10 @@ class _HomeBannerState extends State { void _startTimer() { _timer = Timer.periodic(const Duration(seconds: 5), (_) { if (!mounted || widget.slides.length < 2) return; + if (MediaQuery.disableAnimationsOf(context) || + !TickerMode.valuesOf(context).enabled) { + return; + } if (!_ctrl.hasClients) return; // Auto-advancing on top of a drag or a fling yanked the slide out from // under the finger; skip this tick and take the next one instead. @@ -169,9 +173,9 @@ class _SlideContent extends StatelessWidget { return switch (slide) { MovieHeroSlide(:final movie) => _MovieSlide(movie: movie), BannerHeroSlide(:final banner) => _BannerSlide( - banner: banner, - onTap: () => onBannerTap(banner), - ), + banner: banner, + onTap: () => onBannerTap(banner), + ), }; } } @@ -253,8 +257,9 @@ class _BannerSlide extends StatelessWidget { fit: BoxFit.cover, placeholder: (_, _) => const HomeImagePlaceholder(icon: Icons.image_outlined), - errorWidget: (_, _, _) => - const HomeImagePlaceholder(icon: Icons.image_not_supported_outlined), + errorWidget: (_, _, _) => const HomeImagePlaceholder( + icon: Icons.image_not_supported_outlined, + ), ), const _SlideOverlays(), Positioned( @@ -641,8 +646,10 @@ class _DesktopSlideState extends State<_DesktopSlide> vsync: this, duration: const Duration(seconds: 14), ); - _zoom = Tween(begin: 1.05, end: 1.16) - .animate(CurvedAnimation(parent: _kb, curve: Curves.easeOut)); + _zoom = Tween( + begin: 1.05, + end: 1.16, + ).animate(CurvedAnimation(parent: _kb, curve: Curves.easeOut)); _kb.forward(); Future.delayed(const Duration(milliseconds: 250), () { if (mounted) _in.forward(); @@ -769,8 +776,7 @@ class _DesktopSlideState extends State<_DesktopSlide> Wrap( spacing: 6, runSpacing: 6, - children: - meta.take(4).map((m) => _MetaChip(label: m)).toList(), + children: meta.take(4).map((m) => _MetaChip(label: m)).toList(), ), const SizedBox(height: 12), Text( @@ -784,7 +790,11 @@ class _DesktopSlideState extends State<_DesktopSlide> height: 1.12, letterSpacing: -0.5, shadows: [ - Shadow(color: Colors.black, blurRadius: 6, offset: Offset(1, 1)), + Shadow( + color: Colors.black, + blurRadius: 6, + offset: Offset(1, 1), + ), ], ), ), @@ -837,7 +847,11 @@ class _DesktopSlideState extends State<_DesktopSlide> height: 1.12, letterSpacing: -0.5, shadows: [ - Shadow(color: Colors.black, blurRadius: 6, offset: Offset(1, 1)), + Shadow( + color: Colors.black, + blurRadius: 6, + offset: Offset(1, 1), + ), ], ), ), diff --git a/lib/features/home/presentation/widgets/home_state_views.dart b/lib/features/home/presentation/widgets/home_state_views.dart index 3687a569..e1f5cb2e 100644 --- a/lib/features/home/presentation/widgets/home_state_views.dart +++ b/lib/features/home/presentation/widgets/home_state_views.dart @@ -315,9 +315,11 @@ class HomeErrorView extends StatelessWidget { ), ], const SizedBox(height: 24), - SizedBox( - width: 156, - height: 44, + ConstrainedBox( + // Minimum, not fixed: "Retry" is five characters in English + // and fourteen in Portuguese, and a hard width clips the + // longer ones silently in a release build. + constraints: const BoxConstraints(minWidth: 156, minHeight: 44), child: ElevatedButton( onPressed: () => context.read().add(HomeLoad()), child: Text('general.retry'.tr()), @@ -398,9 +400,11 @@ class HomeEmptyView extends StatelessWidget { ), ), const SizedBox(height: 24), - SizedBox( - width: 156, - height: 44, + ConstrainedBox( + // Minimum, not fixed: "Retry" is five characters in English + // and fourteen in Portuguese, and a hard width clips the + // longer ones silently in a release build. + constraints: const BoxConstraints(minWidth: 156, minHeight: 44), child: ElevatedButton( onPressed: onRefresh, child: Text('general.retry'.tr()), diff --git a/lib/features/home/presentation/widgets/home_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index 542a55f7..4614309e 100644 --- a/lib/features/home/presentation/widgets/home_top_bar.dart +++ b/lib/features/home/presentation/widgets/home_top_bar.dart @@ -4,11 +4,8 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; -import 'package:soplay/core/navigation/app_tab.dart'; -import 'package:soplay/core/navigation/nav_controller.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/responsive.dart'; @@ -16,19 +13,11 @@ import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/download/domain/entities/download_status.dart'; import 'package:soplay/features/download/domain/usecases/get_downloads_usecase.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; -import 'package:soplay/features/home/presentation/bloc/home/home_state.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_event.dart'; import 'package:soplay/features/notifications/domain/repositories/notifications_repository.dart'; -import 'package:soplay/core/content/content_mode.dart'; -import 'package:soplay/core/widgets/mode_switch_overlay.dart'; -import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; -import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; -import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; -import 'package:soplay/features/streak/data/streak_service.dart'; -import 'package:soplay/features/streak/domain/entities/streak_state.dart'; -import 'package:soplay/features/streak/presentation/widgets/streak_badge.dart'; +import 'package:soplay/features/profile/presentation/widgets/provider_quick_switch.dart'; class HomeTopBar extends StatelessWidget { const HomeTopBar({super.key, required this.blurProgress}); @@ -40,39 +29,16 @@ class HomeTopBar extends StatelessWidget { final topPad = MediaQuery.of(context).padding.top; final progress = blurProgress.clamp(0.0, 1.0); - // The bar reports state. It does not navigate. - // - // It had grown to five permanent actions beside the wordmark, source pill - // and streak, which overflowed a 411dp phone by 18px and had to be scaled - // down to fit — a row that shrinks to survive is a row with too much in it. - // Every destination came out: search is already a default bottom tab, and - // watch party, AniList and Live TV moved to Profile, which is where a place - // you visit occasionally belongs. What is left is what TELLS you something - // without being opened — the live source, the streak, a download in flight, - // an unread count. + // Stable destinations: badges change, actions do not disappear or shrink. + const iconPad = 12.0; final compact = MediaQuery.sizeOf(context).width < 430; - final iconPad = compact ? 6.0 : 8.0; - final actions = [ - // First, and deliberately. This is the only place a viewer will notice - // that history is being suppressed — the player's sheet is not somewhere - // anyone opens to check. Renders nothing when incognito is off. _IncognitoIndicator(pad: iconPad), DesktopRefreshButton( color: AppColors.textPrimary, onRefresh: () => context.read().add(HomeLoad(silent: true)), ), - // Streak sits with the other status, not next to the source pill. - // Grouping the two things that report a count — a streak and an unread - // badge — puts every "here is where you stand" signal in one place and - // leaves the left side to say only what it is: the app, and the source. - const StreakBadge(), _DownloadIndicator(pad: iconPad), - _AnilistShortcut(pad: iconPad), - // Search rides next to AniList, but yields to a live streak: when the - // streak badge is showing its count the row is already full, so the - // search icon steps aside and lives where it always has — the bottom tab. - _SearchShortcut(pad: iconPad), _NotificationsIndicator(pad: iconPad), ]; @@ -91,24 +57,14 @@ class HomeTopBar extends StatelessWidget { ), ), SizedBox(width: compact ? 8 : 10), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: compact ? 116 : 170), - child: const _ProviderSwitcher(), - ), - // The last line of defence: a transient download badge, a long streak - // count or a large system font can still outgrow what is left, and a - // strip that scales a few percent reads better than a yellow bar. - // Expanded, not Flexible: a loose child shrinks to its own width and - // then sits wherever the row left it, so the strip floated mid-bar - // with dead space to its right. Filling the remainder is what lets - // centerRight actually pin it to the edge. - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: AlignmentDirectional.centerEnd, - child: Row(mainAxisSize: MainAxisSize.min, children: actions), + const Expanded( + child: Align( + alignment: AlignmentDirectional.centerStart, + child: _ProviderSwitcher(), ), ), + const SizedBox(width: 4), + ...actions, ], ), ); @@ -185,603 +141,48 @@ class _ProviderSwitcher extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(20), - onTap: () => _openSwitcher(context, state), - child: Container( - padding: const EdgeInsetsDirectional.fromSTEB(5, 4, 7, 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ProviderLogo(image: current?.image ?? '', size: 22), - const SizedBox(width: 6), - Flexible( - child: Text( - current?.name ?? '—', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.keyboard_arrow_down_rounded, - color: AppColors.textHint, - size: 18, - ), - ], - ), - ), - ), - ); - }, - ); - } - - /// Offline, a favourited server provider is dropped rather than offered — - /// picking it from the quick switcher would break the home screen. If that - /// leaves nothing, the caller falls through to the full picker, which - /// explains the outage. - List _resolveFavorites(ProviderLoaded state) { - final favIds = getIt().getFavoriteProviders(); - final byId = { - for (final p in state.providers) - if (state.isUsable(p)) p.id: p, - }; - return [ - for (final id in favIds) - if (byId[id] != null) byId[id]!, - ]; - } - - /// Moves to another kind of catalogue. - /// - /// The provider has to move with it: the home screen is driven by whichever - /// source is current, and leaving a video provider selected in manga mode - /// would show an empty screen and look like the switch failed. So the first - /// usable source of the new kind is picked — preferring a favourite, because - /// somebody who starred a manga source starred it for this. - /// - /// Nothing happens at all when the new mode has no sources: switching into an - /// empty mode is a dead end nobody can get out of except by switching back, - /// and saying so beats stranding them there. - Future _switchMode( - BuildContext context, - ProviderBloc bloc, - ProviderLoaded state, - ContentMode mode, - ) async { - final hive = getIt(); - final candidates = [ - for (final p in state.providers) - if (state.isUsable(p) && p.id.contentMode == mode) p, - ]; - if (candidates.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('mode.none_installed'.tr(args: [mode.labelKey.tr()]))), - ); - return; - } - final favIds = hive.getFavoriteProviders().toSet(); - final pick = candidates.firstWhere( - (p) => favIds.contains(p.id), - orElse: () => candidates.first, - ); - - await hive.setContentMode(mode.id); - if (!context.mounted) return; - - // Subscribed BEFORE the pick is dispatched, or the reload it triggers can - // land before anyone is listening and the cover would wait out its whole - // timeout over content that is already there. - // - // Only when the provider actually changes: MainPage reloads Home off the - // provider id moving, so an unchanged id means no reload to wait for and - // this would be a future that never completes. - final Future? loaded = pick.id == state.currentProviderId - ? null - : context.read().stream - .firstWhere((s) => s is HomeLoaded || s is HomeError) - .then((_) {}); - - // Selected BEFORE the animation, so the reload runs underneath the cover - // rather than starting when it lifts onto an empty screen. - bloc.add(ProviderSelect(pick.id)); - await ModeSwitchOverlay.play(context, mode, until: loaded); - } - - Future _openSwitcher(BuildContext context, ProviderLoaded state) async { - final bloc = context.read(); - final favorites = _resolveFavorites(state); - // Everything usable, not only the favourites. - // - // The sheet used to hold favourites and nothing else, and fell through to - // the full providers PAGE when there were none — which is most people, and - // is why the report was "I have to go to settings every time I want to - // change source". Switching source is the single most repeated action in - // this app and it must never leave the home screen. - final hive = getIt(); - final mode = ContentMode.fromId(hive.getContentMode()); - // Narrowed to the mode. Somebody who came to read should be choosing - // between the four readers they have, not finding them among thirty video - // providers — which is the version of this that sent people to Settings. - final all = [ - for (final p in state.providers) - if (state.isUsable(p) && p.id.contentMode == mode) p, - ]; - final result = await showAdaptiveModal( - context: context, - backgroundColor: AppColors.surface, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (_) => _ProviderQuickSwitchSheet( - favorites: [for (final f in favorites) if (f.id.contentMode == mode) f], - all: all, - mode: mode, - currentProviderId: state.currentProviderId, - ), - ); - if (result == null || !context.mounted) return; - if (result == _kAllProvidersAction) { - openProviderPicker(context, bloc); - return; - } - final switched = ContentMode.values - .where((m) => result == '$_kModePrefix${m.id}') - .firstOrNull; - if (switched != null) { - await _switchMode(context, bloc, state, switched); - return; - } - bloc.add(ProviderSelect(result)); - } -} - -const String _kAllProvidersAction = '__all_providers__'; - -/// Prefix that marks a sheet result as "switch to this mode" rather than -/// "select this provider". They share one return channel because the sheet is -/// one list, and a sentinel is cheaper than a second result type. -const String _kModePrefix = '__mode__:'; - -/// The one place a source gets changed. -/// -/// Favourites first because they are what somebody switches BETWEEN, then every -/// other usable source underneath, with a filter once the list is long enough -/// to need one. The full providers page is still reachable from the bottom, but -/// it is now for managing sources rather than for the everyday act of picking -/// one — which is what it had quietly become. -class _ProviderQuickSwitchSheet extends StatefulWidget { - const _ProviderQuickSwitchSheet({ - required this.favorites, - required this.all, - required this.mode, - required this.currentProviderId, - }); - - final List favorites; - final List all; - - /// The kind of catalogue these sources belong to. Shown as a row of chips at - /// the top, because the mode is the thing that decides what the rest of the - /// sheet contains — putting it anywhere else would make the list look like it - /// had lost sources. - final ContentMode mode; - - final String currentProviderId; - - @override - State<_ProviderQuickSwitchSheet> createState() => - _ProviderQuickSwitchSheetState(); -} - -class _ProviderQuickSwitchSheetState extends State<_ProviderQuickSwitchSheet> { - final TextEditingController _filter = TextEditingController(); - String _query = ''; - - /// Below this the filter is chrome over a list that already fits. - static const int _filterThreshold = 8; - - @override - void dispose() { - _filter.dispose(); - super.dispose(); - } - - /// The sources that are not already in the favourites block above. - /// - /// Listing a favourite twice makes the sheet look like it has duplicates and - /// makes the favourites section pointless. - List get _rest { - final favIds = {for (final p in widget.favorites) p.id}; - final q = _query.trim().toLowerCase(); - return [ - for (final p in widget.all) - if (!favIds.contains(p.id) && - (q.isEmpty || p.name.toLowerCase().contains(q))) - p, - ]; - } - - List get _shownFavorites { - final q = _query.trim().toLowerCase(); - if (q.isEmpty) return widget.favorites; - return [ - for (final p in widget.favorites) - if (p.name.toLowerCase().contains(q)) p, - ]; - } - - @override - Widget build(BuildContext context) { - final bottomPad = MediaQuery.paddingOf(context).bottom; - final favorites = _shownFavorites; - final rest = _rest; - // Tall enough to be worth opening, short enough to leave the home screen - // visible behind it — this is a switch, not a destination. - final maxHeight = MediaQuery.sizeOf(context).height * 0.72; - - return SafeArea( - top: false, - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 10), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppColors.border, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 12), - // Mode first. It is the widest cut, and every other row below is - // filtered by it. - SizedBox( - height: 34, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsetsDirectional.only(start: 16, end: 16), - children: [ - for (final m in ContentMode.values) ...[ - _ModeChip( - label: m.labelKey.tr(), - active: m == widget.mode, - onTap: m == widget.mode - ? null - : () => Navigator.of(context).pop('$_kModePrefix${m.id}'), - ), - const SizedBox(width: 8), - ], - ], - ), - ), - const SizedBox(height: 12), - if (widget.all.length >= _filterThreshold) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: TextField( - controller: _filter, - onChanged: (v) => setState(() => _query = v), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - ), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: AppColors.surfaceVariant, - hintText: 'profile.search_providers_hint'.tr(), - hintStyle: const TextStyle( - color: AppColors.textHint, - fontSize: 13.5, - ), - prefixIcon: const Icon( - Icons.search_rounded, - size: 19, - color: AppColors.textHint, - ), - prefixIconConstraints: const BoxConstraints(minWidth: 40), - contentPadding: const EdgeInsets.symmetric(vertical: 11), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(11), - borderSide: BorderSide.none, - ), + onTap: () => openProviderQuickSwitch(context), + // Keep a 48dp touch target without making the visible pill bulky. + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Container( + constraints: const BoxConstraints(minHeight: 36, maxWidth: 180), + padding: const EdgeInsetsDirectional.fromSTEB(8, 4, 8, 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), ), ), - ), - Flexible( - child: ListView( - shrinkWrap: true, - padding: EdgeInsets.zero, - children: [ - if (favorites.isNotEmpty) ...[ - _sectionLabel( - icon: Icons.star, - iconColor: Colors.amber, - text: 'profile.favorites'.tr(), - ), - for (final p in favorites) - _favoriteProviderTile(context, p, widget.currentProviderId), - ], - if (rest.isNotEmpty) ...[ - _sectionLabel( - icon: Icons.apps_rounded, - iconColor: AppColors.textSecondary, - text: 'profile.all_providers'.tr(), - ), - for (final p in rest) - _favoriteProviderTile(context, p, widget.currentProviderId), - ], - if (favorites.isEmpty && rest.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ProviderLogo(image: current?.image ?? '', size: 22), + const SizedBox(width: 6), + Flexible( child: Text( - 'profile.no_providers_in_category'.tr(), + current?.name ?? '—', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textHint, + color: Colors.white, fontSize: 13, + fontWeight: FontWeight.w600, ), ), ), - ], - ), - ), - Divider(color: AppColors.divider, height: 1), - // Still here, but now for MANAGING sources — favouriting, testing, - // installing — rather than for the everyday act of picking one. - ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.tune_rounded, - color: AppColors.textSecondary, - size: 18, - ), - ), - title: Text( - 'profile.section_providers'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - trailing: const Icon( - Icons.chevron_right_rounded, - color: AppColors.textHint, - size: 20, - ), - onTap: () => Navigator.of(context).pop(_kAllProvidersAction), - ), - SizedBox(height: bottomPad + 8), - ], - ), - ), - ); - } - - Widget _sectionLabel({ - required IconData icon, - required Color iconColor, - required String text, - }) => - Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), - child: Row( - children: [ - Icon(icon, color: iconColor, size: 16), - const SizedBox(width: 8), - Text( - text, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.3, - ), - ), - ], - ), - ); -} - -/// One of the three catalogue kinds. -/// -/// The active one is not tappable: switching to the mode you are already in -/// would replay the animation and reload the screen for no change, which reads -/// as the app stuttering. -class _ModeChip extends StatelessWidget { - const _ModeChip({required this.label, required this.active, this.onTap}); - - final String label; - final bool active; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - selected: active, - child: Material( - color: active ? AppColors.primary : AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(10), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Center( - child: Text( - label, - style: TextStyle( - color: active ? Colors.white : AppColors.textSecondary, - fontSize: 13, - fontWeight: active ? FontWeight.w700 : FontWeight.w500, + const SizedBox(width: 2), + const Icon( + Icons.keyboard_arrow_down_rounded, + color: AppColors.textHint, + size: 18, + ), + ], ), ), ), ), - ), - ), - ); - } -} - -Widget _favoriteProviderTile( - BuildContext context, - ProviderEntity p, - String currentProviderId, -) { - final selected = p.id == currentProviderId; - return ListTile( - leading: _ProviderLogo(image: p.image, size: 36), - title: Text( - p.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected ? AppColors.textPrimary : AppColors.textSecondary, - fontSize: 14, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - ), - ), - trailing: selected - ? Icon(Icons.check_rounded, color: AppColors.primary, size: 20) - : null, - onTap: () => Navigator.of(context).pop(p.id), - ); -} - -class _ProviderLogo extends StatelessWidget { - const _ProviderLogo({required this.image, required this.size}); - - final String image; - final double size; - - @override - Widget build(BuildContext context) { - final fallback = Container( - width: size, - height: size, - color: AppColors.surfaceVariant, - alignment: Alignment.center, - child: Icon( - Icons.movie_filter_outlined, - color: AppColors.textHint, - size: size * 0.55, - ), - ); - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: image.isEmpty - ? fallback - : Image.network( - image, - width: size, - height: size, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => fallback, - loadingBuilder: (_, child, chunk) => - chunk == null ? child : fallback, - ), - ); - } -} - -/// A tap target that matches the notification bell: same 24px icon, same -/// padding, so the row reads as one set of controls. -class _TopBarIconButton extends StatelessWidget { - const _TopBarIconButton({ - required this.child, - required this.onTap, - this.pad = 8, - }); - - final Widget child; - final VoidCallback onTap; - final double pad; - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onTap, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: pad, vertical: 10), - child: SizedBox(width: 24, height: 24, child: Center(child: child)), - ), - ), - ); - } -} - -class _AnilistShortcut extends StatelessWidget { - const _AnilistShortcut({this.pad = 8}); - - final double pad; - - @override - Widget build(BuildContext context) { - return _TopBarIconButton( - pad: pad, - onTap: () => context.push('/anilist'), - child: SvgPicture.asset( - 'assets/icons/anilist.svg', - width: 22, - height: 22, - ), - ); - } -} - -/// Search — but only while no streak is showing its count. A live streak fills -/// the row, so search yields and stays a bottom tab; without one it rides here. -class _SearchShortcut extends StatelessWidget { - const _SearchShortcut({this.pad = 8}); - - final double pad; - - @override - Widget build(BuildContext context) { - final streak = getIt(); - final loggedIn = getIt().isLoggedIn; - return ValueListenableBuilder( - valueListenable: streak.state, - builder: (context, state, _) { - final streakShowing = loggedIn && state.current > 0; - if (streakShowing) return const SizedBox.shrink(); - return _TopBarIconButton( - pad: pad, - onTap: () { - if (!getIt().goToId(TabId.search)) { - context.push('/cross-search'); - } - }, - child: const Icon( - Icons.search_rounded, - color: Colors.white, - size: 24, - ), ); }, ); @@ -893,7 +294,7 @@ class _NotificationsIndicatorState extends State<_NotificationsIndicator> _refresh(force: true); }, child: Padding( - padding: EdgeInsets.symmetric(horizontal: widget.pad, vertical: 10), + padding: EdgeInsets.symmetric(horizontal: widget.pad, vertical: 12), child: SizedBox( width: 24, height: 24, @@ -959,6 +360,7 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> final GetDownloadsUseCase _downloads = getIt(); late final AnimationController _pulse; bool _hasActive = false; + bool _reducedMotion = false; int _activeCount = 0; @override @@ -972,6 +374,17 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> _check(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reducedMotion = MediaQuery.disableAnimationsOf(context); + if (_reducedMotion) { + _pulse.stop(); + } else if (_hasActive && !_pulse.isAnimating) { + _pulse.repeat(); + } + } + @override void dispose() { _downloads.revision.removeListener(_check); @@ -991,7 +404,7 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> _hasActive = hasActive; _activeCount = active; }); - if (hasActive) { + if (hasActive && !_reducedMotion) { _pulse.repeat(); } else { _pulse.stop(); @@ -1002,14 +415,13 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> @override Widget build(BuildContext context) { - if (!_hasActive) return const SizedBox.shrink(); return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(24), onTap: () => context.push('/downloads'), child: Padding( - padding: EdgeInsets.symmetric(horizontal: widget.pad, vertical: 10), + padding: EdgeInsets.symmetric(horizontal: widget.pad, vertical: 12), child: SizedBox( width: 24, height: 24, @@ -1022,37 +434,40 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> color: Color.lerp( AppColors.primary, Colors.white, - (_pulse.value * 2 - 1).abs(), + MediaQuery.disableAnimationsOf(context) + ? 1 + : (_pulse.value * 2 - 1).abs(), ), size: 24, ), ), - Positioned( - right: 0, - top: 0, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 3), - constraints: const BoxConstraints( - minWidth: 12, - minHeight: 12, - ), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: Text( - _activeCount > 9 ? '9+' : '$_activeCount', - style: const TextStyle( - color: Colors.white, - fontSize: 7, - fontWeight: FontWeight.w900, - height: 1, + if (_hasActive) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 3), + constraints: const BoxConstraints( + minWidth: 12, + minHeight: 12, + ), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Center( + child: Text( + _activeCount > 9 ? '9+' : '$_activeCount', + style: const TextStyle( + color: Colors.white, + fontSize: 7, + fontWeight: FontWeight.w900, + height: 1, + ), ), ), ), ), - ), ], ), ), @@ -1062,7 +477,6 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> } } - /// Shown on the home bar whenever incognito is on. /// /// The mode persists across restarts, which is the right default for a privacy @@ -1134,18 +548,15 @@ class _IncognitoIndicatorState extends State<_IncognitoIndicator> { @override Widget build(BuildContext context) { if (!_hive.isIncognito) return const SizedBox.shrink(); - return _TopBarIconButton( - pad: widget.pad, - onTap: _confirmOff, - child: Tooltip( - message: 'profile.incognito'.tr(), - child: Icon( - Icons.visibility_off_rounded, - // The one item in this bar that is a warning rather than a - // shortcut, so it does not wear the same colour as the rest. - color: AppColors.errorLight, - size: 22, - ), + return IconButton( + tooltip: 'profile.incognito'.tr(), + onPressed: _confirmOff, + icon: Icon( + Icons.visibility_off_rounded, + // The one item in this bar that is a warning rather than a + // shortcut, so it does not wear the same colour as the rest. + color: AppColors.errorLight, + size: 22, ), ); } diff --git a/lib/features/home/presentation/widgets/view_all_widgets.dart b/lib/features/home/presentation/widgets/view_all_widgets.dart index 8245fad8..24a482d9 100644 --- a/lib/features/home/presentation/widgets/view_all_widgets.dart +++ b/lib/features/home/presentation/widgets/view_all_widgets.dart @@ -478,9 +478,11 @@ class ViewAllErrorView extends StatelessWidget { ), ], const SizedBox(height: 24), - SizedBox( - width: 156, - height: 44, + ConstrainedBox( + // Minimum, not fixed: "Retry" is five characters in English + // and fourteen in Portuguese, and a hard width clips the + // longer ones silently in a release build. + constraints: const BoxConstraints(minWidth: 156, minHeight: 44), child: ElevatedButton( onPressed: onRetry, child: Text('general.retry'.tr()), diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index dd5a2ef3..ed14f957 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -35,7 +35,6 @@ import '../../../../core/navigation/nav_controller.dart'; /// scope with it and the nav capsule threw on its next rebuild. const String _showcaseScope = 'main-nav'; - class MainPage extends StatefulWidget { const MainPage({super.key}); @@ -68,7 +67,9 @@ class _MainPageState extends State with WidgetsBindingObserver { bool _tvContentFocused = false; FocusScopeNode _tvScopeFor(TabId id) => _tvTabScopes.putIfAbsent( - id, () => FocusScopeNode(debugLabel: 'tvTab.${id.name}')); + id, + () => FocusScopeNode(debugLabel: 'tvTab.${id.name}'), + ); int get _shortsIndex => _visibleTabs.indexOf(TabId.shorts); // -1 if hidden int get _homeIndex { @@ -87,8 +88,9 @@ class _MainPageState extends State with WidgetsBindingObserver { // TV runs a fixed tab set — the customizer is drag-driven and hidden there, // and the persisted mobile order is left completely untouched (never read, // never written) so a user's phone bar survives round-tripping. - _visibleTabs = - isTvPlatform ? List.of(kTvTabs) : sanitizeTabOrder(_hiveService.tabOrder); + _visibleTabs = isTvPlatform + ? List.of(kTvTabs) + : sanitizeTabOrder(_hiveService.tabOrder); NavPrefs.tabOrder.value = _hiveService.tabOrder; NavPrefs.tabOrder.addListener(_onTabSetChange); _navController.tabIndexResolver = (id) => _visibleTabs.indexOf(id); @@ -217,7 +219,9 @@ class _MainPageState extends State with WidgetsBindingObserver { _shortsShowcaseStarted = false; return; } - ShowcaseView.getNamed(_showcaseScope).startShowCase([_shortsRefreshShowcaseKey]); + ShowcaseView.getNamed( + _showcaseScope, + ).startShowCase([_shortsRefreshShowcaseKey]); }); }); } @@ -317,10 +321,12 @@ class _MainPageState extends State with WidgetsBindingObserver { key: ValueKey(defs[i].id), // No-op off TV: returns the page widget unchanged. child: _tvWrapTab( - defs[i].builder(TabBuildContext( - isActive: _index == i, - shortsRefreshTick: _shortsRefreshTick, - )), + defs[i].builder( + TabBuildContext( + isActive: _index == i, + shortsRefreshTick: _shortsRefreshTick, + ), + ), defs[i].id, _index == i, ), @@ -330,6 +336,7 @@ class _MainPageState extends State with WidgetsBindingObserver { // Hide the floating bottom nav while a keyboard is open (e.g. searching), // otherwise it rides up and floats over the keyboard. final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + final readableNav = MediaQuery.textScalerOf(context).scale(12) > 15.6; return AnnotatedRegion( value: const SystemUiOverlayStyle( @@ -382,7 +389,14 @@ class _MainPageState extends State with WidgetsBindingObserver { ) : Scaffold( backgroundColor: AppColors.background, - extendBody: true, + extendBody: !readableNav, + bottomNavigationBar: readableNav && !keyboardOpen + ? ReadableNavigationBar( + index: _index, + items: defs, + onTap: _handleTabTap, + ) + : null, body: Stack( children: [ // The tab body is OUTSIDE the nav-style listener so switching @@ -391,83 +405,86 @@ class _MainPageState extends State with WidgetsBindingObserver { Positioned.fill( child: IndexedStack(index: _index, children: tabs), ), - if (!keyboardOpen) + if (!keyboardOpen && !readableNav) Positioned( - left: 0, - right: 0, - bottom: 0, - child: ValueListenableBuilder( - valueListenable: NavPrefs.navStyle, - builder: (context, style, _) { - // Classic: the original full-width frosted bar (keeps - // the per-tab Showcase + real double-tap-to-refresh). - if (style == NavPrefs.classic) { - return _SoplayClassicBar( - index: _index, - items: defs, - shortsShowcaseKey: _shortsRefreshShowcaseKey, - // _handleTabTap, not _onTabTap: it adds - // reselect-to-refresh and no-ops on every other - // tab. The coach-mark shown to classic users - // described a gesture only the capsule had, so - // following its instructions did nothing. - // Double-tap stays as the additional shortcut. - onTap: _handleTabTap, - onShortsDoubleTap: _refreshShorts, - ); - } - // Glass / Solid: a floating capsule inset 16 each side. - final nativeIosBar = PlatformInfo.isIOS && - PlatformInfo.isIOS26OrHigher(); - return Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - // The native bar reserves the home-indicator - // inset itself: the package sizes it from - // `UITabBar.sizeThatFits`, and a UITabBar folds - // the bottom safe area into that height. Adding - // the inset again here counted it twice and - // floated the bar a safe area's worth too high, - // leaving a gap under it. The Flutter capsule - // has no such notion and still needs it. - nativeIosBar - ? 12 - : MediaQuery.paddingOf(context).bottom + 12, - ), - // A fixed 16dp inset is a capsule on a phone and a - // full-width band on a foldable or a tablet, where - // it stops reading as a floating control at all. - // Nothing changes below 480dp. - child: Center( - child: ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 480), - // iOS 26+ gets the system's own tab bar; - // everywhere else keeps the shader capsule. - // `classic` is handled above and is an - // explicit user choice on every platform. - child: nativeIosBar - ? _SoplayNativeGlassBar( - index: _index, - items: defs, - onTabSelected: _handleTabTap, - ) - : _SoplayGlassCapsule( - index: _index, - items: defs, - glass: style == NavPrefs.glass, - shortsShowcaseKey: - _shortsRefreshShowcaseKey, - onTabSelected: _handleTabTap, - ), + left: 0, + right: 0, + bottom: 0, + child: ValueListenableBuilder( + valueListenable: NavPrefs.navStyle, + builder: (context, style, _) { + // Classic: the original full-width frosted bar (keeps + // the per-tab Showcase + real double-tap-to-refresh). + if (style == NavPrefs.classic) { + return _SoplayClassicBar( + index: _index, + items: defs, + shortsShowcaseKey: _shortsRefreshShowcaseKey, + // _handleTabTap, not _onTabTap: it adds + // reselect-to-refresh and no-ops on every other + // tab. The coach-mark shown to classic users + // described a gesture only the capsule had, so + // following its instructions did nothing. + // Double-tap stays as the additional shortcut. + onTap: _handleTabTap, + onShortsDoubleTap: _refreshShorts, + ); + } + // Glass / Solid: a floating capsule inset 16 each side. + final nativeIosBar = + PlatformInfo.isIOS && + PlatformInfo.isIOS26OrHigher(); + return Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + // The native bar reserves the home-indicator + // inset itself: the package sizes it from + // `UITabBar.sizeThatFits`, and a UITabBar folds + // the bottom safe area into that height. Adding + // the inset again here counted it twice and + // floated the bar a safe area's worth too high, + // leaving a gap under it. The Flutter capsule + // has no such notion and still needs it. + nativeIosBar + ? 12 + : MediaQuery.paddingOf(context).bottom + + 12, ), - ), - ); - }, + // A fixed 16dp inset is a capsule on a phone and a + // full-width band on a foldable or a tablet, where + // it stops reading as a floating control at all. + // Nothing changes below 480dp. + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 480, + ), + // iOS 26+ gets the system's own tab bar; + // everywhere else keeps the shader capsule. + // `classic` is handled above and is an + // explicit user choice on every platform. + child: nativeIosBar + ? _SoplayNativeGlassBar( + index: _index, + items: defs, + onTabSelected: _handleTabTap, + ) + : _SoplayGlassCapsule( + index: _index, + items: defs, + glass: style == NavPrefs.glass, + shortsShowcaseKey: + _shortsRefreshShowcaseKey, + onTabSelected: _handleTabTap, + ), + ), + ), + ); + }, + ), ), - ), ], ), ), @@ -593,11 +610,16 @@ class _SoplayGlassCapsule extends StatelessWidget { verticalPadding: 0, barHeight: _barHeight, barBorderRadius: _barHeight / 2, // full capsule - magnification: glass ? 1.12 : 1.0, // subtle iOS-26 lens on the selected tab + magnification: glass + ? 1.12 + : 1.0, // subtle iOS-26 lens on the selected tab indicatorPinchStrength: glass ? 0.3 : 0.0, // The selected pill used to expand 4dp past the top and bottom of the // 62dp capsule on every tap, clipping against the rim. - indicatorExpansion: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + indicatorExpansion: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 2, + ), // Selected-tab pill: a soft, restrained light pill on the dark body — // unless Appearance → "Colour the tab bar" is on, in which case the whole // selected state moves onto the accent. Off by default: the white pill is @@ -622,21 +644,22 @@ class _SoplayGlassCapsule extends StatelessWidget { settings: glass ? _glassSettings : _solidSettings, // primaryLight, not primary: a selected icon is a small glyph on a dark // bar, and the lighter variant is the one that reads at that size. - selectedIconColor: - AppColors.isNavTinted ? AppColors.primaryLight : Colors.white, + selectedIconColor: AppColors.isNavTinted + ? AppColors.primaryLight + : Colors.white, // #7A7A7A on the capsule body measured 4.14:1 — under the 4.5:1 WCAG AA // floor that a 10.5px label has to clear. #949494 is 5.7:1. unselectedIconColor: const Color(0xFF949494), - selectedLabelColor: - AppColors.isNavTinted ? AppColors.primaryLight : Colors.white, + selectedLabelColor: AppColors.isNavTinted + ? AppColors.primaryLight + : Colors.white, unselectedLabelColor: const Color(0xFF949494), // Clamped, not raw. The packaged bar shrinks the WHOLE tab — icon // included — to fit its label, and each tab scales independently, so at a // large system text scale the icons ended up different sizes across the // bar. Capping the scale at 1.2 keeps the row even; the label stays // readable because it is a one-word tab name, not body copy. - labelFontSize: - MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.2).scale(10.5), + labelFontSize: MediaQuery.textScalerOf(context).scale(12), ); // The package drop shadow is light-mode only, so paint our own soft capsule @@ -772,23 +795,20 @@ class _SoplayNativeGlassBar extends StatelessWidget { /// contract, and a redesign that swaps an icon must not silently blank the /// iOS bar. static ({String icon, String selected}) _sfSymbol(TabId id) => switch (id) { - TabId.home => (icon: 'house', selected: 'house.fill'), - TabId.search => (icon: 'magnifyingglass', selected: 'magnifyingglass'), - TabId.shorts => ( - icon: 'play.rectangle', - selected: 'play.rectangle.fill' - ), - TabId.myList => (icon: 'bookmark', selected: 'bookmark.fill'), - TabId.profile => (icon: 'person', selected: 'person.fill'), - TabId.downloads => ( - icon: 'arrow.down.circle', - selected: 'arrow.down.circle.fill' - ), - TabId.history => (icon: 'clock', selected: 'clock.fill'), - TabId.following => (icon: 'heart', selected: 'heart.fill'), - TabId.buff => (icon: 'sparkles', selected: 'sparkles'), - TabId.liveTv => (icon: 'tv', selected: 'tv.fill'), - }; + TabId.home => (icon: 'house', selected: 'house.fill'), + TabId.search => (icon: 'magnifyingglass', selected: 'magnifyingglass'), + TabId.shorts => (icon: 'play.rectangle', selected: 'play.rectangle.fill'), + TabId.myList => (icon: 'bookmark', selected: 'bookmark.fill'), + TabId.profile => (icon: 'person', selected: 'person.fill'), + TabId.downloads => ( + icon: 'arrow.down.circle', + selected: 'arrow.down.circle.fill', + ), + TabId.history => (icon: 'clock', selected: 'clock.fill'), + TabId.following => (icon: 'heart', selected: 'heart.fill'), + TabId.buff => (icon: 'sparkles', selected: 'sparkles'), + TabId.liveTv => (icon: 'tv', selected: 'tv.fill'), + }; @override Widget build(BuildContext context) { @@ -832,12 +852,14 @@ class _SoplayNativeGlassBar extends StatelessWidget { // alerts). This states it again at the one call site whose *rendering* // depends on it, so the bar cannot silently go white again if the app // ever stops forcing the trait collection. - data: MediaQuery.of(context) - .copyWith(platformBrightness: Brightness.dark), + data: MediaQuery.of( + context, + ).copyWith(platformBrightness: Brightness.dark), child: AnimatedBuilder( animation: covering, builder: (context, _) => IOS26NativeTabBar( - hidden: covering.status == AnimationStatus.forward || + hidden: + covering.status == AnimationStatus.forward || covering.status == AnimationStatus.completed, destinations: [ for (final item in items) @@ -906,8 +928,9 @@ class _CapsuleTabLayer extends StatelessWidget { label: items[i].labelKey.tr(), selected: i == index, onTap: () => onTabSelected(i), - showcaseKey: - items[i].id == TabId.shorts ? shortsShowcaseKey : null, + showcaseKey: items[i].id == TabId.shorts + ? shortsShowcaseKey + : null, ), ), ], @@ -1065,8 +1088,9 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { @override Widget build(BuildContext context) { - final selectedColor = - AppColors.isNavTinted ? AppColors.primaryLight : Colors.white; + final selectedColor = AppColors.isNavTinted + ? AppColors.primaryLight + : Colors.white; // Same AA floor as the capsule's labels — see the note there. final color = widget.selected ? selectedColor : const Color(0xFF949494); @@ -1102,7 +1126,9 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { widget.selected ? widget.item.activeIcon : widget.item.icon, - key: ValueKey('${widget.item.labelKey}-${widget.selected}'), + key: ValueKey( + '${widget.item.labelKey}-${widget.selected}', + ), size: 24, color: color, shadows: widget.selected @@ -1121,9 +1147,10 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { duration: const Duration(milliseconds: 160), style: TextStyle( color: color, - fontSize: 10.5, - fontWeight: - widget.selected ? FontWeight.w700 : FontWeight.w500, + fontSize: 12, + fontWeight: widget.selected + ? FontWeight.w700 + : FontWeight.w500, height: 1, ), child: Text( @@ -1161,8 +1188,11 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { /// Sozo-Desktop style floating bottom-center rounded pill navigation. /// Desktop only — mobile uses [_SoplayGlassCapsule]. Reuses the same 5 tabs. class _SoplayFloatingNav extends StatelessWidget { - const _SoplayFloatingNav( - {required this.index, required this.onTap, required this.items}); + const _SoplayFloatingNav({ + required this.index, + required this.onTap, + required this.items, + }); final int index; final ValueChanged onTap; @@ -1332,9 +1362,7 @@ class _TvNavRailState extends State<_TvNavRail> { child: AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOut, - width: _expanded - ? _TvNavRail.expandedWidth - : _TvNavRail.collapsedWidth, + width: _expanded ? _TvNavRail.expandedWidth : _TvNavRail.collapsedWidth, decoration: BoxDecoration( color: AppColors.navBackground, border: Border( @@ -1464,8 +1492,9 @@ class _TvRailButtonState extends State<_TvRailButton> { style: TextStyle( color: color, fontSize: 14, - fontWeight: - widget.selected ? FontWeight.w800 : FontWeight.w600, + fontWeight: widget.selected + ? FontWeight.w800 + : FontWeight.w600, ), ), ), @@ -1603,3 +1632,69 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { ); } } + +/// Large type gets real layout space, rather than scaling labels back down. +class ReadableNavigationBar extends StatelessWidget { + const ReadableNavigationBar({ + super.key, + required this.index, + required this.items, + required this.onTap, + }); + final int index; + final List items; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) => Material( + color: AppColors.navBackground, + child: SafeArea( + top: false, + child: LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 480 ? 3 : 2; + return Wrap( + children: [ + for (var i = 0; i < items.length; i++) + SizedBox( + width: constraints.maxWidth / columns, + child: Semantics( + selected: i == index, + button: true, + child: InkWell( + onTap: () => onTap(i), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Icon( + i == index ? items[i].activeIcon : items[i].icon, + color: i == index + ? AppColors.primaryLight + : AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + items[i].labelKey.tr(), + style: TextStyle( + fontSize: 12, + color: i == index + ? AppColors.textPrimary + : AppColors.textSecondary, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + ), + ); +} diff --git a/lib/features/mal/data/mal_service.dart b/lib/features/mal/data/mal_service.dart index fee3f5dd..e8158f8c 100644 --- a/lib/features/mal/data/mal_service.dart +++ b/lib/features/mal/data/mal_service.dart @@ -79,7 +79,7 @@ class MalService extends ChangeNotifier { notifyListeners(); return false; } - return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + return await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); } on DioException catch (e) { _lastError = e.response?.statusCode == 503 ? 'MyAnimeList is not configured on the server yet' diff --git a/lib/features/manga/presentation/pages/manga_sources_page.dart b/lib/features/manga/presentation/pages/manga_sources_page.dart index 5af00348..6777c666 100644 --- a/lib/features/manga/presentation/pages/manga_sources_page.dart +++ b/lib/features/manga/presentation/pages/manga_sources_page.dart @@ -435,7 +435,7 @@ class _MangaSourcesPageState extends State { ? null : IconButton( icon: const Icon(Icons.clear, color: AppColors.textHint), - tooltip: 'Clear', + tooltip: 'general.clear'.tr(), onPressed: _busy ? null : () => setState(() => _controller.clear()), diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index c4cb84a8..f9038a64 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -418,7 +418,7 @@ class _ReaderPageState extends State { shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: IconButton( - tooltip: 'Close', + tooltip: 'general.close'.tr(), icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => context.pop(), ), diff --git a/lib/features/notifications/data/datasources/notifications_data_source.dart b/lib/features/notifications/data/datasources/notifications_data_source.dart index 20b75c88..a6747230 100644 --- a/lib/features/notifications/data/datasources/notifications_data_source.dart +++ b/lib/features/notifications/data/datasources/notifications_data_source.dart @@ -42,10 +42,11 @@ class NotificationsDataSource { Future registerFcmToken({ required String token, required String platform, + required String language, }) async { await dio.post( '/notifications/fcm/register', - data: {'token': token, 'platform': platform}, + data: {'token': token, 'platform': platform, 'language': language}, ); } diff --git a/lib/features/notifications/data/repositories/notifications_repository_impl.dart b/lib/features/notifications/data/repositories/notifications_repository_impl.dart index f13b748e..ff62ce2b 100644 --- a/lib/features/notifications/data/repositories/notifications_repository_impl.dart +++ b/lib/features/notifications/data/repositories/notifications_repository_impl.dart @@ -37,9 +37,13 @@ class NotificationsRepositoryImpl implements NotificationsRepository { Future> registerFcmToken({ required String token, required String platform, + required String language, }) => - _wrap(() => - dataSource.registerFcmToken(token: token, platform: platform)); + _wrap(() => dataSource.registerFcmToken( + token: token, + platform: platform, + language: language, + )); @override Future> unregisterFcmToken(String token) => diff --git a/lib/features/notifications/data/services/notification_service.dart b/lib/features/notifications/data/services/notification_service.dart index ccfb75d4..6478eca2 100644 --- a/lib/features/notifications/data/services/notification_service.dart +++ b/lib/features/notifications/data/services/notification_service.dart @@ -9,7 +9,9 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; +import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/notifications/domain/repositories/notifications_repository.dart'; typedef NotificationTapHandler = void Function(Map data); @@ -49,6 +51,7 @@ class NotificationService { bool _initialized = false; String? _registeredToken; + String? _registeredLanguage; StreamSubscription? _foregroundSub; StreamSubscription? _openedSub; StreamSubscription? _tokenRefreshSub; @@ -240,6 +243,7 @@ class NotificationService { // Re-register as an anonymous device so the row's lastSeen keeps moving // and a later sign-in claims the same token. _registeredToken = null; + _registeredLanguage = null; await _registerToken(token); } @@ -252,15 +256,38 @@ class NotificationService { _tokenRefreshSub = null; } + /// Re-sends the registration after the UI language changed. + /// + /// Safe to call when nothing is registered yet — the token is only known once + /// Firebase has handed one over, and until then the next launch does the work. + Future refreshRegistration() async { + final token = _registeredToken; + if (token == null || token.isEmpty) return; + await _registerToken(token); + } + + /// Registers the device, and carries the UI language up with it. + /// + /// Push copy is the one text the client cannot translate — the OS draws the + /// notification from what the server sent, and by then the app may not be + /// running. So the server has to know the language, and this call is where it + /// learns it: it happens on every launch, which means an account that never + /// touches its profile still ends up on the right language. + /// + /// The guard keys on the language as well as the token, so switching language + /// re-registers instead of being swallowed as a duplicate. Future _registerToken(String token) async { - if (_registeredToken == token) return; + final language = getIt().getLanguage(); + if (_registeredToken == token && _registeredLanguage == language) return; final platform = Platform.isIOS ? 'ios' : 'android'; final result = await repository.registerFcmToken( token: token, platform: platform, + language: language, ); if (result is Success) { _registeredToken = token; + _registeredLanguage = language; } else if (kDebugMode) { debugPrint('[FCM] register failed'); } diff --git a/lib/features/notifications/domain/repositories/notifications_repository.dart b/lib/features/notifications/domain/repositories/notifications_repository.dart index c01dc4e0..6ce608a8 100644 --- a/lib/features/notifications/domain/repositories/notifications_repository.dart +++ b/lib/features/notifications/domain/repositories/notifications_repository.dart @@ -19,6 +19,7 @@ abstract class NotificationsRepository { Future> registerFcmToken({ required String token, required String platform, + required String language, }); Future> unregisterFcmToken(String token); diff --git a/lib/features/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 132c64af..dc292f11 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/localization/language_picker.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/auth/data/services/google_auth_service.dart'; @@ -37,6 +38,8 @@ class _OnboardingPageState extends State { final _pageController = PageController(); int _page = 0; bool _googlePending = false; + bool _languageConfirmed = false; + bool _leaving = false; @override void dispose() { @@ -47,12 +50,28 @@ class _OnboardingPageState extends State { /// Marked on the way out rather than on entry: a first launch killed halfway /// through should still get the introduction next time. Future _leave(String route) async { + if (_leaving) return; + _leaving = true; + if (!_languageConfirmed) { + _languageConfirmed = await confirmIntroLanguage(context); + if (!mounted || !_languageConfirmed) { + _leaving = false; + return; + } + } await getIt().markOnboardingSeen(); if (!mounted) return; context.go(route); } - void _continueWithGoogle() { + Future _continueWithGoogle() async { + if (_leaving || _googlePending) return; + _leaving = true; + if (!_languageConfirmed) { + _languageConfirmed = await confirmIntroLanguage(context); + } + _leaving = false; + if (!mounted || !_languageConfirmed) return; setState(() => _googlePending = true); context.read().add(const AuthGoogleRequested()); } @@ -100,74 +119,106 @@ class _OnboardingPageState extends State { ), ), SafeArea( - child: Column( - children: [ - Padding( - // Sits clear of the status bar rather than tucked against - // it — over artwork the two collide and both stop reading. - padding: const EdgeInsetsDirectional.fromSTEB(20, 18, 16, 0), - child: Row( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: SizedBox( + height: constraints.maxHeight.clamp( + 540 + + (MediaQuery.textScalerOf(context).scale(14) - 14) * + 32, + double.infinity, + ), + child: Column( children: [ - Text( - 'app_name'.tr().toUpperCase(), - style: TextStyle( - color: AppColors.primary, - fontSize: 22, - fontWeight: FontWeight.w900, - letterSpacing: 3, + OnboardingHeader(onSkip: () => _leave('/main')), + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: _slides, + onPageChanged: (i) => setState(() => _page = i), + itemBuilder: (context, i) => + _Slide(index: i, controller: _pageController), + ), + ), + const SizedBox(height: 16), + _Dots(count: _slides, active: _page), + const SizedBox(height: 26), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: BlocBuilder( + builder: (context, state) { + final loading = state is AuthLoading; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (GoogleAuthService.isSupported) ...[ + GoogleAuthButton( + loading: loading && _googlePending, + onPressed: loading + ? null + : _continueWithGoogle, + ), + const SizedBox(height: 10), + ], + FilledButton( + onPressed: loading + ? null + : () => _leave('/register'), + child: Text( + 'onboarding.continue_with_email'.tr(), + ), + ), + AuthSwitchPrompt( + text: 'auth.already_have_account'.tr(), + action: 'auth.sign_in'.tr(), + onTap: () => _leave('/login'), + ), + ], + ); + }, ), ), - const Spacer(), - _SkipChip(onTap: () => _leave('/main')), ], ), ), - Expanded( - child: PageView.builder( - controller: _pageController, - itemCount: _slides, - onPageChanged: (i) => setState(() => _page = i), - itemBuilder: (context, i) => - _Slide(index: i, controller: _pageController), - ), - ), - const SizedBox(height: 16), - _Dots(count: _slides, active: _page), - const SizedBox(height: 26), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), - child: BlocBuilder( - builder: (context, state) { - final loading = state is AuthLoading; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (GoogleAuthService.isSupported) ...[ - GoogleAuthButton( - loading: loading && _googlePending, - onPressed: loading ? null : _continueWithGoogle, - ), - const SizedBox(height: 10), - ], - AuthPrimaryButton( - label: 'onboarding.continue_with_email'.tr(), - onPressed: loading - ? null - : () => _leave('/register'), - ), - AuthSwitchPrompt( - text: 'auth.already_have_account'.tr(), - action: 'auth.sign_in'.tr(), - onTap: () => _leave('/login'), - ), - ], - ); - }, - ), - ), - ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class OnboardingHeader extends StatelessWidget { + const OnboardingHeader({super.key, required this.onSkip}); + + final VoidCallback onSkip; + + @override + Widget build(BuildContext context) { + return MediaQuery( + // This single-line brand bar must stay stable; the page copy and buttons + // below still use the user's full text scale. + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling), + child: Padding( + // Keeps both controls on their own edges, clear of the status bar. + padding: const EdgeInsetsDirectional.fromSTEB(20, 12, 12, 0), + child: Row( + children: [ + Text( + 'SOZO', + style: TextStyle( + color: AppColors.primary, + fontSize: 18, + fontWeight: FontWeight.w900, + letterSpacing: 2, + height: 1, ), ), + const Spacer(), + _SkipChip(onTap: onSkip), ], ), ), @@ -175,7 +226,7 @@ class _OnboardingPageState extends State { } } -/// Skip, made to look like a control rather than a stray word on a poster. +/// A quiet secondary action anchored to the top-right of the intro. class _SkipChip extends StatelessWidget { const _SkipChip({required this.onTap}); @@ -183,34 +234,24 @@ class _SkipChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(999), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(14, 8, 10, 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'onboarding.skip'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.chevron_right_rounded, - size: 18, - color: AppColors.textPrimary, - ), - ], + return TextButton( + onPressed: onTap, + style: TextButton.styleFrom( + foregroundColor: AppColors.textPrimary, + minimumSize: const Size(0, 44), + padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 4, 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'onboarding.skip'.tr(), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), - ), + const SizedBox(width: 2), + const Icon(Icons.chevron_right_rounded, size: 18), + ], ), ); } diff --git a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart index 167c0e35..60352718 100644 --- a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart +++ b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart @@ -29,6 +29,17 @@ class _AnimeRibbonsState extends State duration: const Duration(seconds: 48), )..repeat(); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reduced = MediaQuery.disableAnimationsOf(context); + if (reduced) { + _controller.stop(); + } else if (!_controller.isAnimating) { + _controller.repeat(); + } + } + @override void dispose() { _controller.dispose(); diff --git a/lib/features/onboarding/presentation/widgets/poster_wall.dart b/lib/features/onboarding/presentation/widgets/poster_wall.dart index eff759ee..eecdbf40 100644 --- a/lib/features/onboarding/presentation/widgets/poster_wall.dart +++ b/lib/features/onboarding/presentation/widgets/poster_wall.dart @@ -41,6 +41,17 @@ class _PosterWallState extends State duration: _cycle, )..repeat(); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reduced = MediaQuery.disableAnimationsOf(context); + if (reduced) { + _controller.stop(); + } else if (!_controller.isAnimating) { + _controller.repeat(); + } + } + @override void dispose() { _controller.dispose(); diff --git a/lib/features/onboarding/presentation/widgets/tv_showcase.dart b/lib/features/onboarding/presentation/widgets/tv_showcase.dart index 070b1488..0bb02c2d 100644 --- a/lib/features/onboarding/presentation/widgets/tv_showcase.dart +++ b/lib/features/onboarding/presentation/widgets/tv_showcase.dart @@ -34,6 +34,26 @@ class _TvShowcaseState extends State with TickerProviderStateMixin { duration: const Duration(milliseconds: 1300), )..forward(); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final reduced = MediaQuery.disableAnimationsOf(context); + if (reduced) { + _loop.stop(); + } else if (!_loop.isAnimating) { + _loop.repeat(); + } + if (reduced) { + _strips.stop(); + } else if (!_strips.isAnimating) { + _strips.repeat(); + } + if (reduced) { + _entrance.stop(); + _entrance.value = 1; + } + } + @override void dispose() { _loop.dispose(); diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 6b114715..c941d02b 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -11,6 +11,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/stats/presentation/watch_stats_page.dart'; +import 'package:soplay/features/profile/presentation/widgets/provider_quick_switch.dart'; import 'package:soplay/features/profile/presentation/widgets/home_rail_customizer_sheet.dart'; import 'package:soplay/features/profile/presentation/widgets/tab_customizer_sheet.dart'; import 'package:soplay/core/bridge/bridge_control.dart'; diff --git a/lib/features/profile/presentation/pages/profile_page.hub.dart b/lib/features/profile/presentation/pages/profile_page.hub.dart index 3964a878..a187ace2 100644 --- a/lib/features/profile/presentation/pages/profile_page.hub.dart +++ b/lib/features/profile/presentation/pages/profile_page.hub.dart @@ -164,6 +164,12 @@ class _HubOverview extends StatelessWidget { onTap: () => context.push('/library'), ), const SettingsDivider(), + SettingsNavTile( + icon: Icons.download_rounded, + title: 'profile.downloads'.tr(), + onTap: () => context.push('/downloads'), + ), + const SettingsDivider(), SettingsNavTile( icon: Icons.timeline_rounded, title: 'profile.activity'.tr(), @@ -243,25 +249,46 @@ class _ConnectionsHubTileState extends State<_ConnectionsHubTile> { } } +/// Two rows, because they are two jobs. +/// +/// One row showing the current source and leading to the extension-management +/// screen made the everyday act — switching source — three screens deep: +/// Sources, then Active source, then the full providers page, which is really +/// for installing repos. Changing source is the most repeated thing anyone does +/// here, so it gets the row that names it and opens the switcher directly. class _SourcesHubTile extends StatelessWidget { const _SourcesHubTile(); @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final loaded = state is ProviderLoaded ? state : null; - final current = loaded?.currentProvider; - return SettingsNavTile( + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + BlocBuilder( + builder: (context, state) { + final loaded = state is ProviderLoaded ? state : null; + final current = loaded?.currentProvider; + return SettingsNavTile( + icon: Icons.swap_horiz_rounded, + title: 'profile.provider'.tr(), + valueLeading: current != null && current.image.isNotEmpty + ? ProviderMark(url: current.image) + : null, + value: current?.name ?? loaded?.currentProviderId, + // The same sheet the home screen's chip opens — one decision, one + // control, wherever it is reached from. + onTap: () => openProviderQuickSwitch(context), + ); + }, + ), + const SettingsDivider(), + SettingsNavTile( icon: Icons.extension_outlined, - title: 'profile.sources_title'.tr(), - valueLeading: current != null && current.image.isNotEmpty - ? ProviderMark(url: current.image) - : null, - value: current?.name ?? loaded?.currentProviderId, + title: 'profile.sources_row'.tr(), + subtitle: 'profile.sources_row_subtitle'.tr(), onTap: () => context.push('/sources'), - ); - }, + ), + ], ); } } diff --git a/lib/features/profile/presentation/pages/provider_test_page.dart b/lib/features/profile/presentation/pages/provider_test_page.dart index 5f964390..50dbf61c 100644 --- a/lib/features/profile/presentation/pages/provider_test_page.dart +++ b/lib/features/profile/presentation/pages/provider_test_page.dart @@ -293,6 +293,10 @@ class _StepRow extends StatelessWidget { width: 86, child: Text( _labels[step.stage]!.tr(), + // The column is fixed so the stage names line up; without this a + // longer translation wraps and the row grows by a line. + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white70, fontSize: 12), ), ), diff --git a/lib/features/profile/presentation/pages/providers_page.dart b/lib/features/profile/presentation/pages/providers_page.dart index bde6bcd0..f9edbcd0 100644 --- a/lib/features/profile/presentation/pages/providers_page.dart +++ b/lib/features/profile/presentation/pages/providers_page.dart @@ -1211,14 +1211,14 @@ class _BrowseOnlyBadge extends StatelessWidget { borderRadius: BorderRadius.circular(6), border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.visibility_outlined, size: 9, color: color), - SizedBox(width: 3), + const Icon(Icons.visibility_outlined, size: 9, color: color), + const SizedBox(width: 3), Text( - 'FAQAT KO\'RISH', - style: TextStyle( + 'profile.view_only'.tr(), + style: const TextStyle( color: color, fontSize: 9, fontWeight: FontWeight.w700, diff --git a/lib/features/profile/presentation/pages/settings_page.dart b/lib/features/profile/presentation/pages/settings_page.dart index 18914ad2..c0d8b7c8 100644 --- a/lib/features/profile/presentation/pages/settings_page.dart +++ b/lib/features/profile/presentation/pages/settings_page.dart @@ -2,7 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; -import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/localization/app_language.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/app_lock/domain/repositories/app_lock_repository.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; @@ -16,20 +16,7 @@ class SettingsPage extends StatefulWidget { } class _SettingsPageState extends State { - /// Native names on purpose: a person looking for their own language finds it - /// fastest written the way they write it. - static const _languageNames = { - 'en': 'English', - 'uz': "O'zbekcha", - 'ru': 'Русский', - 'ar': 'العربية', - }; - - Future _setLanguage(String code) async { - if (code == context.locale.languageCode) return; - await context.setLocale(Locale(code)); - await getIt().saveLanguage(code); - } + Future _setLanguage(String code) => AppLanguage.set(context, code); @override Widget build(BuildContext context) { @@ -49,7 +36,7 @@ class _SettingsPageState extends State { options: [ for (final l in context.supportedLocales) l.languageCode, ], - labelOf: (code) => _languageNames[code] ?? code, + labelOf: AppLanguage.labelOf, onChanged: _setLanguage, ), const SettingsDivider(), diff --git a/lib/features/profile/presentation/pages/sources_page.dart b/lib/features/profile/presentation/pages/sources_page.dart index 8f74b7a6..fa266741 100644 --- a/lib/features/profile/presentation/pages/sources_page.dart +++ b/lib/features/profile/presentation/pages/sources_page.dart @@ -2,7 +2,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:soplay/core/aniyomi/aniyomi_channel.dart'; import 'package:soplay/core/bridge/bridge_control.dart'; import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; @@ -13,6 +12,7 @@ import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/extensions/presentation/pages/mangayomi_sources_page.dart'; import 'package:soplay/features/extensions/presentation/pages/source_catalog_page.dart'; import 'package:soplay/features/manga/presentation/pages/manga_sources_page.dart'; +import 'package:soplay/features/profile/presentation/widgets/provider_quick_switch.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; @@ -77,7 +77,7 @@ class SourcesPage extends StatelessWidget { 'https://raw.githubusercontent.com/kodjodevf/mangayomi/main/assets/app_icons/icon-red.png', fallback: Icons.javascript_outlined, ), - title: 'Mangayomi Sources', + title: 'profile.mangayomi_sources'.tr(), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const MangayomiSourcesPage()), ), @@ -130,7 +130,11 @@ class ProviderTile extends StatelessWidget { ? ProviderMark(url: current.image) : null, value: total > 0 ? '$name · $total' : name, - onTap: () => context.push('/providers'), + // Shows the current source, so it changes the current source — + // the same sheet the home chip and the profile hub open. The full + // providers page is still one row down inside it, where managing + // rather than picking belongs. + onTap: () => openProviderQuickSwitch(context), ); }, ); diff --git a/lib/features/profile/presentation/widgets/provider_quick_switch.dart b/lib/features/profile/presentation/widgets/provider_quick_switch.dart new file mode 100644 index 00000000..e01d4bff --- /dev/null +++ b/lib/features/profile/presentation/widgets/provider_quick_switch.dart @@ -0,0 +1,490 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/core/content/content_mode.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; +import 'package:soplay/core/widgets/mode_switch_overlay.dart'; +import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; +import 'package:soplay/features/home/presentation/bloc/home/home_state.dart'; +import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; + +/// Changing the active source, from wherever somebody happens to be. +/// +/// This used to live inside the home screen's top bar, reachable only from the +/// chip up there. Profile had its own path to the same decision — Sources, then +/// Active source, then the full providers page — which is three screens to do +/// the thing this app asks people to do most often, and the last of those +/// screens is really for installing extensions. +/// +/// Lifted out so both entry points open the same sheet. The alternative was a +/// second picker in Profile, and two pickers for one decision drift. + +/// Opens the quick switcher over whatever screen called it. +/// +/// Reads the bloc itself so a caller needs nothing but a context: the home bar +/// has the loaded state to hand, Profile does not, and making them agree on a +/// parameter was the only thing standing between the two entry points. +Future openProviderQuickSwitch(BuildContext context) async { + final bloc = context.read(); + final state = bloc.state; + if (state is! ProviderLoaded) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + (state is ProviderError + ? 'profile.providers_error' + : 'search.loading_sources') + .tr(), + ), + ), + ); + return; + } + await _openSwitcher(context, bloc, state); +} + +/// Offline, a favourited server provider is dropped rather than offered — +/// picking it from the quick switcher would break the home screen. If that +/// leaves nothing, the caller falls through to the full picker, which +/// explains the outage. +List _resolveFavorites(ProviderLoaded state) { + final favIds = getIt().getFavoriteProviders(); + final byId = { + for (final p in state.providers) + if (state.isUsable(p)) p.id: p, + }; + return [ + for (final id in favIds) + if (byId[id] != null) byId[id]!, + ]; +} + +/// Moves to another kind of catalogue. +/// +/// The provider has to move with it: the home screen is driven by whichever +/// source is current, and leaving a video provider selected in manga mode +/// would show an empty screen and look like the switch failed. So the first +/// usable source of the new kind is picked — preferring a favourite, because +/// somebody who starred a manga source starred it for this. +/// +/// Nothing happens at all when the new mode has no sources: switching into an +/// empty mode is a dead end nobody can get out of except by switching back, +/// and saying so beats stranding them there. +Future _switchMode( + BuildContext context, + ProviderBloc bloc, + ProviderLoaded state, + ContentMode mode, +) async { + final hive = getIt(); + final candidates = [ + for (final p in state.providers) + if (state.isUsable(p) && p.id.contentMode == mode) p, + ]; + if (candidates.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('mode.none_installed'.tr(args: [mode.labelKey.tr()])), + ), + ); + return; + } + final favIds = hive.getFavoriteProviders().toSet(); + final pick = candidates.firstWhere( + (p) => favIds.contains(p.id), + orElse: () => candidates.first, + ); + + await hive.setContentMode(mode.id); + if (!context.mounted) return; + + // Subscribed BEFORE the pick is dispatched, or the reload it triggers can + // land before anyone is listening and the cover would wait out its whole + // timeout over content that is already there. + // + // Only when the provider actually changes: MainPage reloads Home off the + // provider id moving, so an unchanged id means no reload to wait for and + // this would be a future that never completes. + final Future? loaded = pick.id == state.currentProviderId + ? null + : context + .read() + .stream + .firstWhere((s) => s is HomeLoaded || s is HomeError) + .then((_) {}); + + // Selected BEFORE the animation, so the reload runs underneath the cover + // rather than starting when it lifts onto an empty screen. + bloc.add(ProviderSelect(pick.id)); + await ModeSwitchOverlay.play(context, mode, until: loaded); +} + +Future _openSwitcher( + BuildContext context, + ProviderBloc bloc, + ProviderLoaded state, +) async { + final favorites = _resolveFavorites(state); + // Everything usable, not only the favourites. + // + // The sheet used to hold favourites and nothing else, and fell through to + // the full providers PAGE when there were none — which is most people, and + // is why the report was "I have to go to settings every time I want to + // change source". Switching source is the single most repeated action in + // this app and it must never leave the home screen. + final hive = getIt(); + final mode = ContentMode.fromId(hive.getContentMode()); + // Narrowed to the mode. Somebody who came to read should be choosing + // between the four readers they have, not finding them among thirty video + // providers — which is the version of this that sent people to Settings. + final all = [ + for (final p in state.providers) + if (state.isUsable(p) && p.id.contentMode == mode) p, + ]; + final result = await showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => ProviderQuickSwitchSheet( + favorites: [ + for (final f in favorites) + if (f.id.contentMode == mode) f, + ], + all: all, + mode: mode, + currentProviderId: state.currentProviderId, + ), + ); + if (result == null || !context.mounted) return; + if (result == _kAllProvidersAction) { + openProviderPicker(context, bloc); + return; + } + final switched = ContentMode.values + .where((m) => result == '$_kModePrefix${m.id}') + .firstOrNull; + if (switched != null) { + await _switchMode(context, bloc, state, switched); + return; + } + bloc.add(ProviderSelect(result)); +} + +const String _kAllProvidersAction = '__all_providers__'; + +/// Prefix that marks a sheet result as "switch to this mode" rather than +/// "select this provider". They share one return channel because the sheet is +/// one list, and a sentinel is cheaper than a second result type. +const String _kModePrefix = '__mode__:'; + +/// The one place a source gets changed. +/// +/// Favourites first because they are what somebody switches BETWEEN, then every +/// other usable source underneath, with a filter once the list is long enough +/// to need one. The full providers page is still reachable from the bottom, but +/// it is now for managing sources rather than for the everyday act of picking +/// one — which is what it had quietly become. +class ProviderQuickSwitchSheet extends StatefulWidget { + const ProviderQuickSwitchSheet({ + super.key, + required this.favorites, + required this.all, + required this.mode, + required this.currentProviderId, + }); + + final List favorites; + final List all; + + /// The kind of catalogue these sources belong to. Shown as a row of chips at + /// the top, because the mode is the thing that decides what the rest of the + /// sheet contains — putting it anywhere else would make the list look like it + /// had lost sources. + final ContentMode mode; + + final String currentProviderId; + + @override + State createState() => + ProviderQuickSwitchSheetState(); +} + +class ProviderQuickSwitchSheetState extends State { + final TextEditingController _filter = TextEditingController(); + String _query = ''; + + /// Below this the filter is chrome over a list that already fits. + static const int _filterThreshold = 8; + + @override + void dispose() { + _filter.dispose(); + super.dispose(); + } + + /// The sources that are not already in the favourites block above. + /// + /// Listing a favourite twice makes the sheet look like it has duplicates and + /// makes the favourites section pointless. + List get _rest { + final favIds = {for (final p in widget.favorites) p.id}; + final q = _query.trim().toLowerCase(); + return [ + for (final p in widget.all) + if (!favIds.contains(p.id) && + (q.isEmpty || p.name.toLowerCase().contains(q))) + p, + ]; + } + + List get _shownFavorites { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return widget.favorites; + return [ + for (final p in widget.favorites) + if (p.name.toLowerCase().contains(q)) p, + ]; + } + + @override + Widget build(BuildContext context) { + final keyboard = MediaQuery.viewInsetsOf(context).bottom; + final available = + (MediaQuery.sizeOf(context).height - + keyboard - + MediaQuery.paddingOf(context).top - + 24) + .clamp(0.0, double.infinity); + final favorites = _shownFavorites; + final items = [...favorites, ..._rest]; + return Padding( + padding: EdgeInsets.only(bottom: keyboard), + child: SafeArea( + top: false, + child: SizedBox( + height: available * (keyboard > 0 ? 1 : 0.78), + child: Column( + children: [ + Expanded( + child: ListView.builder( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: items.length + 1, + itemBuilder: (context, index) { + if (index > 0) { + final p = items[index - 1]; + return _favoriteProviderTile( + context, + p, + widget.currentProviderId, + favorite: index <= favorites.length, + ); + } + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'ux.change_source'.tr(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final m in ContentMode.values) + _ModeChip( + label: m.labelKey.tr(), + active: m == widget.mode, + onTap: m == widget.mode + ? null + : () => Navigator.of( + context, + ).pop('$_kModePrefix${m.id}'), + ), + ], + ), + const SizedBox(height: 12), + if (widget.all.length >= _filterThreshold) + TextField( + controller: _filter, + onChanged: (v) => setState(() => _query = v), + decoration: InputDecoration( + hintText: 'profile.search_providers_hint'.tr(), + prefixIcon: const Icon(Icons.search), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: 'general.clear'.tr(), + onPressed: () { + _filter.clear(); + setState(() => _query = ''); + }, + icon: const Icon(Icons.close), + ), + ), + ), + if (items.isEmpty) ...[ + const SizedBox(height: 24), + Text( + (_query.isEmpty + ? 'profile.no_providers_in_category' + : 'ux.no_source_match') + .tr(), + style: const TextStyle( + color: AppColors.textSecondary, + ), + ), + if (_query.isNotEmpty) + TextButton( + onPressed: () { + _filter.clear(); + setState(() => _query = ''); + }, + child: Text('general.clear'.tr()), + ), + ], + ], + ), + ); + }, + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.tune_rounded), + title: Text('ux.manage_sources'.tr()), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).pop(_kAllProvidersAction), + ), + ], + ), + ), + ), + ); + } +} + +/// One of the three catalogue kinds. +/// +/// The active one is not tappable: switching to the mode you are already in +/// would replay the animation and reload the screen for no change, which reads +/// as the app stuttering. +class _ModeChip extends StatelessWidget { + const _ModeChip({required this.label, required this.active, this.onTap}); + + final String label; + final bool active; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: active, + child: Material( + color: active ? AppColors.primary : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Center( + widthFactor: 1, + heightFactor: 1, + child: Text( + label, + style: TextStyle( + color: active ? Colors.white : AppColors.textSecondary, + fontSize: 13, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } +} + +Widget _favoriteProviderTile( + BuildContext context, + ProviderEntity p, + String currentProviderId, { + bool favorite = false, +}) { + final selected = p.id == currentProviderId; + return ListTile( + leading: ProviderLogo(image: p.image, size: 36), + title: Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? AppColors.textPrimary : AppColors.textSecondary, + fontSize: 14, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + trailing: selected + ? Icon(Icons.check_rounded, color: AppColors.primary, size: 20) + : favorite + ? const Icon(Icons.star_rounded, color: Colors.amber, size: 20) + : null, + onTap: () => Navigator.of(context).pop(p.id), + ); +} + +class ProviderLogo extends StatelessWidget { + const ProviderLogo({required this.image, required this.size, super.key}); + + final String image; + final double size; + + @override + Widget build(BuildContext context) { + final fallback = Container( + width: size, + height: size, + color: AppColors.surfaceVariant, + alignment: Alignment.center, + child: Icon( + Icons.movie_filter_outlined, + color: AppColors.textHint, + size: size * 0.55, + ), + ); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: image.isEmpty + ? fallback + : Image.network( + image, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + loadingBuilder: (_, child, chunk) => + chunk == null ? child : fallback, + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/settings_tiles.dart b/lib/features/profile/presentation/widgets/settings_tiles.dart index 16473b55..a2a30008 100644 --- a/lib/features/profile/presentation/widgets/settings_tiles.dart +++ b/lib/features/profile/presentation/widgets/settings_tiles.dart @@ -110,13 +110,15 @@ class SettingsLabel extends StatelessWidget { ), ), const SizedBox(width: 7), - Text( - label, - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), ), ), if (featureIds.isNotEmpty) SettingsNewDot(ids: featureIds), @@ -241,7 +243,11 @@ class SettingsLeadingChip extends StatelessWidget { /// Remote logo in the leading slot; the plain icon chip stands in while it /// loads and if it fails, so the row never has a hole where its mark should be. class SettingsTileLogo extends StatelessWidget { - const SettingsTileLogo({super.key, required this.url, required this.fallback}); + const SettingsTileLogo({ + super.key, + required this.url, + required this.fallback, + }); final String url; final IconData fallback; diff --git a/lib/features/search/domain/entities/cross_search_scope.dart b/lib/features/search/domain/entities/cross_search_scope.dart index b799b013..53910a87 100644 --- a/lib/features/search/domain/entities/cross_search_scope.dart +++ b/lib/features/search/domain/entities/cross_search_scope.dart @@ -16,7 +16,9 @@ class CrossSearchScope { /// Narrows to [ids]; an empty selection is not a scope, it is [all]. factory CrossSearchScope.only(Iterable ids) { final set = ids.toSet(); - return set.isEmpty ? const CrossSearchScope.all() : CrossSearchScope._only(set); + return set.isEmpty + ? const CrossSearchScope.all() + : CrossSearchScope._only(set); } /// Null means "everything usable", which is the default. @@ -34,22 +36,21 @@ class CrossSearchScope { bool includes(String id) => ids == null || ids!.contains(id); - List resolve(List providers) => - ids == null ? providers : providers.where((p) => ids!.contains(p.id)).toList(); + List resolve(List providers) => ids == null + ? providers + : providers.where((p) => ids!.contains(p.id)).toList(); - int selectedCount(List providers) => resolve(providers).length; + int selectedCount(List providers) => + resolve(providers).length; /// Drops ids that are no longer installed. Narrowing to nothing widens back - /// to [all] rather than leaving a search with no legs at all, and a narrowing - /// that names every installed source collapses to [all] too — the old picker - /// persisted exactly that list when a user ticked everything, and read back - /// literally it would pin them to the list as it stood that day. + /// to [all] rather than leaving a search with no legs at all. An explicit set + /// stays explicit even if it names every installed source: changing it into + /// quick search on the next launch would silently apply the 60-source cap. CrossSearchScope pruned(List providers) { if (ids == null) return this; - final kept = ids!.where((id) => providers.any((p) => p.id == id)).toSet(); - if (providers.isNotEmpty && kept.length == providers.length) { - return const CrossSearchScope.all(); - } + final installed = providers.map((p) => p.id).toSet(); + final kept = ids!.where(installed.contains).toSet(); return CrossSearchScope.only(kept); } @@ -69,7 +70,8 @@ class CrossSearchScope { bool operator ==(Object other) => other is CrossSearchScope && (ids == null) == (other.ids == null) && - (ids == null || (ids!.length == other.ids!.length && ids!.containsAll(other.ids!))); + (ids == null || + (ids!.length == other.ids!.length && ids!.containsAll(other.ids!))); @override int get hashCode => ids == null ? 0 : Object.hashAllUnordered(ids!); diff --git a/lib/features/search/domain/services/cross_search_engine.dart b/lib/features/search/domain/services/cross_search_engine.dart index a502fae3..95793608 100644 --- a/lib/features/search/domain/services/cross_search_engine.dart +++ b/lib/features/search/domain/services/cross_search_engine.dart @@ -30,7 +30,37 @@ typedef _Leg = ({List items, int page, int totalPages}); /// /// Native `cs:`/`an:`/`mn:` searches already run off the platform thread, so the /// pool only guards against overwhelming the device — the UI thread never blocks. -class CrossSearchEngine { +/// What a cross-search surface actually needs from the engine. +/// +/// Named so the controller can be driven by a stub in a test. The engine itself +/// reaches WebViews, a Dio client and the Mangayomi bridge; standing all three +/// up to assert that arriving legs are batched would test the fakes, not the +/// batching. +abstract interface class SearchFanOut { + /// The legs an all-source run will actually cover, healthiest first. + /// + /// Called by the surface that owns the set rather than inside [search], so + /// the counters the UI shows — how many are pending, how many answered — + /// describe the run that is really happening. + List planLegs(List set, {int limit}); + + Stream search({ + required List set, + required String query, + int page, + int concurrency, + Duration perProviderTimeout, + }); + + Future searchProvider( + ProviderRef ref, + String query, { + int page, + Duration timeout, + }); +} + +class CrossSearchEngine implements SearchFanOut { CrossSearchEngine({ required this.jsRuntime, required this.dataSource, @@ -46,6 +76,22 @@ class CrossSearchEngine { /// long — see [SourceHealthStore]. final SourceHealthStore health; + /// How many sources one all-source run will actually ask. + /// + /// Not a performance tweak — a ceiling on wall-clock. The pool is bounded, so + /// a run costs roughly `legs / concurrency * timeout` in the worst case: with + /// a couple of thousand extension sources installed that is over an hour, and + /// a search nobody waits for is a search that did not happen. + /// + /// The legs kept are the healthiest ones ([SourceHealthStore.order] runs + /// first), which are also the ones most likely to answer at all. Sixty of + /// those is roughly two minutes of worst case and, in practice, seconds — + /// most legs return long before their budget. + /// + /// A user who wants a specific source beyond the ceiling can still pick it: + /// narrowing the scope is not capped, because then the count is theirs. + static const int maxLegs = 60; + static const int defaultConcurrency = 5; static const Duration defaultTimeout = Duration(seconds: 10); @@ -64,6 +110,13 @@ class CrossSearchEngine { /// backend takes an explicit `provider`, so collapsing them into one call was /// both a lie in the summary ("1 of 1 sources") and a silent no-op for every /// server provider the user picked beyond the first. + @override + List planLegs(List set, {int limit = maxLegs}) { + if (set.length <= limit) return set; + return health.order(List.of(set), (r) => r.id).take(limit).toList(); + } + + @override Stream search({ required List set, required String query, @@ -107,6 +160,7 @@ class CrossSearchEngine { } /// One leg on its own — used to retry a single failed source and to page it. + @override Future searchProvider( ProviderRef ref, String query, { diff --git a/lib/features/search/presentation/blocs/cross_search_controller.dart b/lib/features/search/presentation/blocs/cross_search_controller.dart index e68d7d51..8f7270a4 100644 --- a/lib/features/search/presentation/blocs/cross_search_controller.dart +++ b/lib/features/search/presentation/blocs/cross_search_controller.dart @@ -18,7 +18,7 @@ class CrossSearchController extends ChangeNotifier { CrossSearchController({required this.engine, required List set}) : _set = set; - final CrossSearchEngine engine; + final SearchFanOut engine; List _set; final QueryDebouncer _debouncer = QueryDebouncer(); @@ -34,6 +34,20 @@ class CrossSearchController extends ChangeNotifier { final Set _retrying = {}; List _merged = const []; + /// Coalesces arriving legs into one merge per tick. + /// + /// `mergeSearchResults` rebuilds from scratch: it regroups every item of + /// every leg collected so far and re-scores each one against the query. Doing + /// that per arrival is quadratic in the number of sources — at 2000 installed + /// sources it is tens of millions of item comparisons over a run, all of it + /// on the UI isolate, which is why the screen stopped responding long before + /// the search itself was finished. + /// + /// Batching does not delay a result by more than this window, and a user + /// cannot read a list that reflows sixty times a second anyway. + static const Duration _flushWindow = Duration(milliseconds: 250); + Timer? _flush; + static const int _cacheEntries = 6; static const Duration _cacheTtl = Duration(minutes: 5); final LinkedHashMap _cache = LinkedHashMap(); @@ -261,20 +275,32 @@ class CrossSearchController extends ChangeNotifier { (result) { if (token != _token) return; _results[result.provider.id] = result; - _remerge(); - notifyListeners(); + _scheduleFlush(); }, onDone: () { if (token != _token) return; _phase = CrossSearchPhase.done; _store(q); - notifyListeners(); + // The last legs may still be sitting in the current window; the run is + // not allowed to report itself finished on a stale list. + _flushNow(); }, ); } void _remerge() => _merged = mergeSearchResults(results, query: _query); + void _scheduleFlush() { + _flush ??= Timer(_flushWindow, _flushNow); + } + + void _flushNow() { + _flush?.cancel(); + _flush = null; + _remerge(); + notifyListeners(); + } + void _store(String q) { // Never replay a run that contains a failure: retyping the query is how a // user asks for another attempt, and serving them the cached failure for @@ -312,6 +338,8 @@ class CrossSearchController extends ChangeNotifier { void _cancel() { _debouncer.cancel(); + _flush?.cancel(); + _flush = null; _sub?.cancel(); _sub = null; } diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index 58391ef7..04f99601 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -24,7 +24,11 @@ import 'package:soplay/features/search/presentation/widgets/search_set_sheet.dar import 'package:soplay/features/search/presentation/widgets/source_scope_bar.dart'; class CrossSearchPage extends StatefulWidget { - const CrossSearchPage({super.key, this.initialQuery, this.initialProviderIds}); + const CrossSearchPage({ + super.key, + this.initialQuery, + this.initialProviderIds, + }); final String? initialQuery; @@ -96,8 +100,10 @@ class _CrossSearchPageState extends State { /// before the providers had loaded left it permanently empty. void _syncProviders(ProviderState state) { final next = _providersOf(state); - final sameList = next.length == _providers.length && - next.every((p) => _providers.any((e) => e.id == p.id)); + final previousIds = _providers.map((p) => p.id).toSet(); + final sameList = + next.length == _providers.length && + next.every((p) => previousIds.contains(p.id)); final offline = state is ProviderLoaded && state.offline; final loading = state is! ProviderLoaded && state is! ProviderError; if (sameList && offline == _offline && loading == _loadingProviders) return; @@ -119,17 +125,22 @@ class _CrossSearchPageState extends State { /// source and was told "Found in 0 of 1" — which reads as an empty internet /// rather than as a scope of one. CrossSearchScope _initialScope(List providers) { - final requested = widget.initialProviderIds - ?.where((id) => providers.any((p) => p.id == id)); + final requested = widget.initialProviderIds?.where( + (id) => providers.any((p) => p.id == id), + ); if (requested != null && requested.isNotEmpty) { return CrossSearchScope.only(requested); } - final stored = - CrossSearchScope.fromStored(getIt().getCrossSearchProviders()); + final stored = CrossSearchScope.fromStored( + getIt().getCrossSearchProviders(), + ); return providers.isEmpty ? stored : stored.pruned(providers); } - List _orderedIds(List providers, CrossSearchScope scope) { + List _orderedIds( + List providers, + CrossSearchScope scope, + ) { final selected = []; final rest = []; for (final p in providers) { @@ -138,8 +149,23 @@ class _CrossSearchPageState extends State { return [...selected, ...rest]; } - List _refs() => - _scope.resolve(_providers).map(ProviderRef.fromEntity).toList(); + /// Every source the scope names, before the ceiling. + int get _scopedCount => _scope.resolve(_providers).length; + + /// True when an all-source run is covering fewer sources than are installed. + /// Only ever true for [CrossSearchScope.all] — a scope the user picked by + /// hand is theirs, and is never trimmed. + bool get _capped => _scope.isAll && _scopedCount > _refs().length; + + List _refs() { + final selected = _scope + .resolve(_providers) + .map(ProviderRef.fromEntity) + .toList(); + return _scope.isAll + ? getIt().planLegs(selected) + : selected; + } void _applyScope(CrossSearchScope scope, {bool reorder = false}) { if (scope == _scope) return; @@ -203,16 +229,24 @@ class _CrossSearchPageState extends State { final hit = title.hits[i]; return ListTile( dense: true, - leading: Icon(Icons.play_circle_outline, - color: AppColors.primary, size: 20), - title: Text(hit.provider.name, - style: - const TextStyle(color: Colors.white, fontSize: 14)), - subtitle: Text(hit.item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textHint, fontSize: 12)), + leading: Icon( + Icons.play_circle_outline, + color: AppColors.primary, + size: 20, + ), + title: Text( + hit.provider.name, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + subtitle: Text( + hit.item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + ), + ), onTap: () { Navigator.of(context).pop(); _push(hit); @@ -259,9 +293,10 @@ class _CrossSearchPageState extends State { surfaceTintColor: Colors.transparent, scrolledUnderElevation: 0, elevation: 0, - title: Text('search.all_source_search'.tr(), - style: - const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + title: Text( + 'search.all_source_search'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), actions: [ IconButton( tooltip: _grouped @@ -275,29 +310,58 @@ class _CrossSearchPageState extends State { ), ], ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _searchField(), - SourceScopeBar( - providers: _providers, - order: _railOrder, - scope: _scope, - loading: _loadingProviders && _providers.isEmpty, - onToggle: (id) => _applyScope(_scope.toggle(id)), - onSelectAll: () => - _applyScope(const CrossSearchScope.all(), reorder: true), - onOpenPicker: _openSetSheet, - ), - const SizedBox(height: 10), - const Divider(height: 1, color: Colors.white10), - Expanded( - child: AnimatedBuilder( - animation: _controller, - builder: (_, _) => _body(), + body: LayoutBuilder( + builder: (context, constraints) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * 0.6, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _searchField(), + SourceScopeBar( + providers: _providers, + order: _railOrder, + scope: _scope, + loading: _loadingProviders && _providers.isEmpty, + onToggle: (id) => _applyScope(_scope.toggle(id)), + onSelectAll: () => _applyScope( + const CrossSearchScope.all(), + reorder: true, + ), + onOpenPicker: _openSetSheet, + ), + if (_capped) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + 'ux.quick_search_note'.tr( + args: ['${_refs().length}', '$_scopedCount'], + ), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + ), + ), + const SizedBox(height: 10), + ], + ), + ), ), - ), - ], + const Divider(height: 1, color: Colors.white10), + Expanded( + child: AnimatedBuilder( + animation: _controller, + builder: (_, _) => _body(), + ), + ), + ], + ), ), ), ); @@ -320,8 +384,11 @@ class _CrossSearchPageState extends State { isDense: true, hintText: 'search.cross_hint'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), - prefixIcon: - const Icon(Icons.search, color: AppColors.textHint, size: 20), + prefixIcon: const Icon( + Icons.search, + color: AppColors.textHint, + size: 20, + ), // Driven by the controller: nothing else subscribes to it, so the // clear button used to appear only on an unrelated rebuild. suffixIcon: ValueListenableBuilder( @@ -367,8 +434,9 @@ class _CrossSearchPageState extends State { if (_controller.query.isEmpty) { return _centered( icon: Icons.travel_explore, - text: 'search.type_to_search_n' - .tr(args: ['${_controller.expectedLegs}']), + text: 'search.type_to_search_n'.tr( + args: ['${_controller.expectedLegs}'], + ), ); } if (_controller.awaitingLongerQuery) { @@ -404,8 +472,9 @@ class _CrossSearchPageState extends State { child: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: OutlinedButton( - onPressed: - _controller.loadingMore ? null : _controller.loadMore, + onPressed: _controller.loadingMore + ? null + : _controller.loadMore, child: _controller.loadingMore ? const SizedBox( width: 16, @@ -427,22 +496,19 @@ class _CrossSearchPageState extends State { return SliverPadding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (_, i) { - final title = merged[i]; - return SearchResultCard( - key: ValueKey('${title.key}|${title.year ?? ''}'), - movie: title.primary, - provider: _providerIdOf(title.hits.first), - sourceLabel: title.sourceCount > 1 - ? 'search.sources_n'.tr(args: ['${title.sourceCount}']) - : title.primaryProvider.name, - sourceCount: title.sourceCount, - onTap: () => _openDetail(title), - ); - }, - childCount: merged.length, - ), + delegate: SliverChildBuilderDelegate((_, i) { + final title = merged[i]; + return SearchResultCard( + key: ValueKey('${title.key}|${title.year ?? ''}'), + movie: title.primary, + provider: _providerIdOf(title.hits.first), + sourceLabel: title.sourceCount > 1 + ? 'search.sources_n'.tr(args: ['${title.sourceCount}']) + : title.primaryProvider.name, + sourceCount: title.sourceCount, + onTap: () => _openDetail(title), + ); + }, childCount: merged.length), gridDelegate: searchGridDelegate(context), ), ); @@ -457,12 +523,13 @@ class _CrossSearchPageState extends State { final c = _controller; final headline = c.searching ? (c.pendingSources.isEmpty - ? 'search.searching'.tr() - : 'search.waiting_for'.tr(args: [c.pendingSources.take(2).join(', ')])) - : 'search.found_in'.tr(args: [ - '${c.sourcesWithResults}', - '${c.expectedLegs}', - ]); + ? 'search.searching'.tr() + : 'search.waiting_for'.tr( + args: [c.pendingSources.take(2).join(', ')], + )) + : 'search.found_in'.tr( + args: ['${c.sourcesWithResults}', '${c.expectedLegs}'], + ); return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), @@ -485,7 +552,9 @@ class _CrossSearchPageState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.textSecondary, fontSize: 12.5), + color: AppColors.textSecondary, + fontSize: 12.5, + ), ), ), if (c.failedLegs.isNotEmpty && !c.searching) @@ -500,6 +569,23 @@ class _CrossSearchPageState extends State { ], ), _statusPills(c), + // Said out loud rather than left to be inferred from a count that + // does not match the source list. A user who wants one of the + // sources beyond the ceiling can still reach it by narrowing the + // scope, and the line is where they find out that is the move. + if (_capped) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + 'ux.quick_search_note'.tr( + args: ['${c.expectedLegs}', '$_scopedCount'], + ), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + ), + ), ], ), ); @@ -508,20 +594,30 @@ class _CrossSearchPageState extends State { Widget _statusPills(CrossSearchController c) { final pills = [ if (c.runningSources > 0) - _pill('search.status_searching_n'.tr(args: ['${c.runningSources}']), - AppColors.textSecondary), + _pill( + 'search.status_searching_n'.tr(args: ['${c.runningSources}']), + AppColors.textSecondary, + ), if (c.sourcesWithResults > 0) - _pill('search.status_with_results_n'.tr(args: ['${c.sourcesWithResults}']), - AppColors.success), + _pill( + 'search.status_with_results_n'.tr(args: ['${c.sourcesWithResults}']), + AppColors.success, + ), if (c.emptySources > 0) - _pill('search.status_no_match_n'.tr(args: ['${c.emptySources}']), - AppColors.textHint), + _pill( + 'search.status_no_match_n'.tr(args: ['${c.emptySources}']), + AppColors.textHint, + ), if (c.timedOutSources > 0) - _pill('search.status_timed_out_n'.tr(args: ['${c.timedOutSources}']), - Colors.orange), + _pill( + 'search.status_timed_out_n'.tr(args: ['${c.timedOutSources}']), + Colors.orange, + ), if (c.erroredSources > 0) - _pill('search.status_failed_n'.tr(args: ['${c.erroredSources}']), - AppColors.error), + _pill( + 'search.status_failed_n'.tr(args: ['${c.erroredSources}']), + AppColors.error, + ), ]; if (pills.isEmpty) return const SizedBox.shrink(); return Padding( @@ -531,15 +627,16 @@ class _CrossSearchPageState extends State { } Widget _pill(String text, Color color) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(20), - ), - child: Text(text, - style: TextStyle( - color: color, fontSize: 11, fontWeight: FontWeight.w600)), - ); + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + text, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600), + ), + ); /// Torrent streaming is Android-only — the engine is a native Android /// library. Hidden elsewhere rather than shown and failing. @@ -555,8 +652,9 @@ class _CrossSearchPageState extends State { ? 'search.all_sources_failed_one'.tr() : 'search.all_sources_failed'.tr(args: ['$broken']); } else if (broken > 0) { - text = 'search.no_results_with_failures' - .tr(args: ['${c.emptySources}', '$broken']); + text = 'search.no_results_with_failures'.tr( + args: ['${c.emptySources}', '$broken'], + ); } else { text = 'search.no_results_any'.tr(); } @@ -612,9 +710,10 @@ class _CrossSearchPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w700), + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), ), Container( @@ -623,11 +722,14 @@ class _CrossSearchPageState extends State { color: AppColors.primary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), - child: Text('${r.items.length}', - style: TextStyle( - color: AppColors.primary, - fontSize: 11, - fontWeight: FontWeight.w700)), + child: Text( + '${r.items.length}', + style: TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), ), ], ), @@ -689,85 +791,94 @@ class _CrossSearchPageState extends State { List pending, ) { return [ - for (final leg in legs) - ListTile( - dense: true, - contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 8), - title: Text(leg.provider.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 13)), - subtitle: Text( - _statusLabel(leg), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: _statusColor(leg), fontSize: 11.5), - ), - trailing: leg.status == ProviderSearchStatus.ok || - leg.status == ProviderSearchStatus.empty - ? null - : _controller.isRetrying(leg.provider.id) - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : TextButton( - onPressed: () => - _controller.retryProvider(leg.provider.id), - child: Text('general.retry'.tr()), - ), - ), - // Legs that have not answered are listed too: an absent row is what - // made a still-running source look like a source with no results. - for (final name in pending) - ListTile( - dense: true, - contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 8), - title: Text(name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white, fontSize: 13)), - subtitle: Text('search.status_searching'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, fontSize: 11.5)), - trailing: const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), + for (final leg in legs) + ListTile( + dense: true, + contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 8), + title: Text( + leg.provider.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + subtitle: Text( + _statusLabel(leg), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: _statusColor(leg), fontSize: 11.5), + ), + trailing: + leg.status == ProviderSearchStatus.ok || + leg.status == ProviderSearchStatus.empty + ? null + : _controller.isRetrying(leg.provider.id) + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : TextButton( + onPressed: () => _controller.retryProvider(leg.provider.id), + child: Text('general.retry'.tr()), + ), + ), + // Legs that have not answered are listed too: an absent row is what + // made a still-running source look like a source with no results. + for (final name in pending) + ListTile( + dense: true, + contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 8), + title: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + subtitle: Text( + 'search.status_searching'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, ), + ), + trailing: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), ]; } String _statusLabel(ProviderSearchResult leg) => switch (leg.status) { - ProviderSearchStatus.ok => - 'search.results_n'.tr(args: ['${leg.items.length}']), - ProviderSearchStatus.empty => 'search.no_results'.tr(), - ProviderSearchStatus.timeout => 'errors.timeout'.tr(), - ProviderSearchStatus.error => leg.message.isEmpty - ? 'search.source_failed'.tr() - : leg.message, - }; + ProviderSearchStatus.ok => 'search.results_n'.tr( + args: ['${leg.items.length}'], + ), + ProviderSearchStatus.empty => 'search.no_results'.tr(), + ProviderSearchStatus.timeout => 'errors.timeout'.tr(), + ProviderSearchStatus.error => + leg.message.isEmpty ? 'search.source_failed'.tr() : leg.message, + }; Color _statusColor(ProviderSearchResult leg) => switch (leg.status) { - ProviderSearchStatus.ok => AppColors.success, - ProviderSearchStatus.empty => AppColors.textHint, - ProviderSearchStatus.timeout => Colors.orange, - ProviderSearchStatus.error => AppColors.error, - }; + ProviderSearchStatus.ok => AppColors.success, + ProviderSearchStatus.empty => AppColors.textHint, + ProviderSearchStatus.timeout => Colors.orange, + ProviderSearchStatus.error => AppColors.error, + }; Widget _note(String text) => Container( - margin: const EdgeInsets.fromLTRB(16, 6, 16, 0), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Text(text, - style: const TextStyle( - color: Colors.orange, fontSize: 11.5, height: 1.3)), - ); + margin: const EdgeInsets.fromLTRB(16, 6, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: const TextStyle(color: Colors.orange, fontSize: 11.5, height: 1.3), + ), + ); Widget _centered({ required IconData icon, @@ -780,16 +891,19 @@ class _CrossSearchPageState extends State { children: [ Icon(icon, color: AppColors.textHint.withValues(alpha: 0.5), size: 56), const SizedBox(height: 14), - Text(text, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textHint, fontSize: 14)), + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textHint, fontSize: 14), + ), if (action != null) ...[const SizedBox(height: 16), action], ], ); if (!padded) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 60), - child: Center(child: content)); + padding: const EdgeInsets.symmetric(vertical: 60), + child: Center(child: content), + ); } return Center( child: Padding(padding: const EdgeInsets.all(32), child: content), diff --git a/lib/features/search/presentation/pages/search_page.dart b/lib/features/search/presentation/pages/search_page.dart index bf21b278..34b1df1a 100644 --- a/lib/features/search/presentation/pages/search_page.dart +++ b/lib/features/search/presentation/pages/search_page.dart @@ -112,7 +112,9 @@ class _SearchViewState extends State<_SearchView> { backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => SearchFilterSheet( - initialSelection: SearchFilterSelection(genre: bloc.state.criteria.genre), + initialSelection: SearchFilterSelection( + genre: bloc.state.criteria.genre, + ), genres: bloc.state.genres, // Always dispatch: a genre picked or cleared while text is in the box // used to change nothing but the button's active dot. @@ -125,66 +127,81 @@ class _SearchViewState extends State<_SearchView> { Widget build(BuildContext context) { final topPad = MediaQuery.paddingOf(context).top; final bottomPad = MediaQuery.paddingOf(context).bottom; - final headerHeight = topPad + 128.0; return Scaffold( backgroundColor: AppColors.background, body: BlocConsumer( listener: (context, state) => _maybeAutoFill(state), - builder: (context, state) => Stack( - children: [ - SearchContentView( - state: state, - scrollController: _scrollController, - topPad: headerHeight, - bottomPad: bottomPad, - onRetry: () => context.read().add(const SearchRetry()), - onSuggestion: _runQuery, - onGenre: (genre) => - context.read().add(SearchGenreSelected(genre)), - onRemoveRecent: (query) => - context.read().add(SearchRecentRemoved(query)), - onClearRecents: () => - context.read().add(const SearchRecentsCleared()), - onTryAllSources: () => _openCrossSearch(state.criteria.text), - onSearchTorrents: _torrentsAvailable - ? () => _openTorrents(state.criteria.text) - : null, - ), - ValueListenableBuilder( - valueListenable: _blurProgress, - builder: (context, progress, _) => SearchStickyHeader( - progress: progress, - topPad: topPad, - controller: _controller, - focus: _focus, - hasActiveFilter: state.criteria.genre.isNotEmpty, - showFilter: state.hasGenres, - onFilterTap: _openFilter, - onTorrentTap: _torrentsAvailable - ? () => _openTorrents(_controller.text) - : null, - onMultiSearchTap: _openCrossSearch, - onQueryChanged: (q) => - context.read().add(SearchQueryChanged(q)), - onSubmitted: (q) => - context.read().add(SearchSubmitted(q)), - onClear: _clearSearch, - voiceButton: VoiceSearchButton( - // Partial results land in the field as they are heard; only - // the final transcript runs a search, so a half-heard title - // never fires a query of its own. - onText: (t) { - _controller.text = t; - _controller.selection = - TextSelection.collapsed(offset: t.length); - context.read().add(SearchQueryChanged(t)); - }, - onSubmit: _runQuery, + builder: (context, state) => LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight * 0.6, + ), + child: SingleChildScrollView( + child: ValueListenableBuilder( + valueListenable: _blurProgress, + builder: (context, progress, _) => SearchStickyHeader( + progress: progress, + topPad: topPad, + controller: _controller, + focus: _focus, + hasActiveFilter: state.criteria.genre.isNotEmpty, + showFilter: state.hasGenres, + onFilterTap: _openFilter, + onTorrentTap: _torrentsAvailable + ? () => _openTorrents(_controller.text) + : null, + onMultiSearchTap: _openCrossSearch, + onQueryChanged: (q) => + context.read().add(SearchQueryChanged(q)), + onSubmitted: (q) => + context.read().add(SearchSubmitted(q)), + onClear: _clearSearch, + voiceButton: VoiceSearchButton( + // Partial results land in the field as they are heard; only + // the final transcript runs a search, so a half-heard title + // never fires a query of its own. + onText: (t) { + _controller.text = t; + _controller.selection = TextSelection.collapsed( + offset: t.length, + ); + context.read().add(SearchQueryChanged(t)); + }, + onSubmit: _runQuery, + ), + ), + ), + ), + ), + Expanded( + child: SearchContentView( + state: state, + scrollController: _scrollController, + topPad: 0, + bottomPad: bottomPad, + onRetry: () => + context.read().add(const SearchRetry()), + onSuggestion: _runQuery, + onGenre: (genre) => context.read().add( + SearchGenreSelected(genre), + ), + onRemoveRecent: (query) => context.read().add( + SearchRecentRemoved(query), + ), + onClearRecents: () => context.read().add( + const SearchRecentsCleared(), + ), + onTryAllSources: () => _openCrossSearch(state.criteria.text), + onSearchTorrents: _torrentsAvailable + ? () => _openTorrents(state.criteria.text) + : null, ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 58f97f9c..942719f6 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -49,93 +49,62 @@ class SearchStickyHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final compactProgress = Curves.easeOutCubic.transform( - progress.clamp(0.0, 1.0), - ); - final topSpacing = lerpDouble(topPad + 18, topPad + 10, compactProgress)!; - final titleHeight = lerpDouble(28, 0, compactProgress)!; - final titleGap = lerpDouble(14, 8, compactProgress)!; - final bottomGap = lerpDouble(16, 10, compactProgress)!; final blurred = progress > 0.01; final backgroundColor = blurred - ? AppColors.background.withValues(alpha: 0.82) + ? AppColors.background.withValues(alpha: 0.95) : AppColors.background; - - final inner = Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: topSpacing), - ClipRect( - child: SizedBox( - height: titleHeight, - child: Opacity( - opacity: 1 - compactProgress, - child: Transform.translate( - offset: Offset(0, -10 * compactProgress), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - 'search.title'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 28, - fontWeight: FontWeight.w800, - height: 1, - ), - ), - ), - ), - ), + final inner = Padding( + padding: EdgeInsets.fromLTRB(16, topPad + 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'search.title'.tr(), + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w800), ), - ), - SizedBox(height: titleGap), - Padding( - padding: EdgeInsets.fromLTRB(16, 0, 16, bottomGap), - child: Row( - children: [ - Expanded( - child: _SearchField( - controller: controller, - focus: focus, - onChanged: onQueryChanged, - onSubmitted: onSubmitted, - onClear: onClear, - voiceButton: voiceButton, - ), - ), - // Only once something is typed. The action needs a query to - // carry, so showing it on an empty field would offer a button - // that cannot do anything — and it would cost the search field a - // permanent 40dp on a phone that has none to spare. - if (onTorrentTap != null) - ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) => value.text.trim().isEmpty - ? const SizedBox.shrink() - : Padding( - padding: const EdgeInsetsDirectional.only(start: 10), - child: _HeaderIconButton( - icon: Icons.hub_rounded, - onTap: onTorrentTap!, - tooltip: 'search.try_torrents'.tr(), - ), - ), + const SizedBox(height: 12), + _SearchField( + controller: controller, + focus: focus, + onChanged: onQueryChanged, + onSubmitted: onSubmitted, + onClear: onClear, + voiceButton: voiceButton, + ), + const SizedBox(height: 8), + // Actions never steal width from the query or appear mid-keystroke. + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _SearchAction( + icon: Icons.travel_explore_rounded, + label: 'search.all_source_search'.tr(), + onTap: onMultiSearchTap, ), - const SizedBox(width: 10), - _HeaderIconButton( - icon: Icons.travel_explore_rounded, - onTap: onMultiSearchTap, - tooltip: 'search.all_source_search'.tr(), - ), - if (showFilter) ...[ - const SizedBox(width: 10), - _FilterButton(active: hasActiveFilter, onTap: onFilterTap), + if (onTorrentTap != null) ...[ + const SizedBox(width: 8), + _SearchAction( + icon: Icons.hub_rounded, + label: 'ux.torrents'.tr(), + onTap: onTorrentTap!, + ), + ], + if (showFilter) ...[ + const SizedBox(width: 8), + _SearchAction( + icon: Icons.tune_rounded, + label: 'ux.filters'.tr(), + onTap: onFilterTap, + selected: hasActiveFilter, + ), + ], ], - ], + ), ), - ), - ], + ], + ), ); final surface = Container( @@ -227,7 +196,11 @@ class _SearchField extends StatelessWidget { child: icon, ); } - return GestureDetector(onTap: onClear, child: icon); + return IconButton( + tooltip: 'general.clear'.tr(), + onPressed: onClear, + icon: icon, + ); }, ), border: InputBorder.none, @@ -252,7 +225,9 @@ class _SearchField extends StatelessWidget { return AnimatedContainer( duration: const Duration(milliseconds: 160), curve: Curves.easeOut, - height: 46, + constraints: BoxConstraints( + minHeight: 52 + (MediaQuery.textScalerOf(context).scale(15) - 15), + ), // Focus reads as lit, not as outlined. A saturated red rectangle // around a text field is louder than anything else on the screen // and fights the dark surface it sits on; the brand colour carries @@ -283,90 +258,35 @@ class _SearchField extends StatelessWidget { } } -class _HeaderIconButton extends StatelessWidget { - const _HeaderIconButton({ +class _SearchAction extends StatelessWidget { + const _SearchAction({ required this.icon, + required this.label, required this.onTap, - this.tooltip, + this.selected = false, }); - final IconData icon; + final String label; final VoidCallback onTap; - final String? tooltip; - - @override - Widget build(BuildContext context) { - final content = Container( - height: 46, - width: 46, - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Icon(icon, size: 20, color: AppColors.textSecondary), - ); - // Android TV: the search header's action buttons were unreachable by the - // D-pad. Off TV this is the GestureDetector that was always here. - final button = isTvPlatform - ? TvFocusable(onPressed: onTap, borderRadius: 14, child: content) - : GestureDetector(onTap: onTap, child: content); - return tooltip == null ? button : Tooltip(message: tooltip!, child: button); - } -} - -class _FilterButton extends StatelessWidget { - const _FilterButton({required this.onTap, required this.active}); - - final VoidCallback onTap; - final bool active; + final bool selected; @override - Widget build(BuildContext context) { - final content = Container( - height: 46, - width: 46, - decoration: BoxDecoration( - color: active - ? AppColors.primary.withValues(alpha: 0.18) - : AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: active - ? AppColors.primary.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.08), - ), + Widget build(BuildContext context) => Semantics( + selected: selected, + child: TextButton.icon( + onPressed: onTap, + icon: Icon(icon, size: 20), + label: Text(label), + style: TextButton.styleFrom( + minimumSize: const Size(48, 48), + foregroundColor: selected + ? AppColors.primaryLight + : AppColors.textSecondary, + backgroundColor: selected + ? AppColors.primary.withValues(alpha: 0.14) + : AppColors.surface, + padding: const EdgeInsets.symmetric(horizontal: 12), ), - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - Icons.tune_rounded, - size: 20, - color: active ? AppColors.primary : AppColors.textSecondary, - ), - if (active) - Positioned( - top: 9, - right: 9, - child: Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ); - - // Android TV: without this, the filter sheet could not be opened at all. - if (isTvPlatform) { - return TvFocusable(onPressed: onTap, borderRadius: 14, child: content); - } - - return GestureDetector(onTap: onTap, child: content); - } + ), + ); } diff --git a/lib/features/search/presentation/widgets/search_set_sheet.dart b/lib/features/search/presentation/widgets/search_set_sheet.dart index ef242d13..092c81c3 100644 --- a/lib/features/search/presentation/widgets/search_set_sheet.dart +++ b/lib/features/search/presentation/widgets/search_set_sheet.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/search/domain/entities/cross_search_scope.dart'; @@ -44,7 +45,7 @@ class _SearchSetSheetState extends State { }; String _query = ''; - bool get _isAll => _selected.isEmpty || _selected.length == widget.providers.length; + bool get _isAll => _selected.isEmpty; CrossSearchScope get _result => _isAll ? const CrossSearchScope.all() : CrossSearchScope.only(_selected); @@ -55,8 +56,11 @@ class _SearchSetSheetState extends State { final q = _query.trim().toLowerCase(); if (q.isEmpty) return widget.providers; return widget.providers - .where((p) => - p.name.toLowerCase().contains(q) || p.id.toLowerCase().contains(q)) + .where( + (p) => + p.name.toLowerCase().contains(q) || + p.id.toLowerCase().contains(q), + ) .toList(); } @@ -72,7 +76,9 @@ class _SearchSetSheetState extends State { Widget build(BuildContext context) { final bottom = MediaQuery.viewInsetsOf(context).bottom; final items = _filtered; - final effective = _isAll ? widget.providers.length : _selected.length; + final effective = _isAll + ? widget.providers.length.clamp(0, CrossSearchEngine.maxLegs) + : _selected.length; final overCap = effective > _softCap; return Padding( @@ -106,26 +112,39 @@ class _SearchSetSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('search.search_sources'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w800)), Text( - _isAll - ? 'search.all_sources_n' - .tr(args: ['${widget.providers.length}']) - : 'search.selected_n_of_m'.tr(args: [ + 'search.search_sources'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + Text( + _isAll + ? (widget.providers.length > + CrossSearchEngine.maxLegs + ? 'ux.quick_search' + : 'search.all_sources_n') + .tr(args: ['$effective']) + : 'search.selected_n_of_m'.tr( + args: [ '${_selected.length}', '${widget.providers.length}', - ]), - style: const TextStyle( - color: AppColors.textHint, fontSize: 12)), + ], + ), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + ), + ), ], ), ), TextButton( - onPressed: _isAll ? null : () => setState(_selected.clear), + onPressed: _isAll + ? null + : () => setState(_selected.clear), child: Text('search.select_all_sources'.tr()), ), ], @@ -140,8 +159,11 @@ class _SearchSetSheetState extends State { isDense: true, hintText: 'search.filter_providers'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), - prefixIcon: const Icon(Icons.search, - color: AppColors.textHint, size: 20), + prefixIcon: const Icon( + Icons.search, + color: AppColors.textHint, + size: 20, + ), filled: true, fillColor: AppColors.surface, border: OutlineInputBorder( @@ -155,16 +177,28 @@ class _SearchSetSheetState extends State { Container( width: double.infinity, margin: const EdgeInsets.fromLTRB(16, 0, 16, 6), - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), decoration: BoxDecoration( color: Colors.orange.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Text( - 'search.many_sources_warning'.tr(args: ['$effective']), + _isAll && + widget.providers.length > CrossSearchEngine.maxLegs + ? 'ux.quick_search_note'.tr( + args: ['$effective', '${widget.providers.length}'], + ) + : 'search.many_sources_warning'.tr( + args: ['$effective'], + ), style: const TextStyle( - color: Colors.orange, fontSize: 11.5, height: 1.3), + color: Colors.orange, + fontSize: 11.5, + height: 1.3, + ), ), ), Expanded( @@ -198,24 +232,33 @@ class _SearchSetSheetState extends State { title: Row( children: [ Expanded( - child: Text(p.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, fontSize: 14)), + child: Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), ), Container( padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: AppColors.surfaceVariant, borderRadius: BorderRadius.circular(5), ), - child: Text(_tag(p), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 9, - fontWeight: FontWeight.w700)), + child: Text( + _tag(p), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 9, + fontWeight: FontWeight.w700, + ), + ), ), ], ), @@ -232,11 +275,16 @@ class _SearchSetSheetState extends State { width: double.infinity, child: FilledButton( style: FilledButton.styleFrom( - backgroundColor: AppColors.primary), + backgroundColor: AppColors.primary, + ), onPressed: () => Navigator.of(context).pop(_result), - child: Text(_isAll - ? 'search.apply_all'.tr() - : 'search.apply_n'.tr(args: ['${_selected.length}'])), + child: Text( + _isAll + ? 'search.apply_all'.tr() + : 'search.apply_n'.tr( + args: ['${_selected.length}'], + ), + ), ), ), ), diff --git a/lib/features/search/presentation/widgets/source_scope_bar.dart b/lib/features/search/presentation/widgets/source_scope_bar.dart index c7cfb49a..c0f2f5a6 100644 --- a/lib/features/search/presentation/widgets/source_scope_bar.dart +++ b/lib/features/search/presentation/widgets/source_scope_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/search/domain/entities/cross_search_scope.dart'; +import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; /// The scope control for all-source search: a scrolling rail of source chips /// with "All sources" pinned at the front. @@ -43,54 +44,69 @@ class SourceScopeBar extends StatelessWidget { @override Widget build(BuildContext context) { final byId = {for (final p in providers) p.id: p}; - final ids = [for (final id in order) if (byId.containsKey(id)) id]; + // Only explicit selections belong in the rail. The full catalogue lives + // behind a permanently visible picker, even with thousands of sources. + final ids = [ + for (final id in order) + if (byId.containsKey(id) && !scope.isAll && scope.includes(id)) id, + ]; - return SizedBox( - height: 40, - child: Row( + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(width: 16), - // Outside the scroll view, not merely first in it: "widen back to - // everything" has to stay one tap away after the user has scrolled - // the rail, which is exactly when a narrowed search looks broken. - _Chip( - label: loading - ? 'search.loading_sources'.tr() - : 'search.all_sources_n'.tr(args: ['${providers.length}']), - selected: scope.isAll, - icon: Icons.travel_explore, - onTap: loading ? null : onSelectAll, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Outside the scroll view, not merely first in it: "widen back to + // everything" has to stay one tap away after the user has scrolled + // the rail, which is exactly when a narrowed search looks broken. + _Chip( + label: loading + ? 'search.loading_sources'.tr() + : providers.length > CrossSearchEngine.maxLegs + ? 'ux.quick_search'.tr( + args: ['${CrossSearchEngine.maxLegs}'], + ) + : 'search.all_sources_n'.tr(args: ['${providers.length}']), + selected: scope.isAll, + icon: Icons.travel_explore, + onTap: loading ? null : onSelectAll, + ), + _Chip( + label: 'search.search_sources'.tr(), + selected: false, + icon: Icons.tune, + onTap: loading ? null : onOpenPicker, + ), + ], ), - const SizedBox(width: 8), - Expanded( - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsetsDirectional.only(end: 16), - itemCount: ids.length + 1, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (_, i) { - if (i == ids.length) { + if (ids.isNotEmpty) ...[ + const SizedBox(height: 8), + SizedBox( + height: 48 + (MediaQuery.textScalerOf(context).scale(13) - 13), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsetsDirectional.only(end: 16), + itemCount: ids.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final p = byId[ids[i]]!; + // In "all" mode no individual chip reads as picked, so a tap on + // one narrows to it instead of subtracting it from everything. + final on = !scope.isAll && scope.includes(p.id); return _Chip( - label: 'search.more_sources'.tr(), - selected: false, - icon: Icons.tune, - onTap: loading ? null : onOpenPicker, + label: p.name, + selected: on, + icon: on ? Icons.check : null, + onTap: () => onToggle(p.id), ); - } - final p = byId[ids[i]]!; - // In "all" mode no individual chip reads as picked, so a tap on - // one narrows to it instead of subtracting it from everything. - final on = !scope.isAll && scope.includes(p.id); - return _Chip( - label: p.name, - selected: on, - icon: on ? Icons.check : null, - onTap: () => onToggle(p.id), - ); - }, + }, + ), ), - ), + ], ], ), ); @@ -113,35 +129,40 @@ class _Chip extends StatelessWidget { @override Widget build(BuildContext context) { final fg = selected ? Colors.white : AppColors.textSecondary; - return Material( - color: selected ? AppColors.primary : AppColors.surface, - borderRadius: BorderRadius.circular(20), - child: InkWell( - onTap: onTap, + return Semantics( + selected: selected, + button: true, + child: Material( + color: selected ? AppColors.primary : AppColors.surface, borderRadius: BorderRadius.circular(20), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: 15, color: fg), - const SizedBox(width: 6), - ], - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 160), - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: fg, - fontSize: 13, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 15, color: fg), + const SizedBox(width: 6), + ], + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/main.dart b/lib/main.dart index 9c774f34..4925190a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -140,9 +140,29 @@ void main() async { // and its seek bar, which are physical geometry rather than reading // order and read the same in every language. Locale('ar'), + // Latin-script tier. Nothing here needs a bundled font or a mirrored + // layout: the app ships no custom font, so the system one covers them, + // and every one of them reads left to right like English does. + Locale('de'), + Locale('nl'), + Locale('es'), + // `pt`, not `pt-BR`, though the copy is written in Brazilian Portuguese. + // A region tag would have been the only two-part code in the set — a + // special case in the file names, in the check script, and in + // `kSubtitleTranslateLanguages`, which lists plain `pt` and would have + // silently dropped a `pt-BR` reader back to the default target language. + Locale('pt'), + Locale('fr'), + Locale('tr'), + Locale('id'), ], path: 'assets/translations', fallbackLocale: const Locale('en'), + // Without this a key missing from one locale renders as the key itself — + // the user reads `profile.language_desc` where a sentence belongs. CI gates + // on the key check, so this should never fire; it is here because the day + // it does, English is a far better answer than raw dot-notation. + useFallbackTranslations: true, child: const MyApp(), ); if (usesFlutterGlass) { diff --git a/pubspec.lock b/pubspec.lock index c2c3d7af..9e61d340 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -386,7 +386,7 @@ packages: source: hosted version: "2.0.8" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -1629,7 +1629,7 @@ packages: source: hosted version: "5.0.2" shared_preferences: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf diff --git a/pubspec.yaml b/pubspec.yaml index 7570a8eb..1f1b8f33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -162,6 +162,7 @@ dependencies: web_socket_channel: ^3.0.1 dev_dependencies: + shared_preferences: 2.5.5 flutter_test: sdk: flutter integration_test: @@ -172,6 +173,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. + fake_async: ^1.3.3 flutter_lints: ^6.0.0 json_serializable: ^6.13.1 build_runner: ^2.14.0 diff --git a/test/core/localization_test.dart b/test/core/localization_test.dart index b196c34d..ea1677b2 100644 --- a/test/core/localization_test.dart +++ b/test/core/localization_test.dart @@ -3,10 +3,20 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -/// Guards the four translation files against the two failures that reach users -/// as visible breakage rather than as a missing word. +/// Guards the translation files against the two failures that reach users as +/// visible breakage rather than as a missing word. void main() { - const locales = ['en', 'ru', 'uz', 'ar']; + // Read from disk rather than listed here. A hard-coded list silently stops + // covering the locale it was not updated for, and the whole point of this + // file is to catch what nobody remembered to check. + final locales = Directory('assets/translations') + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.json')) + .map((n) => n.substring(0, n.length - 5)) + .toList() + ..sort(); Map load(String locale) => jsonDecode( File('assets/translations/$locale.json').readAsStringSync(), diff --git a/test/core/player/hls_variants_test.dart b/test/core/player/hls_variants_test.dart new file mode 100644 index 00000000..fd33a19e --- /dev/null +++ b/test/core/player/hls_variants_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/hls_variants.dart'; + +const _base = 'https://cdn.example.test/hls/master.m3u8'; + +void main() { + final base = Uri.parse(_base); + + test('the best rendition wins, not the first one listed', () { + // The shape that caused the bug: packagers list the lowest bitrate first so + // a client starts conservatively. Taking the first entry saved 480p of a + // film the player was showing at 1080p. + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480 +index-s480p-v1-a1.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x720 +index-s720p-v1-a1.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080 +index-s1080p-v1-a1.m3u8 +'''; + final variants = parseHlsVariants(master, base); + + expect(variants.map((v) => v.height), [1080, 720, 480]); + expect(variants.first.url, endsWith('index-s1080p-v1-a1.m3u8')); + }); + + test('a widescreen film is named by its packager, not its pixel height', () { + // A 2.40:1 film encoded at 1080p carries RESOLUTION=1920x800. Ranking on + // the pixel height alone would sort it below a true 1080p variant and call + // it 800p in the menu. + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x800 +index-s1080p-v1-a1.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=2400000,RESOLUTION=1280x536 +index-s720p-v1-a1.m3u8 +'''; + final variants = parseHlsVariants(master, base); + + expect(variants.first.height, 1080); + expect(variants.last.height, 720); + }); + + test('RESOLUTION carries it when the uri has no name token', () { + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480 +low/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080 +high/playlist.m3u8 +'''; + final variants = parseHlsVariants(master, base); + + expect(variants.first.height, 1080); + expect(variants.first.url, 'https://cdn.example.test/hls/high/playlist.m3u8'); + }); + + test('relative and absolute variant uris both resolve', () { + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080 +https://other.example.test/v/1080.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480 +../low/480.m3u8 +'''; + final variants = parseHlsVariants(master, base); + + expect(variants.first.url, 'https://other.example.test/v/1080.m3u8'); + expect(variants.last.url, 'https://cdn.example.test/low/480.m3u8'); + }); + + test('a directory base resolves the same as the master url', () { + // What the downloader actually passes: `_baseOf` trims to the directory + // and keeps the trailing slash. The two must agree or the file saved is + // not the file the parser ranked. + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080 +index-s1080p-v1-a1.m3u8 +'''; + final fromMaster = parseHlsVariants(master, base); + final fromDir = + parseHlsVariants(master, Uri.parse('https://cdn.example.test/hls/')); + + expect(fromDir.single.url, fromMaster.single.url); + expect(fromDir.single.url, + 'https://cdn.example.test/hls/index-s1080p-v1-a1.m3u8'); + }); + + test('a media playlist yields nothing, so the caller keeps its own url', () { + const media = ''' +#EXTM3U +#EXT-X-TARGETDURATION:10 +#EXTINF:10.0, +segment0.ts +#EXTINF:10.0, +segment1.ts +#EXT-X-ENDLIST +'''; + expect(parseHlsVariants(media, base), isEmpty); + }); + + test('a variant stating neither a name nor a resolution is skipped', () { + // Skipped rather than ranked at zero: a nameless entry sorted last would + // still be returned when it is the only one, and the caller has a better + // fallback than a variant it knows nothing about. + const master = ''' +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=800000 +mystery.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080 +index-s1080p-v1-a1.m3u8 +'''; + final variants = parseHlsVariants(master, base); + + expect(variants, hasLength(1)); + expect(variants.single.height, 1080); + }); +} diff --git a/test/core/player/source_ladder_test.dart b/test/core/player/source_ladder_test.dart index bd500e05..1742617d 100644 --- a/test/core/player/source_ladder_test.dart +++ b/test/core/player/source_ladder_test.dart @@ -68,15 +68,39 @@ void main() { expect(ladder.next(), 1); }); - test('with neither, the backend order stands', () { - // The list arrives ranked. Absent a reason, do not reorder it. + test('with neither, the highest resolution leads', () { + // This used to assert that the backend order stands, on the premise that + // the list arrives ranked. It does from most providers — but asilmedia + // and animefenix mark no default and emit sources in page order, which on + // asilmedia puts a 360p ahead of a 720p. Films from those played, and + // downloaded, at the lower one with the higher sitting in the same list. final ladder = SourceLadder( sources: [src('720p'), src('1080p'), src('480p')], hasDirective: false, ); + expect(ladder.ordered(), [1, 0, 2]); + }); + + test('labels with no resolution keep the backend order', () { + // vidapi's entries are mirrors, not qualities — "Server 1", "Server 2". + // Nothing in the label ranks them, so the order the backend chose is the + // only information there is, and inventing a preference would be noise. + final ladder = SourceLadder( + sources: [src('Server 1'), src('Server 2'), src('Server 3')], + hasDirective: false, + ); expect(ladder.ordered(), [0, 1, 2]); }); + test('a provider default still outranks a higher resolution', () { + // The default is somebody's decision; the resolution guess is mine. + final ladder = SourceLadder( + sources: [src('1080p'), src('480p', isDefault: true)], + hasDirective: false, + ); + expect(ladder.next(), 1); + }); + test('a remembered label that matches nothing is ignored, not fatal', () { // The title was watched at a quality this provider no longer serves. final ladder = SourceLadder( diff --git a/test/features/mobile_ux_test.dart b/test/features/mobile_ux_test.dart new file mode 100644 index 00000000..d8264779 --- /dev/null +++ b/test/features/mobile_ux_test.dart @@ -0,0 +1,423 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:soplay/core/localization/language_picker.dart'; +import 'package:soplay/core/content/content_mode.dart'; +import 'package:soplay/core/navigation/app_tab.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; +import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; +import 'package:soplay/features/download/data/models/download_item_model.dart'; +import 'package:soplay/features/download/domain/entities/download_item.dart'; +import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; +import 'package:soplay/features/profile/presentation/widgets/provider_quick_switch.dart'; +import 'package:soplay/features/main/presentation/pages/main_page.dart'; +import 'package:soplay/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:soplay/features/search/domain/entities/cross_search_scope.dart'; +import 'package:soplay/features/search/presentation/widgets/search_header.dart'; +import 'package:soplay/features/search/presentation/widgets/source_scope_bar.dart'; + +class _Translations extends AssetLoader { + const _Translations(); + @override + Future> load(String path, Locale locale) async => + jsonDecode( + File( + 'assets/translations/${locale.languageCode}.json', + ).readAsStringSync(), + ) + as Map; +} + +class _ManifestAdapter implements HttpClientAdapter { + RequestOptions? request; + @override + Future fetch( + RequestOptions options, + Stream? body, + Future? cancelFuture, + ) async { + request = options; + return ResponseBody.fromString( + '#EXTM3U\n' + '#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=854x480\n480/index.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1920x1080\n1080/index.m3u8\n', + 200, + headers: { + Headers.contentTypeHeader: ['application/vnd.apple.mpegurl'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void _noop() {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + Future pump( + WidgetTester tester, + Widget child, { + double width = 360, + double height = 800, + double scale = 1, + double keyboard = 0, + String language = 'en', + }) async { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + EasyLocalization( + key: ValueKey('$language-$scale-$width'), + supportedLocales: const [ + Locale('en'), + Locale('uz'), + Locale('ru'), + Locale('ar'), + Locale('de'), + Locale('nl'), + Locale('es'), + Locale('pt'), + Locale('fr'), + Locale('tr'), + Locale('id'), + ], + startLocale: Locale(language), + fallbackLocale: const Locale('en'), + saveLocale: false, + path: 'assets/translations', + assetLoader: const _Translations(), + child: Builder( + builder: (context) => MaterialApp( + theme: AppTheme.dark, + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(scale), + viewInsets: EdgeInsets.only(bottom: keyboard), + ), + child: child!, + ), + home: child, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + final providers = List.generate( + 2001, + (i) => ProviderEntity( + id: 'p$i', + name: 'Source $i', + image: '', + url: '', + description: '', + domains: const [], + ), + ); + + for (final locale in ['en', 'de', 'nl', 'ar']) { + testWidgets('source picker stays visible with 2001 sources in $locale', ( + tester, + ) async { + var opened = false; + await pump( + tester, + Scaffold( + body: SingleChildScrollView( + child: SourceScopeBar( + providers: providers, + order: providers.map((p) => p.id).toList(), + scope: const CrossSearchScope.all(), + onToggle: (_) {}, + onSelectAll: () {}, + onOpenPicker: () => opened = true, + ), + ), + ), + width: 320, + scale: 2, + language: locale, + ); + expect(tester.takeException(), isNull); + expect(find.text('Source 2000'), findsNothing); + await tester.tap(find.byIcon(Icons.tune)); + expect(opened, isTrue); + }); + } + + testWidgets('search input retains its width while typing with every action', ( + tester, + ) async { + final controller = TextEditingController(); + final focus = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focus.dispose); + await pump( + tester, + Scaffold( + body: SearchStickyHeader( + progress: 0, + topPad: 0, + controller: controller, + focus: focus, + hasActiveFilter: false, + showFilter: true, + onFilterTap: () {}, + onMultiSearchTap: () {}, + onTorrentTap: () {}, + onQueryChanged: (_) {}, + onSubmitted: (_) {}, + onClear: controller.clear, + ), + ), + width: 320, + scale: 2, + language: 'de', + ); + final before = tester.getSize(find.byType(TextField)).width; + await tester.enterText(find.byType(TextField), 'A long movie title'); + await tester.pump(); + expect(tester.getSize(find.byType(TextField)).width, before); + // 16dp gutters and a 1dp border on each side; actions cost no input width. + expect(before, 286); + expect(tester.takeException(), isNull); + }); + + testWidgets('intro language choice scrolls at large text and short height', ( + tester, + ) async { + await pump( + tester, + const IntroLanguagePage(), + width: 320, + height: 480, + scale: 2, + language: 'de', + ); + expect(find.byType(FilledButton), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Bahasa Indonesia'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.drag(find.byType(ListView), const Offset(0, -120)); + await tester.pumpAndSettle(); + expect(find.text('🇮🇩'), findsOneWidget); + await tester.tap(find.text('Bahasa Indonesia')); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'intro header keeps logo left and skip right without language chip', + (tester) async { + await pump( + tester, + Scaffold(body: OnboardingHeader(onSkip: _noop)), + width: 320, + height: 480, + scale: 2, + language: 'de', + ); + final logo = tester.getRect(find.text('SOZO')); + final skip = tester.getRect(find.text('Überspringen')); + expect(logo.left, lessThan(32)); + expect(skip.right, greaterThan(280)); + expect(find.byIcon(Icons.language_rounded), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'download confirms the chosen source headers and declared resolution', + (tester) async { + DownloadSelection? selected; + await pump( + tester, + Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => selected = await chooseDownload( + context, + url: 'https://example.test/first.mp4', + headers: const {'Referer': 'first'}, + type: 'mp4', + sources: const [ + VideoSourceEntity( + quality: '720p', + videoUrl: 'https://example.test/second.mp4', + isDefault: false, + accessible: true, + type: 'mp4', + height: 720, + headers: {'Referer': 'second'}, + ), + ], + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('Quality unknown'), findsOneWidget); + await tester.tap(find.text('720p')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(selected?.url, 'https://example.test/second.mp4'); + expect(selected?.headers, {'Referer': 'second'}); + expect(selected?.height, 720); + }, + ); + + test( + 'download resolution survives storage and legacy data stays unknown', + () { + const item = DownloadItem( + id: 'a', + contentUrl: 'page', + provider: 'p', + title: 'Film', + sourceUrl: 'video', + relativePath: 'downloads/a/video.mp4', + createdAt: 0, + videoHeight: 1080, + ); + final json = DownloadItemModel.toJson(item.copyWith(sizeBytes: 2048)); + expect(DownloadItemModel.fromJson(json).videoHeight, 1080); + json.remove('videoHeight'); + expect(DownloadItemModel.fromJson(json).videoHeight, isNull); + }, + ); + + testWidgets('source switcher is lazy and recoverable with a keyboard', ( + tester, + ) async { + await pump( + tester, + Scaffold( + resizeToAvoidBottomInset: false, + body: ProviderQuickSwitchSheet( + favorites: const [], + all: providers, + mode: ContentMode.video, + currentProviderId: 'p0', + ), + ), + width: 320, + height: 600, + scale: 2, + keyboard: 250, + ); + expect(tester.takeException(), isNull); + expect(find.byType(ListTile).evaluate().length, lessThan(30)); + await tester.ensureVisible(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'not installed anywhere'); + await tester.pumpAndSettle(); + expect(find.text('No sources match your search.'), findsOneWidget); + expect(find.text('Manage sources'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('six navigation destinations remain readable at 200 percent', ( + tester, + ) async { + var selected = -1; + await pump( + tester, + Scaffold( + bottomNavigationBar: ReadableNavigationBar( + index: 0, + items: [ + for (final id in [...kDefaultTabs, TabId.downloads]) + kTabRegistry[id]!, + ], + onTap: (i) => selected = i, + ), + ), + width: 320, + height: 640, + scale: 2, + language: 'de', + ); + expect(tester.takeException(), isNull); + await tester.tap(find.byIcon(kTabRegistry[TabId.downloads]!.icon)); + expect(selected, 5); + }); + + testWidgets( + 'HLS download offers actual renditions and keeps the chosen URL', + (tester) async { + final adapter = _ManifestAdapter(); + final dio = Dio()..httpClientAdapter = adapter; + DownloadSelection? selected; + await pump( + tester, + Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('open'), + onPressed: () async => + selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => DownloadChoiceSheet( + url: 'https://example.test/movie/master.m3u8', + headers: const {'Referer': 'required'}, + type: 'hls', + manifestClient: dio, + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('1080p'), findsOneWidget); + expect(find.text('480p'), findsOneWidget); + expect(adapter.request?.headers['Referer'], 'required'); + await tester.tap(find.text('480p')); + await tester.pump(); + await tester.tap(find.byType(FilledButton)); + await tester.pumpAndSettle(); + expect(selected?.url, 'https://example.test/movie/480/index.m3u8'); + expect(selected?.height, 480); + }, + ); + + test('secondary copy clears 4.5:1 on every shipped dark surface', () { + for (final background in [ + AppColors.background, + AppColors.surface, + AppColors.surfaceVariant, + ]) { + final ratio = + (AppColors.textHint.computeLuminance() + 0.05) / + (background.computeLuminance() + 0.05); + expect(ratio, greaterThanOrEqualTo(4.5)); + } + }); +} diff --git a/test/features/search/cross_search_controller_test.dart b/test/features/search/cross_search_controller_test.dart new file mode 100644 index 00000000..8e7dd85a --- /dev/null +++ b/test/features/search/cross_search_controller_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; +import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; +import 'package:soplay/features/search/presentation/blocs/cross_search_controller.dart'; + +/// A fan-out that emits results without touching a network, a WebView or a +/// plugin host — the controller's batching is the thing under test, and the +/// real engine's dependencies would only be faked into silence anyway. +class _StubFanOut implements SearchFanOut { + _StubFanOut(this.emit); + + /// Drives what the run produces, so a test can decide when legs land. + final void Function(StreamController out) emit; + + @override + Stream search({ + required List set, + required String query, + int page = 1, + int concurrency = 5, + Duration perProviderTimeout = const Duration(seconds: 10), + }) { + final out = StreamController(); + emit(out); + return out.stream; + } + + /// The stub searches exactly what it is given: the ceiling is the engine's + /// job and has its own test. + @override + List planLegs(List set, {int limit = 60}) => set; + + @override + Future searchProvider( + ProviderRef ref, + String query, { + int page = 1, + Duration timeout = const Duration(seconds: 10), + }) async => + throw UnimplementedError(); +} + +ProviderRef _ref(int i) => + ProviderRef(id: 'p$i', name: 'Provider $i', kind: ProviderKind.server); + +MovieEntity _item(int i) => MovieEntity( + externalId: 'x$i', + title: 'Naruto $i', + description: '', + slug: 's$i', + url: 'https://example.test/$i', + provider: 'p$i', + thumbnail: null, + year: null, + rating: null, + qualities: null, + category: "", + ); + +ProviderSearchResult _leg(int i) => ProviderSearchResult( + provider: _ref(i), + items: [_item(i)], + status: ProviderSearchStatus.ok, + ); + +void main() { + test('a thousand legs do not cause a thousand rebuilds', () { + // The bug this guards: every arriving leg used to trigger a full + // `mergeSearchResults` — which regroups and re-scores every item collected + // so far — plus a `notifyListeners`. That is quadratic in the number of + // sources, on the UI isolate, and it is what made an all-source search + // stop responding on a device with a large provider list. + fakeAsync((async) { + const legs = 1000; + late StreamController out; + final controller = CrossSearchController( + engine: _StubFanOut((c) => out = c), + set: [for (var i = 0; i < legs; i++) _ref(i)], + ); + + var notifications = 0; + controller.addListener(() => notifications++); + + controller.onQueryChanged('naruto'); + async.elapse(const Duration(milliseconds: 500)); // past the debounce + final beforeLegs = notifications; + + // Every leg lands inside one flush window. + for (var i = 0; i < legs; i++) { + out.add(_leg(i)); + } + async.elapse(const Duration(milliseconds: 300)); + + final forLegs = notifications - beforeLegs; + expect( + forLegs, + lessThan(5), + reason: '$legs legs in one window should coalesce into a handful of ' + 'rebuilds, not one each', + ); + expect(controller.merged, hasLength(legs)); + + controller.dispose(); + }); + }); + + test('the last legs are not stranded in an unflushed window', () { + // The batching's own failure mode: a run that ends while results are still + // sitting in the current window would report itself finished on a list that + // does not contain them. + fakeAsync((async) { + late StreamController out; + final controller = CrossSearchController( + engine: _StubFanOut((c) => out = c), + set: [_ref(0), _ref(1)], + ); + + controller.onQueryChanged('naruto'); + async.elapse(const Duration(milliseconds: 500)); + + out.add(_leg(0)); + out.add(_leg(1)); + // Closing immediately, well inside the 250ms window. + out.close(); + async.elapse(const Duration(milliseconds: 10)); + + expect(controller.phase, CrossSearchPhase.done); + expect(controller.merged, hasLength(2), + reason: 'both legs must be merged before the run reports done'); + + controller.dispose(); + }); + }); + + test('a new query drops the previous run\'s pending flush', () { + fakeAsync((async) { + final controllers = >[]; + final controller = CrossSearchController( + engine: _StubFanOut(controllers.add), + set: [_ref(0)], + ); + + controller.onQueryChanged('naruto'); + async.elapse(const Duration(milliseconds: 500)); + controllers.first.add(_leg(0)); + + // Retyping before the window elapses: the first run's merge must not + // land on top of the second run's empty list. + controller.onQueryChanged('bleach'); + async.elapse(const Duration(milliseconds: 500)); + + expect(controller.merged, isEmpty); + + controller.dispose(); + }); + }); +} diff --git a/test/features/search/cross_search_scope_test.dart b/test/features/search/cross_search_scope_test.dart index cff240ec..74d119d8 100644 --- a/test/features/search/cross_search_scope_test.dart +++ b/test/features/search/cross_search_scope_test.dart @@ -3,13 +3,13 @@ import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/search/domain/entities/cross_search_scope.dart'; ProviderEntity _p(String id) => ProviderEntity( - id: id, - name: id, - image: '', - url: '', - description: '', - domains: const [], - ); + id: id, + name: id, + image: '', + url: '', + description: '', + domains: const [], +); void main() { final providers = [_p('a'), _p('b'), _p('c')]; @@ -41,13 +41,15 @@ void main() { expect(CrossSearchScope.only({'gone'}).pruned(providers).isAll, isTrue); }); - test('a stored set naming every source is all, so later installs count', - () { - final stored = CrossSearchScope.fromStored(const ['a', 'b', 'c']); - final scope = stored.pruned(providers); - expect(scope.isAll, isTrue); - expect(scope.resolve([...providers, _p('d')]).length, 4); - }); + test( + 'an explicit set stays explicit rather than becoming capped quick search', + () { + final stored = CrossSearchScope.fromStored(const ['a', 'b', 'c']); + final scope = stored.pruned(providers); + expect(scope.isAll, isFalse); + expect(scope.resolve([...providers, _p('d')]).length, 3); + }, + ); }); group('toggling from all', () { @@ -61,7 +63,9 @@ void main() { final scope = CrossSearchScope.only({'a', 'c'}); expect(CrossSearchScope.fromStored(scope.toStored()), scope); expect( - CrossSearchScope.fromStored(const CrossSearchScope.all().toStored()).isAll, + CrossSearchScope.fromStored( + const CrossSearchScope.all().toStored(), + ).isAll, isTrue, ); }); diff --git a/tool/check_expansion.dart b/tool/check_expansion.dart new file mode 100644 index 00000000..4024c4f6 --- /dev/null +++ b/tool/check_expansion.dart @@ -0,0 +1,128 @@ +// Ranks translation keys by how much longer they got than English. +// +// dart run tool/check_expansion.dart # the worst 40, all locales +// dart run tool/check_expansion.dart de 100 # the worst 100 in German +// +// ## Why this exists +// +// German and Dutch run 10-35% longer than English, and short button labels are +// worse than that: "Settings" becomes "Einstellungen" (+67%), "Download" +// becomes "Herunterladen" (+150%). A `Text` inside a fixed-width `SizedBox` is +// then clipped SILENTLY in a release build — no exception, no log line, nothing +// in the console. Nobody files a bug about a half-cut button; they just decide +// the app looks cheap. +// +// Overflow is not random: it happens where the most-expanded string meets the +// tightest box. Sorting by expansion ratio therefore names the screens worth +// opening in German, instead of auditing all sixty of them. +// +// Only keys whose English side is short are ranked. A paragraph growing 40% +// wraps to another line; a six-character label growing 40% runs out of button. + +import 'dart:convert'; +import 'dart:io'; + +const translationsDir = 'assets/translations'; +const referenceLocale = 'en'; + +/// Above this, the English text is a sentence and wrapping absorbs the growth. +const int shortEnough = 24; + +/// Worth looking at. Below this the growth fits in the padding. +const double minRatio = 1.15; + +void main(List args) { + final locale = args.isNotEmpty && !RegExp(r'^\d+$').hasMatch(args.first) + ? args.first + : null; + final limit = int.tryParse(args.lastOrNull ?? '') ?? 40; + + final english = _load(referenceLocale); + if (english == null) { + stderr.writeln('No $referenceLocale.json in $translationsDir/'); + exit(2); + } + + final locales = locale != null + ? [locale] + : (Directory(translationsDir) + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.json')) + .map((n) => n.substring(0, n.length - 5)) + .where((l) => l != referenceLocale) + .toList() + ..sort()); + + final rows = <_Row>[]; + for (final code in locales) { + final translated = _load(code); + if (translated == null) { + stderr.writeln('Missing $code.json'); + exit(2); + } + english.forEach((key, source) { + if (source.isEmpty || source.length > shortEnough) return; + final value = translated[key]; + if (value == null || value == source) return; + final ratio = value.length / source.length; + if (ratio < minRatio) return; + rows.add(_Row(code, key, source, value, ratio)); + }); + } + + rows.sort((a, b) => b.ratio.compareTo(a.ratio)); + + final shown = rows.take(limit).toList(); + stdout.writeln( + 'Top $limit of ${rows.length} short strings that grew by ${(minRatio - 1) * 100}% or more:\n', + ); + for (final row in shown) { + final pct = ((row.ratio - 1) * 100).round(); + stdout.writeln('+$pct%\t${row.locale}\t${row.key}'); + stdout.writeln('\t\t"${row.source}" -> "${row.value}"'); + } + + // Grouped by key, because one key overflowing in four languages is one screen + // to fix, not four. + final byKey = {}; + for (final row in rows) { + byKey[row.key] = (byKey[row.key] ?? 0) + 1; + } + final worst = byKey.entries.where((e) => e.value >= 4).map((e) => e.key).toList()..sort(); + if (worst.isNotEmpty) { + stdout.writeln('\n${worst.length} key(s) grow in four or more locales:'); + for (final key in worst) { + stdout.writeln(' $key'); + } + } +} + +Map? _load(String locale) { + final file = File('$translationsDir/$locale.json'); + if (!file.existsSync()) return null; + final out = {}; + void walk(Map node, String prefix) { + node.forEach((key, value) { + final path = prefix.isEmpty ? key : '$prefix.$key'; + if (value is Map) { + walk(value, path); + } else { + out[path] = value.toString(); + } + }); + } + + walk(jsonDecode(file.readAsStringSync()) as Map, ''); + return out; +} + +class _Row { + _Row(this.locale, this.key, this.source, this.value, this.ratio); + final String locale; + final String key; + final String source; + final String value; + final double ratio; +} diff --git a/tool/check_translations.dart b/tool/check_translations.dart index 039593f1..e8bcee65 100644 --- a/tool/check_translations.dart +++ b/tool/check_translations.dart @@ -29,6 +29,19 @@ import 'dart:io'; /// identically incomplete and none of them is wrong. const String referenceLocale = 'en'; +/// Locales a missing key is a build failure in. +/// +/// The team writes these three, so "you added a key and did not translate it" +/// is a thing someone here can actually fix before merging. +/// +/// The other eight are machine-translated and topped up in batches. Blocking a +/// PR on them would not produce eight translations — it would produce eight +/// copies of the English string pasted in to get the build green, which is the +/// same debt with the tracking removed. They are reported instead, and +/// `useFallbackTranslations` in main.dart means a gap renders as English rather +/// than as raw dot-notation. +const Set blockingLocales = {'en', 'uz', 'ru'}; + const String translationsDir = 'assets/translations'; const String sourceDir = 'lib'; @@ -84,13 +97,20 @@ void main(List args) { } // ---- 2. Locales that have fallen behind the reference -------------------- + var behind = 0; for (final entry in locales.entries) { if (entry.key == referenceLocale) continue; final missing = reference.difference(entry.value).toList()..sort(); if (missing.isEmpty) continue; - failures += missing.length; - stdout.writeln('\n✗ ${entry.key}.json is missing ${missing.length} key(s) ' - 'that $referenceLocale.json has:'); + final blocks = blockingLocales.contains(entry.key); + if (blocks) { + failures += missing.length; + } else { + behind += missing.length; + } + stdout.writeln('\n${blocks ? '✗' : '·'} ${entry.key}.json is missing ' + '${missing.length} key(s) that $referenceLocale.json has' + '${blocks ? '' : ' (advisory)'}:'); for (final key in missing.take(40)) { stdout.writeln(' $key'); } @@ -98,6 +118,10 @@ void main(List args) { stdout.writeln(' … and ${missing.length - 40} more'); } } + if (behind > 0) { + stdout.writeln('\n· $behind key(s) missing across the non-blocking ' + 'locales. Those render in English until the next translation pass.'); + } // ---- 3. Keys a locale has that the reference does not ------------------- // Not a failure: a translator adding something ahead of the code is fine, diff --git a/tool/torrent_live_check.dart b/tool/torrent_live_check.dart index 42b0413a..be4b7609 100644 --- a/tool/torrent_live_check.dart +++ b/tool/torrent_live_check.dart @@ -1,3 +1,9 @@ +// ignore_for_file: avoid_print +// +// Its whole output IS print: this is a terminal program, and routing the +// results through the app logger would send them somewhere no terminal can +// see them. + // Live smoke check for the torrent indexers. // // Unit tests cover the parsers with fixed input; this one talks to the real