From cd472c3b7d61efa6cb3b3e00f94de14c38c2d62a Mon Sep 17 00:00:00 2001 From: azamov Date: Mon, 24 Aug 2026 09:51:21 +0500 Subject: [PATCH 01/21] feat(mal): integrate MyAnimeList tracking and enhance episode reporting --- .../com/soplay/sozo/cloudstream/PluginHost.kt | 75 +- assets/translations/en.json | 48 +- assets/translations/ru.json | 48 +- assets/translations/uz.json | 48 +- lib/app.dart | 60 + lib/core/constants/app_constants.dart | 17 + lib/core/deeplink/deeplink_opt_in.dart | 5 +- lib/core/di/injection.dart | 8 + lib/core/js/dart_fetch.dart | 21 +- lib/core/js/js_runtime_service.dart | 13 +- lib/core/js/js_timeouts.dart | 7 +- lib/core/network/cf_bypass_service.dart | 38 +- lib/core/router/app_router.dart | 5 + lib/core/storage/hive_service.dart | 32 + .../subtitle_translation_service.dart | 70 + lib/core/theme/app_accent.dart | 274 ++++ lib/core/theme/app_colors.dart | 91 +- lib/core/theme/app_palette.dart | 182 +++ lib/core/theme/app_theme.dart | 93 +- lib/core/theme/theme_controller.dart | 150 ++- lib/core/widgets/app_buttons.dart | 129 ++ lib/core/widgets/app_tab_bar.dart | 9 +- lib/features/anilist/data/anilist_api.dart | 59 +- .../pages/airing_calendar_page.dart | 4 +- .../pages/anilist_browse_page.dart | 3 +- .../pages/anilist_library_page.dart | 2 +- .../presentation/pages/connections_page.dart | 7 +- .../presentation/widgets/anilist_brand.dart | 5 +- .../widgets/anilist_entry_sheet.dart | 4 +- .../widgets/anilist_link_sheet.dart | 2 +- .../pages/app_lock_settings_page.dart | 6 +- .../presentation/pages/pin_verify_page.dart | 6 +- .../presentation/pages/force_update_page.dart | 3 +- .../widgets/release_notes_view.dart | 2 +- .../presentation/pages/otp_verify_page.dart | 2 +- .../presentation/widgets/auth_widgets.dart | 52 +- .../widgets/banners_carousel.dart | 2 +- .../cloudflare/cloudflare_solver_page.dart | 5 +- .../pages/cloudstream_plugins_page.dart | 4 +- .../pages/cloudstream_sources_page.dart | 4 +- .../presentation/widgets/comments_panel.dart | 9 +- .../pages/desktop_share_page.dart | 4 +- .../detail/presentation/pages/actor_page.dart | 21 +- .../presentation/pages/detail_page.dart | 4 +- .../presentation/pages/episodes_page.dart | 12 +- .../pages/player_page.subtitles.dart | 170 ++- .../pages/player_page.widgets.dart | 6 +- .../presentation/widgets/detail_hero.dart | 12 +- .../presentation/widgets/detail_info.dart | 7 +- .../widgets/detail_more_menu.dart | 8 +- .../presentation/widgets/detail_related.dart | 2 +- .../widgets/detail_screenshots.dart | 2 +- .../widgets/player_engine_sheet.dart | 3 +- .../presentation/pages/downloads_page.dart | 20 +- .../presentation/repo_file_import.dart | 4 +- .../presentation/pages/history_page.dart | 18 +- .../home/presentation/widgets/genre_card.dart | 14 +- .../presentation/widgets/home_banner.dart | 45 +- .../presentation/widgets/home_content.dart | 3 +- .../widgets/home_history_section.dart | 6 +- .../widgets/home_movie_section.dart | 35 +- .../widgets/home_state_views.dart | 2 +- .../presentation/widgets/home_top_bar.dart | 190 +-- .../widgets/view_all_widgets.dart | 2 +- .../presentation/pages/link_tv_page.dart | 4 +- .../presentation/pages/live_tv_page.dart | 4 +- .../main/presentation/pages/main_page.dart | 98 +- .../presentation/widgets/mal_entry_sheet.dart | 7 +- .../presentation/widgets/mal_link_sheet.dart | 2 +- .../pages/manga_source_settings_page.dart | 10 +- .../pages/manga_sources_page.dart | 8 +- .../manga/presentation/pages/reader_page.dart | 13 +- .../widgets/my_list_background.dart | 10 +- .../presentation/pages/no_internet_page.dart | 4 +- .../pages/notifications_page.dart | 16 +- .../presentation/pages/onboarding_page.dart | 15 +- .../presentation/widgets/anime_ribbons.dart | 2 +- .../presentation/widgets/tv_showcase.dart | 10 +- .../presentation/pages/private_list_page.dart | 2 +- .../pages/private_unlock_page.dart | 2 +- .../presentation/pages/appearance_page.dart | 1146 +++++++++++++++++ .../pages/player_settings_page.dart | 2 +- .../presentation/pages/profile_edit_page.dart | 2 +- .../presentation/pages/profile_page.dart | 435 +++++-- .../presentation/pages/providers_page.dart | 2 +- .../presentation/widgets/library_accents.dart | 121 ++ .../presentation/widgets/settings_tiles.dart | 4 +- .../widgets/tab_customizer_sheet.dart | 5 +- .../presentation/widgets/theme_preview.dart | 381 ++++++ .../presentation/pages/tv_remote_page.dart | 2 +- .../presentation/widgets/report_sheet.dart | 3 +- .../presentation/pages/cross_search_page.dart | 4 +- .../widgets/search_filter_sheet.dart | 5 +- .../presentation/widgets/search_header.dart | 4 +- .../widgets/search_set_sheet.dart | 2 +- .../widgets/search_state_views.dart | 6 +- .../presentation/pages/shorts_page.dart | 2 +- .../presentation/widgets/short_reel_item.dart | 12 +- .../widgets/shorts_state_views.dart | 2 +- .../presentation/widgets/netflix_splash.dart | 8 +- .../dialogs/streak_milestone_dialog.dart | 11 +- .../presentation/widgets/streak_card.dart | 7 +- .../presentation/pages/following_page.dart | 2 +- .../presentation/pages/actor_hero_page.dart | 27 +- .../presentation/pages/buff_hub_page.dart | 12 +- .../pages/challenge_landing_page.dart | 8 +- .../trivia/presentation/pages/game_page.dart | 9 +- .../presentation/pages/leaderboard_page.dart | 2 +- .../presentation/pages/result_page.dart | 5 +- .../presentation/pages/top_fans_page.dart | 4 +- .../widgets/buff_empty_panel.dart | 3 +- .../presentation/widgets/cast_card.dart | 2 +- .../presentation/widgets/countdown_ring.dart | 4 +- .../presentation/widgets/option_chip.dart | 9 +- .../presentation/widgets/share_card.dart | 2 +- .../presentation/pages/user_lists_page.dart | 4 +- .../presentation/pages/watch_party_page.dart | 6 +- .../widgets/party_chat_panel.dart | 2 +- .../widgets/party_code_sheet.dart | 9 +- .../widgets/party_error_views.dart | 3 +- .../widgets/party_member_bar.dart | 2 +- test/core/theme/app_palette_test.dart | 282 ++++ test/features/profile/theme_preview_test.dart | 91 ++ 123 files changed, 4487 insertions(+), 568 deletions(-) create mode 100644 lib/core/theme/app_accent.dart create mode 100644 lib/core/theme/app_palette.dart create mode 100644 lib/core/widgets/app_buttons.dart create mode 100644 lib/features/profile/presentation/pages/appearance_page.dart create mode 100644 lib/features/profile/presentation/widgets/library_accents.dart create mode 100644 lib/features/profile/presentation/widgets/theme_preview.dart create mode 100644 test/core/theme/app_palette_test.dart create mode 100644 test/features/profile/theme_preview_test.dart diff --git a/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt b/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt index dc094062..f3792422 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/cloudstream/PluginHost.kt @@ -17,6 +17,7 @@ import com.lagradost.cloudstream3.TvSeriesLoadResponse import com.lagradost.cloudstream3.plugins.BasePlugin import com.lagradost.cloudstream3.plugins.Plugin import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType import dalvik.system.PathClassLoader import org.json.JSONArray import org.json.JSONObject @@ -431,9 +432,14 @@ class PluginHost(private val appContext: Context) { }.toString() } + // AudioFile is @Prerelease in the CloudStream library; the annotation is an + // IDE hint with BINARY retention, so reading the list a plugin may have filled + // costs nothing at runtime and is empty for every plugin that sets none. + @OptIn(com.lagradost.cloudstream3.Prerelease::class) suspend fun loadLinksJson(providerName: String, data: String): String { val api = apiByName(providerName) - val videoSources = JSONArray() + // (quality, source) pairs so the list can be ordered before it is emitted. + val collected = ArrayList>() val subs = JSONArray() val seenUrls = HashSet() val seenSubs = HashSet() @@ -446,27 +452,69 @@ class PluginHost(private val appContext: Context) { if (sf.url.isNotEmpty() && seenSubs.add(sf.url)) { subs.put(JSONObject().apply { put("label", sf.lang); put("file", sf.url); put("default", false) + // A subtitle is fetched on its own, so it inherits none of + // the stream's headers. Plenty of CloudStream sources hand + // out tracks that 403 without the Referer the extractor + // set, and SubtitleFile has carried those headers since + // v4.7 — dropping them is why a provider could return + // subtitles and the player still show none. + sf.headers?.takeIf { it.isNotEmpty() }?.let { + put("headers", JSONObject(it as Map<*, *>)) + } }) } }, callback = { link: ExtractorLink -> - if (link.url.isNotEmpty() && seenUrls.add(link.url)) { - val headers = JSONObject(link.headers as Map<*, *>) - if (link.referer.isNotEmpty()) headers.put("Referer", link.referer) + // Torrents and magnets are not streams. Passed through as an + // ordinary source they reached the player as a bare "Source + // error"; the honest thing is to not offer them. Relative urls + // ("dl.php?id=…") go for the same reason — ExoPlayer resolves + // those as a local file path. + val streamable = link.type != ExtractorLinkType.TORRENT && + link.type != ExtractorLinkType.MAGNET + if (streamable && link.url.startsWith("http", ignoreCase = true) && + seenUrls.add(link.url) + ) { + // getAllHeaders() folds in the referer exactly the way + // CloudStream's own player does, instead of us overwriting a + // Referer an extractor had deliberately set. + val headers = JSONObject(link.getAllHeaders() as Map<*, *>) // quality is a resolution int (e.g. 1080) or a Qualities // sentinel; build a readable, distinct " · p". val q = link.quality val res = if (q in 144..4320) "${q}p" else null val nm = link.name.ifBlank { "Source" } val label = if (res != null) "$nm · $res" else nm - videoSources.put(JSONObject().apply { + collected.add((if (q in 144..4320) q else 0) to JSONObject().apply { put("quality", label) put("videoUrl", link.url) - put("type", if (link.isM3u8) "hls" else "http") + // DASH used to fall into the `else` branch and be handed + // over as a plain progressive file, so every .mpd source + // failed to open. + put("type", when (link.type) { + ExtractorLinkType.M3U8 -> "hls" + ExtractorLinkType.DASH -> "dash" + else -> "http" + }) put("host", link.name) - put("isDefault", videoSources.length() == 0) put("accessible", true) put("headers", headers) + // Separate audio renditions the extractor says belong with + // this video. A dual-audio release puts its dub here rather + // than in the manifest, so discarding them left the player + // with one track and nothing to switch to. + if (link.audioTracks.isNotEmpty()) { + put("audioTracks", JSONArray().apply { + link.audioTracks.forEach { a -> + put(JSONObject().apply { + put("url", a.url) + a.headers?.takeIf { it.isNotEmpty() }?.let { + put("headers", JSONObject(it as Map<*, *>)) + } + }) + } + }) + } }) } } @@ -474,7 +522,18 @@ class PluginHost(private val appContext: Context) { } catch (t: Throwable) { Log.e(TAG, "loadLinks ${api.name}: ${t.javaClass.simpleName}: ${t.message}") } - Log.i(TAG, "loadLinks ${api.name}: ${videoSources.length()} source(s), ${subs.length()} sub(s)") + Log.i(TAG, "loadLinks ${api.name}: ${collected.size} source(s), ${subs.length()} sub(s)") + } + // Best first. Extractors call back in whatever order they finish, so the + // default source used to be a race: a 360p mirror that resolved quickly + // won over a 1080p one that took a moment longer. CloudStream's own + // player orders by quality for the same reason. The sort is stable, so + // sources of equal quality keep the order the provider produced them in. + collected.sortByDescending { it.first } + val videoSources = JSONArray() + collected.forEachIndexed { i, entry -> + entry.second.put("isDefault", i == 0) + videoSources.put(entry.second) } // Shape matches MediaResolveModel.fromJson (videoUrl/type/headers + videoSources + subtitles). val first = if (videoSources.length() > 0) videoSources.getJSONObject(0) else null diff --git a/assets/translations/en.json b/assets/translations/en.json index 44850587..c98bdb4b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -228,7 +228,9 @@ "ai_translate_remaining": "{}/{} translations left today", "ai_translate_used_up": "You've used today's translation limit", "ai_translate_menu": "AI translate", - "ai_translate_menu_desc": "Translates a subtitle to {}" + "ai_translate_menu_desc": "Translates a subtitle to {}", + "ready_translations": "Ready translations", + "lines": "lines" }, "profile": { "title": "Profile", @@ -272,6 +274,9 @@ "sign_out": "Sign Out", "sign_out_confirm": "Are you sure you want to sign out?", "section_providers": "PROVIDERS", + "section_content": "CONTENT", + "sources_row": "Extension sources", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Provider", "choose_provider": "Choose Provider", "search_providers_hint": "Search providers…", @@ -350,6 +355,47 @@ "remove_photo": "Remove photo", "photo_pick_failed": "Could not pick a photo" }, + "appearance": { + "title": "Appearance", + "entry_subtitle": "Accent colour, pure black", + "section_preview": "PREVIEW", + "section_accent": "ACCENT COLOUR", + "accent_footnote": "The accent colours buttons, highlights, progress bars and the active tab — everywhere in the app.", + "accent_custom": "Custom", + "accent_red": "Sozo Red", + "accent_ember": "Ember", + "accent_amber": "Amber", + "accent_emerald": "Emerald", + "accent_teal": "Teal", + "accent_sky": "Sky", + "accent_ocean": "Ocean", + "accent_indigo": "Indigo", + "accent_violet": "Violet", + "accent_magenta": "Magenta", + "accent_rose": "Rose", + "accent_slate": "Slate", + "section_darkness": "BACKGROUND", + "darkness_dark": "Dark", + "darkness_dark_desc": "The standard dark greys", + "darkness_black": "Pure black", + "darkness_black_desc": "True black backgrounds", + "amoled_footnote": "On an OLED screen a black pixel is switched off, so pure black gives deeper contrast and uses less battery.", + "reset": "Reset to default", + "reset_tooltip": "Back to the default look", + "custom_title": "Custom colour", + "custom_hue": "HUE", + "custom_saturation": "SATURATION", + "custom_lightness": "LIGHTNESS", + "custom_apply": "Use this colour", + "custom_adjusted": "Darkened a little so white text stays readable on it.", + "section_library": "FROM YOUR LIBRARY", + "library_footnote": "Colours taken from the posters of what you have been watching.", + "section_extras": "MORE", + "tint_nav": "Colour the tab bar", + "tint_nav_desc": "Paint the selected tab in your accent instead of white", + "shuffle": "Try the next colour", + "shuffle_desc": "Step through the palette one accent at a time" + }, "search": { "title": "Search", "hint": "Search movies, series...", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 91776ebd..68d2c73b 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -228,7 +228,9 @@ "ai_translate_remaining": "Сегодня осталось {}/{}", "ai_translate_used_up": "Дневной лимит перевода исчерпан", "ai_translate_menu": "ИИ перевод", - "ai_translate_menu_desc": "Переводит субтитры на {}" + "ai_translate_menu_desc": "Переводит субтитры на {}", + "ready_translations": "Готовые переводы", + "lines": "строк" }, "profile": { "title": "Профиль", @@ -272,6 +274,9 @@ "sign_out": "Выйти", "sign_out_confirm": "Вы уверены, что хотите выйти?", "section_providers": "ИСТОЧНИКИ", + "section_content": "КОНТЕНТ", + "sources_row": "Источники расширений", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Источник", "choose_provider": "Выберите источник", "search_providers_hint": "Поиск источников…", @@ -350,6 +355,47 @@ "remove_photo": "Удалить фото", "photo_pick_failed": "Не удалось выбрать фото" }, + "appearance": { + "title": "Внешний вид", + "entry_subtitle": "Акцентный цвет, чёрный фон", + "section_preview": "ПРЕДПРОСМОТР", + "section_accent": "АКЦЕНТНЫЙ ЦВЕТ", + "accent_footnote": "Акцентный цвет применяется к кнопкам, выделениям, полосам прогресса и активной вкладке — во всём приложении.", + "accent_custom": "Свой цвет", + "accent_red": "Красный Sozo", + "accent_ember": "Уголь", + "accent_amber": "Янтарь", + "accent_emerald": "Изумруд", + "accent_teal": "Бирюза", + "accent_sky": "Небо", + "accent_ocean": "Океан", + "accent_indigo": "Индиго", + "accent_violet": "Фиолетовый", + "accent_magenta": "Пурпурный", + "accent_rose": "Розовый", + "accent_slate": "Серый", + "section_darkness": "ФОН", + "darkness_dark": "Тёмный", + "darkness_dark_desc": "Обычный тёмно-серый фон", + "darkness_black": "Чистый чёрный", + "darkness_black_desc": "Полностью чёрный фон", + "amoled_footnote": "На OLED-экране чёрный пиксель просто не горит, поэтому чистый чёрный даёт более глубокий контраст и экономит заряд.", + "reset": "Сбросить по умолчанию", + "reset_tooltip": "Вернуть стандартный вид", + "custom_title": "Свой цвет", + "custom_hue": "ОТТЕНОК", + "custom_saturation": "НАСЫЩЕННОСТЬ", + "custom_lightness": "СВЕТЛОТА", + "custom_apply": "Применить цвет", + "custom_adjusted": "Цвет слегка затемнён, чтобы белый текст на нём оставался читаемым.", + "section_library": "ИЗ ВАШЕЙ БИБЛИОТЕКИ", + "library_footnote": "Цвета, взятые с постеров того, что вы смотрите.", + "section_extras": "ДОПОЛНИТЕЛЬНО", + "tint_nav": "Раскрасить панель вкладок", + "tint_nav_desc": "Активная вкладка в вашем цвете вместо белого", + "shuffle": "Попробовать следующий цвет", + "shuffle_desc": "Перебирать палитру по одному цвету" + }, "search": { "title": "Поиск", "hint": "Поиск фильмов, сериалов...", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index ee6ed8bc..73570cf8 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -228,7 +228,9 @@ "ai_translate_remaining": "Bugun {}/{} tarjima qoldi", "ai_translate_used_up": "Bugungi tarjima chegarangiz tugadi", "ai_translate_menu": "AI tarjima", - "ai_translate_menu_desc": "Subtitrni {} tiliga o'giradi" + "ai_translate_menu_desc": "Subtitrni {} tiliga o'giradi", + "ready_translations": "Tayyor tarjimalar", + "lines": "qator" }, "profile": { "title": "Profil", @@ -272,6 +274,9 @@ "sign_out": "Chiqish", "sign_out_confirm": "Hisobdan chiqmoqchimisiz?", "section_providers": "MANBALAR", + "section_content": "KONTENT", + "sources_row": "Kengaytma manbalari", + "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Manba", "choose_provider": "Manbani tanlang", "search_providers_hint": "Manbalarni qidirish…", @@ -350,6 +355,47 @@ "remove_photo": "Rasmni olib tashlash", "photo_pick_failed": "Rasmni tanlab bo'lmadi" }, + "appearance": { + "title": "Ko'rinish", + "entry_subtitle": "Asosiy rang, toza qora", + "section_preview": "KO'RIB CHIQISH", + "section_accent": "ASOSIY RANG", + "accent_footnote": "Asosiy rang tugmalar, ajratilgan joylar, progress chiziqlari va faol bo'limga — ilovaning hamma yerida qo'llanadi.", + "accent_custom": "O'zim tanlayman", + "accent_red": "Sozo qizili", + "accent_ember": "Cho'g'", + "accent_amber": "Kahrabo", + "accent_emerald": "Zumrad", + "accent_teal": "Firuza", + "accent_sky": "Osmon", + "accent_ocean": "Okean", + "accent_indigo": "Indigo", + "accent_violet": "Binafsha", + "accent_magenta": "Fuksiya", + "accent_rose": "Atirgul", + "accent_slate": "Kulrang", + "section_darkness": "FON", + "darkness_dark": "Qorong'i", + "darkness_dark_desc": "Odatdagi to'q kulrang fon", + "darkness_black": "Toza qora", + "darkness_black_desc": "Butunlay qora fon", + "amoled_footnote": "OLED ekranda qora piksel umuman yonmaydi — shuning uchun toza qora chuqurroq kontrast beradi va batareyani tejaydi.", + "reset": "Standart holatga qaytarish", + "reset_tooltip": "Standart ko'rinishga qaytarish", + "custom_title": "O'z rangingiz", + "custom_hue": "RANG TUSI", + "custom_saturation": "TO'YINGANLIK", + "custom_lightness": "YORUG'LIK", + "custom_apply": "Shu rangni qo'llash", + "custom_adjusted": "Ustidagi oq yozuv o'qilishi uchun rang bir oz to'qlashtirildi.", + "section_library": "KUTUBXONANGIZDAN", + "library_footnote": "Siz ko'rayotgan kontent posterlaridan olingan ranglar.", + "section_extras": "QO'SHIMCHA", + "tint_nav": "Panelni ranglash", + "tint_nav_desc": "Tanlangan bo'limni oq emas, o'z rangingizda ko'rsatish", + "shuffle": "Keyingi rangni sinash", + "shuffle_desc": "Palitrani birma-bir aylanib chiqish" + }, "search": { "title": "Qidirish", "hint": "Kino, seriallar qidiring...", diff --git a/lib/app.dart b/lib/app.dart index 5ba68b24..0224d171 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -18,7 +19,9 @@ import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'core/system/desktop_window.dart'; +import 'core/theme/app_colors.dart'; import 'core/theme/app_theme.dart'; +import 'core/theme/theme_controller.dart'; import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/home/presentation/bloc/home/home_bloc.dart'; @@ -45,9 +48,12 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { + final ThemeController _theme = getIt(); + @override void initState() { super.initState(); + _theme.addListener(_onThemeChanged); getIt().onTap = _handlePushTap; // Desktop: hide the custom title-bar strip on the immersive full-bleed // routes (player / reader) by watching the router itself — reliable @@ -69,6 +75,7 @@ class _MyAppState extends State { @override void dispose() { + _theme.removeListener(_onThemeChanged); if (isDesktopPlatform) { AppRouter.router.routerDelegate.removeListener(_syncImmersive); } @@ -76,6 +83,54 @@ class _MyAppState extends State { super.dispose(); } + /// Repaint the entire app in the newly chosen colours, without a restart and + /// without losing a single route, scroll offset or piece of widget state. + /// + /// Two things have to happen, and neither one covers the other: + /// + /// * `setState` re-reads [AppTheme.dark], so every Material component + /// default (buttons, inputs, tab indicators, dialogs) picks up the new + /// palette through `Theme.of`, + /// * [_rebuildEverything] marks the rest of the tree dirty, because the + /// ~1900 direct `AppColors.*` reads are plain static getters — they have + /// no `InheritedWidget` to notify them, so nothing else would ever tell + /// those widgets that their colours moved. + void _onThemeChanged() { + if (!mounted) return; + setState(() {}); + _rebuildEverything(); + } + + /// Mark every element below this one for rebuild. + /// + /// `markNeedsBuild` works on the *element*, so it reaches widgets a normal + /// parent rebuild would skip — including `const` ones, which is most of this + /// app's leaf UI. + /// + /// Guarded on the scheduler phase: a notification that arrives mid-build + /// (a theme set from inside a `build`, which nothing does today but is cheap + /// to be safe about) would otherwise assert. In that case it is deferred by + /// one frame instead. + void _rebuildEverything() { + void markDirty(Element element) { + element.markNeedsBuild(); + element.visitChildren(markDirty); + } + + void run() { + if (!mounted) return; + (context as Element).visitChildren(markDirty); + } + + final phase = SchedulerBinding.instance.schedulerPhase; + if (phase == SchedulerPhase.persistentCallbacks || + phase == SchedulerPhase.midFrameMicrotasks) { + WidgetsBinding.instance.addPostFrameCallback((_) => run()); + } else { + run(); + } + } + void _handlePushTap(Map data) { final router = AppRouter.router; @@ -131,6 +186,11 @@ class _MyAppState extends State { child: MaterialApp.router( title: 'app_name'.tr(), debugShowCheckedModeBanner: false, + // The colour the OS paints behind the app — the task-switcher card and + // the gap before the first frame. Left at its default it is the theme's + // primary, so an accent change would otherwise leave a red card behind + // a blue app. + color: AppColors.background, theme: AppTheme.dark, darkTheme: AppTheme.dark, themeMode: ThemeMode.dark, diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 2a84470a..837e3cbb 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -94,7 +94,24 @@ class AppConstants { static const String playerEngineKey = 'player_engine'; static const String defaultPlayerEngine = 'default'; static const String telegramPromoSeenKey = 'telegram_promo_seen'; + /// Appearance → "Pure black". Absent ⇒ off, i.e. the greys the app has + /// always shipped. static const String amoledModeKey = 'amoled_mode'; + + /// Appearance → accent colour. Holds an [AppAccent] preset id, or + /// `AppAccent.customId` when the user picked their own colour — in which case + /// the colour itself lives under [customAccentKey]. Absent ⇒ the default red. + static const String accentIdKey = 'accent_id'; + + /// The user's own accent, stored as a 32-bit ARGB int. Only consulted when + /// [accentIdKey] is `AppAccent.customId`. + static const String customAccentKey = 'custom_accent'; + + /// Appearance → "Colour the tab bar". Absent ⇒ **on**: the accent is a + /// setting people choose in order to see it, and the tab bar is the one piece + /// of chrome that is on screen the whole time. Turning it off puts the + /// original white pill back. + static const String tintNavKey = 'tint_nav'; static const String onboardingSeenKey = 'onboarding_seen'; static const String deeplinkPromptSeenKey = 'deeplink_prompt_seen'; static const String deeplinkOptInKey = 'deeplink_opt_in'; diff --git a/lib/core/deeplink/deeplink_opt_in.dart b/lib/core/deeplink/deeplink_opt_in.dart index 03f6a80b..59fe2eca 100644 --- a/lib/core/deeplink/deeplink_opt_in.dart +++ b/lib/core/deeplink/deeplink_opt_in.dart @@ -6,6 +6,7 @@ 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/core/theme/app_theme.dart'; import 'deeplink_settings.dart'; @@ -82,7 +83,7 @@ class _OptInSheet extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.link_rounded, size: 28, color: AppColors.primary, @@ -116,7 +117,7 @@ class _OptInSheet extends StatelessWidget { backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: () => diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index ce7fdc8a..d49df7ed 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -19,6 +19,7 @@ import 'package:soplay/core/network/provider_interceptor.dart'; import 'package:soplay/core/player/local_hls_proxy.dart'; import 'package:soplay/core/player/webview_stream_extractor.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/theme_controller.dart'; import 'package:soplay/features/anilist/data/airing_reminders.dart'; import 'package:soplay/features/live_tv/data/live_tv_service.dart'; import 'package:soplay/features/remote/data/remote_control_service.dart'; @@ -158,6 +159,13 @@ final getIt = GetIt.instance; Future configureDependencies() async { getIt.registerSingleton(DeeplinkService()); getIt.registerSingleton(HiveService()); + // Eager, and immediately after Hive: the constructor reads the stored accent + // and AMOLED preference and installs the palette synchronously, so the first + // frame after runApp() is already painted in the user's colours instead of + // flashing the default red. + getIt.registerSingleton( + ThemeController(getIt()), + ); getIt.registerSingleton(HistoryService()); getIt.registerSingleton(DownloadService()); diff --git a/lib/core/js/dart_fetch.dart b/lib/core/js/dart_fetch.dart index abf89d9b..964ca071 100644 --- a/lib/core/js/dart_fetch.dart +++ b/lib/core/js/dart_fetch.dart @@ -77,6 +77,13 @@ class DartFetch { final existing = extraHeaders['Cookie'] ?? extraHeaders['cookie']; extraHeaders['Cookie'] = existing != null ? '$cached; $existing' : cached; + // cf_clearance is bound to the agent that earned it, and it was earned + // under the app's own. Letting the extractor's agent ride along with + // the cookie made Cloudflare reissue the challenge on every request + // after the first — one call solved the challenge three times and still + // came back empty. + extraHeaders.remove('user-agent'); + extraHeaders['User-Agent'] = kSozoUserAgent; } } @@ -102,9 +109,17 @@ class DartFetch { cf != null && _looksLikeCfChallenge(status, headers, response.data)) { JsLog.req('fetch', 'CF challenge on $host — solving …'); - final solveAgent = req.headers['User-Agent'] ?? - req.headers['user-agent'] ?? - kSozoUserAgent; + // ALWAYS the app's own agent, never the one the extractor asked for. + // + // The challenge is solved inside an Android WebView that is forced to + // whatever agent we pass here, and several backend extractors ask for a + // desktop one. A desktop agent on an Android WebView is the platform + // mismatch Cloudflare's managed challenge is looking for: it never + // issued cf_clearance, the 30s watchdog expired, and the extension was + // handed the challenge page. Pinning it here means a stale extractor + // cannot reintroduce the mismatch, and the replay below sends the same + // agent the clearance was earned under — which Cloudflare requires. + const solveAgent = kSozoUserAgent; final cookieHeader = await cf.solve( host: host, url: req.url, diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index c92af31d..b6531d4a 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -172,6 +172,9 @@ class JsRuntimeService { // provider's results under another's name. await _ensureExtractor(extractor.name, extractor.version) .timeout(kJsCallTimeout); + // Any request this call refuses is recorded on DartFetch; clearing it + // first means whatever is left afterwards belongs to THIS call. + dartFetch.clearBlock(); // Unlocked on purpose. The provider is looked up by name, so several // cross-search legs can be in flight at once and their network waits // overlap instead of queueing — which is what made searching several @@ -203,9 +206,15 @@ class JsRuntimeService { } final error = result.error; if (error != null && error.isNotEmpty) { - JsLog.err(tag, '$fn threw: $error'); - throw Exception(error); + // An extractor parses whatever body it is handed, so a Cloudflare + // challenge surfaces as a JSON parse error deep in provider code. When + // the network layer refused something during this call, that refusal is + // the cause and the only half a user can act on. + final blocked = dartFetch.takeBlock(); + JsLog.err(tag, '$fn threw: $error${blocked == null ? '' : ' ($blocked)'}'); + throw Exception(blocked ?? error); } + dartFetch.clearBlock(); final map = _coerceMap(result.value); JsLog.res( tag, diff --git a/lib/core/js/js_timeouts.dart b/lib/core/js/js_timeouts.dart index 9605277a..012c7abf 100644 --- a/lib/core/js/js_timeouts.dart +++ b/lib/core/js/js_timeouts.dart @@ -5,4 +5,9 @@ /// than tight: a single call can legitimately be several requests plus a /// Cloudflare solve, and cutting a slow-but-working source off reads as the /// same bug from the other side. -const Duration kJsCallTimeout = Duration(seconds: 40); +/// +/// 40s was not generous enough for the worst honest case: a Cloudflare-gated +/// source (animepahe) spends up to 30s in [CfBypassService.solve] alone before +/// its first request even starts, and the catalog fetch still has to follow. +/// That timed out mid-solve and looked exactly like a dead provider. +const Duration kJsCallTimeout = Duration(seconds: 60); diff --git a/lib/core/network/cf_bypass_service.dart b/lib/core/network/cf_bypass_service.dart index 6ea816f4..e52fa5df 100644 --- a/lib/core/network/cf_bypass_service.dart +++ b/lib/core/network/cf_bypass_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:soplay/core/js/js_log.dart'; import 'package:soplay/core/system/webview_env.dart'; class CfBypassService { @@ -30,9 +31,16 @@ class CfBypassService { required String userAgent, required Duration timeout, }) async { + final sw = Stopwatch()..start(); final completer = Completer(); Timer? poll; Timer? watchdog; + Timer? retarget; + InAppWebViewController? controller; + + // Cloudflare scopes cf_clearance to the whole zone, so the challenge on any + // page of a host clears every other page of it. + final origin = 'https://$host/'; final headless = HeadlessInAppWebView( webViewEnvironment: await WebViewEnv.ensure(), @@ -44,14 +52,34 @@ class CfBypassService { cacheEnabled: true, useShouldInterceptRequest: false, ), + onWebViewCreated: (c) => controller = c, ); Future stop() async { poll?.cancel(); watchdog?.cancel(); + retarget?.cancel(); try { await headless.dispose(); } catch (_) {} } + // The failing request is often an API endpoint, and a challenge served for + // one is a document the WebView renders, but the page it lands on after + // solving is raw JSON — which Android hands to the download manager instead + // of loading, aborting the navigation partway through the flow. Half a + // budget in, fall back to the host root: it is an ordinary HTML page, it + // carries the same zone-wide challenge, and the clearance it earns is the + // one the original request needed. Costs nothing when the first target + // works, because this timer never fires. + if (url != origin) { + retarget = Timer(timeout ~/ 2, () async { + if (completer.isCompleted) return; + JsLog.info('cf', 'retargeting $host solve to the site root'); + try { + await controller?.loadUrl(urlRequest: URLRequest(url: WebUri(origin))); + } catch (_) {} + }); + } + poll = Timer.periodic(_pollInterval, (_) async { try { final cookies = await CookieManager.instance() @@ -65,14 +93,20 @@ class CfBypassService { .where((c) => '${c.value}'.isNotEmpty) .map((c) => '${c.name}=${c.value}') .join('; '); - if (!completer.isCompleted) completer.complete(header); + if (!completer.isCompleted) { + JsLog.res('cf', 'solved $host', ms: sw.elapsedMilliseconds); + completer.complete(header); + } await stop(); } catch (_) { } }); watchdog = Timer(timeout, () async { - if (!completer.isCompleted) completer.complete(null); + if (!completer.isCompleted) { + JsLog.err('cf', 'no cf_clearance for $host after ${timeout.inSeconds}s'); + completer.complete(null); + } await stop(); }); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index f7beb8e4..02073531 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -50,6 +50,7 @@ import 'package:soplay/features/trivia/presentation/pages/top_fans_page.dart'; import 'package:soplay/features/trivia/presentation/trivia_args.dart'; import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/presentation/pages/user_lists_page.dart'; +import 'package:soplay/features/profile/presentation/pages/appearance_page.dart'; import 'package:soplay/features/profile/presentation/pages/player_settings_page.dart'; import 'package:soplay/features/profile/presentation/pages/providers_page.dart'; import 'package:soplay/features/live_tv/presentation/pages/live_tv_page.dart'; @@ -239,6 +240,10 @@ class AppRouter { path: '/mal/links', builder: (context, state) => const MalLinksPage(), ), + GoRoute( + path: '/appearance', + builder: (context, state) => const AppearancePage(), + ), GoRoute( path: '/navbar', builder: (context, state) => const NavbarPage(), diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 0b46ed5c..f9215275 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -472,6 +472,38 @@ class HiveService { await _settingsBox.put(AppConstants.amoledModeKey, enabled); } + /// Accent colour id, or `AppAccent.customId`. Empty ⇒ never chosen, so the + /// caller falls back to the shipped default rather than to a stored value. + String get accentId { + final raw = _settingsBox.get(AppConstants.accentIdKey); + return raw is String ? raw : ''; + } + + Future setAccentId(String id) async { + await _settingsBox.put(AppConstants.accentIdKey, id); + } + + /// The user's own accent as a 32-bit ARGB int, or null if they never picked + /// one. Anything non-int in the box is treated as absent: a corrupt value + /// must fall back to a preset, never crash the first paint. + int? get customAccentArgb { + final raw = _settingsBox.get(AppConstants.customAccentKey); + return raw is int ? raw : null; + } + + Future setCustomAccentArgb(int argb) async { + await _settingsBox.put(AppConstants.customAccentKey, argb); + } + + bool get isNavTinted { + return _settingsBox.get(AppConstants.tintNavKey, defaultValue: true) == + true; + } + + Future setNavTinted(bool enabled) async { + await _settingsBox.put(AppConstants.tintNavKey, enabled); + } + bool get hasOnboardingSeen { return _settingsBox.get(AppConstants.onboardingSeenKey, defaultValue: false) == true; } diff --git a/lib/core/subtitles/subtitle_translation_service.dart b/lib/core/subtitles/subtitle_translation_service.dart index b132ae5a..cbb53e70 100644 --- a/lib/core/subtitles/subtitle_translation_service.dart +++ b/lib/core/subtitles/subtitle_translation_service.dart @@ -20,6 +20,18 @@ class SubtitleTranslationResult { final bool cached; } +/// A translation already made for the current media, ready to load. +class ReadySubtitle { + const ReadySubtitle({ + required this.url, + required this.targetLang, + required this.cueCount, + }); + final String url; + final String targetLang; + final int cueCount; +} + /// Today's translation allowance for the account. class SubtitleQuota { const SubtitleQuota({ @@ -52,6 +64,64 @@ class SubtitleDailyLimitReached implements Exception { class SubtitleTranslationService { const SubtitleTranslationService(); + /// Translations already made for this title/episode, across languages. + Future> fetchReady({ + required String tmdbId, + required String type, + int? season, + int? episode, + }) async { + try { + final response = await getIt().get( + '/contents/subtitles/ready', + queryParameters: { + 'tmdbId': tmdbId, + 'type': type, + 'season': ?season, + 'episode': ?episode, + }, + ); + final items = (response.data is Map ? response.data['items'] : null) as List? ?? + const []; + return [ + for (final m in items) + if (m is Map && m['url'] != null) + ReadySubtitle( + url: '${m['url']}', + targetLang: '${m['targetLang'] ?? ''}', + cueCount: (m['cueCount'] as num?)?.toInt() ?? 0, + ), + ]; + } catch (_) { + return const []; + } + } + + /// Publishes a finished translation so other viewers can load it directly. + /// Best-effort — a failure here never blocks playback. + Future publishReady({ + required String tmdbId, + required String type, + int? season, + int? episode, + required String targetLang, + required String srt, + }) async { + try { + await getIt().post( + '/contents/subtitles/ready', + data: { + 'tmdbId': tmdbId, + 'type': type, + 'season': ?season, + 'episode': ?episode, + 'targetLang': targetLang, + 'srt': srt, + }, + ); + } catch (_) {} + } + /// How many translations the account has left today, for showing before the /// person spends one. Returns null if it cannot be read — the UI then simply /// omits the count rather than blocking. diff --git a/lib/core/theme/app_accent.dart b/lib/core/theme/app_accent.dart new file mode 100644 index 00000000..e0f1c2f1 --- /dev/null +++ b/lib/core/theme/app_accent.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; + +/// One accent colour the user can pick in Settings → Appearance. +/// +/// An accent is a *triple*, not a single colour: [base] is what +/// `AppColors.primary` becomes, [dark] and [light] are the pressed / hover / +/// glow variants that `AppColors.primaryDark` and `AppColors.primaryLight` +/// become. Every screen in the app already reads those three, so swapping an +/// accent repaints the whole product without a single call site changing. +/// +/// ## The legibility contract +/// +/// The app is dark-only and puts **white** text and icons on top of +/// `AppColors.primary` in ~140 places (buttons, chips, badges, the play FAB). +/// None of those pass a colour down — they hard-code `Colors.white`. So an +/// accent is only safe if white stays readable on it. +/// +/// Every preset below therefore sits at or above **3.8:1 against white**, and +/// [AppAccent.custom] pushes an arbitrary picked colour down in lightness until +/// it does too. 3.8 is not arbitrary: the shipped Sozo red is 4.79:1 against +/// white and 3.70:1 against the dark background, so the floor is set just under +/// the red's *worse* direction. Any accent the user can choose is at least as +/// legible as the one the app has always shipped, in both directions. +/// +/// ## The family rule +/// +/// [dark] and [light] are not eyeballed per colour — they are the exact +/// relationship the shipped red triple already had, so every accent is a +/// sibling of the original rather than a different-looking design: +/// +/// * `dark` = base scaled by 0.7773 in sRGB (#E50914 → #B20710, exact) +/// * `light` = HSL lightness raised 33.8% of the +/// way to white, saturation × 1.0818 (#E50914 → #FF4B55, exact) +/// +/// The preset triples below are the pre-computed result, so no colour maths +/// runs at startup; [AppAccent.custom] applies the same two rules live. +@immutable +class AppAccent { + const AppAccent({ + required this.id, + required this.base, + required this.dark, + required this.light, + this.isCustom = false, + }); + + /// Stable key persisted in Hive. Never localise or renumber these — an + /// install that stored `'violet'` must still resolve to violet after an + /// update, and an id that no longer exists falls back to [fallback]. + final String id; + + final Color base; + final Color dark; + final Color light; + + /// True only for the user's own picked colour, which is stored as an ARGB + /// value rather than by id. + final bool isCustom; + + /// Translation key for the swatch label. + String get labelKey => isCustom ? 'appearance.accent_custom' : 'appearance.accent_$id'; + + /// The id stored for a custom colour. Its actual value lives under + /// [AppConstants.customAccentKey]. + static const String customId = 'custom'; + + /// The accent the app has always shipped. Also the value a corrupt or + /// unknown stored id falls back to, so a bad preference can never leave the + /// app unthemed. + static const AppAccent fallback = AppAccent( + id: 'red', + base: Color(0xFFE50914), + dark: Color(0xFFB20710), + light: Color(0xFFFF4B55), + ); + + /// The swatch row, in hue order starting from the default red. Slate sits + /// last on purpose: it is the "no colour" choice and reads as the end of the + /// wheel rather than a hue on it. + static const List presets = [ + fallback, + AppAccent( + id: 'ember', + base: Color(0xFFED4700), + dark: Color(0xFFB83700), + light: Color(0xFFFF804A), + ), + AppAccent( + id: 'amber', + base: Color(0xFFB57408), + dark: Color(0xFF8D5A06), + light: Color(0xFFFEAF2C), + ), + AppAccent( + id: 'emerald', + base: Color(0xFF179644), + dark: Color(0xFF127535), + light: Color(0xFF37E876), + ), + AppAccent( + id: 'teal', + base: Color(0xFF0E928E), + dark: Color(0xFF0B716E), + light: Color(0xFF24F3EC), + ), + AppAccent( + id: 'sky', + base: Color(0xFF0C8BC4), + dark: Color(0xFF096C98), + light: Color(0xFF3BBFFB), + ), + AppAccent( + id: 'ocean', + base: Color(0xFF2F7BF6), + dark: Color(0xFF2560BF), + light: Color(0xFF70A6FE), + ), + AppAccent( + id: 'indigo', + base: Color(0xFF6366F1), + dark: Color(0xFF4D4FBB), + light: Color(0xFF9496FA), + ), + AppAccent( + id: 'violet', + base: Color(0xFF9B51E0), + dark: Color(0xFF783FAE), + light: Color(0xFFBD88EE), + ), + AppAccent( + id: 'magenta', + base: Color(0xFFE040A0), + dark: Color(0xFFAE327C), + light: Color(0xFFEF7CC1), + ), + AppAccent( + id: 'rose', + base: Color(0xFFF43657), + dark: Color(0xFFBE2A44), + light: Color(0xFFFD758C), + ), + AppAccent( + id: 'slate', + base: Color(0xFF64748B), + dark: Color(0xFF4E5A6C), + light: Color(0xFF95A2B5), + ), + ]; + + /// Resolve a persisted id. Unknown ids (an accent removed in a later build, a + /// hand-edited box) return null so the caller can fall back deliberately. + static AppAccent? byId(String? id) { + if (id == null || id.isEmpty) return null; + for (final accent in presets) { + if (accent.id == id) return accent; + } + return null; + } + + /// Minimum contrast an accent must have against white, so the app-wide + /// `Colors.white` foreground stays readable on top of it. See the class doc. + static const double minWhiteContrast = 3.8; + + /// Minimum contrast an accent must have against the *page*, so accent-coloured + /// text and icons stay readable on the background. + /// + /// Measured against true black, which is the harder of the two darkness + /// levels to sit on. 3.6 is just under the shipped red's 3.70 against the + /// ordinary #181818, so this floor never rejects anything the app already + /// ships. Without it a custom pick of near-black would satisfy the white rule + /// perfectly and then vanish — the splash wordmark, which is nothing but + /// accent on black, would be a blank screen. + static const double minBackgroundContrast = 3.6; + + /// Build a full triple from a colour the user picked on the wheel. + /// + /// [seed] is honoured in hue and saturation, but its lightness is pulled into + /// the band where the colour works in BOTH directions — white stays legible on + /// top of it, and it stays legible on the page. Picking pure yellow gives a + /// deep gold rather than an unreadable button; picking near-black gives a + /// visible dark tone rather than an invisible one. The step is small (0.2% + /// lightness) so the result stays as close to the pick as the contract allows. + factory AppAccent.custom(Color seed) { + final base = _enforceLegibility(seed); + return AppAccent( + id: customId, + base: base, + dark: _deriveDark(base), + light: _deriveLight(base), + isCustom: true, + ); + } + + /// Contrast ratio of [color] against pure white, per WCAG 2.1. + static double whiteContrast(Color color) => + 1.05 / (color.computeLuminance() + 0.05); + + /// Contrast ratio of [color] against pure black, per WCAG 2.1. + static double blackContrast(Color color) => + (color.computeLuminance() + 0.05) / 0.05; + + /// True when [color] is legible in both directions — see the class doc. + static bool isLegibleAccent(Color color) => + whiteContrast(color) >= minWhiteContrast && + blackContrast(color) >= minBackgroundContrast; + + /// Walk the seed's HSL lightness into the legible band. + /// + /// The band is never empty: the two rules put relative luminance between + /// 0.130 and 0.226, and luminance rises continuously with HSL lightness, so + /// every hue and saturation passes through it. The walk is one-directional — + /// too bright means darken, too dark means lighten — and bounded, so it + /// always terminates. + static Color _enforceLegibility(Color seed) { + final opaque = seed.withValues(alpha: 1.0); + if (isLegibleAccent(opaque)) return opaque; + + final hsl = HSLColor.fromColor(opaque); + final tooBright = whiteContrast(opaque) < minWhiteContrast; + final step = tooBright ? -0.002 : 0.002; + var lightness = hsl.lightness; + var best = opaque; + + for (var i = 0; i < 500; i++) { + final next = lightness + step; + if (next < 0 || next > 1) break; + lightness = next; + best = hsl.withLightness(lightness).toColor(); + if (isLegibleAccent(best)) return best; + } + return best; + } + + /// sRGB scale — the exact transform that takes #E50914 to #B20710. + static Color _deriveDark(Color base) => scaleChannels(base, 0.7773); + + /// Multiply every channel and snap the result back onto the 8-bit grid. + /// + /// The rounding is not cosmetic. Flutter's wide-gamut [Color] keeps channels + /// as doubles, so `0x14 * 0.7773` lands on 0.06097 rather than on `0x10`'s + /// 0.06275 — the same pixel once rasterised, but a different value to `==`. + /// Snapping keeps derived colours comparable, hashable and equal to the hex + /// codes this file documents. + static Color scaleChannels(Color base, double factor) => Color.fromARGB( + 255, + (base.r * 255 * factor).round().clamp(0, 255), + (base.g * 255 * factor).round().clamp(0, 255), + (base.b * 255 * factor).round().clamp(0, 255), + ); + + /// Lift 33.8% of the way to white with a small saturation boost — the exact + /// transform that takes #E50914 to #FF4B55. + static Color _deriveLight(Color base) { + final hsl = HSLColor.fromColor(base); + return hsl + .withSaturation((hsl.saturation * 1.0818).clamp(0.0, 1.0)) + .withLightness( + (hsl.lightness + (1.0 - hsl.lightness) * 0.3380).clamp(0.0, 1.0), + ) + .toColor(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppAccent && + other.id == id && + other.base == base && + other.dark == dark && + other.light == light; + + @override + int get hashCode => Object.hash(id, base, dark, light); +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 5314413a..5cf77089 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,23 +1,94 @@ import 'package:flutter/material.dart'; +import 'app_palette.dart'; + +/// The app's colour vocabulary. +/// +/// ## Why some of these are getters +/// +/// Settings → Appearance lets the user change the accent colour and switch the +/// neutrals to true black (AMOLED). Both have to reach ~1900 existing call +/// sites across 140 files without any of them being rewritten to take a +/// `BuildContext`, so the members those two settings touch read through +/// [AppPalette.current] instead of being compile-time constants. +/// +/// Everything the settings do *not* touch is still `const`, both because it is +/// free and because it keeps `const` widget constructors working everywhere +/// they already did: +/// +/// * text colours — white-on-dark is right at every accent and both darkness +/// levels, so there is nothing to vary, +/// * [error] — must stay red even when the accent is green. Destructive +/// actions are not a place to express a theme. (It happens to equal the +/// default accent, which is why it was never visible as a separate colour +/// before; it is one now.) +/// * [success], [rating], the medals, and the fixed black/transparent +/// gradient ends. +/// +/// Reading a getter costs one static field load and one field read — no map +/// lookup, no `InheritedWidget`, nothing per-frame. What it does cost is that +/// `AppColors.primary` can no longer appear inside a `const` expression; drop +/// the `const` on the widget that holds it. class AppColors { AppColors._(); - static const Color background = Color(0xFF181818); + // ── Accent-driven ───────────────────────────────────────────────────────── + + static Color get primary => AppPalette.current.primary; + static Color get primaryDark => AppPalette.current.primaryDark; + static Color get primaryLight => AppPalette.current.primaryLight; + + /// Foreground for content painted on top of [primary]. Always white — see the + /// legibility contract on `AppAccent`, which is what makes that safe. + static Color get onPrimary => AppPalette.current.onPrimary; + + // ── Darkness-driven neutrals ────────────────────────────────────────────── + + static Color get background => AppPalette.current.background; + static Color get navBackground => AppPalette.current.navBackground; + static Color get surface => AppPalette.current.surface; + static Color get card => AppPalette.current.card; + static Color get surfaceVariant => AppPalette.current.surfaceVariant; + static Color get border => AppPalette.current.border; + static Color get divider => AppPalette.current.divider; + + /// The three stops of the accent-tinted page backdrop Profile paints. Follow + /// both the accent and the darkness level. + static Color get heroTop => AppPalette.current.heroTop; + static Color get heroMid => AppPalette.current.heroMid; + static Color get heroBottom => AppPalette.current.heroBottom; + + /// True while the user has AMOLED on. For the handful of places that need to + /// *behave* differently on true black rather than just be a shade darker. + static bool get isBlack => AppPalette.current.isBlack; + + /// True while the bottom bar's selected tab should carry the accent rather + /// than the shipped white. + static bool get isNavTinted => AppPalette.current.tintNav; + + // ── Fixed ───────────────────────────────────────────────────────────────── + + /// The splash has always been pure black, at every theme: it is the frame the + /// native launch screen hands over to, and a mismatch there is a visible + /// flash rather than a colour choice. static const Color splashBackground = Color(0xFF000000); - static const Color primary = Color(0xFFE50914); - static const Color primaryDark = Color(0xFFB20710); - static const Color primaryLight = Color(0xFFFF4B55); - static const Color surface = Color(0xFF242424); - static const Color surfaceVariant= Color(0xFF303030); - static const Color card = Color(0xFF282828); - static const Color navBackground = Color(0xFF0F0F0F); + static const Color textPrimary = Color(0xFFFFFFFF); static const Color textSecondary = Color(0xFFAAAAAA); static const Color textHint = Color(0xFF666666); - static const Color border = Color(0xFF3A3A3A); - static const Color divider = Color(0xFF2A2A2A); + + /// 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); + + /// The lifted partner to [error], for a rim or a label that has to sit on an + /// error fill without collapsing into it. + /// + /// It is exactly the old default [primaryLight]. Before the accent became a + /// 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); diff --git a/lib/core/theme/app_palette.dart b/lib/core/theme/app_palette.dart new file mode 100644 index 00000000..038fab79 --- /dev/null +++ b/lib/core/theme/app_palette.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import 'app_accent.dart'; + +/// How dark the neutral surfaces go. +/// +/// Deliberately an enum rather than a bool even though there are two values: +/// the setting is persisted as a name, and a third tier can be added later +/// without another migration. +enum AppDarkness { + /// The greys the app has always shipped. Default, and byte-identical to the + /// old hard-coded [AppColors] constants. + dark, + + /// Pure black background and near-black surfaces. On an OLED panel a black + /// pixel is an *off* pixel, so this is both the deepest contrast the app can + /// show and the cheapest to light. + black, +} + +/// The full set of colours the app repaints when Appearance changes. +/// +/// Resolved once per theme change and parked in [AppPalette.current], which +/// every `AppColors.*` getter reads. Nothing here is computed per frame. +/// +/// Only the neutrals and the accent live here. Text, error, success, rating and +/// the medal colours stay compile-time constants in `AppColors`, on purpose: +/// +/// * white-on-dark text is correct at every accent and both darkness levels, +/// * `error` must stay **red** even when the accent is green — an accent that +/// recoloured destructive actions would be a real bug, not a theme. +@immutable +class AppPalette { + const AppPalette._({ + required this.accent, + required this.darkness, + required this.tintNav, + required this.background, + required this.navBackground, + required this.surface, + required this.card, + required this.surfaceVariant, + required this.border, + required this.divider, + }); + + final AppAccent accent; + final AppDarkness darkness; + + /// Paint the bottom bar's selected tab in the accent instead of white. + /// + /// The stored *preference* ships on — see [AppConstants.tintNavKey]. This + /// parameter still defaults to false, because [resolve] is a pure function + /// and a caller that builds a palette by hand should get what it asked for + /// rather than a policy decision. + final bool tintNav; + + final Color background; + final Color navBackground; + final Color surface; + final Color card; + final Color surfaceVariant; + final Color border; + final Color divider; + + Color get primary => accent.base; + Color get primaryDark => accent.dark; + Color get primaryLight => accent.light; + + /// Foreground for anything painted **on** [primary]. + /// + /// Always white, and that is a guarantee rather than a coincidence: every + /// accent — presets and custom picks alike — is held at or above + /// [AppAccent.minWhiteContrast] against white precisely so the ~140 places + /// that hard-code `Colors.white` on an accent fill stay correct. See + /// [AppAccent]'s legibility contract. + Color get onPrimary => Colors.white; + + bool get isBlack => darkness == AppDarkness.black; + + // ── Neutral ramps ───────────────────────────────────────────────────────── + // + // `dark` is the shipped palette, unchanged. `black` keeps the *same ordering* + // (nav ≤ background < surface < card < surfaceVariant) so every screen's + // depth cues survive — it just compresses the whole ramp toward zero and + // pulls background and nav all the way to true black. + + static const Color _darkBackground = Color(0xFF181818); + static const Color _darkNavBackground = Color(0xFF0F0F0F); + static const Color _darkSurface = Color(0xFF242424); + static const Color _darkCard = Color(0xFF282828); + static const Color _darkSurfaceVariant = Color(0xFF303030); + static const Color _darkBorder = Color(0xFF3A3A3A); + static const Color _darkDivider = Color(0xFF2A2A2A); + + static const Color _blackBackground = Color(0xFF000000); + static const Color _blackNavBackground = Color(0xFF000000); + static const Color _blackSurface = Color(0xFF0E0E0E); + static const Color _blackCard = Color(0xFF141414); + static const Color _blackSurfaceVariant = Color(0xFF1E1E1E); + + /// Borders and dividers are *raised* relative to the rest of the ramp under + /// AMOLED. On a #181818 background a #3A3A3A hairline separates by luminance + /// difference; on true black the same job needs a line that is still clearly + /// above its surface, or every card edge disappears. + static const Color _blackBorder = Color(0xFF2B2B2B); + static const Color _blackDivider = Color(0xFF1A1A1A); + + /// The palette in force. Swapped by `ThemeController`, read by every + /// `AppColors` getter. Starts at the shipped defaults so anything that + /// touches a colour before the controller has loaded — an early error screen, + /// a widget test with no DI — still paints the app the app has always been. + static AppPalette current = resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + + static AppPalette resolve({ + required AppAccent accent, + required AppDarkness darkness, + bool tintNav = false, + }) { + final black = darkness == AppDarkness.black; + return AppPalette._( + accent: accent, + darkness: darkness, + tintNav: tintNav, + background: black ? _blackBackground : _darkBackground, + navBackground: black ? _blackNavBackground : _darkNavBackground, + surface: black ? _blackSurface : _darkSurface, + card: black ? _blackCard : _darkCard, + surfaceVariant: black ? _blackSurfaceVariant : _darkSurfaceVariant, + border: black ? _blackBorder : _darkBorder, + divider: black ? _blackDivider : _darkDivider, + ); + } + + // ── Accent-tinted page backdrop ─────────────────────────────────────────── + // + // Profile paints a three-stop vertical gradient behind its content. It used + // to be the literal list [#1E1416, #181818, #101010] — a red-tinted top over + // the dark grey — which silently stayed red no matter what accent was chosen. + // These three getters reproduce that gradient exactly for the default accent + // and follow the chosen one otherwise. + + /// Top stop: the background nudged onto the accent's hue. + /// + /// At the default red this evaluates to #1E1414 — the shipped #1E1416 to + /// within one step of blue. Under AMOLED it is a far deeper whisper of accent + /// (#0B0404 at red) so the header still reads as *this* app's header without + /// giving up the black. + Color get heroTop { + final hue = HSLColor.fromColor(primary).hue; + final base = HSLColor.fromColor(background); + return HSLColor.fromAHSL( + 1, + hue, + isBlack ? 0.50 : 0.20, + (base.lightness + (isBlack ? 0.030 : 0.004)).clamp(0.0, 1.0), + ).toColor(); + } + + /// Middle stop: the plain background, so the gradient lands on the same + /// colour every other screen uses. + Color get heroMid => background; + + /// Bottom stop: the background scaled to 0.667 in sRGB — exactly the + /// #181818 → #101010 fall-off the gradient always had. Under AMOLED the + /// background is already zero, so this stays true black. + Color get heroBottom => AppAccent.scaleChannels(background, 0.667); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppPalette && + other.accent == accent && + other.darkness == darkness && + other.tintNav == tintNav; + + @override + int get hashCode => Object.hash(accent, darkness, tintNav); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 034e5ea0..22f7f99d 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -2,9 +2,28 @@ import 'package:flutter/material.dart'; import '../system/platform_utils.dart'; import 'app_colors.dart'; +/// The app's one button height and one corner radius. +/// +/// Named rather than repeated so a screen that has to build its own control by +/// hand — a gradient CTA, a custom pill — lands on the same grid as the themed +/// buttons beside it instead of being eyeballed. +const double kButtonHeight = 52; +const double kButtonRadius = 10; + +/// Fields, cards, sheets and snackbars — everything that is a *surface* rather +/// than a control. Same 10 as the buttons, so a form reads as one object. +const double kFieldRadius = 10; + class AppTheme { AppTheme._(); + /// Built fresh on every read, from whatever `AppColors` currently resolves to. + /// + /// That is cheap (one `ThemeData` allocation) and it is what lets Appearance + /// change the accent or switch to true black without a restart: `MyApp` reads + /// this again on each rebuild, so the Material component defaults follow the + /// palette in exactly the same breath as the widgets that read `AppColors` + /// directly. static ThemeData get dark => ThemeData( brightness: Brightness.dark, useMaterial3: true, @@ -12,15 +31,20 @@ class AppTheme { colorScheme: ColorScheme( brightness: Brightness.dark, primary: AppColors.primary, - onPrimary: Colors.white, + onPrimary: AppColors.onPrimary, secondary: AppColors.primary, - onSecondary: Colors.white, + onSecondary: AppColors.onPrimary, error: AppColors.error, onError: Colors.white, surface: AppColors.surface, onSurface: AppColors.textPrimary, surfaceContainerHighest: AppColors.surfaceVariant, outline: AppColors.border, + // Left unset, M3 resolves outlineVariant to onSurface — i.e. white — + // and every component that draws its own hairline (TabBar, Divider, + // ListTile separators) paints it near-white. Harmless-looking on + // #181818, glaring on true black. + outlineVariant: AppColors.divider, ), appBarTheme: const AppBarTheme( backgroundColor: Colors.transparent, @@ -112,22 +136,31 @@ class AppTheme { elevation: 0, margin: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), ), ), + // ── The one button shape ──────────────────────────────────────── + // + // 52 tall, 10 radius, 15.5/w700 label. These are the numbers the + // onboarding and auth screens were already using through their own + // `AuthPrimaryButton`, and they are here now so every ElevatedButton and + // FilledButton in the app is that button — no screen has to opt in, and + // none can drift. elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, + disabledBackgroundColor: AppColors.surfaceVariant, + disabledForegroundColor: AppColors.textHint, elevation: 0, - minimumSize: const Size(double.infinity, 50), + minimumSize: const Size(double.infinity, kButtonHeight), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kButtonRadius), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 15.5, fontWeight: FontWeight.w700, - letterSpacing: 0.5, + letterSpacing: 0.2, ), ), ), @@ -137,15 +170,18 @@ class AppTheme { filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: AppColors.primary, - foregroundColor: Colors.white, + foregroundColor: AppColors.onPrimary, + disabledBackgroundColor: AppColors.surfaceVariant, + disabledForegroundColor: AppColors.textHint, elevation: 0, + minimumSize: const Size(0, kButtonHeight), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kButtonRadius), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 15.5, fontWeight: FontWeight.w700, - letterSpacing: 0.5, + letterSpacing: 0.2, ), ), ), @@ -153,13 +189,14 @@ class AppTheme { style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, side: const BorderSide(color: AppColors.textSecondary, width: 1.5), - minimumSize: const Size(double.infinity, 50), + minimumSize: const Size(double.infinity, kButtonHeight), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kButtonRadius), ), textStyle: const TextStyle( - fontSize: 16, + fontSize: 15.5, fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), ), ), @@ -180,23 +217,23 @@ class AppTheme { vertical: 14, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), - borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + borderRadius: BorderRadius.circular(kFieldRadius), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), ), errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: const BorderSide(color: AppColors.error, width: 1.5), ), focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(kFieldRadius), borderSide: const BorderSide(color: AppColors.error, width: 1.5), ), hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 14), @@ -219,7 +256,7 @@ class AppTheme { ), navigationBarTheme: NavigationBarThemeData( backgroundColor: AppColors.background, - indicatorColor: Color(0x33E50914), + indicatorColor: AppColors.primary.withValues(alpha: 0.2), elevation: 0, height: 64, labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, @@ -229,10 +266,10 @@ class AppTheme { selectedColor: AppColors.primary, disabledColor: AppColors.surfaceVariant, labelStyle: const TextStyle(color: AppColors.textPrimary, fontSize: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: BorderSide.none, ), - progressIndicatorTheme: const ProgressIndicatorThemeData( + progressIndicatorTheme: ProgressIndicatorThemeData( color: AppColors.primary, ), switchTheme: SwitchThemeData( @@ -251,13 +288,13 @@ class AppTheme { activeTrackColor: AppColors.primary, inactiveTrackColor: AppColors.surfaceVariant, thumbColor: AppColors.primary, - overlayColor: Color(0x33E50914), + overlayColor: AppColors.primary.withValues(alpha: 0.2), ), snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.surfaceVariant, contentTextStyle: const TextStyle(color: AppColors.textPrimary), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(isDesktopPlatform ? 10 : 4), + borderRadius: BorderRadius.circular(kFieldRadius), ), behavior: SnackBarBehavior.floating, elevation: isDesktopPlatform ? 6 : null, @@ -304,7 +341,7 @@ class AppTheme { return AppColors.textSecondary; }), ), - tabBarTheme: const TabBarThemeData( + tabBarTheme: TabBarThemeData( labelColor: AppColors.textPrimary, unselectedLabelColor: AppColors.textHint, indicatorColor: AppColors.primary, @@ -319,7 +356,7 @@ class AppTheme { tooltipTheme: TooltipThemeData( decoration: BoxDecoration( color: AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(8), ), textStyle: const TextStyle(color: AppColors.textPrimary, fontSize: 12), ), diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 48f6d6dd..a43676fe 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,17 +1,155 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'app_accent.dart'; +import 'app_palette.dart'; + +/// Owns everything Settings → Appearance can change: the accent colour and how +/// dark the neutrals go. +/// +/// ## How a change reaches the whole app +/// +/// Almost every widget in Sozo reads a colour as `AppColors.primary` — a static +/// getter, with no `BuildContext` and therefore no `InheritedWidget` to depend +/// on. So a theme change is two separate jobs, and this class does the first: +/// +/// 1. **here** — swap [AppPalette.current], persist, then `notifyListeners()`, +/// 2. **`MyApp`** — hand `MaterialApp` a freshly built [ThemeData] *and* mark +/// every element in the tree dirty, so the static readers repaint too. +/// +/// Splitting it that way is what keeps the change instant and total: no restart, +/// no route rebuild, no lost navigation stack or scroll position. +/// +/// The constructor loads and applies synchronously, before `runApp`, so the very +/// first frame is already painted in the user's colours — there is never a flash +/// of red before a blue theme appears. class ThemeController extends ChangeNotifier { + ThemeController(this._hive) { + _accent = _readAccent(); + _darkness = _hive.isAmoledMode ? AppDarkness.black : AppDarkness.dark; + _tintNav = _hive.isNavTinted; + _apply(); + } + final HiveService _hive; - ThemeController(this._hive) : _amoled = _hive.isAmoledMode; + late AppAccent _accent; + late AppDarkness _darkness; + late bool _tintNav; + + AppAccent get accent => _accent; + AppDarkness get darkness => _darkness; + + /// Whether the bottom bar's selected tab carries the accent. + bool get isNavTinted => _tintNav; + + /// True while the neutrals are true black. + bool get isAmoled => _darkness == AppDarkness.black; + + /// The id to tick in the swatch row. For a custom colour this is + /// [AppAccent.customId], not the nearest preset. + String get accentId => _accent.id; + + /// Everything is at its shipped value — what the "Reset" action undoes. + /// + /// The tab-bar tint ships **on**, so "default" means on here. + bool get isDefault => + _accent.id == AppAccent.fallback.id && + _darkness == AppDarkness.dark && + _tintNav; + + AppAccent _readAccent() { + final id = _hive.accentId; + if (id == AppAccent.customId) { + final argb = _hive.customAccentArgb; + // A stored custom id with no stored colour is a half-written preference; + // fall back rather than paint an arbitrary colour. + if (argb != null) return AppAccent.custom(Color(argb)); + return AppAccent.fallback; + } + return AppAccent.byId(id) ?? AppAccent.fallback; + } + + void _apply() { + AppPalette.current = AppPalette.resolve( + accent: _accent, + darkness: _darkness, + tintNav: _tintNav, + ); + } + + /// Pick one of the [AppAccent.presets]. + Future setAccent(AppAccent accent) async { + if (accent.id == _accent.id && accent.base == _accent.base) return; + _accent = accent; + _apply(); + notifyListeners(); + await _hive.setAccentId(accent.id); + } + + /// Pick an arbitrary colour. [seed] is normalised by [AppAccent.custom] so + /// white stays legible on it; the *seed* is what gets persisted, so re-opening + /// the picker shows the wheel where the user left it rather than where the + /// contract moved it. + Future setCustomAccent(Color seed) async { + final accent = AppAccent.custom(seed); + _accent = accent; + _apply(); + notifyListeners(); + await _hive.setCustomAccentArgb(seed.withValues(alpha: 1.0).toARGB32()); + await _hive.setAccentId(AppAccent.customId); + } + + /// The seed behind a custom accent, for re-opening the picker on the colour + /// the user actually chose. Null unless the current accent is custom. + Color? get customSeed { + if (!_accent.isCustom) return null; + final argb = _hive.customAccentArgb; + return argb == null ? null : Color(argb); + } + + Future setAmoled(bool enabled) async { + final next = enabled ? AppDarkness.black : AppDarkness.dark; + if (next == _darkness) return; + _darkness = next; + _apply(); + notifyListeners(); + await _hive.setAmoledMode(enabled); + } - bool _amoled; - bool get isAmoled => _amoled; + Future toggleAmoled() => setAmoled(!isAmoled); + + Future setNavTinted(bool enabled) async { + if (enabled == _tintNav) return; + _tintNav = enabled; + _apply(); + notifyListeners(); + await _hive.setNavTinted(enabled); + } + + /// Jump to a preset the user is not already on. + /// + /// Deterministic rather than random: [step] walks the ring, so tapping it + /// repeatedly tours the palette instead of landing on the same colour twice + /// in a row the way `Random` eventually does. + Future cycleAccent({int step = 1}) async { + final presets = AppAccent.presets; + final at = presets.indexWhere((a) => a.id == _accent.id); + // A custom accent is not on the ring, so stepping starts from the default. + final next = ((at < 0 ? -1 : at) + step) % presets.length; + await setAccent(presets[next < 0 ? next + presets.length : next]); + } - Future toggle() async { - _amoled = !_amoled; - await _hive.setAmoledMode(_amoled); + /// Back to the shipped look: default red, ordinary dark greys. + Future reset() async { + if (isDefault) return; + _accent = AppAccent.fallback; + _darkness = AppDarkness.dark; + _tintNav = true; + _apply(); notifyListeners(); + await _hive.setAccentId(AppAccent.fallback.id); + await _hive.setAmoledMode(false); + await _hive.setNavTinted(true); } } diff --git a/lib/core/widgets/app_buttons.dart b/lib/core/widgets/app_buttons.dart new file mode 100644 index 00000000..d5fd604e --- /dev/null +++ b/lib/core/widgets/app_buttons.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; + +/// The app's primary call to action. +/// +/// This is the button the onboarding and auth screens always used, lifted out +/// of `features/auth` so the rest of the app can use the same one instead of +/// hand-rolling a container with a colour and a radius. Its metrics come from +/// [kButtonHeight] / [kButtonRadius] and the shared `elevatedButtonTheme`, so +/// it and every plain `ElevatedButton` in the app are the same object. +/// +/// [loading] swaps the label for a spinner *without* changing the button's size, +/// so a form does not jump when it is submitted. +class AppPrimaryButton extends StatelessWidget { + const AppPrimaryButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + this.icon, + this.expand = true, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + final IconData? icon; + + /// Full width (a form's submit) vs. hugging its label (a row of actions). + final bool expand; + + @override + Widget build(BuildContext context) { + final glyph = icon; + final child = loading + ? SizedBox( + width: 21, + height: 21, + child: CircularProgressIndicator( + color: AppColors.onPrimary, + strokeWidth: 2.2, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (glyph != null) ...[ + Icon(glyph, size: 19), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + + return SizedBox( + height: kButtonHeight, + width: expand ? double.infinity : null, + child: ElevatedButton( + onPressed: loading ? null : onPressed, + style: expand + ? null + : ElevatedButton.styleFrom( + minimumSize: const Size(0, kButtonHeight), + padding: const EdgeInsets.symmetric(horizontal: 20), + ), + child: child, + ), + ); + } +} + +/// The quieter partner to [AppPrimaryButton] — same height and radius, an +/// outline instead of a fill. For the "not now" beside a "continue". +class AppSecondaryButton extends StatelessWidget { + const AppSecondaryButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.expand = true, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool expand; + + @override + Widget build(BuildContext context) { + final glyph = icon; + return SizedBox( + height: kButtonHeight, + width: expand ? double.infinity : null, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, kButtonHeight), + padding: EdgeInsets.symmetric(horizontal: expand ? 16 : 20), + side: BorderSide( + color: AppColors.textPrimary.withValues(alpha: 0.22), + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (glyph != null) ...[ + Icon(glyph, size: 19), + const SizedBox(width: 8), + ], + Flexible( + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/app_tab_bar.dart b/lib/core/widgets/app_tab_bar.dart index a11a3fd1..790f4bdf 100644 --- a/lib/core/widgets/app_tab_bar.dart +++ b/lib/core/widgets/app_tab_bar.dart @@ -16,7 +16,7 @@ class AppTabBar extends StatefulWidget implements PreferredSizeWidget { this.controller, this.isScrollable = true, this.showDivider = true, - this.background = AppColors.background, + this.background, this.padding = const EdgeInsets.only(left: 8), }); @@ -26,7 +26,10 @@ class AppTabBar extends StatefulWidget implements PreferredSizeWidget { final TabController? controller; final bool isScrollable; final bool showDivider; - final Color background; + /// Defaults to [AppColors.background]. Nullable rather than defaulted in the + /// constructor because the palette is a runtime value now, and a default + /// parameter has to be a compile-time constant. + final Color? background; final EdgeInsets padding; static const double _dividerHeight = 0.5; @@ -110,7 +113,7 @@ class _AppTabBarState extends State @override Widget build(BuildContext context) { return Container( - color: widget.background, + color: widget.background ?? AppColors.background, child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/anilist/data/anilist_api.dart b/lib/features/anilist/data/anilist_api.dart index fefe8efb..18ef5925 100644 --- a/lib/features/anilist/data/anilist_api.dart +++ b/lib/features/anilist/data/anilist_api.dart @@ -35,6 +35,16 @@ class AnilistApi { static const String _rateLimitMessage = 'AniList is rate limiting requests'; + /// AniList refusing everything, and the reason it gave. + /// + /// Separate from [_throttledUntil]: a rate limit is our fault and clears in + /// seconds, an outage is theirs and lasts as long as it lasts. Keeping them + /// apart means we replay AniList's own words instead of telling the user to + /// slow down when slowing down cannot help. + DateTime? _outageUntil; + String? _outageMessage; + static const Duration _outageBackoff = Duration(minutes: 5); + static Duration _retryAfter(Response? response) { final headers = response?.headers; final retryAfter = int.tryParse(headers?.value('retry-after') ?? ''); @@ -100,6 +110,13 @@ class AnilistApi { if (until != null && DateTime.now().isBefore(until)) { throw const AnilistException(_rateLimitMessage, rateLimited: true); } + // While AniList is refusing everything there is nothing to gain by asking + // again — and the calendar alone fires two requests per visit (the selected + // day plus tomorrow's prefetch), every one of them a guaranteed failure. + final outage = _outageUntil; + if (outage != null && DateTime.now().isBefore(outage)) { + throw AnilistException(_outageMessage ?? 'AniList is unavailable'); + } final Response response; try { @@ -115,27 +132,57 @@ class AnilistApi { _throttledUntil = DateTime.now().add(_retryAfter(e.response)); throw const AnilistException(_rateLimitMessage, rateLimited: true); } + // GraphQL errors normally arrive in a 200 body and are read below, but a + // refusal comes back as a non-2xx — which Dio throws on, so the body was + // discarded and the reason with it. When AniList disabled its API it + // answered every query with 403 and "The AniList API has been temporarily + // disabled due to severe stability issues", and every screen showed its + // own generic "could not load" beside a Try again that could not work. + final message = _graphqlError(e.response?.data); + if (message != null) { + // Back off only when the service is refusing everyone — 403, or a 5xx. + // A 400 means we sent something wrong, and muting our own bug for five + // minutes would hide it rather than fix it, so that one is reported and + // the next call still goes out. + final code = e.response?.statusCode ?? 0; + if (code == 403 || code >= 500) { + _outageUntil = DateTime.now().add(_outageBackoff); + _outageMessage = message; + } + throw AnilistException(message); + } rethrow; } _throttledUntil = null; + _outageUntil = null; + _outageMessage = null; final body = response.data; if (body is! Map) throw const AnilistException('Unexpected AniList reply'); // GraphQL reports failures in a 200 body, so a non-throwing Dio call is not // the same as a successful query. - final errors = body['errors']; - if (errors is List && errors.isNotEmpty) { - final first = errors.first; - final message = first is Map ? first['message']?.toString() : null; - throw AnilistException(message ?? 'AniList rejected the request'); - } + final message = _graphqlError(body); + if (message != null) throw AnilistException(message); final data = body['data']; if (data is! Map) throw const AnilistException('AniList returned no data'); return data.cast(); } + /// The first message out of a GraphQL `errors` array, wherever it arrives — + /// a 200 body or the body of a refusal. Null when the payload carries none. + static String? _graphqlError(dynamic body) { + if (body is! Map) return null; + final errors = body['errors']; + if (errors is! List || errors.isEmpty) return null; + final first = errors.first; + final message = first is Map ? first['message']?.toString() : null; + return (message != null && message.isNotEmpty) + ? message + : 'AniList rejected the request'; + } + /// Who the stored token belongs to. Also the cheapest way to tell whether a /// token is still valid — AniList tokens last about a year but can be revoked. Future viewer(String token) async { diff --git a/lib/features/anilist/presentation/pages/airing_calendar_page.dart b/lib/features/anilist/presentation/pages/airing_calendar_page.dart index 20d23f2b..254f8254 100644 --- a/lib/features/anilist/presentation/pages/airing_calendar_page.dart +++ b/lib/features/anilist/presentation/pages/airing_calendar_page.dart @@ -1012,7 +1012,7 @@ class _MediaActions extends StatelessWidget { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), @@ -1056,7 +1056,7 @@ class _MediaActions extends StatelessWidget { ], ), const SizedBox(height: 12), - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), _SheetAction( icon: Icons.travel_explore_rounded, label: 'anilist.find_in_sources'.tr(), diff --git a/lib/features/anilist/presentation/pages/anilist_browse_page.dart b/lib/features/anilist/presentation/pages/anilist_browse_page.dart index 2f72b741..d1992174 100644 --- a/lib/features/anilist/presentation/pages/anilist_browse_page.dart +++ b/lib/features/anilist/presentation/pages/anilist_browse_page.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/data/anilist_api.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; @@ -458,7 +459,7 @@ class _MediaSheet extends StatelessWidget { style: OutlinedButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: const Icon(Icons.expand_more_rounded, size: 20), diff --git a/lib/features/anilist/presentation/pages/anilist_library_page.dart b/lib/features/anilist/presentation/pages/anilist_library_page.dart index f67ae4b4..acd185bf 100644 --- a/lib/features/anilist/presentation/pages/anilist_library_page.dart +++ b/lib/features/anilist/presentation/pages/anilist_library_page.dart @@ -168,7 +168,7 @@ class _StatusTabBar extends StatelessWidget { Widget build(BuildContext context) { return Container( alignment: Alignment.centerLeft, - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(bottom: BorderSide(color: AppColors.divider, width: 0.5)), ), child: TabBar( diff --git a/lib/features/anilist/presentation/pages/connections_page.dart b/lib/features/anilist/presentation/pages/connections_page.dart index 4d4d0ad6..d0b929ec 100644 --- a/lib/features/anilist/presentation/pages/connections_page.dart +++ b/lib/features/anilist/presentation/pages/connections_page.dart @@ -6,6 +6,7 @@ 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/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/data/anilist_link_store.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; @@ -399,10 +400,10 @@ class _TrackerCard extends StatelessWidget { onPressed: onDisconnect, style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.link_off_rounded, size: 18), @@ -415,7 +416,7 @@ class _TrackerCard extends StatelessWidget { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.open_in_new_rounded, size: 18), diff --git a/lib/features/anilist/presentation/widgets/anilist_brand.dart b/lib/features/anilist/presentation/widgets/anilist_brand.dart index b98f4c16..fab90b69 100644 --- a/lib/features/anilist/presentation/widgets/anilist_brand.dart +++ b/lib/features/anilist/presentation/widgets/anilist_brand.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; /// AniList's own blue. Used only for AniList affordances, so a "connect" or @@ -187,7 +188,7 @@ class AnilistStateMessage extends StatelessWidget { minimumSize: const Size(0, 42), padding: const EdgeInsets.symmetric(horizontal: 20), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text(actionLabel!), @@ -281,7 +282,7 @@ class AnilistConnectPrompt extends StatelessWidget { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: busy diff --git a/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart b/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart index 157b51cf..ea5230d2 100644 --- a/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart +++ b/lib/features/anilist/presentation/widgets/anilist_entry_sheet.dart @@ -138,7 +138,7 @@ class _AnilistEntrySheetState extends State { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), @@ -247,7 +247,7 @@ class _AnilistEntrySheetState extends State { ], ), const SizedBox(height: 20), - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), const SizedBox(height: 8), _Action( icon: Icons.travel_explore_rounded, diff --git a/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart b/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart index 1876e611..123b532f 100644 --- a/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart +++ b/lib/features/anilist/presentation/widgets/anilist_link_sheet.dart @@ -170,7 +170,7 @@ class _AnilistLinkSheetState extends State { maxChildSize: 0.95, expand: false, builder: (context, scrollController) => Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), diff --git a/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart b/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart index 050d8a77..eac6a72f 100644 --- a/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart +++ b/lib/features/app_lock/presentation/pages/app_lock_settings_page.dart @@ -131,7 +131,7 @@ class _AppLockSettingsPageState extends State { ), ), if (enabled) ...[ - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), _Row( icon: Icons.pin_rounded, title: 'app_lock.change_pin'.tr(), @@ -143,7 +143,7 @@ class _AppLockSettingsPageState extends State { onTap: _changePin, ), if (_biometricAvailable) ...[ - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), _Row( icon: Icons.fingerprint_rounded, title: 'app_lock.biometric'.tr(), @@ -155,7 +155,7 @@ class _AppLockSettingsPageState extends State { ), ], ], - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), Opacity( opacity: enabled ? 1.0 : 0.5, child: _Row( diff --git a/lib/features/app_lock/presentation/pages/pin_verify_page.dart b/lib/features/app_lock/presentation/pages/pin_verify_page.dart index 727d6c9c..5e06b0dd 100644 --- a/lib/features/app_lock/presentation/pages/pin_verify_page.dart +++ b/lib/features/app_lock/presentation/pages/pin_verify_page.dart @@ -69,8 +69,10 @@ class _PinVerifyViewState extends State<_PinVerifyView> { onPressed: () => Navigator.of(dctx).pop(true), child: Text( 'app_lock.reset'.tr(), + // This wipes the user's PIN — the same weight of action as the + // app's other error-coloured confirmations. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontWeight: FontWeight.w700, ), ), @@ -126,7 +128,7 @@ class _PinVerifyViewState extends State<_PinVerifyView> { color: AppColors.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.lock_rounded, color: AppColors.primary, size: 30, 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 2d956562..8131aa63 100644 --- a/lib/features/app_updater/presentation/pages/force_update_page.dart +++ b/lib/features/app_updater/presentation/pages/force_update_page.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/app_updater/domain/entities/app_version_check.dart'; import 'package:soplay/features/app_updater/presentation/widgets/release_notes_view.dart'; @@ -92,7 +93,7 @@ class ForceUpdatePage extends StatelessWidget { backgroundColor: accent, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: () => onUpdate(context), diff --git a/lib/features/app_updater/presentation/widgets/release_notes_view.dart b/lib/features/app_updater/presentation/widgets/release_notes_view.dart index 0e25f6fe..1082fcdc 100644 --- a/lib/features/app_updater/presentation/widgets/release_notes_view.dart +++ b/lib/features/app_updater/presentation/widgets/release_notes_view.dart @@ -95,7 +95,7 @@ class ReleaseNotesView extends StatelessWidget { 'blockquote': Style( margin: Margins.only(bottom: 8), padding: HtmlPaddings.only(left: 10), - border: const Border( + border: Border( left: BorderSide(color: AppColors.border, width: 3), ), color: AppColors.textSecondary, diff --git a/lib/features/auth/presentation/pages/otp_verify_page.dart b/lib/features/auth/presentation/pages/otp_verify_page.dart index d473ae33..00437321 100644 --- a/lib/features/auth/presentation/pages/otp_verify_page.dart +++ b/lib/features/auth/presentation/pages/otp_verify_page.dart @@ -206,7 +206,7 @@ class _OtpVerifyPageState extends State ), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.mark_email_read_rounded, color: AppColors.primary, size: 40, diff --git a/lib/features/auth/presentation/widgets/auth_widgets.dart b/lib/features/auth/presentation/widgets/auth_widgets.dart index 493e5cbc..7e804b69 100644 --- a/lib/features/auth/presentation/widgets/auth_widgets.dart +++ b/lib/features/auth/presentation/widgets/auth_widgets.dart @@ -2,7 +2,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/onboarding/presentation/widgets/poster_wall.dart'; /// The shell every auth screen sits in: a living poster header that dissolves @@ -142,7 +144,7 @@ class _AuthTopBar extends StatelessWidget { const SizedBox(width: 12), Text( 'app_name'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 22, fontWeight: FontWeight.w900, @@ -333,6 +335,12 @@ class AuthErrorBanner extends StatelessWidget { /// The one call-to-action on each screen, with the spinner built in so no page /// re-invents the swap between label and progress. +/// The auth screens' name for [AppPrimaryButton]. +/// +/// Kept as a thin alias rather than deleted: its metrics became the app-wide +/// button in `app_theme.dart`, so there is now exactly one definition of what a +/// primary button is, and the dozen auth/onboarding call sites did not have to +/// be rewritten to say so. class AuthPrimaryButton extends StatelessWidget { const AuthPrimaryButton({ super.key, @@ -346,35 +354,11 @@ class AuthPrimaryButton extends StatelessWidget { final bool loading; @override - Widget build(BuildContext context) { - return SizedBox( - height: 52, - child: ElevatedButton( - onPressed: loading ? null : onPressed, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: loading - ? const SizedBox( - width: 21, - height: 21, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.2, - ), - ) - : Text( - label, - style: const TextStyle( - fontSize: 15.5, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } + Widget build(BuildContext context) => AppPrimaryButton( + label: label, + onPressed: onPressed, + loading: loading, + ); } class AuthDivider extends StatelessWidget { @@ -384,7 +368,7 @@ class AuthDivider extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - const Expanded(child: Divider(color: AppColors.border, height: 1)), + Expanded(child: Divider(color: AppColors.border, height: 1)), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( @@ -392,7 +376,7 @@ class AuthDivider extends StatelessWidget { style: const TextStyle(color: AppColors.textHint, fontSize: 12.5), ), ), - const Expanded(child: Divider(color: AppColors.border, height: 1)), + Expanded(child: Divider(color: AppColors.border, height: 1)), ], ); } @@ -422,7 +406,7 @@ class GoogleAuthButton extends StatelessWidget { disabledBackgroundColor: Colors.white24, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: loading @@ -478,7 +462,7 @@ class AuthSwitchPrompt extends StatelessWidget { onPressed: onTap, child: Text( action, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontWeight: FontWeight.w700, ), diff --git a/lib/features/banners/presentation/widgets/banners_carousel.dart b/lib/features/banners/presentation/widgets/banners_carousel.dart index 13801749..18b065fe 100644 --- a/lib/features/banners/presentation/widgets/banners_carousel.dart +++ b/lib/features/banners/presentation/widgets/banners_carousel.dart @@ -123,7 +123,7 @@ class _BannerCard extends StatelessWidget { CachedNetworkImage( imageUrl: item.imageUrl, fit: BoxFit.cover, - errorWidget: (_, _, _) => const ColoredBox( + errorWidget: (_, _, _) => ColoredBox( color: AppColors.surfaceVariant, ), ), diff --git a/lib/features/cloudflare/cloudflare_solver_page.dart b/lib/features/cloudflare/cloudflare_solver_page.dart index be6ae3b5..05088600 100644 --- a/lib/features/cloudflare/cloudflare_solver_page.dart +++ b/lib/features/cloudflare/cloudflare_solver_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:soplay/core/system/webview_env.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; class CloudflareSolverPage extends StatefulWidget { const CloudflareSolverPage({ @@ -125,7 +126,7 @@ class _CloudflareSolverPageState extends State { }, ), if (!_firstLoadDone) - const ColoredBox( + ColoredBox( color: AppColors.background, child: Center( child: CircularProgressIndicator( @@ -186,7 +187,7 @@ class _CloudflareSolverPageState extends State { foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: _onManualDone, diff --git a/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart b/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart index 09f8faf4..1cc1d889 100644 --- a/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart +++ b/lib/features/cloudstream/presentation/pages/cloudstream_plugins_page.dart @@ -312,7 +312,7 @@ class _CloudStreamPluginsPageState extends State { maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 12.5), ), ), @@ -405,7 +405,7 @@ class _CloudStreamPluginsPageState extends State { color: AppColors.primary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), - child: const Icon(Icons.extension_rounded, + child: Icon(Icons.extension_rounded, color: AppColors.primary, size: 18), ); if (url == null || url.isEmpty) return fallback(); diff --git a/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart b/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart index 9469657a..09899a16 100644 --- a/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart +++ b/lib/features/cloudstream/presentation/pages/cloudstream_sources_page.dart @@ -225,7 +225,7 @@ class _CloudStreamSourcesPageState extends State { borderRadius: BorderRadius.circular(10), child: Image.network(_icon, width: 44, height: 44, fit: BoxFit.cover, errorBuilder: (_, _, _) => - const Icon(Icons.extension_outlined, color: AppColors.primary, size: 40)), + Icon(Icons.extension_outlined, color: AppColors.primary, size: 40)), ), const SizedBox(width: 12), const Expanded( @@ -349,7 +349,7 @@ class _CloudStreamSourcesPageState extends State { borderRadius: BorderRadius.circular(8), child: Image.network(_icon, width: 34, height: 34, fit: BoxFit.cover, errorBuilder: (_, _, _) => - const Icon(Icons.extension_outlined, color: AppColors.primary)), + Icon(Icons.extension_outlined, color: AppColors.primary)), ), title: Text(name, maxLines: 1, diff --git a/lib/features/comments/presentation/widgets/comments_panel.dart b/lib/features/comments/presentation/widgets/comments_panel.dart index 0048a88a..8dd50110 100644 --- a/lib/features/comments/presentation/widgets/comments_panel.dart +++ b/lib/features/comments/presentation/widgets/comments_panel.dart @@ -4,6 +4,7 @@ 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/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; import 'package:soplay/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_card.dart'; @@ -87,7 +88,7 @@ class _CommentsViewState extends State<_CommentsView> { bool loggedIn, ) { if (state.loading) { - return const Center( + return Center( child: CircularProgressIndicator( color: AppColors.primary, strokeWidth: 2.4, @@ -237,7 +238,7 @@ class _CommentsViewState extends State<_CommentsView> { const _SignInPrompt() else Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, border: Border( top: BorderSide(color: AppColors.divider, width: 0.6), @@ -289,7 +290,7 @@ class _SignInPrompt extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.login_rounded, size: 18), @@ -362,7 +363,7 @@ class _CommentTree extends StatelessWidget { child: Column( children: [ if (repliesLoading && replies.isEmpty) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 16), child: SizedBox( width: 18, diff --git a/lib/features/desktop_share/presentation/pages/desktop_share_page.dart b/lib/features/desktop_share/presentation/pages/desktop_share_page.dart index 0624c654..709542c0 100644 --- a/lib/features/desktop_share/presentation/pages/desktop_share_page.dart +++ b/lib/features/desktop_share/presentation/pages/desktop_share_page.dart @@ -345,7 +345,7 @@ class _DesktopSharePageState extends State { }), ), if (!_shareAll) ...[ - const Divider(color: AppColors.divider, height: 16), + Divider(color: AppColors.divider, height: 16), _pickerControls(), const SizedBox(height: 4), _pickerList(), @@ -608,7 +608,7 @@ class _DesktopSharePageState extends State { ), child: Text( '${i + 1}', - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 12, fontWeight: FontWeight.w800, diff --git a/lib/features/detail/presentation/pages/actor_page.dart b/lib/features/detail/presentation/pages/actor_page.dart index b4230965..5d619b65 100644 --- a/lib/features/detail/presentation/pages/actor_page.dart +++ b/lib/features/detail/presentation/pages/actor_page.dart @@ -56,8 +56,21 @@ class _ActorScaffold extends StatefulWidget { class _ActorScaffoldState extends State<_ActorScaffold> { late final ScrollController _scroll; final ValueNotifier _collapse = ValueNotifier(0); - Color _accent = const Color(0xFFB20710); - Color _accentDeep = const Color(0xFF3A0306); + // Shown until the poster's palette is extracted (and kept when there is no + // poster). Follows the chosen accent rather than the old hard-coded brand + // red, so the hero never opens in a colour the app no longer uses. + Color _accent = AppColors.primaryDark; + Color _accentDeep = _deepen(AppColors.primary); + + /// The very dark, desaturated floor a hero gradient falls to. The old literal + /// #3A0306 is what this produces for the default red. + static Color _deepen(Color c) { + final hsl = HSLColor.fromColor(c); + return hsl + .withSaturation((hsl.saturation * 0.94).clamp(0.0, 1.0)) + .withLightness((hsl.lightness * 0.265).clamp(0.0, 1.0)) + .toColor(); + } static const double _heroExtent = 320; @@ -154,7 +167,7 @@ class _ActorScaffoldState extends State<_ActorScaffold> { ), ), if (state is ViewAllLoading) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center( @@ -298,7 +311,7 @@ class _ActorScaffoldState extends State<_ActorScaffold> { ), ), if (state.isLoadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 8156c4e4..cf379e78 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -751,7 +751,7 @@ class _DetailViewState extends State<_DetailView> child: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator(color: AppColors.primary), + CircularProgressIndicator(color: AppColors.primary), const SizedBox(height: 18), TextButton( onPressed: () => Navigator.of(dctx).pop(), @@ -1470,7 +1470,7 @@ class _ErrorView extends StatelessWidget { onPressed: onSolveCloudflare, style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), ), icon: const Icon(Icons.shield_outlined, size: 18), label: Text('cloudflare.solve'.tr()), diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index 06eb2a42..06a27dcf 100644 --- a/lib/features/detail/presentation/pages/episodes_page.dart +++ b/lib/features/detail/presentation/pages/episodes_page.dart @@ -369,7 +369,7 @@ class _EpisodesPageState extends State { }, ), if (_loadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 22), child: Center( @@ -738,7 +738,7 @@ class _EpisodeRow extends StatelessWidget { value: progress!, minHeight: 3, backgroundColor: AppColors.divider, - valueColor: const AlwaysStoppedAnimation( + valueColor: AlwaysStoppedAnimation( AppColors.primary, ), ), @@ -795,7 +795,7 @@ class _DownloadControl extends StatelessWidget { return Container( width: 34, height: 34, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), @@ -811,13 +811,15 @@ class _DownloadControl extends StatelessWidget { final button = Container( width: 34, height: 34, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), child: Icon( failed ? Icons.refresh_rounded : Icons.download_outlined, - color: failed ? AppColors.primary : AppColors.textSecondary, + // The retry glyph IS the failure signal on this row — it has to stay + // red rather than turn into whatever the accent is. + color: failed ? AppColors.error : AppColors.textSecondary, size: 18, ), ); diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index 9f842141..5774bd30 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -168,7 +168,7 @@ extension _PlayerSubtitles on _PlayerPageState { // the standout action, not another neutral row. ListTile( focusColor: _kTvFocusFill, - leading: const Icon(Icons.auto_awesome_rounded, + leading: Icon(Icons.auto_awesome_rounded, color: AppColors.primaryLight, size: 20), title: Text('player.ai_translate_menu'.tr(), style: const TextStyle( @@ -220,17 +220,66 @@ extension _PlayerSubtitles on _PlayerPageState { ); } - void _toast(String message) { + void _toast(String message, {IconData icon = Icons.info_outline_rounded}) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( SnackBar( - content: Text(message), + content: Row( + children: [ + Icon(icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 10), + Expanded( + child: Text(message, + style: const TextStyle(fontSize: 13, color: Colors.white)), + ), + ], + ), + backgroundColor: const Color(0xFF1C1C1E), behavior: SnackBarBehavior.floating, + elevation: 8, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white.withValues(alpha: 0.08)), + ), duration: const Duration(seconds: 2), ), ); } + /// TMDB id + type parsed from the content url, for finding and publishing + /// translations by the media they belong to. + ({String id, String type})? _tmdbRef() { + final url = widget.args.contentUrl; + if (url == null || url.isEmpty) return null; + final m = RegExp(r'themoviedb\.org/(movie|tv)/(\d+)').firstMatch(url); + if (m == null) return null; + return (id: m.group(2)!, type: m.group(1)!); + } + + /// Serialises translated cues to SubRip, for publishing a finished track. + String _buildSrt(List cues) { + String stamp(Duration d) { + final h = d.inHours.toString().padLeft(2, '0'); + final m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final sec = (d.inSeconds % 60).toString().padLeft(2, '0'); + final ms = (d.inMilliseconds % 1000).toString().padLeft(3, '0'); + return '$h:$m:$sec,$ms'; + } + + final buf = StringBuffer(); + for (var i = 0; i < cues.length; i++) { + final c = cues[i]; + buf.writeln(i + 1); + buf.writeln('${stamp(c.start)} --> ${stamp(c.end)}'); + buf.writeln(c.text); + buf.writeln(); + } + return buf.toString(); + } + int? _currentEpisodeNumber() { if (!widget.args.isSerial) return null; // Read the episode number from the entity — parsing the ' · <label>' @@ -290,14 +339,27 @@ extension _PlayerSubtitles on _PlayerPageState { List<OnlineSubtitle> results = const []; SubtitleQuota? quota; var quotaLoaded = false; + List<ReadySubtitle> ready = const []; return StatefulBuilder( builder: (ctx, setSheet) { if (!quotaLoaded) { quotaLoaded = true; - const SubtitleTranslationService().fetchQuota().then((q) { + const service = SubtitleTranslationService(); + service.fetchQuota().then((q) { if (ctx.mounted) setSheet(() => quota = q); }); + final ref = _tmdbRef(); + if (ref != null) { + service.fetchReady( + tmdbId: ref.id, + type: ref.type, + season: _currentSeasonNumber(), + episode: _currentEpisodeNumber(), + ).then((r) { + if (ctx.mounted && r.isNotEmpty) setSheet(() => ready = r); + }); + } } Future<void> runSearch() async { final q = queryCtrl.text.trim(); @@ -417,6 +479,7 @@ extension _PlayerSubtitles on _PlayerPageState { ), const SizedBox(height: 10), _aiTranslateBanner(quota), + if (ready.isNotEmpty) _readyTranslations(sheetCtx, ready), const SizedBox(height: 8), ConstrainedBox( constraints: BoxConstraints(maxHeight: listMaxHeight), @@ -512,6 +575,85 @@ extension _PlayerSubtitles on _PlayerPageState { ); } + /// Translations other viewers already made for this episode. + /// + /// One tap loads a ready file straight from the cache — no waiting, no quota + /// spent — which is the fast path once a popular title has been translated + /// once. + Widget _readyTranslations(BuildContext sheetCtx, List<ReadySubtitle> ready) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 10, 16, 0), + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Icon(Icons.bolt_rounded, + size: 15, color: AppColors.primaryLight), + const SizedBox(width: 6), + Text('player.ready_translations'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700)), + ], + ), + ), + for (final r in ready) + ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: const Icon(Icons.subtitles_rounded, + size: 18, color: Colors.white70), + title: Text( + '${_langName(r.targetLang)} · ${r.cueCount} ${'player.lines'.tr()}', + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + trailing: const Icon(Icons.download_rounded, + size: 16, color: Colors.white38), + onTap: () { + Navigator.of(sheetCtx).pop(); + _applyReadyTranslation(r); + }, + ), + ], + ), + ); + } + + String _langName(String code) { + const map = { + 'uz': "O'zbekcha", 'ru': 'Русский', 'en': 'English', 'tr': 'Türkçe', + 'ar': 'العربية', 'de': 'Deutsch', 'fr': 'Français', 'es': 'Español', + }; + return map[code.toLowerCase()] ?? code.toUpperCase(); + } + + /// Loads a ready translation straight from its url. + Future<void> _applyReadyTranslation(ReadySubtitle r) async { + final entity = SubtitleEntity( + label: 'AI · ${r.targetLang.toUpperCase()}', + file: r.url, + ); + setState(() => _subtitles = [..._subtitles, entity]); + final added = _subtitles.length - 1; + final ok = await _loadSubtitle(added); + if (!mounted) return; + if (ok) { + _toast('player.subtitle_loaded'.tr(), icon: Icons.check_circle_rounded); + } else if (added < _subtitles.length && _activeSubtitleIndex != added) { + setState(() => _subtitles = [..._subtitles]..removeAt(added)); + } + } + /// The prominent AI-translate explainer at the top of the search sheet. /// /// Names the target language and shows how many translations are left today, @@ -724,7 +866,19 @@ extension _PlayerSubtitles on _PlayerPageState { setState(() => _captionFile = List<Caption>.from(translated)); } } - if (mounted) _toast('player.subtitle_loaded'.tr()); + if (mounted) _toast('player.subtitle_loaded'.tr(), icon: Icons.check_circle_rounded); + // Publish it so the next viewer of this episode loads it in one tap. + final ref = _tmdbRef(); + if (ref != null && applied == source.length) { + unawaited(service.publishReady( + tmdbId: ref.id, + type: ref.type, + season: _currentSeasonNumber(), + episode: _currentEpisodeNumber(), + targetLang: targetLang, + srt: _buildSrt(translated), + )); + } } on SubtitleDailyLimitReached catch (e) { if (mounted) { _toast(e.message); @@ -1478,12 +1632,12 @@ class _AiTranslateChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.auto_awesome_rounded, + Icon(Icons.auto_awesome_rounded, size: 13, color: AppColors.primaryLight), const SizedBox(width: 5), Text( 'AI → $lang', - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 11.5, fontWeight: FontWeight.w700, diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index 9b201320..3cf98e94 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -204,7 +204,7 @@ class _LoadingOverlay extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 360), child: ClipRRect( borderRadius: BorderRadius.circular(3), - child: const LinearProgressIndicator( + child: LinearProgressIndicator( minHeight: 3, backgroundColor: Colors.white12, valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary), @@ -546,7 +546,7 @@ class _EpisodeRow extends StatelessWidget { ), ), if (isActive) - const Icon( + Icon( Icons.play_arrow_rounded, color: AppColors.primary, size: 22, @@ -606,7 +606,7 @@ class _QualityRow extends StatelessWidget { ), child: Text( 'player.default'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 10, fontWeight: FontWeight.w700, diff --git a/lib/features/detail/presentation/widgets/detail_hero.dart b/lib/features/detail/presentation/widgets/detail_hero.dart index 7242c7ed..db457a73 100644 --- a/lib/features/detail/presentation/widgets/detail_hero.dart +++ b/lib/features/detail/presentation/widgets/detail_hero.dart @@ -40,10 +40,14 @@ class DetailHeroBackground extends StatelessWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, + // All three stops are the page background at falling + // opacity — that is what makes the poster dissolve INTO the + // page. The middle one used to be the literal #181818, which + // left a grey band hanging in mid-air under AMOLED. colors: [ AppColors.background, - const Color(0xEE181818), - const Color(0x00000000), + AppColors.background.withValues(alpha: 0.933), + AppColors.background.withValues(alpha: 0.0), ], stops: const [0.0, 0.5, 1.0], ), @@ -108,13 +112,13 @@ class _ThumbnailImage extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), CachedNetworkImage( imageUrl: url!, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 240), fadeInCurve: Curves.easeOut, - placeholder: (_, _) => const ColoredBox(color: AppColors.surfaceVariant), + placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => Container( color: AppColors.surfaceVariant, child: const Center( diff --git a/lib/features/detail/presentation/widgets/detail_info.dart b/lib/features/detail/presentation/widgets/detail_info.dart index 84a6dde1..5f54d740 100644 --- a/lib/features/detail/presentation/widgets/detail_info.dart +++ b/lib/features/detail/presentation/widgets/detail_info.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; import 'package:soplay/features/history/data/history_service.dart'; @@ -144,7 +145,7 @@ class _ContinueWatchingCard extends StatelessWidget { foregroundColor: Colors.black, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.play_arrow_rounded, size: 26), @@ -169,7 +170,7 @@ class _ContinueWatchingCard extends StatelessWidget { value: progress, minHeight: 3, backgroundColor: AppColors.surfaceVariant, - valueColor: const AlwaysStoppedAnimation(AppColors.primary), + valueColor: AlwaysStoppedAnimation(AppColors.primary), ), ), ], @@ -475,7 +476,7 @@ class _PlayButton extends StatelessWidget { backgroundColor: Colors.white, foregroundColor: Colors.black, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(kButtonRadius)), ), icon: Icon( reader ? Icons.menu_book_rounded : Icons.play_arrow_rounded, diff --git a/lib/features/detail/presentation/widgets/detail_more_menu.dart b/lib/features/detail/presentation/widgets/detail_more_menu.dart index 8c868f38..744c2582 100644 --- a/lib/features/detail/presentation/widgets/detail_more_menu.dart +++ b/lib/features/detail/presentation/widgets/detail_more_menu.dart @@ -407,12 +407,12 @@ class _DetailMoreMenuState extends State<_DetailMoreMenu> { width: 38, height: 54, fit: BoxFit.cover, - placeholder: (_, _) => const SizedBox( + placeholder: (_, _) => SizedBox( width: 38, height: 54, child: ColoredBox(color: AppColors.surfaceVariant), ), - errorWidget: (_, _, _) => const SizedBox( + errorWidget: (_, _, _) => SizedBox( width: 38, height: 54, child: ColoredBox(color: AppColors.surfaceVariant), @@ -522,7 +522,7 @@ class _DetailMoreMenuState extends State<_DetailMoreMenu> { : widget.onMoveToPrivate, ), ), - const Divider( + Divider( color: AppColors.divider, height: 13, indent: 16, @@ -574,7 +574,7 @@ class _DetailMoreMenuState extends State<_DetailMoreMenu> { label: 'detail.copy_link'.tr(), onTap: _copyLink, ), - const Divider( + Divider( color: AppColors.divider, height: 13, indent: 16, diff --git a/lib/features/detail/presentation/widgets/detail_related.dart b/lib/features/detail/presentation/widgets/detail_related.dart index 411a1ca6..a3d3a419 100644 --- a/lib/features/detail/presentation/widgets/detail_related.dart +++ b/lib/features/detail/presentation/widgets/detail_related.dart @@ -141,7 +141,7 @@ class _RelatedThumbnail extends StatelessWidget { imageUrl: url!, fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 180), - placeholder: (_, _) => const ColoredBox(color: AppColors.surfaceVariant), + placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => Container( color: AppColors.surfaceVariant, child: const Center( diff --git a/lib/features/detail/presentation/widgets/detail_screenshots.dart b/lib/features/detail/presentation/widgets/detail_screenshots.dart index 4949f17a..1bad3dc6 100644 --- a/lib/features/detail/presentation/widgets/detail_screenshots.dart +++ b/lib/features/detail/presentation/widgets/detail_screenshots.dart @@ -105,7 +105,7 @@ class _ScreenshotCard extends StatelessWidget { fit: BoxFit.cover, fadeInDuration: const Duration(milliseconds: 180), placeholder: (_, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => const Center( child: Icon( Icons.broken_image_outlined, diff --git a/lib/features/detail/presentation/widgets/player_engine_sheet.dart b/lib/features/detail/presentation/widgets/player_engine_sheet.dart index 75f12088..161e2ab6 100644 --- a/lib/features/detail/presentation/widgets/player_engine_sheet.dart +++ b/lib/features/detail/presentation/widgets/player_engine_sheet.dart @@ -6,6 +6,7 @@ import 'package:soplay/core/player/media_controller.dart' import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// Icon shown for each backend. Shared with Settings → Player so the row a /// user taps in the sheet is visually the same row they see in settings. @@ -170,7 +171,7 @@ Future<bool> showPlayerEngineSheet(BuildContext context) async { backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: () => Navigator.of(builderContext).pop(true), diff --git a/lib/features/download/presentation/pages/downloads_page.dart b/lib/features/download/presentation/pages/downloads_page.dart index 91fd49dd..717676c9 100644 --- a/lib/features/download/presentation/pages/downloads_page.dart +++ b/lib/features/download/presentation/pages/downloads_page.dart @@ -70,8 +70,10 @@ class _DownloadsPageState extends State<DownloadsPage> { }, child: Text( 'general.delete'.tr(), + // error, not primary: this wipes every download at once, and the + // single-row delete two screens down already reads as error. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontWeight: FontWeight.w700, ), ), @@ -203,7 +205,7 @@ class _DownloadsPageState extends State<DownloadsPage> { else SliverList.separated( itemCount: _items.length, - separatorBuilder: (_, _) => const Divider( + separatorBuilder: (_, _) => Divider( color: AppColors.divider, height: 1, indent: 82, @@ -277,8 +279,9 @@ class _PillButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Text( label, + // The page's only pill is "Clear all" — destructive, so error. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -440,7 +443,7 @@ class _DownloadRow extends StatelessWidget { value: item.progress, minHeight: 3, backgroundColor: AppColors.divider, - valueColor: const AlwaysStoppedAnimation<Color>( + valueColor: AlwaysStoppedAnimation<Color>( AppColors.primary, ), ), @@ -483,8 +486,11 @@ class _DownloadRow extends StatelessWidget { else if (item.status == DownloadStatus.failed) Text( 'downloads.failed'.tr(), + // Sits directly under the green "completed" label — + // a failure has to be the opposite colour, not the + // theme colour. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 11, fontWeight: FontWeight.w600, ), @@ -511,7 +517,7 @@ class _DownloadRow extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.15), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.refresh_rounded, color: AppColors.primary, size: 20, @@ -522,7 +528,7 @@ class _DownloadRow extends StatelessWidget { Container( width: 36, height: 36, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, shape: BoxShape.circle, ), diff --git a/lib/features/extensions/presentation/repo_file_import.dart b/lib/features/extensions/presentation/repo_file_import.dart index 1e26ee16..ba327bec 100644 --- a/lib/features/extensions/presentation/repo_file_import.dart +++ b/lib/features/extensions/presentation/repo_file_import.dart @@ -211,7 +211,7 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -240,7 +240,7 @@ class _RepoFileSheetState extends State<_RepoFileSheet> { color: AppColors.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(11), ), - child: const Icon(Icons.extension_rounded, + child: Icon(Icons.extension_rounded, color: AppColors.primary, size: 22), ), const SizedBox(width: 12), diff --git a/lib/features/history/presentation/pages/history_page.dart b/lib/features/history/presentation/pages/history_page.dart index a0ac323d..951ecee3 100644 --- a/lib/features/history/presentation/pages/history_page.dart +++ b/lib/features/history/presentation/pages/history_page.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/presentation/widgets/library_accents.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/data/history_sync_service.dart'; @@ -80,11 +81,17 @@ class _HistoryPageState extends State<HistoryPage> { .rememberClearedAll() .then((_) => _historyService.clearAll()) .then((_) => _syncService.sync()); + // Appearance suggests accents from these posters and caches the + // result for the app run. With the library gone, the cache is + // suggesting colours from titles the user just deleted. + LibraryAccents.invalidate(); }, child: Text( 'history.clear'.tr(), + // error, not primary: clearing history writes a tombstone, so + // sync cannot bring it back. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontWeight: FontWeight.w700, ), ), @@ -235,8 +242,9 @@ class _PillButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Text( label, + // The page's only pill is "Clear all" — destructive, so error. style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 12, fontWeight: FontWeight.w600, ), @@ -349,7 +357,7 @@ class _HistoryRow extends StatelessWidget { value: item.progress, minHeight: 3, backgroundColor: Colors.black45, - valueColor: const AlwaysStoppedAnimation<Color>( + valueColor: AlwaysStoppedAnimation<Color>( AppColors.primary, ), ), @@ -381,7 +389,7 @@ class _HistoryRow extends StatelessWidget { if (item.isSerial && item.episodeNumber != null) ...[ Text( 'EP ${item.episodeNumber}', - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.w700, @@ -430,7 +438,7 @@ class _HistoryRow extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.15), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.play_arrow_rounded, color: AppColors.primary, size: 20, diff --git a/lib/features/home/presentation/widgets/genre_card.dart b/lib/features/home/presentation/widgets/genre_card.dart index a28eee6c..e59843fe 100644 --- a/lib/features/home/presentation/widgets/genre_card.dart +++ b/lib/features/home/presentation/widgets/genre_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/system/responsive.dart'; +import '../../../../core/theme/app_colors.dart'; import '../../../search/domain/entities/genre_entity.dart'; import '../../domain/entities/view_all.dart'; import 'home_shared_widgets.dart'; @@ -43,6 +44,9 @@ class GenreCard extends StatelessWidget { borderRadius: BorderRadius.zero, placeholderIcon: Icons.category_outlined, ), + // The scrim leans onto the accent as it deepens, so a wall of + // genre thumbnails carries the chosen colour instead of being + // twelve identical black fades. DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -50,11 +54,19 @@ class GenreCard extends StatelessWidget { end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.18), - Colors.black.withValues(alpha: 0.72), + AppColors.primaryDark.withValues(alpha: 0.30), + Colors.black.withValues(alpha: 0.78), ], + stops: const [0, 0.55, 1], ), ), ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container(height: 2.5, color: AppColors.primary), + ), Positioned( left: desktop ? 12 : 8, right: desktop ? 12 : 8, diff --git a/lib/features/home/presentation/widgets/home_banner.dart b/lib/features/home/presentation/widgets/home_banner.dart index 5921b6ac..fdf8c609 100644 --- a/lib/features/home/presentation/widgets/home_banner.dart +++ b/lib/features/home/presentation/widgets/home_banner.dart @@ -320,8 +320,8 @@ class _SlideOverlays extends StatelessWidget { end: Alignment.topCenter, colors: [ AppColors.background, - const Color(0xBB181818), - const Color(0x00000000), + AppColors.background.withValues(alpha: 0.733), + AppColors.background.withValues(alpha: 0.0), ], stops: const [0.0, 0.52, 1.0], ), @@ -712,19 +712,21 @@ class _DesktopSlideState extends State<_DesktopSlide> ), ), ), - // Scrims for text legibility. - const Positioned.fill( + // Scrims for text legibility. Same rule as _EdgeBlend directly + // below: every stop is the page background at some opacity, so the + // scrim follows the theme instead of freezing at #181818. + Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ - Color(0xF2181818), - Color(0xCC181818), - Color(0x00181818), + AppColors.background.withValues(alpha: 0.949), + AppColors.background.withValues(alpha: 0.8), + AppColors.background.withValues(alpha: 0.0), ], - stops: [0.0, 0.42, 0.72], + stops: const [0.0, 0.42, 0.72], ), ), ), @@ -880,12 +882,16 @@ class _DesktopSlideState extends State<_DesktopSlide> class _EdgeBlend extends StatelessWidget { const _EdgeBlend(); - static const Color _bg = AppColors.background; - static const Color _clear = Color(0x00181818); - + /// Every stop is the page background at some opacity — that is the whole + /// trick, the card fades into whatever the feed sits on. So they are derived + /// from [AppColors.background] rather than written as literals: under AMOLED + /// the feed is true black, and a hard-coded #181818 edge would leave a grey + /// halo glowing around the hero. @override Widget build(BuildContext context) { - return const IgnorePointer( + final bg = AppColors.background; + final clear = bg.withValues(alpha: 0.0); + return IgnorePointer( child: Stack( fit: StackFit.expand, children: [ @@ -895,8 +901,8 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [_bg, Color(0xCC181818), _clear], - stops: [0.0, 0.14, 0.5], + colors: [bg, bg.withValues(alpha: 0.8), clear], + stops: const [0.0, 0.14, 0.5], ), ), ), @@ -910,7 +916,7 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.centerRight, end: Alignment.centerLeft, - colors: [Color(0xCC181818), _clear], + colors: [bg.withValues(alpha: 0.8), clear], ), ), ), @@ -925,7 +931,7 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, - colors: [Color(0xB3181818), _clear], + colors: [bg.withValues(alpha: 0.702), clear], ), ), ), @@ -940,7 +946,7 @@ class _EdgeBlend extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0x99181818), _clear], + colors: [bg.withValues(alpha: 0.6), clear], ), ), ), @@ -974,9 +980,12 @@ class _BannerButtonState extends State<_BannerButton> { @override Widget build(BuildContext context) { final primary = widget.primary; + // Play stays white — it sits on artwork and needs the highest contrast + // there is. The second action is where the accent belongs: tinted glass, + // so the row says which theme is on without ever fighting the poster. final bg = primary ? Colors.white - : Colors.white.withValues(alpha: _hover ? 0.28 : 0.18); + : AppColors.primary.withValues(alpha: _hover ? 0.46 : 0.30); final fg = primary ? Colors.black : Colors.white; return MouseRegion( diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 0dbad68f..58c01df5 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -7,6 +7,7 @@ import 'package:soplay/core/navigation/nav_controller.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/core/theme/app_theme.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/banners/domain/entities/banner_item.dart'; import 'package:soplay/features/banners/presentation/bloc/banners_bloc.dart'; @@ -472,7 +473,7 @@ class _TelegramPromoSheetState extends State<_TelegramPromoSheet> { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Row( diff --git a/lib/features/home/presentation/widgets/home_history_section.dart b/lib/features/home/presentation/widgets/home_history_section.dart index 03717a46..805e7232 100644 --- a/lib/features/home/presentation/widgets/home_history_section.dart +++ b/lib/features/home/presentation/widgets/home_history_section.dart @@ -156,7 +156,7 @@ class _HistoryCard extends StatelessWidget { ), const SizedBox(height: 8), ListTile( - leading: const Icon( + leading: Icon( Icons.play_arrow_rounded, color: AppColors.primary, ), @@ -279,7 +279,7 @@ class _HistoryCard extends StatelessWidget { child: Container( width: 28, height: 28, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -299,7 +299,7 @@ class _HistoryCard extends StatelessWidget { value: item.progress, minHeight: 3, backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation<Color>( + valueColor: AlwaysStoppedAnimation<Color>( AppColors.primary, ), ), diff --git a/lib/features/home/presentation/widgets/home_movie_section.dart b/lib/features/home/presentation/widgets/home_movie_section.dart index 4c3ae846..f6ae7ba7 100644 --- a/lib/features/home/presentation/widgets/home_movie_section.dart +++ b/lib/features/home/presentation/widgets/home_movie_section.dart @@ -50,17 +50,28 @@ class MovieSection extends StatelessWidget { padding: const EdgeInsets.fromLTRB(17, 18, 20, 14), child: Row( children: [ - if (isHighlighted) ...[ - Container( - width: 3, - height: 17, - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(2), + // The accent tick is on EVERY row now, not only the + // highlighted one — it is the mark that carries the chosen + // colour down the whole of Home. Highlighted rows keep their + // distinction by being taller and gradient-filled. + Container( + width: 3, + height: isHighlighted ? 19 : 15, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isHighlighted + ? [AppColors.primaryLight, AppColors.primary] + : [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.55), + ], ), + borderRadius: BorderRadius.circular(2), ), - const SizedBox(width: 10), - ], + ), + const SizedBox(width: 10), Expanded( child: Text( title, @@ -74,9 +85,11 @@ class MovieSection extends StatelessWidget { ), ), ), - const Icon( + Icon( Icons.chevron_right_rounded, - color: AppColors.textHint, + color: isHighlighted + ? AppColors.primary + : AppColors.textHint, size: 22, ), ], diff --git a/lib/features/home/presentation/widgets/home_state_views.dart b/lib/features/home/presentation/widgets/home_state_views.dart index 2e9a20de..27ee6b8b 100644 --- a/lib/features/home/presentation/widgets/home_state_views.dart +++ b/lib/features/home/presentation/widgets/home_state_views.dart @@ -241,7 +241,7 @@ class HomeErrorView extends StatelessWidget { onPressed: () => _solveCloudflare(context), style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), ), icon: const Icon(Icons.shield_outlined, size: 18), label: Text('cloudflare.solve'.tr()), diff --git a/lib/features/home/presentation/widgets/home_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index d99136fe..3678b78f 100644 --- a/lib/features/home/presentation/widgets/home_top_bar.dart +++ b/lib/features/home/presentation/widgets/home_top_bar.dart @@ -1,15 +1,15 @@ -import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; import 'dart:async'; 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/error/result.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'; import 'package:soplay/core/theme/app_colors.dart'; @@ -23,8 +23,9 @@ 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/watch_party/presentation/party_entry.dart'; class HomeTopBar extends StatelessWidget { const HomeTopBar({super.key, required this.blurProgress}); @@ -36,11 +37,16 @@ class HomeTopBar extends StatelessWidget { final topPad = MediaQuery.of(context).padding.top; final progress = blurProgress.clamp(0.0, 1.0); - // Eight targets — wordmark, source pill, streak, and five actions — do not - // fit a phone at full spacing; the row overflowed by 18px on a 411dp - // screen. The space comes out of the wordmark and the icon gaps, NOT out of - // the source name: which source is live is the one piece of state in this - // bar, and a bare logo does not say it. + // 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. final compact = MediaQuery.sizeOf(context).width < 430; final iconPad = compact ? 6.0 : 8.0; @@ -49,33 +55,17 @@ class HomeTopBar extends StatelessWidget { color: AppColors.textPrimary, onRefresh: () => context.read<HomeBloc>().add(HomeLoad(silent: true)), ), - _TopBarIcon( - icon: Icons.groups_rounded, - pad: iconPad, - onTap: () => showPartyEntrySheet(context), - ), - _TopBarIcon( - icon: Icons.search_rounded, - pad: iconPad, - onTap: () => getIt<NavController>().goToId(TabId.search), - ), - // AniList lived four taps deep behind the profile. Discovery and the - // airing calendar are things people open daily, and a tracker nobody - // can find is a tracker nobody connects. - _TopBarIcon.custom( - pad: iconPad, - child: const AnilistLogo(size: 19, radius: 5), - onTap: () => context.push('/anilist'), - ), - // Live TV was reachable only through the profile, which is where - // things go to be forgotten. It is a line-up you open and leave, not - // a setting. - _TopBarIcon( - icon: Icons.live_tv_rounded, - pad: iconPad, - onTap: () => context.push('/live-tv'), - ), + // 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), ]; @@ -98,8 +88,6 @@ class HomeTopBar extends StatelessWidget { constraints: BoxConstraints(maxWidth: compact ? 116 : 170), child: const _ProviderSwitcher(), ), - const SizedBox(width: 8), - const StreakBadge(), // 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. @@ -346,7 +334,7 @@ class _ProviderQuickSwitchSheet extends StatelessWidget { ), ), ), - const Divider(color: AppColors.divider, height: 1), + Divider(color: AppColors.divider, height: 1), ListTile( leading: Container( width: 36, @@ -402,7 +390,7 @@ Widget _favoriteProviderTile( ), ), trailing: selected - ? const Icon(Icons.check_rounded, color: AppColors.primary, size: 20) + ? Icon(Icons.check_rounded, color: AppColors.primary, size: 20) : null, onTap: () => Navigator.of(context).pop(p.id), ); @@ -444,6 +432,88 @@ class _ProviderLogo extends StatelessWidget { } } +/// 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<StreakService>(); + final loggedIn = getIt<HiveService>().isLoggedIn; + return ValueListenableBuilder<StreakState>( + 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<NavController>().goToId(TabId.search)) { + context.push('/cross-search'); + } + }, + child: const Icon( + Icons.search_rounded, + color: Colors.white, + size: 24, + ), + ); + }, + ); + } +} + class _NotificationsIndicator extends StatefulWidget { const _NotificationsIndicator({this.pad = 8}); @@ -601,50 +671,6 @@ class _NotificationsIndicatorState extends State<_NotificationsIndicator> } } -class _TopBarIcon extends StatelessWidget { - const _TopBarIcon({ - required IconData this.icon, - required this.onTap, - this.pad = 8, - }) : child = null; - - /// For a brand mark, which is an image rather than an icon font glyph. - const _TopBarIcon.custom({ - required Widget this.child, - required this.onTap, - this.pad = 8, - }) : icon = null; - - final IconData? icon; - final Widget? child; - final VoidCallback onTap; - - /// Horizontal breathing room, tightened on phones where the row is full. - final double pad; - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(24), - onTap: onTap, - child: Padding( - // Vertical 10 (not 8): a 40dp target was under the minimum, and - // widening it instead would overflow the row on a small phone. - padding: EdgeInsets.symmetric(horizontal: pad, vertical: 10), - // Sized to match the icons beside it, so the row stays even. - child: SizedBox( - width: 24, - height: 24, - child: child ?? Icon(icon, color: Colors.white, size: 24), - ), - ), - ), - ); - } -} - class _DownloadIndicator extends StatefulWidget { const _DownloadIndicator({this.pad = 8}); diff --git a/lib/features/home/presentation/widgets/view_all_widgets.dart b/lib/features/home/presentation/widgets/view_all_widgets.dart index f350330d..45caea06 100644 --- a/lib/features/home/presentation/widgets/view_all_widgets.dart +++ b/lib/features/home/presentation/widgets/view_all_widgets.dart @@ -120,7 +120,7 @@ class ViewAllGrid extends StatelessWidget { ), ), if (state.isLoadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( diff --git a/lib/features/link_tv/presentation/pages/link_tv_page.dart b/lib/features/link_tv/presentation/pages/link_tv_page.dart index d8b19901..2f382078 100644 --- a/lib/features/link_tv/presentation/pages/link_tv_page.dart +++ b/lib/features/link_tv/presentation/pages/link_tv_page.dart @@ -237,7 +237,7 @@ class _ScannerPlaceholder extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.qr_code_scanner, + Icon(Icons.qr_code_scanner, size: 64, color: AppColors.primary), const SizedBox(height: 16), Text('link_tv.scan_button'.tr(), @@ -494,7 +494,7 @@ class _DeviceRow extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: ListTile( - leading: const Icon(Icons.tv, color: AppColors.primary), + leading: Icon(Icons.tv, color: AppColors.primary), title: Text( device.deviceName?.isNotEmpty == true ? device.deviceName! diff --git a/lib/features/live_tv/presentation/pages/live_tv_page.dart b/lib/features/live_tv/presentation/pages/live_tv_page.dart index 2c6653fc..e4d10187 100644 --- a/lib/features/live_tv/presentation/pages/live_tv_page.dart +++ b/lib/features/live_tv/presentation/pages/live_tv_page.dart @@ -597,7 +597,7 @@ class _LivePill extends StatelessWidget { Container( width: 5, height: 5, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -842,7 +842,7 @@ class _ChannelCard extends StatelessWidget { ), ), if (favourite) - const Positioned( + Positioned( top: 6, right: 6, child: Icon( diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index b2e021fd..8ba877d5 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -25,6 +25,13 @@ import 'package:showcaseview/showcaseview.dart'; import '../../../../core/navigation/app_tab.dart'; import '../../../../core/navigation/nav_controller.dart'; +/// Addressed by name, never through `ShowcaseView.get()`: that returns the most recently +/// registered instance, so opening a detail page — which registers its own private scope — +/// silently rebound this page's showcases to it. When the detail page went away it took the +/// 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}); @@ -84,6 +91,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { _navController.index.addListener(_onNavChange); WidgetsBinding.instance.addObserver(this); ShowcaseView.register( + scope: _showcaseScope, blurValue: 1.5, overlayColor: Colors.black, overlayOpacity: 0.76, @@ -136,7 +144,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { WidgetsBinding.instance.removeObserver(this); _navController.index.removeListener(_onNavChange); NavPrefs.tabOrder.removeListener(_onTabSetChange); - ShowcaseView.get().unregister(); + ShowcaseView.getNamed(_showcaseScope).unregister(); _tvRailScope.dispose(); for (final n in _tvTabScopes.values) { n.dispose(); @@ -205,7 +213,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { _shortsShowcaseStarted = false; return; } - ShowcaseView.get().startShowCase([_shortsRefreshShowcaseKey]); + ShowcaseView.getNamed(_showcaseScope).startShowCase([_shortsRefreshShowcaseKey]); }); }); } @@ -463,7 +471,10 @@ class _SoplayGlassCapsule extends StatelessWidget { // The rim is deliberately quiet: a bright specular edge on a dark capsule // reads as a drawn outline rather than as glass, so the highlight, the // fresnel glow and the refraction band are all kept thin. - static const _glassSettings = LiquidGlassSettings( + // Not const: `backerColor` is the page background, and that is a runtime + // value now — on AMOLED the pad has to go to true black with the rest of the + // app instead of staying a #1A1A1A slab floating on nothing. + static LiquidGlassSettings get _glassSettings => LiquidGlassSettings( thickness: 14, // narrower edge band → a finer rim, not a fat outline blur: 5, // less haze behind the bar → cleaner, not muddy chromaticAberration: 0.08, // barely-there edge tint @@ -472,14 +483,16 @@ class _SoplayGlassCapsule extends StatelessWidget { lightIntensity: 0.6, // soft top-edge sheen instead of a hard stroke ambientStrength: 1, glowIntensity: 0.25, // faint luminous rim - glassColor: Color(0x12FFFFFF), // faint white sheen - backerColor: Color(0xE61A1A1A), // near-solid dark pad → premium, not hazy + glassColor: const Color(0x12FFFFFF), // faint white sheen + // Near-solid pad → premium, not hazy. #181818 at 90% on the default theme, + // which is where the old #1A1A1A literal sat. + backerColor: AppColors.background.withValues(alpha: 0.902), whitenStrength: 0.0, ); // Solid (glass off): a near-opaque dark pad, no blur/refraction — the cheap, // always-smooth path. - static const _solidSettings = LiquidGlassSettings( + static LiquidGlassSettings get _solidSettings => LiquidGlassSettings( thickness: 0, blur: 0, chromaticAberration: 0, @@ -487,8 +500,8 @@ class _SoplayGlassCapsule extends StatelessWidget { saturation: 1, lightIntensity: 0, ambientStrength: 0, - glassColor: Color(0x00000000), - backerColor: Color(0xF01A1A1A), // ~94% solid dark + glassColor: const Color(0x00000000), + backerColor: AppColors.background.withValues(alpha: 0.941), // ~94% solid whitenStrength: 0.0, ); @@ -514,19 +527,33 @@ class _SoplayGlassCapsule extends StatelessWidget { barBorderRadius: _barHeight / 2, // full capsule magnification: glass ? 1.12 : 1.0, // subtle iOS-26 lens on the selected tab indicatorPinchStrength: glass ? 0.3 : 0.0, - // Selected-tab pill: a soft, restrained light pill on the dark body. - indicatorColor: Color(glass ? 0x22FFFFFF : 0x18FFFFFF), + // 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 + // a deliberate part of the shipped design, so it changes only when asked. + indicatorColor: AppColors.isNavTinted + ? AppColors.primary.withValues(alpha: glass ? 0.26 : 0.20) + : Color(glass ? 0x22FFFFFF : 0x18FFFFFF), quality: glass ? null : GlassQuality.minimal, settings: glass ? _glassSettings : _solidSettings, - selectedIconColor: Colors.white, + // 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, unselectedIconColor: const Color(0xFF7A7A7A), - selectedLabelColor: Colors.white, + selectedLabelColor: + AppColors.isNavTinted ? AppColors.primaryLight : Colors.white, unselectedLabelColor: const Color(0xFF7A7A7A), labelFontSize: 10.5, ); // The package drop shadow is light-mode only, so paint our own soft capsule // shadow behind the glass for the "floating" look on the dark UI. + // + // A black shadow only separates the bar from a background that is lighter + // than black. Under AMOLED both are #000000 and the capsule dissolves into + // the page, so there it swaps to a faint light halo — the only direction a + // true-black page leaves to work in. final shadowed = Stack( children: [ Positioned.fill( @@ -534,13 +561,19 @@ class _SoplayGlassCapsule extends StatelessWidget { child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_barHeight / 2), - boxShadow: const [ - BoxShadow( - color: Color(0x66000000), - blurRadius: 24, - spreadRadius: -4, - offset: Offset(0, 10), - ), + boxShadow: [ + AppColors.isBlack + ? BoxShadow( + color: Colors.white.withValues(alpha: 0.10), + blurRadius: 18, + spreadRadius: -6, + ) + : const BoxShadow( + color: Color(0x66000000), + blurRadius: 24, + spreadRadius: -4, + offset: Offset(0, 10), + ), ], ), ), @@ -598,9 +631,10 @@ class _SoplayClassicBar extends StatelessWidget { child: Container( height: 68 + bottomPad, decoration: BoxDecoration( - // A translucent frosted grey (not near-black) so content shows - // through the blur and the bar reads as frosted glass. - color: const Color(0xFF262626).withValues(alpha: 0.72), + // A translucent frosted surface (not near-black) so content shows + // through the blur and the bar reads as frosted glass. Follows the + // palette, so AMOLED gets a genuinely black frosted bar. + color: AppColors.surface.withValues(alpha: 0.72), borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), border: Border( top: BorderSide( @@ -668,7 +702,9 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { @override Widget build(BuildContext context) { - final color = widget.selected ? Colors.white : const Color(0xFF7A7A7A); + final selectedColor = + AppColors.isNavTinted ? AppColors.primaryLight : Colors.white; + final color = widget.selected ? selectedColor : const Color(0xFF7A7A7A); final button = Semantics( button: true, @@ -708,7 +744,7 @@ class _ClassicNavButtonState extends State<_ClassicNavButton> { shadows: widget.selected ? [ Shadow( - color: Colors.white.withValues(alpha: 0.28), + color: selectedColor.withValues(alpha: 0.28), blurRadius: 10, ), ] @@ -793,7 +829,7 @@ class _SoplayFloatingNav extends StatelessWidget { child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: const Color(0xFF262626).withValues(alpha: 0.72), + color: AppColors.surface.withValues(alpha: 0.72), borderRadius: radius, border: Border.all( color: Colors.white.withValues(alpha: 0.10), @@ -937,7 +973,7 @@ class _TvNavRailState extends State<_TvNavRail> { : _TvNavRail.collapsedWidth, decoration: BoxDecoration( color: AppColors.navBackground, - border: const Border( + border: Border( right: BorderSide(color: AppColors.border, width: 0.6), ), boxShadow: _expanded @@ -1088,7 +1124,7 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { width: 276, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: const Color(0xFF121212).withValues(alpha: 0.96), + color: AppColors.navBackground.withValues(alpha: 0.96), borderRadius: BorderRadius.circular(18), border: Border.all(color: Colors.white.withValues(alpha: 0.10)), boxShadow: [ @@ -1114,14 +1150,14 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { height: 34, decoration: BoxDecoration( shape: BoxShape.circle, - gradient: const LinearGradient( - colors: [Color(0xFFE53935), Color(0xFFB71C1C)], + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.primaryDark], begin: Alignment.topLeft, end: Alignment.bottomRight, ), boxShadow: [ BoxShadow( - color: const Color(0xFFE53935).withValues(alpha: 0.35), + color: AppColors.primary.withValues(alpha: 0.35), blurRadius: 14, ), ], @@ -1176,7 +1212,7 @@ class _ShortsRefreshShowcaseCard extends StatelessWidget { ), const Spacer(), GestureDetector( - onTap: () => ShowcaseView.get().dismiss(), + onTap: () => ShowcaseView.getNamed(_showcaseScope).dismiss(), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, diff --git a/lib/features/mal/presentation/widgets/mal_entry_sheet.dart b/lib/features/mal/presentation/widgets/mal_entry_sheet.dart index 2e242bcb..431745d5 100644 --- a/lib/features/mal/presentation/widgets/mal_entry_sheet.dart +++ b/lib/features/mal/presentation/widgets/mal_entry_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/core/theme/app_theme.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; import 'package:soplay/features/mal/domain/entities/mal_entities.dart'; import 'package:soplay/features/mal/presentation/controllers/mal_library_controller.dart'; @@ -124,7 +125,7 @@ class _MalEntrySheetState extends State<MalEntrySheet> { return SafeArea( top: false, child: Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), @@ -256,10 +257,10 @@ class _MalEntrySheetState extends State<MalEntrySheet> { onPressed: busy ? null : () => _confirmRemove(entry), style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, - side: const BorderSide(color: AppColors.border), + side: BorderSide(color: AppColors.border), padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(11), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.delete_outline_rounded, size: 18), diff --git a/lib/features/mal/presentation/widgets/mal_link_sheet.dart b/lib/features/mal/presentation/widgets/mal_link_sheet.dart index b0eabfe5..475e47e1 100644 --- a/lib/features/mal/presentation/widgets/mal_link_sheet.dart +++ b/lib/features/mal/presentation/widgets/mal_link_sheet.dart @@ -169,7 +169,7 @@ class _MalLinkSheetState extends State<MalLinkSheet> { maxChildSize: 0.95, expand: false, builder: (context, scrollController) => Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/features/manga/presentation/pages/manga_source_settings_page.dart b/lib/features/manga/presentation/pages/manga_source_settings_page.dart index df201812..f450100f 100644 --- a/lib/features/manga/presentation/pages/manga_source_settings_page.dart +++ b/lib/features/manga/presentation/pages/manga_source_settings_page.dart @@ -19,7 +19,9 @@ class MangaSourceSettingsPage extends StatefulWidget { } class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; List<Map<String, dynamic>> _prefs = const []; bool _loading = true; @@ -58,7 +60,7 @@ class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { title: Text(widget.name), ), body: _loading - ? const Center( + ? Center( child: CircularProgressIndicator(color: _accent, strokeWidth: 2)) : _prefs.isEmpty ? Center( @@ -193,7 +195,7 @@ class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { style: const TextStyle(color: Colors.white, fontSize: 14)), trailing: isSelected - ? const Icon(Icons.check, color: _accent, size: 20) + ? Icon(Icons.check, color: _accent, size: 20) : null, onTap: () => Navigator.of(context).pop(value), ); @@ -276,7 +278,7 @@ class _MangaSourceSettingsPageState extends State<MangaSourceSettingsPage> { controller: controller, autofocus: true, style: const TextStyle(color: Colors.white), - decoration: const InputDecoration( + decoration: InputDecoration( hintStyle: TextStyle(color: AppColors.textHint), enabledBorder: UnderlineInputBorder( borderSide: BorderSide(color: Colors.white24)), diff --git a/lib/features/manga/presentation/pages/manga_sources_page.dart b/lib/features/manga/presentation/pages/manga_sources_page.dart index cb3a083e..4ecd7d06 100644 --- a/lib/features/manga/presentation/pages/manga_sources_page.dart +++ b/lib/features/manga/presentation/pages/manga_sources_page.dart @@ -24,7 +24,9 @@ class MangaSourcesPage extends StatefulWidget { class _MangaSourcesPageState extends State<MangaSourcesPage> { static const String _logo = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcShNP_m0078YcYRUbudCuZhohC2U143Re4MfQ&s'; - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; late final HiveService _hive = getIt<HiveService>(); @@ -264,7 +266,7 @@ class _MangaSourcesPageState extends State<MangaSourcesPage> { const SizedBox(height: 24), Row( children: [ - const Icon(Icons.tune, size: 15, color: _accent), + Icon(Icons.tune, size: 15, color: _accent), const SizedBox(width: 6), Text('manga.source_settings'.tr(), style: Theme.of(context).textTheme.labelSmall?.copyWith( @@ -415,7 +417,7 @@ class _MangaSourcesPageState extends State<MangaSourcesPage> { decoration: InputDecoration( labelText: 'manga.repo_url'.tr(), labelStyle: const TextStyle(color: AppColors.textHint), - floatingLabelStyle: const TextStyle(color: _accent), + floatingLabelStyle: TextStyle(color: _accent), hintText: 'https://…/index.min.json', hintStyle: const TextStyle(color: AppColors.textHint), filled: true, diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index 15e7283b..4804110d 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -23,6 +23,7 @@ import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/domain/entities/history_item.dart'; import 'package:soplay/features/manga/domain/entities/manga_page_entity.dart'; import 'package:soplay/features/manga/domain/entities/reader_args.dart'; +import 'package:soplay/core/theme/app_colors.dart'; class ReaderPage extends StatefulWidget { final ReaderArgs args; @@ -33,7 +34,9 @@ class ReaderPage extends StatefulWidget { } class _ReaderPageState extends State<ReaderPage> { - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; final _hive = getIt<HiveService>(); final _downloads = getIt<DownloadService>(); @@ -400,7 +403,7 @@ class _ReaderPageState extends State<ReaderPage> { Widget _content() { if (_loading) { - return const Center( + return Center( child: CircularProgressIndicator(color: _accent, strokeWidth: 2), ); } @@ -1033,7 +1036,9 @@ class _PageImage extends StatefulWidget { } class _PageImageState extends State<_PageImage> { - static const Color _accent = Color(0xFF5B8DEF); + /// The manga screens used to carry their own private blue. There is one + /// accent in the app now, and the user chooses it. + static Color get _accent => AppColors.primary; int _retry = 0; /// Chapter headers plus this page's host-scoped cookies, if it has any. @@ -1077,7 +1082,7 @@ class _PageImageState extends State<_PageImage> { width: width, height: width * 1.4, color: Colors.white.withValues(alpha: 0.02), - child: const Center( + child: Center( child: CircularProgressIndicator(color: _accent, strokeWidth: 1.8), ), diff --git a/lib/features/my_list/presentation/widgets/my_list_background.dart b/lib/features/my_list/presentation/widgets/my_list_background.dart index 4c65367c..c36d583f 100644 --- a/lib/features/my_list/presentation/widgets/my_list_background.dart +++ b/lib/features/my_list/presentation/widgets/my_list_background.dart @@ -11,7 +11,15 @@ class MyListBackground extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [const Color(0xFF161616), AppColors.background, const Color(0xFF101010)], + // The old literals were #161616 and #101010 — a shade under the + // background and the same fall-off Profile's backdrop uses. Derived + // now, so the page goes true black with everything else instead of + // staying a grey slab. + colors: [ + Color.lerp(AppColors.background, Colors.black, 0.083)!, + AppColors.background, + AppColors.heroBottom, + ], stops: const [0, 0.42, 1], ), ), diff --git a/lib/features/network/presentation/pages/no_internet_page.dart b/lib/features/network/presentation/pages/no_internet_page.dart index c7476e9d..23447eaa 100644 --- a/lib/features/network/presentation/pages/no_internet_page.dart +++ b/lib/features/network/presentation/pages/no_internet_page.dart @@ -65,7 +65,7 @@ class _NoInternetPageState extends State<NoInternetPage> { color: AppColors.surface, borderRadius: BorderRadius.circular(8), ), - child: const Icon( + child: Icon( Icons.wifi_off_rounded, color: AppColors.primary, size: 26, @@ -117,7 +117,7 @@ class _NoInternetPageState extends State<NoInternetPage> { label: Text('navigation.downloads'.tr()), style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, - side: const BorderSide(color: AppColors.divider), + side: BorderSide(color: AppColors.divider), ), ), ), diff --git a/lib/features/notifications/presentation/pages/notifications_page.dart b/lib/features/notifications/presentation/pages/notifications_page.dart index fcee0bee..c930d0a9 100644 --- a/lib/features/notifications/presentation/pages/notifications_page.dart +++ b/lib/features/notifications/presentation/pages/notifications_page.dart @@ -97,7 +97,7 @@ class _NotificationsViewState extends State<_NotificationsView> { .add(const NotificationsMarkAllRead()), child: Text( 'notifications.mark_all_read'.tr(), - style: const TextStyle(color: AppColors.primary), + style: TextStyle(color: AppColors.primary), ), ); }, @@ -107,7 +107,7 @@ class _NotificationsViewState extends State<_NotificationsView> { body: BlocBuilder<NotificationsBloc, NotificationsState>( builder: (context, state) { if (state.loading && state.items.isEmpty) { - return const Center( + return Center( child: CircularProgressIndicator(color: AppColors.primary), ); } @@ -142,7 +142,7 @@ class _NotificationsViewState extends State<_NotificationsView> { separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { if (index >= state.items.length) { - return const Padding( + return Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: CircularProgressIndicator(color: AppColors.primary), @@ -340,10 +340,10 @@ class _NotificationTile extends StatelessWidget { CachedNetworkImage( imageUrl: item.imageUrl!, fit: BoxFit.cover, - placeholder: (_, _) => const ColoredBox( + placeholder: (_, _) => ColoredBox( color: AppColors.surfaceVariant, ), - errorWidget: (_, _, _) => const ColoredBox( + errorWidget: (_, _, _) => ColoredBox( color: AppColors.surfaceVariant, ), ), @@ -354,7 +354,7 @@ class _NotificationTile extends StatelessWidget { child: Container( width: 10, height: 10, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -413,7 +413,7 @@ class _NotificationTile extends StatelessWidget { width: 8, height: 8, margin: const EdgeInsets.only(top: 6, right: 10), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -564,7 +564,7 @@ class _ErrorView extends StatelessWidget { onPressed: onRetry, child: Text( 'general.retry'.tr(), - style: const TextStyle(color: AppColors.primary), + style: TextStyle(color: AppColors.primary), ), ), ], diff --git a/lib/features/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 30b403a1..94103f5f 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -81,18 +81,21 @@ class _OnboardingPageState extends State<OnboardingPage> { fit: StackFit.expand, children: [ _Backdrops(controller: _pageController, page: _page), - const DecoratedBox( + DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, + // The last two stops are the page background arriving — the + // third used to be the literal #181818, which under AMOLED + // left the poster fading into grey and then jumping to black. colors: [ - Color(0xB3000000), - Color(0x33000000), - Color(0xF2181818), + const Color(0xB3000000), + const Color(0x33000000), + AppColors.background.withValues(alpha: 0.949), AppColors.background, ], - stops: [0, 0.28, 0.62, 0.8], + stops: const [0, 0.28, 0.62, 0.8], ), ), ), @@ -107,7 +110,7 @@ class _OnboardingPageState extends State<OnboardingPage> { children: [ Text( 'app_name'.tr().toUpperCase(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 22, fontWeight: FontWeight.w900, diff --git a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart index 839bc185..7fca60a8 100644 --- a/lib/features/onboarding/presentation/widgets/anime_ribbons.dart +++ b/lib/features/onboarding/presentation/widgets/anime_ribbons.dart @@ -142,7 +142,7 @@ class _Ribbon extends StatelessWidget { errorBuilder: (_, _, _) => SizedBox( width: tileWidth, height: tileHeight, - child: const ColoredBox(color: AppColors.surface), + child: ColoredBox(color: AppColors.surface), ), ), ), diff --git a/lib/features/onboarding/presentation/widgets/tv_showcase.dart b/lib/features/onboarding/presentation/widgets/tv_showcase.dart index c3e45f2d..69a8cfe4 100644 --- a/lib/features/onboarding/presentation/widgets/tv_showcase.dart +++ b/lib/features/onboarding/presentation/widgets/tv_showcase.dart @@ -149,7 +149,7 @@ class _Television extends StatelessWidget { decoration: BoxDecoration( color: const Color(0xFF0C0C0C), borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF3A3A3A), width: 1.4), + border: Border.all(color: AppColors.border, width: 1.4), boxShadow: const [ // Two shadows: one grounds the set, the faint wide one is the // screen bleeding onto the wall behind it. @@ -231,7 +231,7 @@ class _ScreenContent extends StatelessWidget { height: constraints.maxHeight, cacheWidth: width, errorBuilder: (_, _, _) => - const ColoredBox(color: AppColors.surface), + ColoredBox(color: AppColors.surface), ), ), ); @@ -279,7 +279,7 @@ class _LiveBadge extends StatelessWidget { child: Container( width: 6, height: 6, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), @@ -311,12 +311,12 @@ class _Stand extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Container(width: 14, height: 14, color: const Color(0xFF2A2A2A)), + Container(width: 14, height: 14, color: AppColors.divider), Container( width: 86, height: 5, decoration: BoxDecoration( - color: const Color(0xFF3A3A3A), + color: AppColors.border, borderRadius: BorderRadius.circular(3), ), ), diff --git a/lib/features/private_list/presentation/pages/private_list_page.dart b/lib/features/private_list/presentation/pages/private_list_page.dart index f18b0b4b..1fd69706 100644 --- a/lib/features/private_list/presentation/pages/private_list_page.dart +++ b/lib/features/private_list/presentation/pages/private_list_page.dart @@ -43,7 +43,7 @@ class PrivateListPage extends StatelessWidget { shape: BoxShape.circle, color: AppColors.primary.withValues(alpha: 0.12), ), - child: const Icon( + child: Icon( Icons.lock_outline_rounded, color: AppColors.primary, size: 30, diff --git a/lib/features/private_list/presentation/pages/private_unlock_page.dart b/lib/features/private_list/presentation/pages/private_unlock_page.dart index 2b840818..ef796b29 100644 --- a/lib/features/private_list/presentation/pages/private_unlock_page.dart +++ b/lib/features/private_list/presentation/pages/private_unlock_page.dart @@ -88,7 +88,7 @@ class _PrivateUnlockViewState extends State<_PrivateUnlockView> { color: AppColors.primary.withValues(alpha: 0.12), shape: BoxShape.circle, ), - child: const Icon( + child: Icon( Icons.lock_rounded, color: AppColors.primary, size: 30, diff --git a/lib/features/profile/presentation/pages/appearance_page.dart b/lib/features/profile/presentation/pages/appearance_page.dart new file mode 100644 index 00000000..a9f6a32a --- /dev/null +++ b/lib/features/profile/presentation/pages/appearance_page.dart @@ -0,0 +1,1146 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_palette.dart'; +import 'package:soplay/core/theme/theme_controller.dart'; +import 'package:soplay/features/profile/presentation/widgets/library_accents.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; +import 'package:soplay/features/profile/presentation/widgets/theme_preview.dart'; + +/// Settings → Appearance. +/// +/// Two settings, both of which repaint the entire app the instant they are +/// touched: the accent colour and whether the neutrals go to true black. +/// +/// Everything on this page is deliberately shown rather than described. The +/// accent is a row of colours *and* a full miniature of the app; the darkness +/// choice is two miniatures side by side. Nothing here asks the user to imagine +/// what "AMOLED" will do to a screen they are not currently looking at. +class AppearancePage extends StatelessWidget { + const AppearancePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + 'appearance.title'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + actions: const [_ResetAction()], + ), + body: const SingleChildScrollView( + padding: EdgeInsets.only(top: 4, bottom: 40), + child: AppearanceSettings(), + ), + ); + } +} + +/// The body of [AppearancePage], on its own so the desktop Profile can drop it +/// straight into its "Appearance" panel instead of keeping a second copy. +class AppearanceSettings extends StatefulWidget { + const AppearanceSettings({super.key, this.showResetRow = false}); + + /// Desktop has no app bar to hang the reset action off, so it gets a row. + final bool showResetRow; + + @override + State<AppearanceSettings> createState() => _AppearanceSettingsState(); +} + +class _AppearanceSettingsState extends State<AppearanceSettings> { + final ThemeController _theme = getIt<ThemeController>(); + + /// Held in the State, not started in build(): this page rebuilds on every + /// colour tap, and a future created in build() would re-decode five posters + /// each time. + final Future<List<AppAccent>> _libraryAccents = LibraryAccents.load(); + + // No addListener / setState pair here on purpose: a theme change already + // marks the whole element tree dirty from MyApp, so this page rebuilds with + // everything else. Listening as well would just rebuild it twice. + + Future<void> _pick(AppAccent accent) async { + _tap(); + await _theme.setAccent(accent); + } + + Future<void> _setAmoled(bool value) async { + _tap(); + await _theme.setAmoled(value); + } + + void _tap() { + if (isMobilePlatform) HapticFeedback.selectionClick(); + } + + Future<void> _setNavTinted(bool value) async { + _tap(); + await _theme.setNavTinted(value); + } + + Future<void> _shuffle() async { + _tap(); + await _theme.cycleAccent(); + } + + Future<void> _openCustom() async { + _tap(); + final picked = await showCustomAccentSheet( + context, + initial: _theme.customSeed ?? _theme.accent.base, + ); + if (picked != null) await _theme.setCustomAccent(picked); + } + + @override + Widget build(BuildContext context) { + final accent = _theme.accent; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Live preview ──────────────────────────────────────────────── + SettingsLabel('appearance.section_preview'.tr()), + const _PreviewStage(), + const SizedBox(height: 20), + + // ── Accent ────────────────────────────────────────────────────── + SettingsLabel('appearance.section_accent'.tr()), + SettingsCard( + children: [ + _AccentGrid(selected: accent, onPick: _pick), + const SettingsDivider(), + _CurrentAccentRow(accent: accent), + const SettingsDivider(), + _CustomAccentRow(active: accent.isCustom, onTap: _openCustom), + ], + ), + SettingsFootnote('appearance.accent_footnote'.tr()), + const SizedBox(height: 20), + + // ── From the user's own library ───────────────────────────────── + _LibraryAccentSection( + accents: _libraryAccents, + selected: accent, + onPick: (a) async { + _tap(); + await _theme.setCustomAccent(a.base); + }, + ), + + // ── Darkness ──────────────────────────────────────────────────── + SettingsLabel('appearance.section_darkness'.tr()), + SettingsCard( + children: [ + _DarknessPicker( + accent: accent, + value: _theme.darkness, + onChanged: (d) => _setAmoled(d == AppDarkness.black), + ), + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.contrast_rounded, + title: 'appearance.darkness_black'.tr(), + subtitle: 'appearance.darkness_black_desc'.tr(), + value: _theme.isAmoled, + onChanged: _setAmoled, + ), + ], + ), + SettingsFootnote('appearance.amoled_footnote'.tr()), + const SizedBox(height: 20), + + // ── Where the accent is allowed to go ─────────────────────────── + SettingsLabel('appearance.section_extras'.tr()), + SettingsCard( + children: [ + SettingsSwitchTile( + icon: Icons.dashboard_customize_rounded, + title: 'appearance.tint_nav'.tr(), + subtitle: 'appearance.tint_nav_desc'.tr(), + value: _theme.isNavTinted, + onChanged: _setNavTinted, + ), + const SettingsDivider(), + _ActionRow( + icon: Icons.shuffle_rounded, + title: 'appearance.shuffle'.tr(), + subtitle: 'appearance.shuffle_desc'.tr(), + onTap: _shuffle, + ), + ], + ), + + if (widget.showResetRow) ...[ + const SizedBox(height: 20), + SettingsCard( + children: [ + _ResetRow( + enabled: !_theme.isDefault, + onReset: () async { + _tap(); + await _theme.reset(); + }, + ), + ], + ), + ], + ], + ), + ); + } +} + +// ── Preview ───────────────────────────────────────────────────────────────── + +/// Wraps the component sample. +/// +/// No device frame and no stage lighting: the sample is a real card on a real +/// background, and dressing it up as a photographed object would put back the +/// suggestion that it is a picture of a screen somewhere. +class _PreviewStage extends StatelessWidget { + const _PreviewStage(); + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: const ThemePreview(), + ); + } +} + +// ── Accent ────────────────────────────────────────────────────────────────── + +class _AccentGrid extends StatelessWidget { + const _AccentGrid({required this.selected, required this.onPick}); + + final AppAccent selected; + final ValueChanged<AppAccent> onPick; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 16, 14, 16), + child: LayoutBuilder( + builder: (context, constraints) { + // Only the presets live here, and there are twelve of them — which + // divides evenly by 6, 4 and 3. So whatever width the card gets, the + // grid comes out as full rows instead of leaving one swatch stranded + // on a line of its own. (Custom is a labelled row underneath, where + // it can say what it is.) + const gap = 10.0; + final columns = switch (constraints.maxWidth) { + >= 420 => 6, + >= 300 => 6, + >= 220 => 4, + _ => 3, + }; + final size = (constraints.maxWidth - gap * (columns - 1)) / columns; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final accent in AppAccent.presets) + _Swatch( + size: size, + color: accent.base, + selected: !selected.isCustom && selected.id == accent.id, + onTap: () => onPick(accent), + semanticLabel: accent.labelKey.tr(), + ), + ], + ); + }, + ), + ); + } +} + +/// The way into the colour wheel. A row rather than a thirteenth circle: it can +/// carry a label, it never breaks the grid's rhythm, and when a custom colour +/// is in force it shows which one. +class _CustomAccentRow extends StatelessWidget { + const _CustomAccentRow({required this.active, required this.onTap}); + + final bool active; + final VoidCallback onTap; + + /// Hues around the wheel chip. Twelve is enough for the sweep to read as + /// continuous at this size. + static const List<Color> _wheel = [ + Color(0xFFE53935), Color(0xFFF4511E), Color(0xFFFFB300), + Color(0xFFC0CA33), Color(0xFF43A047), Color(0xFF00897B), + Color(0xFF039BE5), Color(0xFF3949AB), Color(0xFF8E24AA), + Color(0xFFD81B60), Color(0xFFE53935), + ]; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const SweepGradient(colors: _wheel), + border: Border.all( + color: active + ? AppColors.textPrimary + : AppColors.textPrimary.withValues(alpha: 0.12), + width: active ? 2 : 1, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'appearance.custom_title'.tr(), + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + if (active) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon( + Icons.check_rounded, + size: 18, + color: AppColors.primary, + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +class _Swatch extends StatelessWidget { + const _Swatch({ + required this.size, + required this.color, + required this.selected, + required this.onTap, + required this.semanticLabel, + }); + + final double size; + final Color color; + final bool selected; + final VoidCallback onTap; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + return Semantics( + label: semanticLabel, + selected: selected, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + // The selection ring sits outside the colour, not on it, so a dark + // accent and a bright one are equally obviously selected. + border: Border.all( + color: selected + ? AppColors.textPrimary + : AppColors.textPrimary.withValues(alpha: 0.10), + width: selected ? 2 : 1, + ), + ), + padding: EdgeInsets.all(selected ? 4 : 3), + child: DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + child: selected + ? Center( + child: Icon( + Icons.check_rounded, + size: size * 0.42, + color: Colors.white, + ), + ) + : null, + ), + ), + ), + ); + } +} + +/// Names the accent in force and, for a custom one, offers the way back into +/// the picker. Without it the grid is twelve unlabelled dots. +class _CurrentAccentRow extends StatelessWidget { + const _CurrentAccentRow({required this.accent}); + + final AppAccent accent; + + @override + Widget build(BuildContext context) { + final hex = accent.base + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .substring(2) + .toUpperCase(); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: accent.base, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + accent.labelKey.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '#$hex', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12.5, + fontFeatures: [FontFeature.tabularFigures()], + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +/// The accents the user's own library is made of. +/// +/// Hides itself entirely when there is nothing to show — a fresh install, an +/// offline session, posters that failed to decode. An empty labelled card that +/// says "we found nothing" is worse than no card: it turns a bonus into a +/// broken feature. +class _LibraryAccentSection extends StatelessWidget { + const _LibraryAccentSection({ + required this.accents, + required this.selected, + required this.onPick, + }); + + final Future<List<AppAccent>> accents; + final AppAccent selected; + final ValueChanged<AppAccent> onPick; + + @override + Widget build(BuildContext context) { + return FutureBuilder<List<AppAccent>>( + future: accents, + builder: (context, snapshot) { + final found = snapshot.data ?? const <AppAccent>[]; + // AnimatedSize so the row slides in when the posters finish decoding + // rather than making the page jump under the user's thumb. + return AnimatedSize( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: found.isEmpty + ? const SizedBox(width: double.infinity) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsLabel('appearance.section_library'.tr()), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), + child: LayoutBuilder( + builder: (context, constraints) { + // Laid out on the same six-column grid as the + // preset row above, so the two rows of circles + // line up instead of being two different sizes. + // Between one and six swatches can turn up, and a + // Wrap would stretch two of them into saucers. + const gap = 10.0; + const columns = 6; + final size = + (constraints.maxWidth - gap * (columns - 1)) / + columns; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final accent in found) + _Swatch( + size: size, + color: accent.base, + selected: selected.isCustom && + selected.base == accent.base, + onTap: () => onPick(accent), + semanticLabel: + 'appearance.accent_custom'.tr(), + ), + ], + ); + }, + ), + ), + ], + ), + SettingsFootnote('appearance.library_footnote'.tr()), + const SizedBox(height: 20), + ], + ), + ); + }, + ); + } +} + +/// A settings row that just does something, with no value to show. +class _ActionRow extends StatelessWidget { + const _ActionRow({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 11, 16, 11), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppColors.primary, size: 18), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Darkness ──────────────────────────────────────────────────────────────── + +class _DarknessPicker extends StatelessWidget { + const _DarknessPicker({ + required this.accent, + required this.value, + required this.onChanged, + }); + + final AppAccent accent; + final AppDarkness value; + final ValueChanged<AppDarkness> onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _DarknessTile( + accent: accent, + darkness: AppDarkness.dark, + labelKey: 'appearance.darkness_dark', + descKey: 'appearance.darkness_dark_desc', + selected: value == AppDarkness.dark, + onTap: () => onChanged(AppDarkness.dark), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _DarknessTile( + accent: accent, + darkness: AppDarkness.black, + labelKey: 'appearance.darkness_black', + descKey: 'appearance.darkness_black_desc', + selected: value == AppDarkness.black, + onTap: () => onChanged(AppDarkness.black), + ), + ), + ], + ), + ); + } +} + +class _DarknessTile extends StatelessWidget { + const _DarknessTile({ + required this.accent, + required this.darkness, + required this.labelKey, + required this.descKey, + required this.selected, + required this.onTap, + }); + + final AppAccent accent; + final AppDarkness darkness; + final String labelKey; + final String descKey; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // Each tile previews ITS OWN mode in the CURRENT accent — the whole point + // is to show the user the option they have not chosen. + final palette = AppPalette.resolve(accent: accent, darkness: darkness); + return Semantics( + label: labelKey.tr(), + selected: selected, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.fromLTRB(9, 9, 9, 10), + decoration: BoxDecoration( + color: selected + ? AppColors.primary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(13), + border: Border.all( + color: selected + ? AppColors.primary.withValues(alpha: 0.55) + : AppColors.textPrimary.withValues(alpha: 0.07), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DarknessSample(palette: palette), + const SizedBox(height: 9), + Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + size: 15, + color: selected ? AppColors.primary : AppColors.textHint, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + labelKey.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 13, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + descKey.tr(), + maxLines: 2, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + height: 1.3, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Reset ─────────────────────────────────────────────────────────────────── + +class _ResetAction extends StatelessWidget { + const _ResetAction(); + + @override + Widget build(BuildContext context) { + final theme = getIt<ThemeController>(); + if (theme.isDefault) return const SizedBox.shrink(); + return IconButton( + tooltip: 'appearance.reset_tooltip'.tr(), + icon: const Icon(Icons.settings_backup_restore_rounded, size: 22), + color: AppColors.textSecondary, + onPressed: () { + if (isMobilePlatform) HapticFeedback.selectionClick(); + theme.reset(); + }, + ); + } +} + +class _ResetRow extends StatelessWidget { + const _ResetRow({required this.enabled, required this.onReset}); + + final bool enabled; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: enabled ? onReset : null, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 13, 16, 13), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.settings_backup_restore_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + 'appearance.reset'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Custom colour sheet ───────────────────────────────────────────────────── + +/// Opens the custom accent picker. Returns the chosen colour, or null if the +/// sheet was dismissed. +Future<Color?> showCustomAccentSheet( + BuildContext context, { + required Color initial, +}) { + return showModalBottomSheet<Color>( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: (_) => _CustomAccentSheet(initial: initial), + ); +} + +class _CustomAccentSheet extends StatefulWidget { + const _CustomAccentSheet({required this.initial}); + + final Color initial; + + @override + State<_CustomAccentSheet> createState() => _CustomAccentSheetState(); +} + +class _CustomAccentSheetState extends State<_CustomAccentSheet> { + late HSLColor _hsl = HSLColor.fromColor(widget.initial); + + Color get _seed => _hsl.toColor(); + + /// What the app would actually use — the seed after the white-legibility + /// clamp. Showing both is what makes the clamp explicable instead of + /// mysterious. + AppAccent get _resolved => AppAccent.custom(_seed); + + bool get _wasAdjusted => + AppAccent.whiteContrast(_seed) < AppAccent.minWhiteContrast; + + void _update(HSLColor next) { + setState(() => _hsl = next); + } + + @override + Widget build(BuildContext context) { + final accent = _resolved; + final hex = accent.base + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .substring(2) + .toUpperCase(); + + return SafeArea( + top: false, + child: SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 10, 18, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 38, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: AppColors.textHint.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + Expanded( + child: Text( + 'appearance.custom_title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '#$hex', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13, + fontFeatures: [FontFeature.tabularFigures()], + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 14), + + // The candidate colour on the same surfaces the rest of the page + // uses. DarknessSample takes an explicit palette, which is what + // this needs — the colour being previewed is deliberately NOT the + // one installed yet. + Center( + child: SizedBox( + width: 240, + child: DarknessSample( + palette: AppPalette.resolve( + accent: accent, + darkness: AppPalette.current.darkness, + ), + ), + ), + ), + const SizedBox(height: 18), + + _GradientSlider( + label: 'appearance.custom_hue'.tr(), + value: _hsl.hue / 360, + thumbColor: HSLColor.fromAHSL(1, _hsl.hue, 1, 0.5).toColor(), + gradient: const [ + Color(0xFFFF0000), Color(0xFFFFFF00), Color(0xFF00FF00), + Color(0xFF00FFFF), Color(0xFF0000FF), Color(0xFFFF00FF), + Color(0xFFFF0000), + ], + onChanged: (t) => _update(_hsl.withHue((t * 360).clamp(0, 360))), + ), + const SizedBox(height: 14), + _GradientSlider( + label: 'appearance.custom_saturation'.tr(), + value: _hsl.saturation, + thumbColor: _seed, + gradient: [ + HSLColor.fromAHSL(1, _hsl.hue, 0, _hsl.lightness).toColor(), + HSLColor.fromAHSL(1, _hsl.hue, 1, _hsl.lightness).toColor(), + ], + onChanged: (t) => _update(_hsl.withSaturation(t)), + ), + const SizedBox(height: 14), + _GradientSlider( + label: 'appearance.custom_lightness'.tr(), + value: _hsl.lightness, + thumbColor: _seed, + gradient: [ + Colors.black, + HSLColor.fromAHSL(1, _hsl.hue, _hsl.saturation, 0.5).toColor(), + Colors.white, + ], + onChanged: (t) => _update(_hsl.withLightness(t)), + ), + + if (_wasAdjusted) ...[ + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.info_outline_rounded, + size: 15, + color: AppColors.textHint, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'appearance.custom_adjusted'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + height: 1.4, + ), + ), + ), + ], + ), + ], + + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + minimumSize: const Size(0, 46), + foregroundColor: AppColors.textSecondary, + ), + child: Text('general.cancel'.tr()), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(_seed), + style: FilledButton.styleFrom( + backgroundColor: accent.base, + foregroundColor: Colors.white, + minimumSize: const Size(0, 46), + ), + child: Text('appearance.custom_apply'.tr()), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// A gradient track with a draggable thumb, normalised to 0..1. +/// +/// Hand-rolled rather than a themed [Slider] because the track *is* the +/// information here: it has to be a real gradient of the values it selects, +/// edge to edge, with the thumb sitting exactly on the colour it represents. +/// A Slider reserves invisible padding for its overlay, which would push the +/// gradient out of alignment with the thumb. +class _GradientSlider extends StatelessWidget { + const _GradientSlider({ + required this.label, + required this.value, + required this.gradient, + required this.thumbColor, + required this.onChanged, + }); + + final String label; + final double value; + final List<Color> gradient; + final Color thumbColor; + final ValueChanged<double> onChanged; + + static const double _trackHeight = 14; + static const double _thumbRadius = 12; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + // The thumb's centre travels between the two inset edges, so the + // track it walks along is shortened by one radius at each end. + final usable = constraints.maxWidth - _thumbRadius * 2; + void emit(double dx) { + if (usable <= 0) return; + onChanged(((dx - _thumbRadius) / usable).clamp(0.0, 1.0)); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => emit(d.localPosition.dx), + onHorizontalDragStart: (d) => emit(d.localPosition.dx), + onHorizontalDragUpdate: (d) => emit(d.localPosition.dx), + child: SizedBox( + height: _thumbRadius * 2 + 8, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: _thumbRadius, + ), + child: Container( + height: _trackHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_trackHeight / 2), + gradient: LinearGradient(colors: gradient), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.10), + width: 0.5, + ), + ), + ), + ), + Positioned( + left: usable.clamp(0, double.infinity) * value.clamp(0.0, 1.0), + child: Container( + width: _thumbRadius * 2, + height: _thumbRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: thumbColor, + border: Border.all(color: Colors.white, width: 2.5), + boxShadow: const [ + BoxShadow( + color: Color(0x66000000), + blurRadius: 5, + offset: Offset(0, 1), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index 0f0c9362..ae945e27 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -688,7 +688,7 @@ class _EngineRow extends StatelessWidget { ), child: Text( 'profile.player_engine_badge_default'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 10, fontWeight: FontWeight.w700, diff --git a/lib/features/profile/presentation/pages/profile_edit_page.dart b/lib/features/profile/presentation/pages/profile_edit_page.dart index ff9fa8f6..8cced0ca 100644 --- a/lib/features/profile/presentation/pages/profile_edit_page.dart +++ b/lib/features/profile/presentation/pages/profile_edit_page.dart @@ -311,7 +311,7 @@ class _AvatarPicker extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: uploading - ? const Center( + ? Center( child: SizedBox( width: 24, height: 24, diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 5fc3cebd..0ca968ba 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -15,6 +15,9 @@ import 'package:soplay/core/aniyomi/aniyomi_channel.dart'; import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; import 'package:soplay/core/bridge/bridge_control.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; +import 'package:soplay/core/theme/theme_controller.dart'; +import 'package:soplay/features/profile/presentation/pages/appearance_page.dart'; import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/extensions/presentation/pages/mangayomi_sources_page.dart'; import 'package:soplay/features/aniyomi/presentation/pages/aniyomi_sources_page.dart'; @@ -43,6 +46,7 @@ import 'package:soplay/features/mal/data/mal_service.dart'; import 'package:soplay/features/mal/presentation/widgets/mal_brand.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; class ProfilePage extends StatelessWidget { const ProfilePage({super.key}); @@ -108,20 +112,24 @@ class _ProfileViewState extends State<_ProfileView> { backgroundColor: AppColors.background, body: Stack( children: [ - const DecoratedBox( + // Accent-tinted at the top, falling to the page background. Used to + // be the literals [#1E1416, #181818, #101010]; those are exactly what + // these three resolve to at the default red, and they now follow the + // chosen accent and darkness instead of staying red on a blue app. + DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xFF1E1416), - Color(0xFF181818), - Color(0xFF101010), + AppColors.heroTop, + AppColors.heroMid, + AppColors.heroBottom, ], - stops: [0, 0.35, 1], + stops: const [0, 0.35, 1], ), ), - child: SizedBox.expand(), + child: const SizedBox.expand(), ), _ProfileScrollFrame( child: RefreshIndicator( @@ -134,60 +142,58 @@ class _ProfileViewState extends State<_ProfileView> { physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter(child: SizedBox(height: headerH + 16)), + // One BlocBuilder over the whole list rather than a stack of + // fixed slivers with conditionals sprinkled through it. + // + // A guest cannot use the streak, a tracker connection or a TV + // pairing — all three bind to a Sozo account — and the old + // list still reserved their gaps, so signed out the page was + // a run of empty space with Settings pushed below the fold. + // Building the sections into a list means the spacing belongs + // to the sections that are actually there, and the reveal + // stagger renumbers itself. SliverToBoxAdapter( child: BlocBuilder<AuthBloc, AuthState>( builder: (context, state) { - final user = state is AuthLoaded - ? state.token.user - : null; - return _Reveal( - order: 0, - child: _ProfileHeader(user: user), + final signedIn = state is AuthLoaded; + final user = signedIn ? state.token.user : null; + final sections = <Widget>[ + _ProfileHeader(user: user), + if (signedIn) const StreakCard(), + if (signedIn) const _ConnectionsSection(), + const _ContentSection(), + // signedIn is passed rather than read inside: a + // `const _WatchHistorySection()` is the same widget + // instance every build, so Flutter would skip + // rebuilding it and the Watch Party row would not + // appear until something else disturbed the tree. + _WatchHistorySection(signedIn: signedIn), + const _SettingsEntriesSection(), + const _AboutSection(), + if (signedIn) const _SignOutSection(), + ]; + return Column( + children: [ + for (var i = 0; i < sections.length; i++) ...[ + // The header carries its own padding; the gap + // after it is the wider one it always had. + if (i > 0) SizedBox(height: i == 1 ? 20 : 16), + _Reveal( + // Keyed by section type, which is unique in + // this list: signing out removes three entries + // and every section below shifts index, and an + // unkeyed Column would hand each one the + // previous occupant's Element and State. + key: ValueKey<Type>(sections[i].runtimeType), + order: i, + child: sections[i], + ), + ], + ], ); }, ), ), - const SliverToBoxAdapter(child: SizedBox(height: 20)), - const SliverToBoxAdapter( - child: _Reveal(order: 1, child: StreakCard()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 2, child: _ConnectionsSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 3, child: _ProvidersSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 4, child: _ExtensionSourcesSection()), - ), - // Signed-in only: approving a TV pairing binds it to an account, so - // there is nothing this can do for a guest. - const SliverToBoxAdapter( - child: _Reveal(order: 5, child: _WatchHistorySection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 6, child: _SecuritySection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 7, child: _SettingsEntriesSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - const SliverToBoxAdapter( - child: _Reveal(order: 8, child: _AboutSection()), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - SliverToBoxAdapter( - child: BlocBuilder<AuthBloc, AuthState>( - builder: (context, state) => state is AuthLoaded - ? const _Reveal(order: 9, child: _SignOutSection()) - : const SizedBox.shrink(), - ), - ), SliverToBoxAdapter( child: SizedBox( height: bottomPad + (isDesktopPlatform ? 112 : 96), @@ -344,13 +350,29 @@ class _ProfileViewState extends State<_ProfileView> { ], ); case 1: - return const _ProvidersSection(); + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProvidersSection(), + SizedBox(height: 20), + _ExtensionSourcesSection(), + ], + ); case 2: return const _WatchHistorySection(); case 3: return const _SecuritySection(); case 4: - return const _AppearanceSection(); + // Theme first, then the window / navigation-bar options that were + // already here — one Appearance panel rather than two half-panels. + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppearanceSettings(showResetRow: true), + SizedBox(height: 20), + _AppearanceSection(), + ], + ); default: return const _AboutSection(); } @@ -470,7 +492,7 @@ class _SettingsNavItemState extends State<_SettingsNavItem> { /// Staggered fade + slide-up entrance for each settings section (desktop only, /// akuse-style). Mobile returns the child unchanged. class _Reveal extends StatefulWidget { - const _Reveal({required this.order, required this.child}); + const _Reveal({super.key, required this.order, required this.child}); final int order; final Widget child; @@ -593,7 +615,7 @@ class _GuestContent extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.2), ), ), - child: const Icon( + child: Icon( Icons.person_outline_rounded, color: AppColors.primaryLight, size: 28, @@ -636,7 +658,7 @@ class _GuestContent extends StatelessWidget { label: Text('profile.sign_in'.tr()), style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), ), @@ -788,6 +810,10 @@ class _SignOutSection extends StatelessWidget { class _ProvidersSection extends StatelessWidget { const _ProvidersSection(); + /// The provider row on its own, so the mobile CONTENT card can put it above + /// the extension sources rather than give it a labelled card of its own. + static Widget row() => const _ProviderRow(); + @override Widget build(BuildContext context) { return Padding( @@ -796,7 +822,19 @@ class _ProvidersSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionLabel('profile.section_providers'.tr()), - BlocBuilder<ProviderBloc, ProviderState>( + _SectionCard(children: [row()]), + ], + ), + ); + } +} + +class _ProviderRow extends StatelessWidget { + const _ProviderRow(); + + @override + Widget build(BuildContext context) { + return BlocBuilder<ProviderBloc, ProviderState>( builder: (context, state) { final loaded = state is ProviderLoaded ? state : null; final currentProvider = loaded?.currentProvider; @@ -804,9 +842,7 @@ class _ProvidersSection extends StatelessWidget { currentProvider?.name ?? loaded?.currentProviderId ?? '—'; final total = loaded?.providers.length ?? 0; - return _SectionCard( - children: [ - _Tile( + return _Tile( icon: Icons.movie_filter_outlined, title: 'profile.provider'.tr(), trailing: Row( @@ -859,14 +895,9 @@ class _ProvidersSection extends StatelessWidget { ], ), onTap: () => context.push('/providers'), - ), - ], - ); + ); }, - ), - ], - ), - ); + ); } } @@ -875,7 +906,10 @@ void openProviderPicker(BuildContext context, ProviderBloc bloc) { } class _WatchHistorySection extends StatefulWidget { - const _WatchHistorySection(); + const _WatchHistorySection({this.signedIn = true}); + + /// Hides the rows that only work with a Sozo account. + final bool signedIn; @override State<_WatchHistorySection> createState() => _WatchHistorySectionState(); @@ -968,6 +1002,37 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { trailing: const _TileChevron(), onTap: () => context.push('/following'), ), + // Moved out of the home top bar, which had five permanent icons + // and no room. Each of these is somewhere you go on purpose, and + // this list is where the app already keeps those; leaving them + // only in the bar was the reason the bar could not be trimmed. + // + // Watch Party is the one that hard-requires an account — tapping + // it signed out only bounces to /login — so a guest is not shown + // a door that opens onto a sign-in wall. + if (widget.signedIn) ...[ + const _TileDivider(), + _Tile( + icon: Icons.groups_rounded, + title: 'watch_party.title'.tr(), + trailing: const _TileChevron(), + onTap: () => showPartyEntrySheet(context), + ), + ], + const _TileDivider(), + _Tile( + icon: Icons.track_changes_rounded, + title: 'navigation.anilist'.tr(), + trailing: const _TileChevron(), + onTap: () => context.push('/anilist'), + ), + const _TileDivider(), + _Tile( + icon: Icons.live_tv_rounded, + title: 'navigation.live_tv'.tr(), + trailing: const _TileChevron(), + onTap: () => context.push('/live-tv'), + ), const _TileDivider(), _Tile( icon: Icons.devices_rounded, @@ -1185,18 +1250,40 @@ class _SecuritySection extends StatefulWidget { } class _SecuritySectionState extends State<_SecuritySection> { - late final AppLockRepository _lock = getIt<AppLockRepository>(); - @override Widget build(BuildContext context) { - final enabled = _lock.isEnabled; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionLabel('app_lock.section_label'.tr()), - _SectionCard( + const _SectionCard(children: [_SecurityRows()]), + ], + ), + ); + } +} + +/// App lock + private list, as two bare rows. +/// +/// Split out of [_SecuritySection] so the mobile SETTINGS card can hold them +/// next to Appearance and Player — where they belong — while desktop keeps its +/// own "Security" panel. +class _SecurityRows extends StatefulWidget { + const _SecurityRows(); + + @override + State<_SecurityRows> createState() => _SecurityRowsState(); +} + +class _SecurityRowsState extends State<_SecurityRows> { + late final AppLockRepository _lock = getIt<AppLockRepository>(); + + @override + Widget build(BuildContext context) { + final enabled = _lock.isEnabled; + return Column( children: [ _Tile( icon: Icons.lock_rounded, @@ -1238,10 +1325,7 @@ class _SecuritySectionState extends State<_SecuritySection> { }, ), ], - ), - ], - ), - ); + ); } } @@ -1540,12 +1624,12 @@ class NavbarPage extends StatelessWidget { } } -/// The two rows that open a settings sub-page ([NavbarPage], -/// [PlayerSettingsPage]). +/// Everything the user can configure, in ONE card. /// -/// One labelled card rather than two unlabelled single-row cards: floating -/// alone between the labelled Security and About sections, they read as -/// leftovers rather than as a group. +/// Appearance, the navigation bar and the player used to be a three-row card +/// with app lock and the private list in a separate labelled "Security" card +/// directly above it. Two labels for five rows that are all "settings" made the +/// page read as longer than it is; one label reads as one place to look. /// /// The Player row is unconditional — the page behind it owns playback defaults /// and subtitle appearance, which apply everywhere; only its engine block is @@ -1563,6 +1647,14 @@ class _SettingsEntriesSection extends StatelessWidget { _SectionLabel('profile.section_settings'.tr()), _SectionCard( children: [ + _Tile( + icon: Icons.palette_outlined, + title: 'appearance.title'.tr(), + subtitle: 'appearance.entry_subtitle'.tr(), + trailing: const _AccentDotChevron(), + onTap: () => context.push('/appearance'), + ), + const _TileDivider(), _Tile( icon: Icons.view_week_rounded, title: 'profile.nav_style'.tr(), @@ -1576,6 +1668,90 @@ class _SettingsEntriesSection extends StatelessWidget { trailing: const _TileChevron(), onTap: () => context.push('/player-settings'), ), + const _TileDivider(), + const _SecurityRows(), + ], + ), + ], + ), + ); + } +} + +/// Where the content comes from: the active provider, and — folded away behind +/// one row — the installable extension ecosystems. +/// +/// These were two labelled cards and five rows. Four of those rows were +/// extension stores most people open once and never again, so they are collapsed +/// by default; the row still says how many there are, and opens them in place. +class _ContentSection extends StatefulWidget { + const _ContentSection(); + + @override + State<_ContentSection> createState() => _ContentSectionState(); +} + +class _ContentSectionState extends State<_ContentSection> { + bool _sourcesOpen = false; + + @override + Widget build(BuildContext context) { + final sources = _ExtensionSourcesSection.rowsFor(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel('profile.section_content'.tr()), + _SectionCard( + children: [ + _ProvidersSection.row(), + if (sources.isNotEmpty) ...[ + const _TileDivider(), + _Tile( + icon: Icons.extension_outlined, + title: 'profile.sources_row'.tr(), + subtitle: 'profile.sources_row_subtitle'.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${sources.length}', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + AnimatedRotation( + turns: _sourcesOpen ? 0.25 : 0, + duration: const Duration(milliseconds: 200), + child: const _TileChevron(), + ), + ], + ), + onTap: () => setState(() => _sourcesOpen = !_sourcesOpen), + ), + // AnimatedSize rather than a route: the stores are one tap + // away either way, and expanding in place keeps the user's + // place on a long page. + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _sourcesOpen + ? Column( + children: [ + for (final row in sources) ...[ + const _TileDivider(), + row, + ], + ], + ) + : const SizedBox(width: double.infinity), + ), + ], ], ), ], @@ -1858,6 +2034,10 @@ class _SectionCard extends StatelessWidget { } } +/// A section heading, with the same accent tick Home puts in front of every +/// row title. Two jobs: it carries the chosen colour down a screen that is +/// otherwise all greys, and it gives a long settings list a visual rhythm so +/// the sections read as separate rather than as one endless column. class _SectionLabel extends StatelessWidget { const _SectionLabel(this.label); final String label; @@ -1866,14 +2046,34 @@ class _SectionLabel extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - label, - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - ), + child: Row( + children: [ + Container( + width: 2.5, + height: 11, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.5), + ], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 7), + Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + ], ), ); } @@ -2012,7 +2212,39 @@ class _TileDivider extends StatelessWidget { @override Widget build(BuildContext context) => - const Divider(color: AppColors.divider, height: 1, indent: 64); + Divider(color: AppColors.divider, height: 1, indent: 64); +} + +/// Chevron with the accent in front of it, for the Appearance row. +/// +/// The row's whole subject is a colour, so the current one belongs on the row — +/// it turns "Appearance ›" into an answer as well as a destination. +class _AccentDotChevron extends StatelessWidget { + const _AccentDotChevron(); + + @override + Widget build(BuildContext context) { + final accent = getIt<ThemeController>().accent; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: accent.base, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + width: 0.8, + ), + ), + ), + const SizedBox(width: 8), + const _TileChevron(), + ], + ); + } } /// Chevron used by every row that opens something. @@ -2162,8 +2394,10 @@ class _ServerCountdownTileState extends State<_ServerCountdownTile> { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( + // "Expired" means the server is down and nothing will load. + // That is an error, not a place to show the theme. color: rem == Duration.zero - ? AppColors.primary + ? AppColors.error : AppColors.textSecondary, fontSize: 13, fontFeatures: const [FontFeature.tabularFigures()], @@ -2233,14 +2467,15 @@ class _ServerSupportSheet extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 14), decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.08), + color: AppColors.error.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), ), child: Text( + // Same reasoning as the countdown row above it. 'profile.server_expired'.tr(), textAlign: TextAlign.center, style: const TextStyle( - color: AppColors.primary, + color: AppColors.error, fontSize: 15, fontWeight: FontWeight.w700, ), @@ -2353,9 +2588,9 @@ class _SheetCountdownCell extends StatelessWidget { class _ExtensionSourcesSection extends StatelessWidget { const _ExtensionSourcesSection(); - @override - Widget build(BuildContext context) { - final rows = <Widget>[ + /// The rows themselves, so the mobile CONTENT card can host them behind an + /// expander instead of standing up a fifth labelled card of its own. + static List<Widget> rowsFor(BuildContext context) => <Widget>[ if (BridgeControl.canHost && CloudStreamChannel.isSupported) _Tile( leading: const _TileLogo( @@ -2412,6 +2647,10 @@ class _ExtensionSourcesSection extends StatelessWidget { ), ), ]; + + @override + Widget build(BuildContext context) { + final rows = rowsFor(context); if (rows.isEmpty) return const SizedBox.shrink(); return Padding( diff --git a/lib/features/profile/presentation/pages/providers_page.dart b/lib/features/profile/presentation/pages/providers_page.dart index 7824a0fa..11644667 100644 --- a/lib/features/profile/presentation/pages/providers_page.dart +++ b/lib/features/profile/presentation/pages/providers_page.dart @@ -734,7 +734,7 @@ class _ProviderListTile extends StatelessWidget { Container( width: 20, height: 20, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), diff --git a/lib/features/profile/presentation/widgets/library_accents.dart b/lib/features/profile/presentation/widgets/library_accents.dart new file mode 100644 index 00000000..36cb4e96 --- /dev/null +++ b/lib/features/profile/presentation/widgets/library_accents.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:palette_generator/palette_generator.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/features/history/data/history_service.dart'; + +/// Accents pulled out of the posters of what the user actually watches. +/// +/// The idea is that the best accent for a person's app is usually already on +/// their home screen — the colour of the show they are three episodes into. So +/// instead of only offering twelve colours somebody else chose, Appearance +/// offers the ones their own library is made of. +/// +/// Every extracted colour still goes through [AppAccent.custom], so a poster's +/// pale cream or near-black is pulled into the legible band like any other +/// custom pick — the swatch row can never produce an unreadable theme. +class LibraryAccents { + LibraryAccents._(); + + /// How many recent titles to read. Each one is a network image decode, so + /// this is deliberately small — the row is a nice-to-have on a settings page, + /// not something worth spending a user's data on. + static const int _maxPosters = 5; + + /// How many swatches the row shows. + static const int _maxSwatches = 6; + + /// Beyond this the extraction is abandoned. A settings page that hangs + /// waiting for a poster is worse than one that quietly has no suggestions. + static const Duration _budget = Duration(seconds: 6); + + /// Resolved once per app run. The posters do not change while the user is on + /// the Appearance page, and this page rebuilds on every colour tap — without + /// the cache, every tap would re-decode five images. + static Future<List<AppAccent>>? _cached; + + static Future<List<AppAccent>> load() => _cached ??= _extract(); + + /// Drops the cache, so a later visit re-reads a library that has since + /// changed. Called when history is cleared. + static void invalidate() => _cached = null; + + static Future<List<AppAccent>> _extract() async { + try { + return await _run().timeout(_budget); + } catch (_) { + // Offline, an expired poster URL, a decode failure — all of them mean + // the same thing here: no suggestions, and no error to show for it. + return const []; + } + } + + static Future<List<AppAccent>> _run() async { + final history = getIt<HistoryService>().getAll(); + final urls = <String>[]; + for (final item in history) { + final thumb = item.thumbnail; + if (thumb == null || thumb.isEmpty) continue; + if (!thumb.startsWith('http')) continue; + if (urls.contains(thumb)) continue; + urls.add(thumb); + if (urls.length >= _maxPosters) break; + } + if (urls.isEmpty) return const []; + + // In parallel: five sequential decodes would routinely exceed the budget, + // and a failure on one poster must not cost the other four. + final palettes = await Future.wait( + urls.map(_paletteOf), + eagerError: false, + ); + + final picked = <AppAccent>[]; + final takenHues = <int>{}; + for (final palette in palettes) { + if (palette == null) continue; + for (final candidate in _candidatesOf(palette)) { + final accent = AppAccent.custom(candidate); + // Bucket by 30° of hue so five posters from the same franchise cannot + // fill the row with five near-identical blues. + final bucket = (HSLColor.fromColor(accent.base).hue ~/ 30).clamp(0, 11); + if (!takenHues.add(bucket)) continue; + picked.add(accent); + if (picked.length >= _maxSwatches) return picked; + break; // one colour per poster, so the row reads as one per title + } + } + return picked; + } + + static Future<PaletteGenerator?> _paletteOf(String url) async { + try { + return await PaletteGenerator.fromImageProvider( + NetworkImage(url), + // Downscaled hard: the palette of a poster survives a 64px thumbnail, + // and decoding full-size art for a settings row would not be a fair + // trade for the user's memory. + size: const Size(64, 64), + maximumColorCount: 8, + ); + } catch (_) { + return null; + } + } + + /// Ordered by how much of the poster's character each one carries. + static Iterable<Color> _candidatesOf(PaletteGenerator palette) sync* { + for (final swatch in [ + palette.vibrantColor, + palette.darkVibrantColor, + palette.lightVibrantColor, + palette.dominantColor, + palette.mutedColor, + ]) { + if (swatch != null) yield swatch.color; + } + } +} diff --git a/lib/features/profile/presentation/widgets/settings_tiles.dart b/lib/features/profile/presentation/widgets/settings_tiles.dart index aca8c4ba..3d1f2de3 100644 --- a/lib/features/profile/presentation/widgets/settings_tiles.dart +++ b/lib/features/profile/presentation/widgets/settings_tiles.dart @@ -80,7 +80,7 @@ class SettingsDivider extends StatelessWidget { @override Widget build(BuildContext context) => - const Divider(color: AppColors.divider, height: 1, indent: 64); + Divider(color: AppColors.divider, height: 1, indent: 64); } /// A row that opens a menu of mutually exclusive values, with the current one @@ -152,7 +152,7 @@ class SettingsDropdownTile<T> extends StatelessWidget { ), ), if (option == value) - const Icon( + Icon( Icons.check_rounded, color: AppColors.primary, size: 17, diff --git a/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart b/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart index fc0b1bde..258b55af 100644 --- a/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart +++ b/lib/features/profile/presentation/widgets/tab_customizer_sheet.dart @@ -5,6 +5,7 @@ import 'package:soplay/core/navigation/app_tab.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/nav_prefs.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// Bottom-bar customizer (Settings → Appearance). Ports satashkent's /// quick-nav customizer to soplay tokens: a draft copy edited freely, persisted @@ -336,7 +337,7 @@ class _Footer extends StatelessWidget { 16, MediaQuery.paddingOf(context).bottom + 12, ), - decoration: const BoxDecoration( + decoration: BoxDecoration( border: Border(top: BorderSide(color: AppColors.divider, width: 1)), ), child: Row( @@ -362,7 +363,7 @@ class _Footer extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: AppColors.primary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text('nav_customize.save'.tr()), diff --git a/lib/features/profile/presentation/widgets/theme_preview.dart b/lib/features/profile/presentation/widgets/theme_preview.dart new file mode 100644 index 00000000..55582649 --- /dev/null +++ b/lib/features/profile/presentation/widgets/theme_preview.dart @@ -0,0 +1,381 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_palette.dart'; +import 'package:soplay/core/theme/app_theme.dart'; + +/// What the chosen colours actually do, shown on the app's own controls. +/// +/// ## Why this is not a picture of a screen +/// +/// The obvious thing to build here is a little phone with the Home page inside +/// it. It was built, twice, and thrown away both times — because it cannot be +/// true. The real Home is a network of blocs, cached artwork and live rows; +/// nothing that paints instantly inside a settings list can be that page. What +/// gets drawn instead is a *guess* at it: invented titles, invented posters, a +/// layout that drifts from the real one the moment anybody touches Home. A +/// preview that misrepresents the app is worse than no preview, and it is what +/// made the earlier versions read as fake. +/// +/// So this shows no screen at all. It shows the **real controls** — a real +/// [ElevatedButton], a real switch, a real card on a real background, the real +/// section tick, a real progress bar — at their real size, in the palette being +/// chosen. Every pixel here is something the user will meet again unchanged. +class ThemePreview extends StatelessWidget { + const ThemePreview({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 16), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(kFieldRadius), + border: Border.all(color: AppColors.border, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SampleSectionHead(), + const SizedBox(height: 10), + const _SampleCard(), + const SizedBox(height: 14), + const _SampleProgress(), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + flex: 3, + child: SizedBox( + height: kButtonHeight, + child: ElevatedButton( + // Inert on purpose: this is a swatch of the button, not a + // button. Disabling it would show the disabled colours. + onPressed: () {}, + child: Text('detail.play'.tr()), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: SizedBox( + height: kButtonHeight, + child: OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, kButtonHeight), + padding: EdgeInsets.zero, + side: BorderSide( + color: AppColors.textPrimary.withValues(alpha: 0.22), + width: 1.2, + ), + ), + child: Text( + 'general.cancel'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// The accent tick every section header on Home and Profile carries. +class _SampleSectionHead extends StatelessWidget { + const _SampleSectionHead(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 3, + height: 15, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.55), + ], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 9), + Text( + 'home.continue_watching'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const Spacer(), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + ], + ); + } +} + +/// A card on the page background, with the app's own 5%-white hairline — the +/// pair that tells you what AMOLED does. On true black the fill nearly +/// disappears and the hairline becomes the edge. +class _SampleCard extends StatelessWidget { + const _SampleCard(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.05), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _SampleRow( + icon: Icons.palette_outlined, + title: 'appearance.title'.tr(), + trailing: _SampleSwitch(), + ), + Divider(color: AppColors.divider, height: 1, indent: 60), + _SampleRow( + icon: Icons.play_circle_outline_rounded, + title: 'profile.section_player'.tr(), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'general.done'.tr(), + style: TextStyle( + color: AppColors.primary, + fontSize: 13.5, + fontWeight: FontWeight.w600, + ), + ), + Icon( + Icons.arrow_drop_down_rounded, + color: AppColors.primary, + size: 22, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SampleRow extends StatelessWidget { + const _SampleRow({ + required this.icon, + required this.title, + required this.trailing, + }); + + final IconData icon; + final String title; + final Widget trailing; + + @override + Widget build(BuildContext context) { + return Padding( + // The metrics from settings_tiles.dart, so the sample row and the real + // rows under it sit on one grid. + padding: const EdgeInsets.fromLTRB(14, 11, 12, 11), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppColors.textSecondary, size: 17), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + trailing, + ], + ), + ); + } +} + +/// The app's switch in its on state — accent track, white thumb. +class _SampleSwitch extends StatelessWidget { + const _SampleSwitch(); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Switch.adaptive( + value: true, + onChanged: (_) {}, + activeThumbColor: Colors.white, + activeTrackColor: AppColors.primary, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } +} + +/// A watched-progress bar, the accent's most common appearance in the app. +class _SampleProgress extends StatelessWidget { + const _SampleProgress(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: 0.62, + minHeight: 4, + backgroundColor: AppColors.surfaceVariant, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Text( + '62%', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + +/// The two darkness levels, side by side, as what they actually are: a stack of +/// surfaces. No pretend screen — just the page colour, a card on it, its +/// hairline, and the accent, in the level being described. +class DarknessSample extends StatelessWidget { + const DarknessSample({super.key, required this.palette}); + + final AppPalette palette; + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: 16 / 10, + child: Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: palette.background, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.border, width: 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: palette.surface, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: AppColors.textPrimary.withValues(alpha: 0.05), + width: 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: palette.primary, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 7), + Expanded( + child: Container( + height: 4, + decoration: BoxDecoration( + color: AppColors.textPrimary.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 7), + SizedBox( + height: 12, + child: Row( + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: palette.card, + borderRadius: BorderRadius.circular(4), + ), + child: const SizedBox.expand(), + ), + ), + const SizedBox(width: 6), + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: palette.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + child: const SizedBox.expand(), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/remote/presentation/pages/tv_remote_page.dart b/lib/features/remote/presentation/pages/tv_remote_page.dart index c346d804..d6dca5d7 100644 --- a/lib/features/remote/presentation/pages/tv_remote_page.dart +++ b/lib/features/remote/presentation/pages/tv_remote_page.dart @@ -276,7 +276,7 @@ class _ConnectionBanner extends StatelessWidget { minHeight: 3, value: (playing!.positionMs ?? 0) / playing.durationMs!, backgroundColor: AppColors.surfaceVariant, - valueColor: const AlwaysStoppedAnimation(AppColors.primary), + valueColor: AlwaysStoppedAnimation(AppColors.primary), ), ), const SizedBox(height: 4), diff --git a/lib/features/reports/presentation/widgets/report_sheet.dart b/lib/features/reports/presentation/widgets/report_sheet.dart index 678455e8..99ef9fa4 100644 --- a/lib/features/reports/presentation/widgets/report_sheet.dart +++ b/lib/features/reports/presentation/widgets/report_sheet.dart @@ -4,6 +4,7 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/domain/repositories/reports_repository.dart'; @@ -178,7 +179,7 @@ class _ReportSheetState extends State<_ReportSheet> { backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), onPressed: _sending ? null : _submit, diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index 5201ac73..9a4c2500 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -201,7 +201,7 @@ class _CrossSearchPageState extends State<CrossSearchPage> { final hit = title.hits[i]; return ListTile( dense: true, - leading: const Icon(Icons.play_circle_outline, + leading: Icon(Icons.play_circle_outline, color: AppColors.primary, size: 20), title: Text(hit.provider.name, style: @@ -608,7 +608,7 @@ class _CrossSearchPageState extends State<CrossSearchPage> { borderRadius: BorderRadius.circular(10), ), child: Text('${r.items.length}', - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.w700)), diff --git a/lib/features/search/presentation/widgets/search_filter_sheet.dart b/lib/features/search/presentation/widgets/search_filter_sheet.dart index 1f728de0..f1571ab7 100644 --- a/lib/features/search/presentation/widgets/search_filter_sheet.dart +++ b/lib/features/search/presentation/widgets/search_filter_sheet.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/search/domain/entities/genre_entity.dart'; @@ -119,7 +120,7 @@ class _SearchFilterSheetState extends State<SearchFilterSheet> { ), padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( @@ -142,7 +143,7 @@ class _SearchFilterSheetState extends State<SearchFilterSheet> { elevation: 0, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 3fb44317..e49f4d01 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -47,7 +47,7 @@ class SearchStickyHeader extends StatelessWidget { final bottomGap = lerpDouble(16, 10, compactProgress)!; final blurred = progress > 0.01; final backgroundColor = blurred - ? const Color(0xFF181818).withValues(alpha: 0.82) + ? AppColors.background.withValues(alpha: 0.82) : AppColors.background; final inner = Column( @@ -314,7 +314,7 @@ class _FilterButton extends StatelessWidget { child: Container( width: 7, height: 7, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), diff --git a/lib/features/search/presentation/widgets/search_set_sheet.dart b/lib/features/search/presentation/widgets/search_set_sheet.dart index c824a0e4..ef242d13 100644 --- a/lib/features/search/presentation/widgets/search_set_sheet.dart +++ b/lib/features/search/presentation/widgets/search_set_sheet.dart @@ -83,7 +83,7 @@ class _SearchSetSheetState extends State<SearchSetSheet> { maxChildSize: 0.95, expand: false, builder: (context, scrollController) => Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), diff --git a/lib/features/search/presentation/widgets/search_state_views.dart b/lib/features/search/presentation/widgets/search_state_views.dart index 70d55c31..2bccfc00 100644 --- a/lib/features/search/presentation/widgets/search_state_views.dart +++ b/lib/features/search/presentation/widgets/search_state_views.dart @@ -44,7 +44,7 @@ class SearchContentView extends StatelessWidget { slivers: [ SliverToBoxAdapter(child: SizedBox(height: topPad)), if (state.status == SearchStatus.refreshing) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: SizedBox( height: 2, child: LinearProgressIndicator( @@ -104,7 +104,7 @@ class SearchContentView extends StatelessWidget { return [ SearchResultsGrid(items: state.items), if (state.isLoadingMore) - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Center( @@ -540,7 +540,7 @@ class _ActionChip extends StatelessWidget { const SizedBox(width: 8), Text( label, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 14, fontWeight: FontWeight.w600, diff --git a/lib/features/shorts/presentation/pages/shorts_page.dart b/lib/features/shorts/presentation/pages/shorts_page.dart index cbccd1c3..a190ef3b 100644 --- a/lib/features/shorts/presentation/pages/shorts_page.dart +++ b/lib/features/shorts/presentation/pages/shorts_page.dart @@ -323,7 +323,7 @@ class _ShortsViewState extends State<_ShortsView> top: topPad, left: 0, right: 0, - child: const LinearProgressIndicator( + child: LinearProgressIndicator( minHeight: 2, color: AppColors.primary, backgroundColor: Colors.transparent, diff --git a/lib/features/shorts/presentation/widgets/short_reel_item.dart b/lib/features/shorts/presentation/widgets/short_reel_item.dart index e1ebd8ad..b42040fb 100644 --- a/lib/features/shorts/presentation/widgets/short_reel_item.dart +++ b/lib/features/shorts/presentation/widgets/short_reel_item.dart @@ -7,6 +7,7 @@ import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/presentation/widgets/report_sheet.dart'; import 'package:soplay/core/player/media_controller.dart'; import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_colors.dart'; import '../../domain/entities/short_entity.dart'; @@ -685,7 +686,7 @@ class _ShortReelItemState extends State<ShortReelItem> s.contentThumbnail, fit: BoxFit.cover, errorBuilder: (_, e, st) => Container( - color: const Color(0xFF2A2A2A), + color: AppColors.surfaceVariant, child: const Icon( Icons.movie_rounded, color: Colors.white54, @@ -830,14 +831,15 @@ class _ShortReelItemState extends State<ShortReelItem> padding: const EdgeInsets.only(left: 4, right: 14), decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), - gradient: const LinearGradient( - colors: [Color(0xFFE53935), Color(0xFFB71C1C)], + // Was a second, hard-coded red — the accent's job, duplicated. + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.primaryDark], begin: Alignment.topLeft, end: Alignment.bottomRight, ), boxShadow: [ BoxShadow( - color: Colors.red.withValues(alpha: 0.3), + color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 4), ), @@ -999,7 +1001,7 @@ class _ShortReelItemState extends State<ShortReelItem> value: _progress, minHeight: 3, backgroundColor: Colors.white24, - color: const Color(0xFFE53935), + color: AppColors.primary, ), ), ); diff --git a/lib/features/shorts/presentation/widgets/shorts_state_views.dart b/lib/features/shorts/presentation/widgets/shorts_state_views.dart index 17721c9c..5f5c476d 100644 --- a/lib/features/shorts/presentation/widgets/shorts_state_views.dart +++ b/lib/features/shorts/presentation/widgets/shorts_state_views.dart @@ -20,7 +20,7 @@ class ShortsLoadingView extends StatelessWidget { color: AppColors.primary.withValues(alpha: 0.15), shape: BoxShape.circle, ), - child: const Padding( + child: Padding( padding: EdgeInsets.all(14), child: CircularProgressIndicator( color: AppColors.primary, diff --git a/lib/features/splash/presentation/widgets/netflix_splash.dart b/lib/features/splash/presentation/widgets/netflix_splash.dart index f142fcda..016e1bc8 100644 --- a/lib/features/splash/presentation/widgets/netflix_splash.dart +++ b/lib/features/splash/presentation/widgets/netflix_splash.dart @@ -106,7 +106,7 @@ class _NetflixSplashState extends State<NetflixSplash> child: Transform.scale( scale: _sScale.value, alignment: Alignment.center, - child: const Text('S', style: _kStyle), + child: Text('S', style: _kStyle), ), ), ClipRect( @@ -115,7 +115,7 @@ class _NetflixSplashState extends State<NetflixSplash> widthFactor: _oplayWidth.value, child: Opacity( opacity: _oplayOpacity.value, - child: const Text('OZO', style: _kStyle), + child: Text('OZO', style: _kStyle), ), ), ), @@ -129,7 +129,9 @@ class _NetflixSplashState extends State<NetflixSplash> } } -const TextStyle _kStyle = TextStyle( +/// A getter, not a `const`: the wordmark is painted in the accent colour, and +/// the accent is a runtime choice now. +TextStyle get _kStyle => TextStyle( color: AppColors.primary, fontSize: 80, fontWeight: FontWeight.w900, diff --git a/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart b/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart index ba719894..b55ef325 100644 --- a/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart +++ b/lib/features/streak/presentation/dialogs/streak_milestone_dialog.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; const Color _ember = Color(0xFFFFA94D); const Color _emberDeep = Color(0xFFEF7A35); @@ -97,10 +98,10 @@ class _StreakMilestoneDialogState extends State<StreakMilestoneDialog> constraints: const BoxConstraints(maxWidth: 360), padding: const EdgeInsets.fromLTRB(24, 30, 24, 22), decoration: BoxDecoration( - gradient: const LinearGradient( + gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF222222), Color(0xFF161616)], + colors: [AppColors.surface, AppColors.background], ), borderRadius: BorderRadius.circular(24), border: Border.all( @@ -158,7 +159,7 @@ class _StreakMilestoneDialogState extends State<StreakMilestoneDialog> ), foregroundColor: AppColors.textPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( @@ -178,7 +179,7 @@ class _StreakMilestoneDialogState extends State<StreakMilestoneDialog> padding: const EdgeInsets.symmetric(vertical: 13), backgroundColor: AppColors.primary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.ios_share_rounded, size: 16), @@ -374,7 +375,7 @@ class StreakFreezeSavedDialog extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 13), backgroundColor: _frostDeep, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( diff --git a/lib/features/streak/presentation/widgets/streak_card.dart b/lib/features/streak/presentation/widgets/streak_card.dart index 2e8c513b..fc0d9982 100644 --- a/lib/features/streak/presentation/widgets/streak_card.dart +++ b/lib/features/streak/presentation/widgets/streak_card.dart @@ -90,10 +90,13 @@ class _CompactStreakRow extends StatelessWidget { onTap: () => context.push('/streak'), child: Ink( decoration: BoxDecoration( - gradient: const LinearGradient( + // surface -> background, which is #242424 -> #181818 on the + // default theme: the same top-left-lighter fall the literals + // #222222 -> #1A1A1A gave, but it follows AMOLED. + gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF222222), Color(0xFF1A1A1A)], + colors: [AppColors.surface, AppColors.background], ), borderRadius: BorderRadius.circular(16), border: Border.all( diff --git a/lib/features/tracker/presentation/pages/following_page.dart b/lib/features/tracker/presentation/pages/following_page.dart index 3266ad16..088511d7 100644 --- a/lib/features/tracker/presentation/pages/following_page.dart +++ b/lib/features/tracker/presentation/pages/following_page.dart @@ -208,7 +208,7 @@ class _FollowedTitlesViewState extends State<FollowedTitlesView> child: Column( children: [ if (_checking) - const LinearProgressIndicator( + LinearProgressIndicator( minHeight: 2, backgroundColor: Colors.transparent, valueColor: AlwaysStoppedAnimation(AppColors.primary), diff --git a/lib/features/trivia/presentation/pages/actor_hero_page.dart b/lib/features/trivia/presentation/pages/actor_hero_page.dart index 65cca721..b7c62289 100644 --- a/lib/features/trivia/presentation/pages/actor_hero_page.dart +++ b/lib/features/trivia/presentation/pages/actor_hero_page.dart @@ -252,12 +252,12 @@ class _Backdrop extends StatelessWidget { fit: BoxFit.cover, alignment: Alignment.topCenter, placeholder: (_, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), errorWidget: (_, _, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), ) else - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -267,7 +267,7 @@ class _Backdrop extends StatelessWidget { ), ), ), - const Positioned( + Positioned( left: 0, right: 0, bottom: 0, @@ -278,12 +278,15 @@ class _Backdrop extends StatelessWidget { gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, + // Every stop is the page background at falling opacity, + // so the backdrop dissolves into whatever the page is — + // #181818 or true black. colors: [ AppColors.background, - Color(0xEE181818), - Color(0x00181818), + AppColors.background.withValues(alpha: 0.933), + AppColors.background.withValues(alpha: 0.0), ], - stops: [0.0, 0.5, 1.0], + stops: const [0.0, 0.5, 1.0], ), ), ), @@ -402,7 +405,7 @@ class _TitlesChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(CupertinoIcons.film_fill, + Icon(CupertinoIcons.film_fill, color: AppColors.primaryLight, size: 14), const SizedBox(width: 7), Text( @@ -771,7 +774,7 @@ class _SecondaryButton extends StatelessWidget { border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), ), child: busy - ? const SizedBox( + ? SizedBox( width: 20, height: 20, child: CircularProgressIndicator( @@ -782,7 +785,7 @@ class _SecondaryButton extends StatelessWidget { : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(CupertinoIcons.person_2_fill, + Icon(CupertinoIcons.person_2_fill, color: AppColors.primary, size: 16), const SizedBox(width: 7), Flexible( @@ -790,7 +793,7 @@ class _SecondaryButton extends StatelessWidget { 'trivia.challenge_friend'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 14, fontWeight: FontWeight.w600, @@ -847,7 +850,7 @@ class _InlineError extends StatelessWidget { ), child: Text( 'general.retry'.tr(), - style: const TextStyle( + style: TextStyle( color: AppColors.primary, fontSize: 14, fontWeight: FontWeight.w600, diff --git a/lib/features/trivia/presentation/pages/buff_hub_page.dart b/lib/features/trivia/presentation/pages/buff_hub_page.dart index 90905587..4a509b3d 100644 --- a/lib/features/trivia/presentation/pages/buff_hub_page.dart +++ b/lib/features/trivia/presentation/pages/buff_hub_page.dart @@ -102,7 +102,7 @@ class _Masthead extends StatelessWidget { children: [ Row( children: [ - const Icon( + Icon( CupertinoIcons.film_fill, color: AppColors.primary, size: 24, @@ -292,7 +292,7 @@ class _FaceStack extends StatelessWidget { color: AppColors.surfaceVariant, borderRadius: BorderRadius.circular(12), ), - child: const Icon( + child: Icon( CupertinoIcons.heart_fill, color: AppColors.primaryLight, size: 24, @@ -314,7 +314,7 @@ class _FaceStack extends StatelessWidget { left: i * _step, child: Container( padding: const EdgeInsets.all(_ring), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surface, shape: BoxShape.circle, ), @@ -919,7 +919,7 @@ class _HowStep extends StatelessWidget { Container( width: 40, height: 40, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), @@ -1045,7 +1045,7 @@ class _RankRow extends StatelessWidget { ringWidth: 2, ) : Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), @@ -1123,7 +1123,7 @@ class _RankRow extends StatelessWidget { child: Text( 'trivia.points_value'.tr(args: ['${entry.score}']), maxLines: 1, - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 13, fontWeight: FontWeight.w800, diff --git a/lib/features/trivia/presentation/pages/challenge_landing_page.dart b/lib/features/trivia/presentation/pages/challenge_landing_page.dart index 4be20437..f3f2a36c 100644 --- a/lib/features/trivia/presentation/pages/challenge_landing_page.dart +++ b/lib/features/trivia/presentation/pages/challenge_landing_page.dart @@ -205,7 +205,7 @@ class _Backdrop extends StatelessWidget { imageUrl: url, fit: BoxFit.cover, errorWidget: (_, _, _) => - const ColoredBox(color: AppColors.surfaceVariant), + ColoredBox(color: AppColors.surfaceVariant), ), BackdropFilter( filter: ImageFilter.blur(sigmaX: 34, sigmaY: 34), @@ -238,7 +238,7 @@ class _SwordsBadge extends StatelessWidget { return Container( width: 88, height: 88, - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.primary, ), @@ -330,7 +330,7 @@ class _MiniAvatar extends StatelessWidget { const size = 34.0; return Container( padding: const EdgeInsets.all(2), - decoration: const BoxDecoration( + decoration: BoxDecoration( shape: BoxShape.circle, color: AppColors.surface, ), @@ -409,7 +409,7 @@ class _LoadingView extends StatelessWidget { @override Widget build(BuildContext context) { - return const Center( + return Center( child: CircularProgressIndicator(color: AppColors.primary), ); } diff --git a/lib/features/trivia/presentation/pages/game_page.dart b/lib/features/trivia/presentation/pages/game_page.dart index c1a37324..21e5400c 100644 --- a/lib/features/trivia/presentation/pages/game_page.dart +++ b/lib/features/trivia/presentation/pages/game_page.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/player/media_controller.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/trivia/domain/entities/trivia_option_entity.dart'; @@ -181,7 +182,7 @@ class _GameViewState extends State<_GameView> { onPressed: () => Navigator.of(ctx).pop(true), child: Text( 'trivia.forfeit_confirm'.tr(), - style: const TextStyle(color: AppColors.primaryLight), + style: TextStyle(color: AppColors.primaryLight), ), ), ], @@ -435,7 +436,7 @@ class _RevealPanel extends StatelessWidget { border: Border.all( color: (reveal.correct ? AppColors.success - : AppColors.primary) + : AppColors.error) .withValues(alpha: 0.5), width: 1.2, ), @@ -463,7 +464,7 @@ class _RevealPanel extends StatelessWidget { style: TextStyle( color: reveal.correct ? AppColors.success - : AppColors.primaryLight, + : AppColors.errorLight, fontSize: 12, fontWeight: FontWeight.w800, height: 1.2, @@ -502,7 +503,7 @@ class _RevealPanel extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(CupertinoIcons.play_fill, size: 16), diff --git a/lib/features/trivia/presentation/pages/leaderboard_page.dart b/lib/features/trivia/presentation/pages/leaderboard_page.dart index 0258cf74..b44aa997 100644 --- a/lib/features/trivia/presentation/pages/leaderboard_page.dart +++ b/lib/features/trivia/presentation/pages/leaderboard_page.dart @@ -234,7 +234,7 @@ class _PinnedMyRow extends StatelessWidget { final bottomSafe = MediaQuery.paddingOf(context).bottom; return Container( padding: EdgeInsets.fromLTRB(16, 10, 16, 10 + bottomSafe), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.navBackground, border: Border(top: BorderSide(color: AppColors.border)), ), diff --git a/lib/features/trivia/presentation/pages/result_page.dart b/lib/features/trivia/presentation/pages/result_page.dart index 54f4a236..0519929e 100644 --- a/lib/features/trivia/presentation/pages/result_page.dart +++ b/lib/features/trivia/presentation/pages/result_page.dart @@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/trivia/domain/entities/actor_ref_entity.dart'; import 'package:soplay/features/trivia/domain/entities/trivia_result_entity.dart'; import 'package:soplay/features/trivia/presentation/trivia_args.dart'; @@ -412,11 +413,11 @@ class _SecondaryButton extends StatelessWidget { side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), foregroundColor: AppColors.textPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: busy - ? const SizedBox( + ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( diff --git a/lib/features/trivia/presentation/pages/top_fans_page.dart b/lib/features/trivia/presentation/pages/top_fans_page.dart index b7582114..9aa7406e 100644 --- a/lib/features/trivia/presentation/pages/top_fans_page.dart +++ b/lib/features/trivia/presentation/pages/top_fans_page.dart @@ -254,7 +254,7 @@ class _PinnedMyRow extends StatelessWidget { final bottomSafe = MediaQuery.paddingOf(context).bottom; return Container( padding: EdgeInsets.fromLTRB(16, 10, 16, 10 + bottomSafe), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.navBackground, border: Border(top: BorderSide(color: AppColors.border)), ), @@ -274,7 +274,7 @@ class _FandomBadge extends StatelessWidget { children: [ Text( '${percent.toStringAsFixed(0)}%', - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontSize: 17, fontWeight: FontWeight.w900, diff --git a/lib/features/trivia/presentation/widgets/buff_empty_panel.dart b/lib/features/trivia/presentation/widgets/buff_empty_panel.dart index cea7a99e..71ad9426 100644 --- a/lib/features/trivia/presentation/widgets/buff_empty_panel.dart +++ b/lib/features/trivia/presentation/widgets/buff_empty_panel.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// The app's shipped empty / error panel, lifted out of the cast picker so every /// Buff surface says "there is nothing here" in exactly one voice. @@ -84,7 +85,7 @@ class BuffEmptyPanel extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( diff --git a/lib/features/trivia/presentation/widgets/cast_card.dart b/lib/features/trivia/presentation/widgets/cast_card.dart index 76e4ac7f..071d8334 100644 --- a/lib/features/trivia/presentation/widgets/cast_card.dart +++ b/lib/features/trivia/presentation/widgets/cast_card.dart @@ -219,7 +219,7 @@ class _HighlightedName extends StatelessWidget { TextSpan(text: name.substring(0, start)), TextSpan( text: name.substring(start, end), - style: const TextStyle( + style: TextStyle( color: AppColors.primaryLight, fontWeight: FontWeight.w800, ), diff --git a/lib/features/trivia/presentation/widgets/countdown_ring.dart b/lib/features/trivia/presentation/widgets/countdown_ring.dart index 0414caf7..1021b340 100644 --- a/lib/features/trivia/presentation/widgets/countdown_ring.dart +++ b/lib/features/trivia/presentation/widgets/countdown_ring.dart @@ -24,7 +24,9 @@ class CountdownRing extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _danger ? AppColors.primaryLight : Colors.white; + // The last four seconds are an alarm state, so they stay red whatever the + // accent is — a green "hurry up" ring says the opposite of what it means. + final color = _danger ? AppColors.error : Colors.white; final target = totalSeconds <= 0 ? 0.0 : (secondsRemaining / totalSeconds).clamp(0.0, 1.0); diff --git a/lib/features/trivia/presentation/widgets/option_chip.dart b/lib/features/trivia/presentation/widgets/option_chip.dart index 8feffc66..527c6d2b 100644 --- a/lib/features/trivia/presentation/widgets/option_chip.dart +++ b/lib/features/trivia/presentation/widgets/option_chip.dart @@ -87,9 +87,14 @@ class OptionChip extends StatelessWidget { icon: Icons.check_rounded, ); case OptionChipStatus.wrong: + // error, not primary. These two chips are shown side by side and the + // whole point is that they read as opposites; under a green accent + // "wrong" and "correct" would both have been green. return _ChipPalette( - fill: AppColors.primary.withValues(alpha: 0.9), - border: AppColors.primaryLight, + fill: AppColors.error.withValues(alpha: 0.9), + // Lifted off the fill so the 1.4px rim still reads as a rim. This is + // the exact colour the chip had before the accent became a setting. + border: AppColors.errorLight, text: Colors.white, icon: Icons.close_rounded, ); diff --git a/lib/features/trivia/presentation/widgets/share_card.dart b/lib/features/trivia/presentation/widgets/share_card.dart index bd7abf02..4c1ecb47 100644 --- a/lib/features/trivia/presentation/widgets/share_card.dart +++ b/lib/features/trivia/presentation/widgets/share_card.dart @@ -54,7 +54,7 @@ class ShareCard extends StatelessWidget { children: [ Row( children: [ - const Icon(Icons.movie_filter_rounded, + Icon(Icons.movie_filter_rounded, color: AppColors.primary, size: 22), const SizedBox(width: 8), Text( diff --git a/lib/features/user_lists/presentation/pages/user_lists_page.dart b/lib/features/user_lists/presentation/pages/user_lists_page.dart index 49e06c41..b0335ebd 100644 --- a/lib/features/user_lists/presentation/pages/user_lists_page.dart +++ b/lib/features/user_lists/presentation/pages/user_lists_page.dart @@ -132,7 +132,7 @@ class _UserListTabState extends State<_UserListTab> super.build(context); final items = _items; if (items == null) { - return const Center( + return Center( child: CircularProgressIndicator(color: AppColors.primary), ); } @@ -148,7 +148,7 @@ class _UserListTabState extends State<_UserListTab> physics: const AlwaysScrollableScrollPhysics(), itemCount: items.length, separatorBuilder: (_, _) => - const Divider(color: AppColors.divider, height: 1, indent: 84), + Divider(color: AppColors.divider, height: 1, indent: 84), itemBuilder: (context, i) { final item = items[i]; return _UserListRow( diff --git a/lib/features/watch_party/presentation/pages/watch_party_page.dart b/lib/features/watch_party/presentation/pages/watch_party_page.dart index 58f77cce..3ebb82ed 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -367,14 +367,14 @@ class _WatchPartyPageState extends State<WatchPartyPage> { ), ), ), - const Divider(height: 1, color: AppColors.border), + Divider(height: 1, color: AppColors.border), // Fixed reaction bar directly above the chat — always reachable instead // of buried at the bottom of the scrollable top region. Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center(child: PartyReactionPicker(service: _service)), ), - const Divider(height: 1, color: AppColors.border), + Divider(height: 1, color: AppColors.border), Expanded( flex: 6, child: Stack( @@ -407,7 +407,7 @@ class _ConnectingView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( + SizedBox( width: 34, height: 34, child: CircularProgressIndicator( diff --git a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart index 056c5ba8..e16c32f0 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -198,7 +198,7 @@ class _Composer extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, border: Border( top: BorderSide(color: AppColors.divider, width: 0.6), diff --git a/lib/features/watch_party/presentation/widgets/party_code_sheet.dart b/lib/features/watch_party/presentation/widgets/party_code_sheet.dart index 85bb5555..94371dfc 100644 --- a/lib/features/watch_party/presentation/widgets/party_code_sheet.dart +++ b/lib/features/watch_party/presentation/widgets/party_code_sheet.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; @@ -102,7 +103,7 @@ class _PartyCreateSheetState extends State<PartyCreateSheet> { ), const SizedBox(height: 20), if (_loading) - const Padding( + Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center( child: SizedBox( @@ -207,7 +208,7 @@ class _CodeReveal extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), icon: const Icon(Icons.groups_rounded, size: 18), @@ -358,7 +359,7 @@ class _PartyJoinSheetState extends State<PartyJoinSheet> { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text( @@ -459,7 +460,7 @@ class _ErrorRetry extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text('general.try_again'.tr()), diff --git a/lib/features/watch_party/presentation/widgets/party_error_views.dart b/lib/features/watch_party/presentation/widgets/party_error_views.dart index ec56e108..5ec005cb 100644 --- a/lib/features/watch_party/presentation/widgets/party_error_views.dart +++ b/lib/features/watch_party/presentation/widgets/party_error_views.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_theme.dart'; /// Full-screen glass card for a party terminal / error state. Mirrors the visual /// idiom of `my_list_state_views.dart`. @@ -98,7 +99,7 @@ class PartyStateView extends StatelessWidget { foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(kButtonRadius), ), ), child: Text(actionLabel!), diff --git a/lib/features/watch_party/presentation/widgets/party_member_bar.dart b/lib/features/watch_party/presentation/widgets/party_member_bar.dart index ca9abacb..81b19bbb 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -104,7 +104,7 @@ class _MemberTile extends StatelessWidget { left: -2, child: Container( padding: const EdgeInsets.all(2), - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.background, shape: BoxShape.circle, ), diff --git a/test/core/theme/app_palette_test.dart b/test/core/theme/app_palette_test.dart new file mode 100644 index 00000000..a746a55d --- /dev/null +++ b/test/core/theme/app_palette_test.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/theme/app_palette.dart'; + +/// The Appearance feature made ~1900 colour reads across the app dynamic. The +/// one thing that must never regress is that the *default* is still exactly the +/// palette the app shipped with — an install that never opens Appearance has to +/// be pixel-identical to the previous build. +void main() { + setUp(() { + AppPalette.current = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + }); + + group('default palette is byte-identical to the shipped constants', () { + test('accent triple', () { + expect(AppColors.primary, const Color(0xFFE50914)); + expect(AppColors.primaryDark, const Color(0xFFB20710)); + expect(AppColors.primaryLight, const Color(0xFFFF4B55)); + }); + + test('neutral ramp', () { + expect(AppColors.background, const Color(0xFF181818)); + expect(AppColors.navBackground, const Color(0xFF0F0F0F)); + expect(AppColors.surface, const Color(0xFF242424)); + expect(AppColors.card, const Color(0xFF282828)); + expect(AppColors.surfaceVariant, const Color(0xFF303030)); + expect(AppColors.border, const Color(0xFF3A3A3A)); + expect(AppColors.divider, const Color(0xFF2A2A2A)); + }); + + test('fixed roles are untouched by the accent', () { + expect(AppColors.error, const Color(0xFFE50914)); + // The exact old primaryLight — the "wrong answer" / "failed" states used + // to borrow it, and must render identically now they point at error. + expect(AppColors.errorLight, const Color(0xFFFF4B55)); + expect(AppColors.success, const Color(0xFF46D369)); + expect(AppColors.rating, const Color(0xFFFFD700)); + expect(AppColors.splashBackground, const Color(0xFF000000)); + + AppPalette.current = AppPalette.resolve( + accent: AppAccent.presets.firstWhere((a) => a.id == 'ocean'), + darkness: AppDarkness.dark, + ); + // Error is deliberately NOT the accent: a destructive action must still + // read as destructive under a blue theme. + expect(AppColors.error, const Color(0xFFE50914)); + expect(AppColors.errorLight, const Color(0xFFFF4B55)); + expect(AppColors.primary, isNot(AppColors.error)); + expect(AppColors.primaryLight, isNot(AppColors.errorLight)); + }); + + test('hero gradient reproduces the old literal stops', () { + // Was hard-coded as [#1E1416, #181818, #101010] in profile_page.dart. + final top = AppColors.heroTop; + expect(top.r * 255, closeTo(0x1E, 1.0)); + expect(top.g * 255, closeTo(0x14, 1.0)); + expect(top.b * 255, closeTo(0x16, 2.0)); + expect(AppColors.heroMid, const Color(0xFF181818)); + expect(AppColors.heroBottom, const Color(0xFF101010)); + }); + }); + + group('AMOLED', () { + setUp(() { + AppPalette.current = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.black, + ); + }); + + test('background and nav go to true black', () { + expect(AppColors.background, const Color(0xFF000000)); + expect(AppColors.navBackground, const Color(0xFF000000)); + expect(AppColors.isBlack, isTrue); + }); + + test('the depth ordering of the ramp survives', () { + double l(Color c) => c.computeLuminance(); + expect(l(AppColors.background), lessThanOrEqualTo(l(AppColors.surface))); + expect(l(AppColors.surface), lessThan(l(AppColors.card))); + expect(l(AppColors.card), lessThan(l(AppColors.surfaceVariant))); + // Hairlines have to stay clearly above the surface they sit on, or every + // card edge disappears into the black. + expect(l(AppColors.border), greaterThan(l(AppColors.surfaceVariant))); + expect(l(AppColors.divider), greaterThan(l(AppColors.surface))); + }); + + test('the accent is untouched by the darkness level', () { + expect(AppColors.primary, const Color(0xFFE50914)); + }); + + test('hero gradient bottoms out at true black', () { + expect(AppColors.heroBottom, const Color(0xFF000000)); + expect(AppColors.heroMid, const Color(0xFF000000)); + }); + }); + + group('accent legibility contract', () { + test('every preset keeps white readable on it', () { + for (final accent in AppAccent.presets) { + expect( + AppAccent.whiteContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minWhiteContrast), + reason: '${accent.id} would make white text unreadable on a fill', + ); + } + }); + + test('every preset satisfies the two-sided contract', () { + for (final accent in AppAccent.presets) { + expect( + AppAccent.isLegibleAccent(accent.base), + isTrue, + reason: '${accent.id} is outside the legible band', + ); + } + }); + + test('every preset stays visible against both backgrounds', () { + double ratio(Color a, Color b) { + final la = a.computeLuminance(); + final lb = b.computeLuminance(); + final hi = la > lb ? la : lb; + final lo = la > lb ? lb : la; + return (hi + 0.05) / (lo + 0.05); + } + + for (final accent in AppAccent.presets) { + // 3.6 is just under the shipped red's own 3.70 against #181818, so no + // accent is ever less legible than the one the app always had. + expect( + ratio(accent.base, const Color(0xFF181818)), + greaterThan(3.6), + reason: '${accent.id} disappears into the dark background', + ); + expect( + ratio(accent.base, const Color(0xFF000000)), + greaterThan(3.6), + reason: '${accent.id} disappears into the AMOLED background', + ); + } + }); + + test('preset ids are unique and stable', () { + final ids = AppAccent.presets.map((a) => a.id).toList(); + expect(ids.toSet().length, ids.length); + expect(ids.first, 'red', reason: 'the default must stay first'); + expect(ids, isNot(contains(AppAccent.customId))); + }); + }); + + group('custom accents', () { + test('a legible seed is kept as-is', () { + const seed = Color(0xFF2F7BF6); + expect(AppAccent.custom(seed).base, seed); + }); + + test('a too-bright seed is darkened until white works on it', () { + // Pure yellow: 1.07:1 against white — unusable as a button fill. + final accent = AppAccent.custom(const Color(0xFFFFFF00)); + expect( + AppAccent.whiteContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minWhiteContrast), + ); + // ...but it is still recognisably yellow, not brown mush. + expect( + HSLColor.fromColor(accent.base).hue, + closeTo(60, 1.0), + ); + }); + + test('a too-dark seed is lightened until it shows on the page', () { + // Near-black would pass the white rule perfectly and then be invisible on + // the page — most visibly on the splash, which is nothing but the accent. + final accent = AppAccent.custom(const Color(0xFF060606)); + expect( + AppAccent.blackContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minBackgroundContrast), + ); + expect( + AppAccent.whiteContrast(accent.base), + greaterThanOrEqualTo(AppAccent.minWhiteContrast), + ); + }); + + test('white and black seeds both terminate inside the legible band', () { + for (final seed in const [ + Color(0xFFFFFFFF), + Color(0xFF000000), + Color(0xFF7F7F7F), + Color(0xFF00FF00), + Color(0xFF0000FF), + ]) { + final base = AppAccent.custom(seed).base; + expect( + AppAccent.isLegibleAccent(base), + isTrue, + reason: 'seed $seed landed outside the legible band at $base', + ); + } + }); + + test('a translucent seed is forced opaque', () { + expect(AppAccent.custom(const Color(0x402F7BF6)).base.a, 1.0); + }); + + test('derivation matches the shipped red family rule exactly', () { + // The dark/light derivation is calibrated so that the default red + // reproduces its own hand-tuned variants. + final derived = AppAccent.custom(const Color(0xFFE50914)); + expect(derived.dark, const Color(0xFFB20710)); + expect(derived.light.r * 255, closeTo(0xFF, 1.0)); + expect(derived.light.g * 255, closeTo(0x4B, 1.0)); + expect(derived.light.b * 255, closeTo(0x55, 1.0)); + }); + + test('byId rejects unknown and custom ids', () { + expect(AppAccent.byId('nope'), isNull); + expect(AppAccent.byId(''), isNull); + expect(AppAccent.byId(null), isNull); + expect(AppAccent.byId(AppAccent.customId), isNull); + expect(AppAccent.byId('violet')?.id, 'violet'); + }); + }); + + group('tab-bar tint', () { + test('resolve() honours what it was asked for, not a policy', () { + // The stored preference ships ON; the pure resolver still defaults off, + // so a hand-built palette is exactly what the caller described. + expect(AppPalette.current.tintNav, isFalse); + expect(AppColors.isNavTinted, isFalse); + }); + + test('rides the same palette every other colour does', () { + AppPalette.current = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + tintNav: true, + ); + expect(AppColors.isNavTinted, isTrue); + // ...and changes nothing else. + expect(AppColors.primary, const Color(0xFFE50914)); + expect(AppColors.background, const Color(0xFF181818)); + }); + + test('counts as a difference, so the tree repaints when it flips', () { + final off = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + final on = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + tintNav: true, + ); + expect(off, isNot(on)); + expect(off.hashCode, isNot(on.hashCode)); + }); + }); + + test('palette equality is by accent and darkness', () { + final a = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + final b = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ); + final c = AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.black, + ); + expect(a, b); + expect(a, isNot(c)); + }); +} diff --git a/test/features/profile/theme_preview_test.dart b/test/features/profile/theme_preview_test.dart new file mode 100644 index 00000000..26ffdd7e --- /dev/null +++ b/test/features/profile/theme_preview_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/theme/app_accent.dart'; +import 'package:soplay/core/theme/app_palette.dart'; +import 'package:soplay/features/profile/presentation/widgets/theme_preview.dart'; + +/// [ThemePreview] is laid out against a hand-computed 240 × 480 canvas — the +/// column of fixed heights has to keep adding up to less than that. These pump +/// it at every accent, both darkness levels, and both the large and thumbnail +/// sizes, so a future tweak to one child's height cannot silently start +/// overflowing. +void main() { + Future<void> pumpAt(WidgetTester tester, Widget child, Size size) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox(width: size.width, height: size.height, child: child), + ), + ), + ), + ); + } + + testWidgets('renders at the large size for every accent and darkness', + (tester) async { + for (final accent in AppAccent.presets) { + for (final darkness in AppDarkness.values) { + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve(accent: accent, darkness: darkness), + ), + const Size(240, 480), + ); + expect( + tester.takeException(), + isNull, + reason: '${accent.id} / ${darkness.name} overflowed', + ); + } + } + }); + + testWidgets('renders as a thumbnail without chrome', (tester) async { + // Roughly the width one darkness tile gives it on a small phone. + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.black, + ), + showChrome: false, + ), + const Size(75, 150), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('survives a very narrow box', (tester) async { + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve( + accent: AppAccent.fallback, + darkness: AppDarkness.dark, + ), + showChrome: false, + ), + const Size(40, 80), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('a custom accent renders too', (tester) async { + await pumpAt( + tester, + ThemePreview( + palette: AppPalette.resolve( + accent: AppAccent.custom(const Color(0xFFFFFF00)), + darkness: AppDarkness.black, + ), + ), + const Size(240, 480), + ); + expect(tester.takeException(), isNull); + }); +} From 78389eb413083786afc734b53d977e76f2df7b56 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 12:11:10 +0500 Subject: [PATCH 02/21] feat(mal): integrate MyAnimeList tracking and enhance episode reporting --- .../auth/presentation/bloc/auth_bloc.dart | 8 +++++ .../history/data/history_service.dart | 11 ++++++ .../history/data/history_sync_service.dart | 35 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index d4cf9f8b..3c86030d 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -386,6 +386,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> { /// tests where the sync service is not wired. Future<void> _syncHistory() async { if (!getIt.isRegistered<HistorySyncService>()) return; + // Before anything is uploaded: the rows sitting on this phone may belong to + // whoever signed in last. Signing out resets the push watermark, so the + // first sync pushes EVERYTHING local — which is what you want when the same + // person returns, and a data leak when it is somebody else. + final userId = hiveService.getUser()?.id; + if (userId != null && userId.isNotEmpty) { + await getIt<HistorySyncService>().adoptFor(userId); + } await getIt<HistorySyncService>().sync(); // The AniList link is stored on the account, so signing in on a new device // is exactly when it should reappear — without this, connecting on the diff --git a/lib/features/history/data/history_service.dart b/lib/features/history/data/history_service.dart index 5ac710f9..aef979a4 100644 --- a/lib/features/history/data/history_service.dart +++ b/lib/features/history/data/history_service.dart @@ -127,6 +127,17 @@ class HistoryService { if (keys.isNotEmpty) revision.value++; } + /// Drops every local row WITHOUT recording tombstones. + /// + /// The distinction from [clearAll] matters: this is not a user deleting their + /// history, it is these rows turning out to belong to a different account + /// (see HistorySyncService.adoptFor). Tombstoning them would push a delete + /// for each one into whichever account signs in next. + Future<void> clearLocalOnly() async { + await _box.clear(); + revision.value++; + } + Future<void> clearAll() async { if (getIt.isRegistered<HistorySyncService>()) { final sync = getIt<HistorySyncService>(); diff --git a/lib/features/history/data/history_sync_service.dart b/lib/features/history/data/history_sync_service.dart index 05b37a8e..c98aba38 100644 --- a/lib/features/history/data/history_sync_service.dart +++ b/lib/features/history/data/history_sync_service.dart @@ -40,6 +40,7 @@ class HistorySyncService { static const String _cursorKey = 'history_sync_cursor'; static const String _pushedAtKey = 'history_sync_pushed_at'; static const String _tombstonesKey = 'history_sync_tombstones'; + static const String _ownerKey = 'history_sync_owner'; bool _running = false; @@ -133,12 +134,46 @@ class HistorySyncService { } /// Sign-out must not leave one account's cursor pointing at another's history. + /// + /// The Hive rows are deliberately LEFT ALONE here — someone who signs out and + /// keeps watching still has their Continue Watching list. What makes that safe + /// is [adoptFor], which refuses to hand those rows to a different account. Future<void> clear() async { await _state.delete(_cursorKey); await _state.delete(_pushedAtKey); await _state.delete(_tombstonesKey); } + /// Decides whether the rows already on this phone belong to [userId]. + /// + /// Sign-out clears the cursor, which resets the push watermark to zero — so + /// the first sync after ANY sign-in uploads every local row. That is right + /// when the same person signs back in, or when someone who had been watching + /// signed out finally makes an account: their viewing follows them. + /// + /// It is badly wrong when the next sign-in is somebody else. Handing one + /// person's watch history to another account is not a sync bug, it is a leak, + /// and nothing was stopping it. So the rows are stamped with the account they + /// belong to, and a different owner wipes them before the first sync can push + /// them anywhere. + /// + /// Unstamped rows have no owner yet (signed-out viewing, or an install from + /// before this existed) and are adopted rather than destroyed. + Future<void> adoptFor(String userId) async { + final id = userId.trim(); + if (id.isEmpty) return; + + final owner = _state.get(_ownerKey) as String?; + if (owner == id) return; + + if (owner != null && owner != id) { + await _local.clearLocalOnly(); + // The cursor and the outgoing tombstones described the previous account. + await clear(); + } + await _state.put(_ownerKey, id); + } + // ─── applying the server's answer ────────────────────────────────────────── Future<void> _applyRemote(List<HistorySyncItem> items) async { From 521fa4ccd5176d13574c8182623d0d9d047d10b9 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 12:31:12 +0500 Subject: [PATCH 03/21] Make the player treat a broadcast as a broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live TV already told the player what it was handing over — `type: 'live'` — and the player never read it. Whether a stream was live was guessed from its duration alone, and plenty of live HLS carries a DVR window and reports a perfectly finite one. Those channels were treated as files: a scrub bar over something unscrubbable, frame-preview extraction against an endless stream, and the live edge read as the end of the video. So the declared type decides, and it is known before the first frame rather than after it, because a channel that is down when you open it fails during initialize() and that is exactly the path that needs to know. Reconnection is the other half. A film either plays or is broken, so four retries a session is generous; a channel drops because a segment was late or a CDN failed over, and the only right answer is to open it again. Four in a two-hour evening meant the player gave up for good on something that was working seconds later. A broadcast now reconnects with a backoff, on errors a file would call fatal — a 403 on a live edge is usually a rotated token, and the next playlist fetch has the current one. Seeking is refused outright. The double-tap gesture reached the same seek as the scrub bar, and a tap landing outside the DVR window stalls the stream with no way back but leaving the channel. Channels also carry the headers their origin insists on, all the way to the player and into the pinned-card cache — a header-gated channel used to work once and 403 forever after being favourited. --- assets/translations/en.json | 1 + assets/translations/ru.json | 1 + assets/translations/uz.json | 1 + .../pages/player_page.controls.dart | 8 + .../presentation/pages/player_page.dart | 27 ++ .../presentation/pages/player_page.media.dart | 74 +++++- .../live_tv/data/live_tv_service.dart | 89 +++++-- .../presentation/pages/live_tv_page.dart | 232 ++++++++++++++++-- 8 files changed, 386 insertions(+), 47 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index c98bdb4b..6db9aee8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1217,6 +1217,7 @@ "retry": "Try again", "recent": "RECENTLY WATCHED", "folders": "FOLDERS", + "countries": "COUNTRIES", "search_in": "Search in {folder}…", "channel_count": { "one": "{} channel", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 68d2c73b..c5cdbc92 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1217,6 +1217,7 @@ "retry": "Повторить", "recent": "НЕДАВНО СМОТРЕЛИ", "folders": "ПАПКИ", + "countries": "СТРАНЫ", "search_in": "Поиск в {folder}…", "channel_count": { "one": "{} канал", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 73570cf8..850210ef 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1217,6 +1217,7 @@ "retry": "Qayta urinish", "recent": "YAQINDA KO‘RILGAN", "folders": "PAPKALAR", + "countries": "DAVLATLAR", "search_in": "{folder} ichida qidirish…", "channel_count": { "one": "{} kanal", diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index c395089b..bb85e8b4 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -75,6 +75,11 @@ extension _PlayerControls on _PlayerPageState { void _seekRelative(Duration delta) { if (_partyBlockLocal()) return; + // There is nothing to skip forward into on a broadcast, and skipping back + // lands outside the DVR window on most channels — the stream stalls and the + // viewer's only way out is to leave and come back. The double-tap gesture + // reaches here too, which is how a stray tap used to kill a channel. + if (_isLive) return; final c = _controller; if (c == null || !c.value.isInitialized) return; final next = c.value.position + delta; @@ -94,6 +99,9 @@ extension _PlayerControls on _PlayerPageState { if (_partyBlockLocal()) return; final c = _controller; if (c == null || !c.value.isInitialized) return; + // `Go live` is the one seek a broadcast accepts, and it asks for the very + // end; anything else is a scrub bar that should not have been reachable. + if (_isLive && position < c.value.duration) return; c.seekTo(position); _scheduleHide(); if (!_isLive) { diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index ed652f56..6a7cf3c2 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -73,6 +73,24 @@ part 'player_page.tv.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = 4; +/// Reconnect budget for a live broadcast, which is a different problem. +/// +/// A film either plays or is broken, so four attempts in a session is generous. +/// A channel drops — a segment gap, a CDN failing over, a phone changing +/// network — and the only correct response is to reconnect and keep watching. +/// Four in a two-hour evening meant the player gave up permanently on something +/// that was working again seconds later. +const int _kMaxLiveRetries = 1000; + +/// How long to wait before reconnecting a dropped channel, by attempt. +/// +/// Fast enough that a blip is invisible, and backing off so a channel that is +/// genuinely off air is not hammered all evening. +Duration _liveRetryBackoff(int attempt) { + const steps = [1, 2, 4, 8, 15]; + return Duration(seconds: steps[attempt < steps.length ? attempt : steps.length - 1]); +} + class PlayerPage extends StatefulWidget { const PlayerPage({super.key, required this.args}); final PlayerArgs args; @@ -113,6 +131,15 @@ class _PlayerPageState extends State<PlayerPage> Map<String, String> _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 + /// hands the player `type: 'live'`, and a stream that is live does not stop + /// being live because its playlist happens to report a duration. Plenty of + /// live HLS carries a sliding DVR window, so the duration heuristic below + /// says "not live" for real channels — which drew a scrub bar on something + /// unscrubbable, ran frame-preview extraction against an endless stream, and + /// treated the live edge as the end of the file. bool _isLive = false; List<VideoSourceEntity> _videoSources = const []; diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 9f93519a..b88bcee4 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -411,6 +411,11 @@ extension _PlayerMedia on _PlayerPageState { _videoUrl = effectiveUrl; _mediaType = type; _isNetworkVideo = !isLocal; + // Known BEFORE the first frame, not after it. A channel that is down at the + // moment you open it fails during initialize(), and the error path has to + // already know it is looking at a broadcast — otherwise the one case that + // most needs reconnecting is the one that gets a dead end. + if (type == 'live' || widget.args.type == 'live') _isLive = true; try { await controller.initialize(); @@ -431,7 +436,13 @@ extension _PlayerMedia on _PlayerPageState { return; } final dur = controller.value.duration; - _isLive = dur <= Duration.zero || dur.inHours >= 12; + // What the caller said, OR what the duration implies. The declared type is + // the reliable half: a live channel with a DVR window reports a perfectly + // finite duration and would otherwise be treated as a file. + _isLive = _mediaType == 'live' || + widget.args.type == 'live' || + dur <= Duration.zero || + dur.inHours >= 12; PlayerLog.instance.setContext({ 'live': _isLive.toString(), 'duration': _isLive ? 'live' : dur.toString(), @@ -517,6 +528,15 @@ extension _PlayerMedia on _PlayerPageState { _isCodecError = true; msg = 'This video format is not supported on your device. You can try playing it in your browser.'; + } else if (_isLive && _lifetimeRetries < _kMaxLiveRetries) { + // A channel that would not open is very often a channel that will open + // in a moment — the origin was mid-restart, or the playlist rolled. The + // same reconnect the mid-playback path uses applies here. + _retryAttempts++; + _lifetimeRetries++; + _autoRetrying = true; + _liveReconnect(); + return; } else if (_isRecoverableError(raw) && _retryAttempts < 2 && _lifetimeRetries < _kMaxLifetimeRetries) { @@ -601,14 +621,25 @@ extension _PlayerMedia on _PlayerPageState { if (msg != null && msg != _lastError && mounted) { _lastError = msg; _plog('playback error: $msg', level: LogLevel.error); + // A live channel reconnects rather than giving up: a drop mid-broadcast + // is the normal case, not a broken source. It also reconnects on errors + // a file would call fatal — a 403 or a 404 on a live edge is usually a + // rotated token or a segment that expired while we were away, and the + // next playlist fetch has the current one. + final liveRetry = _isLive && _lifetimeRetries < _kMaxLiveRetries; if (!_autoRetrying && - _retryAttempts < 2 && - _lifetimeRetries < _kMaxLifetimeRetries && - _isRecoverableError(msg)) { + (liveRetry || + (_retryAttempts < 2 && + _lifetimeRetries < _kMaxLifetimeRetries && + _isRecoverableError(msg)))) { _retryAttempts++; _lifetimeRetries++; _autoRetrying = true; - _autoRetry(); + if (_isLive) { + _liveReconnect(); + } else { + _autoRetry(); + } return; } setState(() => _errorMessage = _humanizeError(msg)); @@ -679,6 +710,39 @@ extension _PlayerMedia on _PlayerPageState { if (changed && mounted) setState(() {}); } + /// Reconnects a dropped live channel, backing off between attempts. + /// + /// Deliberately NOT [_autoRetry]: that one's first move is to fall through to + /// the next quality source, which for a channel with a single url is a no-op, + /// and its second is to surface an error. A broadcast has nowhere else to go — + /// the same url IS the channel — so this reopens it, waits longer each time, + /// and keeps the last frame on screen instead of flashing an error at somebody + /// whose stream will be back in two seconds. + Future<void> _liveReconnect() async { + if (!mounted) return; + final attempt = _lifetimeRetries; + _plog('live stream dropped — reconnecting (attempt $attempt)'); + + setState(() { + _stage = _LoadingStage.loading; + _errorMessage = null; + _isCodecError = false; + }); + + await Future<void>.delayed(_liveRetryBackoff(attempt - 1)); + if (!mounted) return; + + final url = _videoUrl; + if (url == null) { + _autoRetrying = false; + return; + } + await _disposeController(); + if (!mounted) return; + await _initializeWith(url: url, headers: _headers, type: _mediaType); + _autoRetrying = false; + } + Future<void> _autoRetry() async { if (!mounted) return; diff --git a/lib/features/live_tv/data/live_tv_service.dart b/lib/features/live_tv/data/live_tv_service.dart index dc072f09..bf5edac5 100644 --- a/lib/features/live_tv/data/live_tv_service.dart +++ b/lib/features/live_tv/data/live_tv_service.dart @@ -12,6 +12,7 @@ class LiveChannel { this.country = '', this.language = '', this.category = '', + this.headers = const {}, }); final String id; @@ -21,6 +22,14 @@ class LiveChannel { final String country; final String language; + /// Headers this stream's origin insists on, or empty. + /// + /// A good share of broadcast CDNs answer 403 to a request that does not + /// present the User-Agent or Referer they expect. The server knows which + /// channels those are; without carrying them here the player asks plainly and + /// the channel looks simply broken. + final Map<String, String> headers; + /// Filled in from the group it arrived in, so a channel carries its own /// category once it is out of the list. final String category; @@ -33,6 +42,7 @@ class LiveChannel { country: country, language: language, category: value, + headers: headers, ); static LiveChannel? fromJson(Map<String, dynamic> json) { @@ -54,6 +64,13 @@ class LiveChannel { // Present when the channel arrives from a flat listing rather than from // inside a group; withCategory still fills it in for the grouped shape. category: json['category']?.toString() ?? '', + headers: switch (json['headers']) { + final Map<dynamic, dynamic> m => { + for (final entry in m.entries) + if (entry.value != null) entry.key.toString(): entry.value.toString(), + }, + _ => const {}, + }, ); } } @@ -76,6 +93,30 @@ class LiveFolder { final String? logoUrl; } +/// One country in the line-up, and how many channels come from it. +/// +/// Alongside the folders rather than instead of them: the folders answer "what +/// do I feel like watching", this answers "show me our channels", and for most +/// people opening Live TV the second question is the one they have. +@immutable +class LiveCountry { + const LiveCountry({required this.code, required this.count}); + + final String code; + final int count; +} + +/// What `/channels/categories` returns: both ways of slicing the line-up. +@immutable +class LiveIndex { + const LiveIndex({required this.folders, required this.countries}); + + final List<LiveFolder> folders; + final List<LiveCountry> countries; + + static const empty = LiveIndex(folders: [], countries: []); +} + /// One page of channels. @immutable class LivePage { @@ -109,22 +150,38 @@ class LiveTvService { final Dio _dio; - /// The folders. A few dozen rows however large the line-up behind them is. - Future<List<LiveFolder>> folders() async { + /// The folders and the countries. A few dozen rows however large the line-up + /// behind them is. + Future<LiveIndex> index() async { final response = await _dio.get('/channels/categories'); - final raw = (response.data as Map?)?['categories']; - if (raw is! List) return const []; - return [ - for (final item in raw.whereType<Map>()) - if ((item['name']?.toString() ?? '').isNotEmpty) - LiveFolder( - name: item['name'].toString(), - count: (item['count'] as num?)?.toInt() ?? 0, - logoUrl: (item['logoUrl'] as String?)?.trim().isEmpty ?? true - ? null - : item['logoUrl'] as String, - ), - ]; + final data = response.data as Map?; + final raw = data?['categories']; + final rawCountries = data?['countries']; + return LiveIndex( + folders: raw is! List + ? const [] + : [ + for (final item in raw.whereType<Map>()) + if ((item['name']?.toString() ?? '').isNotEmpty) + LiveFolder( + name: item['name'].toString(), + count: (item['count'] as num?)?.toInt() ?? 0, + logoUrl: (item['logoUrl'] as String?)?.trim().isEmpty ?? true + ? null + : item['logoUrl'] as String, + ), + ], + countries: rawCountries is! List + ? const [] + : [ + for (final item in rawCountries.whereType<Map>()) + if ((item['code']?.toString() ?? '').isNotEmpty) + LiveCountry( + code: item['code'].toString(), + count: (item['count'] as num?)?.toInt() ?? 0, + ), + ], + ); } /// One page, optionally inside a folder or matching a search. @@ -134,6 +191,7 @@ class LiveTvService { /// megabytes with extra steps. Future<LivePage> browse({ String? category, + String? country, String? search, int page = 1, int limit = 40, @@ -144,6 +202,7 @@ class LiveTvService { 'page': page, 'limit': limit, if (category != null && category.isNotEmpty) 'category': category, + if (country != null && country.isNotEmpty) 'country': country, if (search != null && search.trim().isNotEmpty) 'search': search.trim(), }, ); diff --git a/lib/features/live_tv/presentation/pages/live_tv_page.dart b/lib/features/live_tv/presentation/pages/live_tv_page.dart index e4d10187..f0da821c 100644 --- a/lib/features/live_tv/presentation/pages/live_tv_page.dart +++ b/lib/features/live_tv/presentation/pages/live_tv_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -34,6 +35,7 @@ class _LiveTvPageState extends State<LiveTvPage> { final _scroll = ScrollController(); List<LiveFolder> _folders = const []; + List<LiveCountry> _countries = const []; List<LiveChannel> _channels = const []; Set<String> _favourites = <String>{}; List<String> _recent = const []; @@ -41,6 +43,11 @@ class _LiveTvPageState extends State<LiveTvPage> { /// The folder being read, or empty for the top level. String _folder = ''; + + /// The country being read, or empty. Mutually exclusive with [_folder] — + /// they are two ways of asking the same question and combining them produces + /// a screen nobody navigated to. + String _country = ''; String _query = ''; Timer? _debounce; @@ -72,7 +79,11 @@ class _LiveTvPageState extends State<LiveTvPage> { } /// True while the screen is showing folders rather than channels. - bool get _atTopLevel => _folder.isEmpty && _query.trim().isEmpty; + bool get _atTopLevel => + _folder.isEmpty && _country.isEmpty && _query.trim().isEmpty; + + /// What the crumb says you are inside of. + String get _openName => _folder.isNotEmpty ? _folder : _countryName(_country); Future<void> _loadFolders() async { setState(() { @@ -80,11 +91,12 @@ class _LiveTvPageState extends State<LiveTvPage> { _error = null; }); try { - final folders = await _service.folders(); + final index = await _service.index(); if (!mounted) return; setState(() { - _folders = folders; - _total = folders.fold(0, (n, f) => n + f.count); + _folders = index.folders; + _countries = index.countries; + _total = index.folders.fold(0, (n, f) => n + f.count); _loading = false; }); } catch (_) { @@ -111,6 +123,7 @@ class _LiveTvPageState extends State<LiveTvPage> { try { final result = await _service.browse( category: _folder.isEmpty ? null : _folder, + country: _country.isEmpty ? null : _country, search: _query, page: page, ); @@ -160,6 +173,17 @@ class _LiveTvPageState extends State<LiveTvPage> { void _openFolder(String name) { setState(() { _folder = name; + _country = ''; + _channels = const []; + }); + if (_scroll.hasClients) _scroll.jumpTo(0); + _loadPage(); + } + + void _openCountry(String code) { + setState(() { + _country = code; + _folder = ''; _channels = const []; }); if (_scroll.hasClients) _scroll.jumpTo(0); @@ -169,6 +193,7 @@ class _LiveTvPageState extends State<LiveTvPage> { void _closeFolder() { setState(() { _folder = ''; + _country = ''; _channels = const []; _query = ''; }); @@ -183,12 +208,7 @@ class _LiveTvPageState extends State<LiveTvPage> { var changed = false; for (final channel in seen) { if (!wanted.contains(channel.id)) continue; - final card = { - 'name': channel.name, - 'streamUrl': channel.streamUrl, - 'logoUrl': channel.logoUrl ?? '', - 'category': channel.category, - }; + final card = _cardOf(channel); if (_cards[channel.id]?.toString() == card.toString()) continue; _cards[channel.id] = card; changed = true; @@ -199,18 +219,91 @@ class _LiveTvPageState extends State<LiveTvPage> { if (changed) getIt<HiveService>().setLiveTvCards(_cards); } + /// A country code as something readable. + /// + /// Only the ones this line-up actually carries are named; anything else keeps + /// its code, which is still better than an empty chip. Home comes first + /// wherever these are sorted, because it is what most people came for. + static const Map<String, String> _countryNames = { + 'UZ': 'O\'zbekiston', + 'RU': 'Rossiya', + 'TR': 'Turkiya', + 'KZ': 'Qozog\'iston', + 'KG': 'Qirg\'iziston', + 'TJ': 'Tojikiston', + 'TM': 'Turkmaniston', + 'AZ': 'Ozarbayjon', + 'US': 'AQSH', + 'UK': 'Buyuk Britaniya', + 'GB': 'Buyuk Britaniya', + 'CA': 'Kanada', + 'DE': 'Germaniya', + 'FR': 'Fransiya', + 'QA': 'Qatar', + 'AE': 'BAA', + 'CN': 'Xitoy', + 'JP': 'Yaponiya', + 'KR': 'Koreya', + 'SG': 'Singapur', + 'IL': 'Isroil', + 'AT': 'Avstriya', + 'SK': 'Slovakiya', + }; + + String _countryName(String code) => + _countryNames[code.toUpperCase()] ?? code.toUpperCase(); + + /// The flag for an ISO country code, built from regional-indicator letters. + /// + /// A picture rather than two letters, and no asset to ship or fail to load — + /// every platform this runs on renders these. + String _flagOf(String code) { + final c = code.toUpperCase(); + if (c.length != 2 || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) return ''; + return String.fromCharCodes([ + 0x1F1E6 + c.codeUnitAt(0) - 0x41, + 0x1F1E6 + c.codeUnitAt(1) - 0x41, + ]); + } + + /// Everything needed to draw and PLAY a pinned channel without its page. + /// + /// `headers` rides along encoded, because a favourite is opened straight from + /// this cache: dropping them here meant a header-gated channel worked the + /// first time and 403'd every time after it was pinned. + Map<String, String> _cardOf(LiveChannel channel) => { + 'name': channel.name, + 'streamUrl': channel.streamUrl, + 'logoUrl': channel.logoUrl ?? '', + 'category': channel.category, + if (channel.headers.isNotEmpty) 'headers': jsonEncode(channel.headers), + }; + LiveChannel? _fromCard(String id) { final card = _cards[id]; if (card == null) return null; final url = card['streamUrl'] ?? ''; final name = card['name'] ?? ''; if (url.isEmpty || name.isEmpty) return null; + Map<String, String> headers = const {}; + final raw = card['headers']; + if (raw != null && raw.isNotEmpty) { + try { + headers = (jsonDecode(raw) as Map).map( + (k, v) => MapEntry(k.toString(), v.toString()), + ); + } catch (_) { + // A card written by an older build, or corrupted. Playing without the + // headers is worth trying; refusing to draw the channel is not. + } + } return LiveChannel( id: id, name: name, streamUrl: url, logoUrl: (card['logoUrl'] ?? '').isEmpty ? null : card['logoUrl'], category: card['category'] ?? '', + headers: headers, ); } @@ -218,12 +311,7 @@ class _LiveTvPageState extends State<LiveTvPage> { setState(() { if (!_favourites.remove(channel.id)) { _favourites.add(channel.id); - _cards[channel.id] = { - 'name': channel.name, - 'streamUrl': channel.streamUrl, - 'logoUrl': channel.logoUrl ?? '', - 'category': channel.category, - }; + _cards[channel.id] = _cardOf(channel); } }); final hive = getIt<HiveService>(); @@ -234,12 +322,7 @@ class _LiveTvPageState extends State<LiveTvPage> { void _play(LiveChannel channel) { final hive = getIt<HiveService>(); hive.pushLiveTvRecent(channel.id); - _cards[channel.id] = { - 'name': channel.name, - 'streamUrl': channel.streamUrl, - 'logoUrl': channel.logoUrl ?? '', - 'category': channel.category, - }; + _cards[channel.id] = _cardOf(channel); hive.setLiveTvCards(_cards); setState(() { _recent = [channel.id, ..._recent.where((e) => e != channel.id)] @@ -251,7 +334,10 @@ class _LiveTvPageState extends State<LiveTvPage> { extra: PlayerArgs( title: channel.name, provider: 'live', - headers: const {}, + // Some broadcast CDNs 403 anything that does not present the + // User-Agent or Referer they expect. The server records which channels + // those are; sending an empty map made them look dead. + headers: channel.headers, movieUrl: channel.streamUrl, thumbnail: channel.logoUrl, // Live has no episodes and nothing to resume to, and offering a @@ -329,9 +415,9 @@ class _LiveTvPageState extends State<LiveTvPage> { textInputAction: TextInputAction.search, decoration: InputDecoration( isDense: true, - hintText: _folder.isEmpty + hintText: _atTopLevel ? 'live_tv.search_hint'.tr() - : 'live_tv.search_in'.tr(namedArgs: {'folder': _folder}), + : 'live_tv.search_in'.tr(namedArgs: {'folder': _openName}), prefixIcon: const Icon(Icons.search_rounded, size: 20), suffixIcon: _query.isEmpty ? null @@ -348,10 +434,10 @@ class _LiveTvPageState extends State<LiveTvPage> { ), // Which folder is open, and the way back out of it. - if (_folder.isNotEmpty) + if (_folder.isNotEmpty || _country.isNotEmpty) SliverToBoxAdapter( child: _FolderCrumb( - folder: _folder, + folder: _openName, total: _total, onBack: _closeFolder, ), @@ -376,6 +462,15 @@ class _LiveTvPageState extends State<LiveTvPage> { // folders, and the one somebody wants is two taps away rather than // twenty megabytes and a scroll. _FolderGrid(folders: _folders, onOpen: _openFolder), + if (_countries.isNotEmpty) ...[ + _Header(label: 'live_tv.countries'.tr()), + _CountryRow( + countries: _countries, + nameOf: _countryName, + flagOf: _flagOf, + onOpen: _openCountry, + ), + ], ] else if (_loading) ...[ const SliverToBoxAdapter( child: Padding( @@ -944,3 +1039,86 @@ class _Empty extends StatelessWidget { ); } } + +/// The countries the line-up covers, as a strip you scroll sideways. +/// +/// A row rather than a grid, and below the folders: it is a shortcut for +/// somebody who already knows what they are after, not the main navigation. +/// Ordered by how much each country actually contributes — the line-up leads +/// with channels people recognise, and pinning a home country in front of that +/// puts a provincial station where a famous one should be. +class _CountryRow extends StatelessWidget { + const _CountryRow({ + required this.countries, + required this.nameOf, + required this.flagOf, + required this.onOpen, + }); + + final List<LiveCountry> countries; + final String Function(String) nameOf; + final String Function(String) flagOf; + final ValueChanged<String> onOpen; + + @override + Widget build(BuildContext context) { + final ordered = [...countries]..sort((a, b) => b.count.compareTo(a.count)); + + return SliverToBoxAdapter( + child: SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: ordered.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final country = ordered[i]; + final flag = flagOf(country.code); + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onOpen(country.code), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (flag.isNotEmpty) ...[ + Text(flag, style: const TextStyle(fontSize: 15)), + const SizedBox(width: 7), + ], + Text( + nameOf(country.code), + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 6), + Text( + '${country.count}', + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} From 8eef7f5805f7a29eaa4bc237518db2eef22154ae Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 12:48:45 +0500 Subject: [PATCH 04/21] Put Live TV where somebody might find it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was reachable only through Profile, seven rows down a settings list. That is a fine place for a preference and the wrong place for a thousand channels: Live TV is content, and it belongs where the content is. Home grows a rail of channel logos above the catalogue sections. A rail rather than a link, because a channel is picked in a second and watched immediately — tapping a logo plays it, and the header still opens the full page for folders and search. Logo-forward and square, since a channel is recognised by its mark long before its name is read. It loads itself and renders nothing until it has channels, so a backend with no line-up — or no network — leaves Home exactly as it was rather than showing an empty shelf. Live TV also remains available as a bottom-bar tab; it has been in the registry all along, just not on by default. --- .../presentation/widgets/home_content.dart | 7 + .../widgets/home_live_tv_section.dart | 257 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 lib/features/home/presentation/widgets/home_live_tv_section.dart diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 58c01df5..3a31b2ad 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -21,6 +21,7 @@ import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_event.dart'; import 'package:soplay/features/home/presentation/widgets/home_banner.dart'; import 'package:soplay/features/home/presentation/widgets/home_history_section.dart'; +import 'package:soplay/features/home/presentation/widgets/home_live_tv_section.dart'; import 'package:soplay/features/home/presentation/widgets/home_movie_section.dart'; import 'package:soplay/features/home/presentation/widgets/home_state_views.dart'; import 'package:soplay/features/search/domain/entities/genre_entity.dart'; @@ -236,6 +237,12 @@ class _HomeContentBody extends StatelessWidget { child: _GenreSection(genres: state.genres), ), ), + // Live TV, above the catalogue rails rather than buried in + // Profile. It loads itself and renders nothing until it has + // channels, so a backend with no line-up leaves Home unchanged. + const SliverToBoxAdapter( + child: RepaintBoundary(child: LiveTvSection()), + ), if (state.collectionLoading) const SliverToBoxAdapter(child: CollectionLoadingRow()), ...sectionSlivers, diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart new file mode 100644 index 00000000..21dfe3dd --- /dev/null +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -0,0 +1,257 @@ +import 'package:cached_network_image/cached_network_image.dart'; +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/theme/app_colors.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/live_tv/data/live_tv_service.dart'; + +/// Live TV, where somebody might actually find it. +/// +/// The line-up was reachable only through Profile → Live TV, seven rows down a +/// settings list, which is a fine place for a preference and the wrong place for +/// a thousand channels. Live TV is content; it belongs where the content is. +/// +/// A rail rather than a link: a channel is chosen in a second and watched +/// immediately, so the row IS the feature — tap a logo and it plays. The header +/// still opens the full page for browsing folders and searching. +/// +/// Self-collapsing. It fetches on its own and renders nothing at all until it +/// has channels, so a backend without a line-up (or without a network) leaves +/// Home exactly as it was rather than showing an empty shelf or an error. +class LiveTvSection extends StatefulWidget { + const LiveTvSection({super.key}); + + /// How many logos to pull. Enough to fill the rail on a tablet and to feel + /// like a line-up rather than a shortcut, without paging a phone's Home. + static const int _limit = 20; + + @override + State<LiveTvSection> createState() => _LiveTvSectionState(); +} + +class _LiveTvSectionState extends State<LiveTvSection> { + List<LiveChannel> _channels = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future<void> _load() async { + try { + final page = await getIt<LiveTvService>().browse( + limit: LiveTvSection._limit, + ); + if (!mounted) return; + setState(() => _channels = page.channels); + } catch (_) { + // Offline, or a backend with no line-up. Home simply does not grow a + // Live TV row, which is the right failure for something nobody asked for + // on this screen. + } + } + + void _play(LiveChannel channel) { + context.push( + '/player', + extra: PlayerArgs( + title: channel.name, + provider: 'live', + headers: channel.headers, + movieUrl: channel.streamUrl, + thumbnail: channel.logoUrl, + // Live has nothing to resume to and no end to download. + type: 'live', + showDownloadAction: false, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_channels.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The whole strip is the tap target, matching every other rail. + InkWell( + onTap: () => context.push('/live-tv'), + child: Padding( + padding: const EdgeInsets.fromLTRB(17, 18, 20, 14), + child: Row( + children: [ + const Icon( + Icons.live_tv_rounded, + color: AppColors.textSecondary, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'live_tv.title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(width: 8), + const _LiveBadge(), + const Spacer(), + Text( + 'home.view_all'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textSecondary, + size: 18, + ), + ], + ), + ), + ), + SizedBox( + height: 104, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: _channels.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (_, i) => + _ChannelTile(channel: _channels[i], onTap: _play), + ), + ), + ], + ), + ); + } +} + +/// One channel: its logo, and its name underneath. +/// +/// Logo-forward and square, because a channel is recognised by its mark long +/// before its name is read — which is the whole difference between scanning a +/// line-up and reading a list. +class _ChannelTile extends StatelessWidget { + const _ChannelTile({required this.channel, required this.onTap}); + + final LiveChannel channel; + final ValueChanged<LiveChannel> onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 76, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onTap(channel), + child: Container( + width: 76, + height: 76, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.06), + ), + ), + child: channel.logoUrl == null + ? const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + // A broadcaster's mark is drawn for a light background + // as often as not, so it is never tinted or cropped — + // just fitted, and given a neutral tile to sit on. + errorWidget: (_, _, _) => const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ), + placeholder: (_, _) => const SizedBox.shrink(), + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + channel.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} + +/// The small red-dot LIVE marker. +/// +/// Static rather than pulsing: this sits in a scrolling feed, and an animation +/// running forever on Home costs a frame callback for the whole session to say +/// something a colour already says. +class _LiveBadge extends StatelessWidget { + const _LiveBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 5, + height: 5, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'LIVE', + style: TextStyle( + color: AppColors.primary, + fontSize: 9, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + ), + ), + ], + ), + ); + } +} From 0af49812de2873c586f0e86d7e472d91d28c603a Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 13:04:30 +0500 Subject: [PATCH 05/21] Drop a const the accent colour cannot live inside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppColors.primary is a getter — the accent is a user setting — so it cannot appear in a constant expression, which is exactly what app_colors.dart says at the top. The LIVE dot's decoration claimed const and failed to compile. --- .../home/presentation/widgets/home_live_tv_section.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart index 21dfe3dd..646b303b 100644 --- a/lib/features/home/presentation/widgets/home_live_tv_section.dart +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -235,7 +235,9 @@ class _LiveBadge extends StatelessWidget { Container( width: 5, height: 5, - decoration: const BoxDecoration( + // Not const: the accent is a user setting, so AppColors.primary is + // a getter and cannot appear in a constant expression. + decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), From 6b26197b89446fdc19a9b3faa99644203ddd02a1 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 14:23:54 +0500 Subject: [PATCH 06/21] feat(sync): enhance history synchronization and prevent data leaks between accounts --- lib/features/home/presentation/widgets/home_live_tv_section.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart index 646b303b..0bce992b 100644 --- a/lib/features/home/presentation/widgets/home_live_tv_section.dart +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -80,7 +80,6 @@ class _LiveTvSectionState extends State<LiveTvSection> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // The whole strip is the tap target, matching every other rail. InkWell( onTap: () => context.push('/live-tv'), child: Padding( From f5f9b34ab2010f5ca19addbbcb9c2e117fe296bc Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 16:33:41 +0500 Subject: [PATCH 07/21] Rebuild Live TV around what is actually on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen was a search box, a folder grid and a paged wall of logos. It told you a thousand channels exist and nothing about any of them, and the guide the backend had started returning was not read at all. Channels now carry their listing. A card shows what is on now, how far through it is, and what follows; a long press opens the channel with its schedule for the day. About 38% of the line-up has no guide, so every one of those paths is written to look deliberate without one rather than to show a gap where a title should be. Around that: categories and countries are one index rather than two competing navigations, pinned and recent channels lead, and the states that were bare — first load, loading more, no results, offline — are all drawn. Country names are in English, like the channel names beside them; a strip reading "O'zbekiston" over a grid of "Pluto TV Comedy" was two languages doing one job. The clock is the part worth being careful about. Times arrive as UTC and are converted on the way in, so an hour rendered is the viewer's own; progress is clamped at both ends so a stale payload reads as finished rather than as 140%; and a bar is only drawn for a slot between five minutes and six hours, because a feed padding a thin guide with a nine-hour block would otherwise show a bar that has not moved since breakfast. --- assets/translations/en.json | 21 +- assets/translations/ru.json | 21 +- assets/translations/uz.json | 21 +- .../widgets/home_live_tv_section.dart | 17 + .../live_tv/data/live_tv_service.dart | 388 +++- .../presentation/pages/live_tv_page.dart | 1661 +++++++++++------ .../presentation/widgets/channel_sheet.dart | 490 +++++ 7 files changed, 1957 insertions(+), 662 deletions(-) create mode 100644 lib/features/live_tv/presentation/widgets/channel_sheet.dart diff --git a/assets/translations/en.json b/assets/translations/en.json index 6db9aee8..59f199e4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1207,18 +1207,25 @@ }, "live_tv": { "title": "Live TV", - "all": "All", - "favourites": "FAVOURITES", - "all_channels": "ALL CHANNELS", + "favourites": "Favourites", + "recent": "Recently watched", + "categories": "Categories", + "countries": "Countries", + "results": "Search results", "search_hint": "Channel name…", + "search_in": "Search in {folder}…", "no_match": "No channel matches that.", "empty": "No channels yet.", "load_failed": "Couldn't load the channels.", "retry": "Try again", - "recent": "RECENTLY WATCHED", - "folders": "FOLDERS", - "countries": "COUNTRIES", - "search_in": "Search in {folder}…", + "now": "Now", + "next": "Next", + "guide": "TV guide", + "no_guide": "No guide for this channel.", + "watch": "Watch", + "favourite_add": "Add to favourites", + "favourite_remove": "Remove from favourites", + "hint_long_press": "Long-press a channel for the guide and favourites.", "channel_count": { "one": "{} channel", "other": "{} channels" diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c5cdbc92..45f87f98 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1207,18 +1207,25 @@ }, "live_tv": { "title": "Эфир", - "all": "Все", - "favourites": "ИЗБРАННОЕ", - "all_channels": "ВСЕ КАНАЛЫ", + "favourites": "Избранное", + "recent": "Недавно смотрели", + "categories": "Категории", + "countries": "Страны", + "results": "Результаты поиска", "search_hint": "Название канала…", + "search_in": "Поиск в {folder}…", "no_match": "Ничего не найдено.", "empty": "Каналов пока нет.", "load_failed": "Не удалось загрузить каналы.", "retry": "Повторить", - "recent": "НЕДАВНО СМОТРЕЛИ", - "folders": "ПАПКИ", - "countries": "СТРАНЫ", - "search_in": "Поиск в {folder}…", + "now": "Сейчас", + "next": "Далее", + "guide": "Телепрограмма", + "no_guide": "Для этого канала нет телепрограммы.", + "watch": "Смотреть", + "favourite_add": "Добавить в избранное", + "favourite_remove": "Убрать из избранного", + "hint_long_press": "Нажмите и удерживайте канал — телепрограмма и избранное.", "channel_count": { "one": "{} канал", "few": "{} канала", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 850210ef..bb790208 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1207,18 +1207,25 @@ }, "live_tv": { "title": "Jonli TV", - "all": "Hammasi", - "favourites": "TANLANGANLAR", - "all_channels": "BARCHA KANALLAR", + "favourites": "Tanlanganlar", + "recent": "Yaqinda ko‘rilgan", + "categories": "Kategoriyalar", + "countries": "Davlatlar", + "results": "Qidiruv natijalari", "search_hint": "Kanal nomi…", + "search_in": "{folder} ichida qidirish…", "no_match": "Mos kanal topilmadi.", "empty": "Hozircha kanal yo‘q.", "load_failed": "Kanallarni yuklab bo‘lmadi.", "retry": "Qayta urinish", - "recent": "YAQINDA KO‘RILGAN", - "folders": "PAPKALAR", - "countries": "DAVLATLAR", - "search_in": "{folder} ichida qidirish…", + "now": "Hozir", + "next": "Keyingi", + "guide": "Dastur jadvali", + "no_guide": "Bu kanal uchun dastur jadvali yo‘q.", + "watch": "Ko‘rish", + "favourite_add": "Tanlanganlarga qo‘shish", + "favourite_remove": "Tanlanganlardan olib tashlash", + "hint_long_press": "Dastur jadvali va tanlanganlar uchun kanalni uzoq bosing.", "channel_count": { "one": "{} kanal", "other": "{} kanal" diff --git a/lib/features/home/presentation/widgets/home_live_tv_section.dart b/lib/features/home/presentation/widgets/home_live_tv_section.dart index 0bce992b..ab806a67 100644 --- a/lib/features/home/presentation/widgets/home_live_tv_section.dart +++ b/lib/features/home/presentation/widgets/home_live_tv_section.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + import 'package:cached_network_image/cached_network_image.dart'; 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/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/live_tv/data/live_tv_service.dart'; @@ -56,6 +59,20 @@ class _LiveTvSectionState extends State<LiveTvSection> { } void _play(LiveChannel channel) { + // Live TV's own "Recently watched" rail is fed from here as well as from + // its page: a channel played from Home is still a channel you watched, and + // the card is what lets the rail draw it without its listing. + final hive = getIt<HiveService>(); + hive.pushLiveTvRecent(channel.id); + final cards = hive.getLiveTvCards(); + cards[channel.id] = { + 'name': channel.name, + 'streamUrl': channel.streamUrl, + 'logoUrl': channel.logoUrl ?? '', + 'category': channel.category, + if (channel.headers.isNotEmpty) 'headers': jsonEncode(channel.headers), + }; + hive.setLiveTvCards(cards); context.push( '/player', extra: PlayerArgs( diff --git a/lib/features/live_tv/data/live_tv_service.dart b/lib/features/live_tv/data/live_tv_service.dart index bf5edac5..34addb06 100644 --- a/lib/features/live_tv/data/live_tv_service.dart +++ b/lib/features/live_tv/data/live_tv_service.dart @@ -1,6 +1,173 @@ import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +/// One programme in a channel's guide. +/// +/// Arrives two ways and is parsed once: inline on a channel as its `now` and +/// `next` slots, and as a row of `/channels/:id/epg`. The server sends the same +/// shape both times. +/// +/// Everything but the title is optional, and the title is the only thing worth +/// refusing a slot over — an entry with no name is a blank line the UI cannot +/// draw. About a third of the line-up has no guide at all, so a null +/// [LiveProgramme] is the ordinary case here, not a failure. +@immutable +class LiveProgramme { + const LiveProgramme({ + required this.title, + this.id = '', + this.start, + this.stop, + this.subtitle = '', + this.description = '', + this.category = '', + this.icon, + this.season, + this.episode, + }); + + final String id; + final String title; + + /// Episode title, tagline, or whatever the feed put in its `sub-title`. + /// Frequently empty even where the rest of the guide is complete. + final String subtitle; + final String description; + + /// The programme's own genre, which is not the channel's category: a film on + /// a news channel says "Movie" here and "News" on the channel. + final String category; + + /// Poster or still for this programme, when the feed carried one. Separate + /// from the channel logo, and absent far more often than present. + final String? icon; + + /// Both usually null, and not reliably 1-based — feeds disagree — so they are + /// carried through exactly as sent and never used in arithmetic. + final int? season; + final int? episode; + + /// Slot boundaries in local time, or null when the entry carried no usable + /// timestamp. + /// + /// Converted with [DateTime.toLocal] on the way in, so nothing above this + /// line has to remember to. Nullable independently of the title: a slot can + /// legitimately name a programme without bounding it. + final DateTime? start; + final DateTime? stop; + + /// True when there is a real window to measure against — both ends present + /// and in the right order. Everything time-shaped below is meaningless + /// without it, so ask this before drawing a clock or a bar. + bool get hasWindow => start != null && stop != null && stop!.isAfter(start!); + + Duration? get duration => hasWindow ? stop!.difference(start!) : null; + + /// How far through this slot [when] falls, from 0 to 1. + /// + /// Zero when the window is missing or nonsensical, on purpose: a bar drawn + /// empty is a bar the eye skips, while a NaN or an overrun is a bar that + /// lies. Clamped at both ends, so a stale payload whose slot already finished + /// reads as full rather than as 1.4. + double progressAt(DateTime when) { + if (!hasWindow) return 0; + final total = stop!.difference(start!).inSeconds; + if (total <= 0) return 0; + final gone = when.difference(start!).inSeconds; + if (gone <= 0) return 0; + if (gone >= total) return 1; + return gone / total; + } + + /// [progressAt] for this instant — and only for this instant. Anything + /// drawing it wants a timer, not a value cached at build time. + double get progress => progressAt(DateTime.now()); + + bool isLiveAt(DateTime when) => + hasWindow && !when.isBefore(start!) && when.isBefore(stop!); + + bool get isLive => isLiveAt(DateTime.now()); + + /// What is left of the slot, or null when it cannot be known. Never negative. + Duration? remainingAt(DateTime when) { + if (!hasWindow) return null; + final left = stop!.difference(when); + return left.isNegative ? Duration.zero : left; + } + + /// Whether a progress bar drawn for this slot would mean anything. + /// + /// XMLTV feeds pad thin guides with nine-hour "Programmes" blocks; a bar that + /// has not visibly moved since breakfast is worse than no bar. Anything under + /// five minutes is a junction or an ident and is over before it is read. + bool get isBarWorthy { + final span = duration; + return span != null && span.inMinutes >= 5 && span.inMinutes <= 360; + } + + /// `S2 E5`, `E5`, or empty. Deliberately language-neutral: the numbers are + /// the content, and any word around them is the UI's to localise. + String get episodeLabel { + final s = season; + final e = episode; + if (s != null && e != null) return 'S$s E$e'; + if (e != null) return 'E$e'; + if (s != null) return 'S$s'; + return ''; + } + + /// Null for anything that is not a usable slot: a missing key, an explicit + /// null, an empty object, or an entry with no title. + static LiveProgramme? fromJson(dynamic raw) { + if (raw is! Map) return null; + final title = _text(raw['title']); + if (title.isEmpty) return null; + final icon = _text(raw['icon']); + return LiveProgramme( + id: _text(raw['id']), + title: title, + subtitle: _text(raw['subtitle']), + description: _text(raw['description']), + category: _text(raw['category']), + icon: icon.isEmpty ? null : icon, + season: _int(raw['season']), + episode: _int(raw['episode']), + start: _time(raw['start']), + stop: _time(raw['stop']), + ); + } + + static String _text(dynamic raw) => raw == null ? '' : raw.toString().trim(); + + static int? _int(dynamic raw) { + if (raw is num) return raw.toInt(); + if (raw is String) return int.tryParse(raw.trim()); + return null; + } + + /// ISO 8601 in, local out. + /// + /// [DateTime.tryParse] returns null rather than throwing on the malformed + /// ones, keeps the zone when the string carries `Z` or an offset, and reads a + /// bare timestamp as local — which is what the server sends and what the + /// viewer means. Epoch numbers are accepted too, because feeds change their + /// minds; seconds and milliseconds are told apart by magnitude. + static DateTime? _time(dynamic raw) { + if (raw is String) { + final text = raw.trim(); + if (text.isEmpty) return null; + return DateTime.tryParse(text)?.toLocal(); + } + if (raw is num) { + final value = raw.toInt(); + if (value == 0) return null; + final ms = value.abs() < 100000000000 ? value * 1000 : value; + return DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true).toLocal(); + } + return null; + } +} + /// One live channel. @immutable class LiveChannel { @@ -13,6 +180,8 @@ class LiveChannel { this.language = '', this.category = '', this.headers = const {}, + this.now, + this.next, }); final String id; @@ -34,16 +203,55 @@ class LiveChannel { /// category once it is out of the list. final String category; - LiveChannel withCategory(String value) => LiveChannel( - id: id, - name: name, - streamUrl: streamUrl, - logoUrl: logoUrl, - country: country, - language: language, - category: value, - headers: headers, - ); + /// What is on, and what follows it. Null for a channel with no guide — about + /// a third of the line-up — and null again for a channel rebuilt from a saved + /// favourite, since a slot is stale within the hour and is never worth + /// persisting. Anything reading these must draw a channel that has neither. + final LiveProgramme? now; + final LiveProgramme? next; + + /// Whether there is anything to show under the name at all. + bool get hasGuide => now != null || next != null; + + /// What to show as "on now" at [when], or null. + /// + /// The listing's `now` was correct when the page was fetched and a long + /// session outlives it, so a `now` whose window has already closed gives way + /// to `next` once `next` has actually started. A slot with no window at all + /// still counts: the feed named the programme without bounding it, and a name + /// is the part worth drawing. Null means "no guide, or a hole at this hour" — + /// the ordinary case for about 38% of the line-up. + LiveProgramme? slotAt(DateTime when) { + final current = now; + if (current != null && (!current.hasWindow || current.isLiveAt(when))) { + return current; + } + final upcoming = next; + if (upcoming != null && upcoming.hasWindow && upcoming.isLiveAt(when)) { + return upcoming; + } + return null; + } + + /// The same channel with a fresher guide, for a screen that rolls its slots + /// forward on a timer rather than re-fetching the page. + /// + /// Both arguments are positional and required so that passing nothing is not + /// a thing anyone can do by accident: this replaces the pair outright, and a + /// null means "no slot", never "leave what was there". + LiveChannel withGuide(LiveProgramme? nowSlot, LiveProgramme? nextSlot) => + LiveChannel( + id: id, + name: name, + streamUrl: streamUrl, + logoUrl: logoUrl, + country: country, + language: language, + category: category, + headers: headers, + now: nowSlot, + next: nextSlot, + ); static LiveChannel? fromJson(Map<String, dynamic> json) { final id = json['id']?.toString(); @@ -61,8 +269,8 @@ class LiveChannel { : json['logoUrl'] as String, country: json['country']?.toString() ?? '', language: json['language']?.toString() ?? '', - // Present when the channel arrives from a flat listing rather than from - // inside a group; withCategory still fills it in for the grouped shape. + // Sent with every channel the browse endpoint returns, so a channel + // carries its own category once it is out of the list. category: json['category']?.toString() ?? '', headers: switch (json['headers']) { final Map<dynamic, dynamic> m => { @@ -71,18 +279,15 @@ class LiveChannel { }, _ => const {}, }, + // Absent for a channel with no guide, and absent from the grouped + // `/channels` shape entirely. Both stay null there, which is a state the + // rest of this class already has to survive. + now: LiveProgramme.fromJson(json['now']), + next: LiveProgramme.fromJson(json['next']), ); } } -@immutable -class LiveCategory { - const LiveCategory({required this.name, required this.channels}); - - final String name; - final List<LiveChannel> channels; -} - /// One folder in the line-up, and how much is inside it. @immutable class LiveFolder { @@ -113,8 +318,6 @@ class LiveIndex { final List<LiveFolder> folders; final List<LiveCountry> countries; - - static const empty = LiveIndex(folders: [], countries: []); } /// One page of channels. @@ -131,8 +334,105 @@ class LivePage { final int page; final int total; final bool hasMore; +} + +/// One channel's guide, as `/channels/:id/epg` returns it. +/// +/// An empty schedule is a normal answer, not a failure: the guide covers around +/// 62% of the line-up, and a channel outside that still plays perfectly well. +@immutable +class LiveSchedule { + const LiveSchedule({ + required this.channelId, + required this.channelName, + required this.programmes, + }); + + final String channelId; + final String channelName; + + /// Ordered by start time, earliest first, with any untimed rows at the end. + /// Sorted here rather than trusted from the wire, because everything below + /// depends on the order and re-sorting a few dozen rows costs nothing. + final List<LiveProgramme> programmes; + + static const empty = LiveSchedule( + channelId: '', + channelName: '', + programmes: [], + ); - static const empty = LivePage(channels: [], page: 1, total: 0, hasMore: false); + bool get isEmpty => programmes.isEmpty; + bool get isNotEmpty => programmes.isNotEmpty; + + /// The slot covering [when], or null — including when the guide simply has a + /// hole at that hour, which real feeds do have. + LiveProgramme? currentAt(DateTime when) { + for (final programme in programmes) { + if (programme.isLiveAt(when)) return programme; + } + return null; + } + + /// The first slot that starts after [when], or null. + LiveProgramme? nextAfter(DateTime when) { + for (final programme in programmes) { + final start = programme.start; + if (start != null && start.isAfter(when)) return programme; + } + return null; + } + + /// What is on now, then everything still to come. What a schedule sheet + /// actually draws — yesterday's rows are noise on a screen about tonight. + List<LiveProgramme> from(DateTime when) { + return programmes.where((programme) { + if (programme.isLiveAt(when)) return true; + final start = programme.start; + return start != null && start.isAfter(when); + }).toList(growable: false); + } + + /// [fallbackId] stands in when the payload does not echo the channel back, + /// so the schedule always knows which channel it belongs to. + static LiveSchedule fromJson(dynamic raw, {String fallbackId = ''}) { + if (raw is! Map) return LiveSchedule.empty; + + var id = fallbackId; + var name = ''; + final channel = raw['channel']; + if (channel is Map) { + final rawId = channel['id']?.toString().trim() ?? ''; + if (rawId.isNotEmpty) id = rawId; + name = channel['name']?.toString().trim() ?? ''; + } else if (channel is String) { + name = channel.trim(); + } + + final rows = raw['programmes']; + final List<LiveProgramme> programmes = rows is! List + ? <LiveProgramme>[] + : rows + .map(LiveProgramme.fromJson) + .whereType<LiveProgramme>() + .toList(); + programmes.sort(_byStart); + + return LiveSchedule( + channelId: id, + channelName: name, + programmes: List<LiveProgramme>.unmodifiable(programmes), + ); + } + + static int _byStart(LiveProgramme a, LiveProgramme b) { + final x = a.start; + final y = b.start; + if (x == null && y == null) return 0; + if (x == null) return 1; + if (y == null) return -1; + return x.compareTo(y); + } } /// The live TV line-up. @@ -222,26 +522,26 @@ class LiveTvService { ); } - Future<List<LiveCategory>> lineup() async { - final response = await _dio.get('/channels'); - final categories = (response.data as Map?)?['categories']; - if (categories is! List) return const []; - - final out = <LiveCategory>[]; - for (final raw in categories.whereType<Map>()) { - final name = raw['name']?.toString() ?? 'general'; - final list = raw['channels']; - if (list is! List) continue; - final channels = list - .whereType<Map>() - .map((e) => LiveChannel.fromJson(e.cast<String, dynamic>())) - .whereType<LiveChannel>() - .map((c) => c.withCategory(name)) - .toList(growable: false); - if (channels.isNotEmpty) { - out.add(LiveCategory(name: name, channels: channels)); - } - } - return out; + /// One channel's guide, [hours] ahead. + /// + /// Separate from [browse] on purpose: a page of forty channels does not want + /// forty schedules hanging off it, so the listing carries only the two slots + /// a tile can show and the full day is read once, for the one channel + /// somebody actually opened. + /// + /// Returns an empty schedule for a channel with no guide, or for a response + /// shaped in a way this does not recognise. Network and HTTP failures throw, + /// as they do everywhere else in this service — the caller decides whether a + /// missing guide is worth saying out loud, and on this screen it is not. + Future<LiveSchedule> schedule(String channelId, {int hours = 24}) async { + final id = channelId.trim(); + if (id.isEmpty) return LiveSchedule.empty; + final response = await _dio.get( + // Encoded because channel ids come from the feed rather than from us and + // routinely contain dots, and occasionally worse. + '/channels/${Uri.encodeComponent(id)}/epg', + queryParameters: {'hours': hours.clamp(1, 168).toInt()}, + ); + return LiveSchedule.fromJson(response.data, fallbackId: id); } } diff --git a/lib/features/live_tv/presentation/pages/live_tv_page.dart b/lib/features/live_tv/presentation/pages/live_tv_page.dart index f0da821c..2254313f 100644 --- a/lib/features/live_tv/presentation/pages/live_tv_page.dart +++ b/lib/features/live_tv/presentation/pages/live_tv_page.dart @@ -8,9 +8,28 @@ 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/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/live_tv/data/live_tv_service.dart'; +import 'package:soplay/features/live_tv/presentation/widgets/channel_sheet.dart'; + +const double _kGutter = 14; +const double _kSpacing = 10; + +int _columnsFor(double width) => width >= 900 ? 5 : (width >= 620 ? 4 : 3); + +/// Width factor for a channel card's progress hairline, or null for no bar. +/// +/// The 0.02 floor stops a slot that started a minute ago rendering as a +/// zero-width sliver, which reads as a paint bug rather than as a beginning. +double? _barFactor(LiveProgramme? slot, DateTime at) { + if (slot == null || !slot.isBarWorthy) return null; + final value = slot.progressAt(at); // already clamped to 0..1 by the model + if (value <= 0) return null; + return value < 0.02 ? 0.02 : value; +} /// Live TV. /// @@ -41,6 +60,10 @@ class _LiveTvPageState extends State<LiveTvPage> { List<String> _recent = const []; Map<String, Map<String, String>> _cards = {}; + /// Pinned channels, rebuilt only when the pins change — never in build(). + List<LiveChannel> _favouriteCards = const []; + List<LiveChannel> _recentCards = const []; + /// The folder being read, or empty for the top level. String _folder = ''; @@ -50,14 +73,31 @@ class _LiveTvPageState extends State<LiveTvPage> { String _country = ''; String _query = ''; Timer? _debounce; + Timer? _ticker; int _page = 1; bool _hasMore = false; + + /// How many channels the OPEN scope has, as browse reports it. int _total = 0; - bool _loading = true; + + /// The whole line-up, summed from the folder counts. A different number with + /// a different meaning, and the one the Categories header wants. + int _indexTotal = 0; + + /// The index request has finished, succeeded or not. + bool _booted = false; + bool _loading = false; bool _loadingMore = false; String? _error; + /// Request generation. Bumped by every scope change, and checked after every + /// await, so a response for a folder the user already left is dropped. + int _seq = 0; + + /// One "now" per tick, so every bar and slot line on screen agrees. + DateTime _now = DateTime.now(); + @override void initState() { super.initState(); @@ -65,57 +105,80 @@ class _LiveTvPageState extends State<LiveTvPage> { _favourites = hive.getLiveTvFavourites().toSet(); _recent = hive.getLiveTvRecent(); _cards = hive.getLiveTvCards(); + _rebuildPins(); _scroll.addListener(_onScroll); - _loadFolders(); + _ticker = Timer.periodic(const Duration(seconds: 60), _onTick); + _loadIndex(); } @override void dispose() { _debounce?.cancel(); + _ticker?.cancel(); _scroll.removeListener(_onScroll); _scroll.dispose(); _search.dispose(); super.dispose(); } - /// True while the screen is showing folders rather than channels. - bool get _atTopLevel => - _folder.isEmpty && _country.isEmpty && _query.trim().isEmpty; + void _onTick(Timer _) { + if (!mounted) return; + // The tab lives inside main_page's IndexedStack for the whole session, so + // this refuses to rebuild anything that has no bar on it. + if (!_scoped) return; + if (!_channels.any((c) => c.now != null)) return; + setState(() => _now = DateTime.now()); + } + + /// True while a folder, a country or a search is open — i.e. whenever the + /// screen is showing channels rather than the line-up's index. + bool get _scoped => + _folder.isNotEmpty || _country.isNotEmpty || _query.trim().isNotEmpty; - /// What the crumb says you are inside of. - String get _openName => _folder.isNotEmpty ? _folder : _countryName(_country); + bool get _searching => _query.trim().isNotEmpty; - Future<void> _loadFolders() async { - setState(() { - _loading = true; - _error = null; - }); + String get _scopeName { + if (_folder.isNotEmpty) return _folder; + if (_country.isNotEmpty) return _countryName(_country); + return 'live_tv.results'.tr(); + } + + Future<void> _loadIndex({bool silent = false}) async { + if (!silent) setState(() => _error = null); try { final index = await _service.index(); if (!mounted) return; + // Ordered here, once, and by the same rule for both strips: the line-up + // leads with what it actually carries. + final folders = [...index.folders] + ..sort((a, b) => b.count.compareTo(a.count)); + final countries = [...index.countries] + ..sort((a, b) => b.count.compareTo(a.count)); setState(() { - _folders = index.folders; - _countries = index.countries; - _total = index.folders.fold(0, (n, f) => n + f.count); - _loading = false; + _folders = folders; + _countries = countries; + _indexTotal = folders.fold(0, (n, f) => n + f.count); + _booted = true; + _error = null; }); } catch (_) { if (!mounted) return; setState(() { - _loading = false; + _booted = true; _error = 'live_tv.load_failed'.tr(); }); } } - /// One page of channels for the open folder, or for the current search. - Future<void> _loadPage({bool append = false}) async { + /// One page of channels for the open scope. + Future<void> _loadPage({bool append = false, bool silent = false}) async { if (append && (_loadingMore || !_hasMore)) return; + final seq = ++_seq; setState(() { if (append) { _loadingMore = true; } else { - _loading = true; + if (!silent) _loading = true; _error = null; } }); @@ -127,54 +190,57 @@ class _LiveTvPageState extends State<LiveTvPage> { search: _query, page: page, ); - if (!mounted) return; + if (!mounted || seq != _seq) return; setState(() { - _channels = append ? [..._channels, ...result.channels] : result.channels; + _channels = append + ? [..._channels, ...result.channels] + : result.channels; _page = result.page; _hasMore = result.hasMore; _total = result.total; _loading = false; _loadingMore = false; + _error = null; }); _rememberCards(result.channels); } catch (_) { - if (!mounted) return; + if (!mounted || seq != _seq) return; + final hasContent = _channels.isNotEmpty; setState(() { _loading = false; _loadingMore = false; - if (!append) _error = 'live_tv.load_failed'.tr(); + if (!hasContent) _error = 'live_tv.load_failed'.tr(); }); + // With a grid already on screen there is nowhere to put an error state, + // and stopping without a word looks like the list simply ended. + if (hasContent) _toast('live_tv.load_failed'.tr()); } } - /// The next page, once the list is close enough to its end to need one. - void _onScroll() { - if (!_scroll.hasClients || _atTopLevel) return; - final position = _scroll.position; - if (position.pixels >= position.maxScrollExtent - 600) _loadPage(append: true); + void _toast(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } - /// Searching goes to the server, so it waits for a pause in the typing. - void _onQueryChanged(String value) { - _debounce?.cancel(); - setState(() => _query = value); - if (value.trim().isEmpty) { - // Back to whatever was on screen before the search started. - if (_folder.isEmpty) { - setState(() => _channels = const []); - } else { - _loadPage(); - } - return; - } - _debounce = Timer(const Duration(milliseconds: 350), _loadPage); + /// Pull to refresh. Silent because RefreshIndicator draws its own spinner and + /// raising [_loading] would swap the grid for skeletons underneath it. + Future<void> _refresh() => + _scoped ? _loadPage(silent: true) : _loadIndex(silent: true); + + /// Everything a scope change must forget. Always called inside a setState. + void _resetPaging() { + _page = 1; + _hasMore = false; + _total = 0; + _error = null; + _channels = const []; } void _openFolder(String name) { setState(() { _folder = name; _country = ''; - _channels = const []; + _resetPaging(); }); if (_scroll.hasClients) _scroll.jumpTo(0); _loadPage(); @@ -184,27 +250,74 @@ class _LiveTvPageState extends State<LiveTvPage> { setState(() { _country = code; _folder = ''; - _channels = const []; + _resetPaging(); }); if (_scroll.hasClients) _scroll.jumpTo(0); _loadPage(); } - void _closeFolder() { + void _closeScope() { + _debounce?.cancel(); + _search.clear(); + _seq++; // drop anything already in flight for the scope being left setState(() { _folder = ''; _country = ''; - _channels = const []; _query = ''; + _resetPaging(); }); - _search.clear(); if (_scroll.hasClients) _scroll.jumpTo(0); } + /// Searching goes to the server, so it waits for a pause in the typing. + void _onQueryChanged(String value) { + _debounce?.cancel(); + setState(() { + _query = value; + _resetPaging(); + }); + if (value.trim().isEmpty) { + if (_folder.isEmpty && _country.isEmpty) { + _seq++; // back to the top level; nothing to fetch, nothing in flight + return; + } + // Still inside a folder or a country: the scope itself is what to show. + _loadPage(); + return; + } + _debounce = Timer(const Duration(milliseconds: 350), _loadPage); + } + + /// The next page, once the list is close enough to its end to need one. + void _onScroll() { + if (!_scroll.hasClients || !_scoped) return; + if (_loading || _loadingMore || !_hasMore) return; + final position = _scroll.position; + if (position.pixels >= position.maxScrollExtent - 600) { + _loadPage(append: true); + } + } + + /// Rebuilds the two pinned rails from the saved cards. + void _rebuildPins() { + final favourites = <LiveChannel>[]; + for (final id in _favourites) { + final channel = _fromCard(id); + if (channel != null) favourites.add(channel); + } + final recents = <LiveChannel>[]; + for (final id in _recent) { + if (_favourites.contains(id)) continue; + final channel = _fromCard(id); + if (channel != null) recents.add(channel); + } + _favouriteCards = favourites.take(12).toList(growable: false); + _recentCards = recents.take(12).toList(growable: false); + } + /// Keeps enough of the pinned channels to draw them without their page. void _rememberCards(List<LiveChannel> seen) { final wanted = {..._favourites, ..._recent}; - if (wanted.isEmpty) return; var changed = false; for (final channel in seen) { if (!wanted.contains(channel.id)) continue; @@ -215,39 +328,57 @@ class _LiveTvPageState extends State<LiveTvPage> { } // Only what is still pinned; a cache that only grows is a leak with a nicer // name. + final before = _cards.length; _cards.removeWhere((id, _) => !wanted.contains(id)); - if (changed) getIt<HiveService>().setLiveTvCards(_cards); + if (_cards.length != before) changed = true; + if (!changed) return; + getIt<HiveService>().setLiveTvCards(_cards); + if (!mounted) return; + setState(_rebuildPins); } - /// A country code as something readable. + /// A country code as something readable, in English. /// - /// Only the ones this line-up actually carries are named; anything else keeps - /// its code, which is still better than an empty chip. Home comes first - /// wherever these are sorted, because it is what most people came for. + /// English because the channel names beside it are: a strip reading + /// "O'zbekiston · Rossiya" above a grid of "Pluto TV Comedy" and "Al Jazeera" + /// is two languages doing one job. Only the countries this line-up actually + /// carries are named; anything else keeps its code, which still beats a blank. static const Map<String, String> _countryNames = { - 'UZ': 'O\'zbekiston', - 'RU': 'Rossiya', - 'TR': 'Turkiya', - 'KZ': 'Qozog\'iston', - 'KG': 'Qirg\'iziston', - 'TJ': 'Tojikiston', - 'TM': 'Turkmaniston', - 'AZ': 'Ozarbayjon', - 'US': 'AQSH', - 'UK': 'Buyuk Britaniya', - 'GB': 'Buyuk Britaniya', - 'CA': 'Kanada', - 'DE': 'Germaniya', - 'FR': 'Fransiya', + 'UZ': 'Uzbekistan', + 'RU': 'Russia', + 'TR': 'Turkey', + 'KZ': 'Kazakhstan', + 'KG': 'Kyrgyzstan', + 'TJ': 'Tajikistan', + 'TM': 'Turkmenistan', + 'AZ': 'Azerbaijan', + 'US': 'United States', + 'UK': 'United Kingdom', + 'GB': 'United Kingdom', + 'CA': 'Canada', + 'AU': 'Australia', + 'DE': 'Germany', + 'FR': 'France', + 'ES': 'Spain', + 'IT': 'Italy', 'QA': 'Qatar', - 'AE': 'BAA', - 'CN': 'Xitoy', - 'JP': 'Yaponiya', - 'KR': 'Koreya', - 'SG': 'Singapur', - 'IL': 'Isroil', - 'AT': 'Avstriya', - 'SK': 'Slovakiya', + 'AE': 'UAE', + 'SA': 'Saudi Arabia', + 'CN': 'China', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'SG': 'Singapore', + 'IL': 'Israel', + 'AT': 'Austria', + 'SK': 'Slovakia', + 'SE': 'Sweden', + 'NL': 'Netherlands', + 'PL': 'Poland', + 'UA': 'Ukraine', + 'GE': 'Georgia', + 'BR': 'Brazil', + 'MX': 'Mexico', }; String _countryName(String code) => @@ -255,8 +386,7 @@ class _LiveTvPageState extends State<LiveTvPage> { /// The flag for an ISO country code, built from regional-indicator letters. /// - /// A picture rather than two letters, and no asset to ship or fail to load — - /// every platform this runs on renders these. + /// A picture rather than two letters, and no asset to ship or fail to load. String _flagOf(String code) { final c = code.toUpperCase(); if (c.length != 2 || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) return ''; @@ -313,6 +443,7 @@ class _LiveTvPageState extends State<LiveTvPage> { _favourites.add(channel.id); _cards[channel.id] = _cardOf(channel); } + _rebuildPins(); }); final hive = getIt<HiveService>(); hive.setLiveTvFavourites(_favourites.toList()); @@ -328,6 +459,7 @@ class _LiveTvPageState extends State<LiveTvPage> { _recent = [channel.id, ..._recent.where((e) => e != channel.id)] .take(12) .toList(); + _rebuildPins(); }); context.push( '/player', @@ -348,6 +480,16 @@ class _LiveTvPageState extends State<LiveTvPage> { ); } + Future<void> _openSheet(LiveChannel channel) { + return showChannelSheet( + context: context, + channel: channel, + favourite: _favourites.contains(channel.id), + onPlay: () => _play(channel), + onToggleFavourite: () => _toggleFavourite(channel), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -358,210 +500,459 @@ class _LiveTvPageState extends State<LiveTvPage> { scrolledUnderElevation: 0, elevation: 0, automaticallyImplyLeading: !widget.embedded, - titleSpacing: widget.embedded ? 18 : null, - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'live_tv.title'.tr(), - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800), + titleSpacing: widget.embedded ? _kGutter : null, + // Inside a folder, a country or a search, back closes the scope rather + // than the screen; outside one this is null and the usual leading + // behaviour applies. + leading: _scoped + ? IconButton( + icon: const Icon(Icons.arrow_back_rounded, size: 22), + color: AppColors.textPrimary, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: _closeScope, + ) + : null, + title: Text( + 'live_tv.title'.tr(), + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + ), + body: SafeArea( + // Inside the SafeArea and outside the RefreshIndicator, so the width + // every grid divides up is the real content width — a notch in + // landscape makes it several points narrower than the screen. + child: LayoutBuilder( + builder: (context, constraints) => RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _refresh, + child: CustomScrollView( + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: _slivers(constraints.maxWidth), ), - const SizedBox(width: 8), - const _LivePill(), - ], + ), ), ), - body: SafeArea(child: _body()), ); } - Widget _body() { - if (_loading && _folders.isEmpty && _channels.isEmpty) { - return const Center(child: CircularProgressIndicator(strokeWidth: 2.5)); - } - if (_error != null && _folders.isEmpty && _channels.isEmpty) { - return _Empty( - icon: Icons.cloud_off_rounded, - text: _error!, - actionLabel: 'live_tv.retry'.tr(), - onAction: _loadFolders, - ); - } - - final searching = _query.trim().isNotEmpty; - final favourites = [ - for (final id in _favourites) - if (_fromCard(id) != null) _fromCard(id)!, - ]; - final recent = [ - for (final id in _recent) - if (!_favourites.contains(id) && _fromCard(id) != null) _fromCard(id)!, + /// Every state of this screen is a sliver under the search field, so the + /// field is never unmounted underneath somebody's typing. + List<Widget> _slivers(double width) { + return [ + _searchSliver(), + if (_scoped) ..._scopedSlivers(width) else ..._topSlivers(width), + SliverToBoxAdapter(child: SizedBox(height: widget.embedded ? 96 : 28)), ]; + } - return RefreshIndicator( - color: AppColors.primary, - backgroundColor: AppColors.surface, - onRefresh: _atTopLevel ? _loadFolders : () => _loadPage(), - child: CustomScrollView( - controller: _scroll, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), - child: TextField( - controller: _search, - onChanged: _onQueryChanged, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - isDense: true, - hintText: _atTopLevel - ? 'live_tv.search_hint'.tr() - : 'live_tv.search_in'.tr(namedArgs: {'folder': _openName}), - prefixIcon: const Icon(Icons.search_rounded, size: 20), - suffixIcon: _query.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.close_rounded, size: 18), - onPressed: () { - _search.clear(); - _onQueryChanged(''); - }, - ), - ), - ), + Widget _searchSliver() { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 8, _kGutter, 10), + child: TextField( + controller: _search, + onChanged: _onQueryChanged, + textInputAction: TextInputAction.search, + style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), + // Fill, padding and radius all come from inputDecorationTheme, so + // this field is the same field as every other one in the app. + decoration: InputDecoration( + hintText: (_folder.isEmpty && _country.isEmpty) + ? 'live_tv.search_hint'.tr() + : 'live_tv.search_in'.tr(namedArgs: {'folder': _scopeName}), + prefixIcon: const Icon( + Icons.search_rounded, + size: 20, + color: AppColors.textSecondary, ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon( + Icons.close_rounded, + size: 18, + color: AppColors.textSecondary, + ), + onPressed: () { + _search.clear(); + _onQueryChanged(''); + }, + ), ), + ), + ), + ); + } - // Which folder is open, and the way back out of it. - if (_folder.isNotEmpty || _country.isNotEmpty) - SliverToBoxAdapter( - child: _FolderCrumb( - folder: _openName, - total: _total, - onBack: _closeFolder, - ), + List<Widget> _topSlivers(double width) { + final indexEmpty = _booted && _folders.isEmpty; + + return [ + if (_recentCards.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.history_rounded, + label: 'live_tv.recent'.tr(), + ), + _PinRail( + channels: _recentCards, + favourites: _favourites, + onPlay: _play, + onMore: _openSheet, + ), + ], + if (_favouriteCards.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.star_rounded, + label: 'live_tv.favourites'.tr(), + ), + _PinRail( + channels: _favouriteCards, + favourites: _favourites, + onPlay: _play, + onMore: _openSheet, + ), + ], + if (_booted && + _recentCards.isEmpty && + _favouriteCards.isEmpty && + _folders.isNotEmpty) + const _HintLine(), + if (indexEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _Empty( + icon: _error != null + ? Icons.cloud_off_rounded + : Icons.live_tv_rounded, + text: _error ?? 'live_tv.empty'.tr(), + actionLabel: 'live_tv.retry'.tr(), + onAction: () => _loadIndex(), + ), + ) + else ...[ + _SectionHeader( + icon: Icons.grid_view_rounded, + label: 'live_tv.categories'.tr(), + trailing: _indexTotal > 0 + ? 'live_tv.channel_count'.plural(_indexTotal) + : null, + ), + if (!_booted) + _CategorySkeleton(width: width) + else + _CategoryGrid(folders: _folders, width: width, onOpen: _openFolder), + if (!_booted || _countries.isNotEmpty) ...[ + _SectionHeader( + icon: Icons.public_rounded, + label: 'live_tv.countries'.tr(), + ), + if (!_booted) + const _CountryRailSkeleton() + else + _CountryRail( + countries: _countries, + nameOf: _countryName, + flagOf: _flagOf, + onOpen: _openCountry, ), + ], + ], + ]; + } - if (_atTopLevel) ...[ - if (recent.isNotEmpty) ...[ - _Header(label: 'live_tv.recent'.tr()), - _RecentRow(channels: recent, onPlay: _play), - ], - if (favourites.isNotEmpty) ...[ - _Header(label: 'live_tv.favourites'.tr()), - _Grid( - channels: favourites, - favourites: _favourites, - onPlay: _play, - onFavourite: _toggleFavourite, - ), - ], - _Header(label: 'live_tv.folders'.tr()), - // Folders, not channels. A hundred thousand channels is a few dozen - // folders, and the one somebody wants is two taps away rather than - // twenty megabytes and a scroll. - _FolderGrid(folders: _folders, onOpen: _openFolder), - if (_countries.isNotEmpty) ...[ - _Header(label: 'live_tv.countries'.tr()), - _CountryRow( - countries: _countries, - nameOf: _countryName, - flagOf: _flagOf, - onOpen: _openCountry, - ), - ], - ] else if (_loading) ...[ - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.only(top: 60), - child: Center(child: CircularProgressIndicator(strokeWidth: 2.5)), + List<Widget> _scopedSlivers(double width) { + return [ + _ScopeLine( + name: _scopeName, + total: _total, + showTotal: _channels.isNotEmpty, + ), + if (_loading && _channels.isEmpty) + _GridSkeleton(width: width, count: 9) + else if (_error != null && _channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _Empty( + icon: Icons.cloud_off_rounded, + text: _error!, + actionLabel: 'live_tv.retry'.tr(), + onAction: () => _loadPage(), + ), + ) + else if (_channels.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _Empty( + icon: _searching ? Icons.search_off_rounded : Icons.live_tv_rounded, + text: _searching ? 'live_tv.no_match'.tr() : 'live_tv.empty'.tr(), + ), + ) + else + _ChannelGrid( + channels: _channels, + favourites: _favourites, + width: width, + now: _now, + onPlay: _play, + onMore: _openSheet, + ), + if (_loadingMore) const _LoadMoreFooter(), + ]; + } +} + +/// A section title, in Home's one header shape. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.icon, + required this.label, + this.trailing, + }); + + final IconData icon; + final String label; + final String? trailing; + + @override + Widget build(BuildContext context) { + final tail = trailing; + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 18, _kGutter, 12), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.textSecondary), + const SizedBox(width: 8), + Semantics( + header: true, + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1.1, + ), ), ), - ] else if (_channels.isEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 60), - child: _Empty( - icon: searching ? Icons.search_off_rounded : Icons.live_tv_rounded, - text: searching ? 'live_tv.no_match'.tr() : 'live_tv.empty'.tr(), + const Spacer(), + if (tail != null) + Text( + tail, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +/// What is open, and how much is in it. The way back out is the AppBar's, which +/// is on screen at every scroll position. +class _ScopeLine extends StatelessWidget { + const _ScopeLine({ + required this.name, + required this.total, + required this.showTotal, + }); + + final String name; + final int total; + final bool showTotal; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 2, _kGutter, 10), + child: Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, ), ), ), - ] else ...[ - _Grid( - channels: _channels, - favourites: _favourites, - onPlay: _play, - onFavourite: _toggleFavourite, - ), - if (_loadingMore) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 18), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2.2), - ), - ), + if (showTotal) ...[ + const SizedBox(width: 10), + Text( + 'live_tv.channel_count'.plural(total), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w700, ), ), + ], ], + ), + ), + ); + } +} - SliverToBoxAdapter(child: SizedBox(height: widget.embedded ? 96 : 28)), - ], +/// The one line that teaches the long press, shown only while nothing is +/// pinned — the moment there is a pin, it has been learnt. +class _HintLine extends StatelessWidget { + const _HintLine(); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(_kGutter, 0, _kGutter, 4), + child: Text( + 'live_tv.hint_long_press'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + height: 1.35, + ), + ), ), ); } } -/// The folder you are inside, and the way back out. -class _FolderCrumb extends StatelessWidget { - const _FolderCrumb({ - required this.folder, - required this.total, - required this.onBack, +/// Recents and favourites, in one shape. +/// +/// A rail because these are shortcuts, not a section to browse: they should +/// never push the line-up itself off the first screen. +class _PinRail extends StatelessWidget { + const _PinRail({ + required this.channels, + required this.favourites, + required this.onPlay, + required this.onMore, }); - final String folder; - final int total; - final VoidCallback onBack; + final List<LiveChannel> channels; + final Set<String> favourites; + final ValueChanged<LiveChannel> onPlay; + final ValueChanged<LiveChannel> onMore; + + @override + Widget build(BuildContext context) { + // 76 tile + 6 gap + one caption line, so text scaling cannot overflow it. + final height = 82 + MediaQuery.textScalerOf(context).scale(10.5) * 1.2; + return SliverToBoxAdapter( + child: SizedBox( + height: height, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + itemCount: channels.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, i) => _PinTile( + channel: channels[i], + favourite: favourites.contains(channels[i].id), + onPlay: onPlay, + onMore: onMore, + ), + ), + ), + ); + } +} + +/// One pinned channel: its logo, and its name underneath. +/// +/// No guide line here on purpose — a pinned channel is rebuilt from a saved +/// card, which never carries a slot, so the line would be permanently blank. +class _PinTile extends StatelessWidget { + const _PinTile({ + required this.channel, + required this.favourite, + required this.onPlay, + required this.onMore, + }); + + final LiveChannel channel; + final bool favourite; + final ValueChanged<LiveChannel> onPlay; + final ValueChanged<LiveChannel> onMore; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 6), - child: Row( + final dpr = MediaQuery.devicePixelRatioOf(context); + return SizedBox( + width: 76, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ Material( - color: AppColors.surface, - borderRadius: BorderRadius.circular(10), + color: AppColors.card, + borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: onBack, - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 7), - child: Icon(Icons.arrow_back_rounded, size: 17), + onTap: () => onPlay(channel), + onLongPress: () => onMore(channel), + onSecondaryTap: () => onMore(channel), + child: Container( + width: 76, + height: 76, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: favourite + ? AppColors.primary.withValues(alpha: 0.45) + : Colors.white.withValues(alpha: 0.06), + ), + ), + child: channel.logoUrl == null + ? const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + // The 54pt content box, not the 76pt tile. + memCacheWidth: (54 * dpr).round(), + errorWidget: (_, _, _) => const Icon( + Icons.live_tv_rounded, + size: 26, + color: AppColors.textHint, + ), + placeholder: (_, _) => const SizedBox.shrink(), + ), ), ), ), - const SizedBox(width: 10), - Expanded( + const SizedBox(height: 6), + FixedTextLines( + fontSize: 10.5, + lineHeight: 1.2, + lines: 1, child: Text( - folder, + channel.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), - ), - ), - Text( - '$total', - style: const TextStyle( - fontSize: 11.5, - fontWeight: FontWeight.w700, - color: AppColors.textHint, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), ), ), ], @@ -570,217 +961,215 @@ class _FolderCrumb extends StatelessWidget { } } -/// The folders themselves. +/// The categories, two per row. /// -/// Two per row and captioned with a count, because the decision being made here -/// is "which of these do I want", and a count is the only thing that -/// distinguishes a folder with four channels in it from one with four thousand. -class _FolderGrid extends StatelessWidget { - const _FolderGrid({required this.folders, required this.onOpen}); +/// Captioned with a count, because the decision being made here is "which of +/// these do I want", and a count is the only thing that distinguishes a +/// category with four channels in it from one with four hundred. +class _CategoryGrid extends StatelessWidget { + const _CategoryGrid({ + required this.folders, + required this.width, + required this.onOpen, + }); final List<LiveFolder> folders; + final double width; final ValueChanged<String> onOpen; + /// The eleven names the line-up actually uses, each with the glyph somebody + /// would draw for it. Anything else the backend grows later falls back. + static const Map<String, IconData> _glyphs = { + 'movies': Icons.movie_rounded, + 'general': Icons.tv_rounded, + 'entertainment': Icons.celebration_rounded, + 'kids': Icons.child_care_rounded, + 'documentary': Icons.public_rounded, + 'news': Icons.article_rounded, + 'music': Icons.music_note_rounded, + 'sports': Icons.sports_soccer_rounded, + 'lifestyle': Icons.spa_rounded, + 'religious': Icons.auto_stories_rounded, + 'family': Icons.family_restroom_rounded, + }; + + static IconData _glyphFor(String name) => + _glyphs[name.toLowerCase()] ?? Icons.folder_rounded; + + static int columnsFor(double width) => + width >= 900 ? 4 : (width >= 620 ? 3 : 2); + + /// Two lines of text plus the tile's own padding, floored at the height the + /// tile has always had — so it is unchanged at normal text size and grows + /// rather than overflows at 1.8×. + static double extentFor(BuildContext context) { + final ts = MediaQuery.textScalerOf(context); + final raw = ts.scale(12.5) * 1.2 + 3 + ts.scale(10.5) * 1.2 + 28; + return raw < 64 ? 64.0 : raw; + } + @override Widget build(BuildContext context) { - final width = MediaQuery.sizeOf(context).width; - final columns = width >= 900 ? 4 : (width >= 620 ? 3 : 2); - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: _kGutter), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: 64, + crossAxisCount: columnsFor(width), + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, + mainAxisExtent: extentFor(context), ), - delegate: SliverChildBuilderDelegate( - (context, i) { - final folder = folders[i]; - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => onOpen(folder.name), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + delegate: SliverChildBuilderDelegate((context, i) { + final folder = folders[i]; + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onOpen(folder.name), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withValues(alpha: 0.06), ), - child: Row( - children: [ - SizedBox( - width: 32, - height: 32, - child: folder.logoUrl == null - ? Icon( - Icons.folder_rounded, - size: 19, - color: AppColors.textHint, - ) - : CachedNetworkImage( - imageUrl: folder.logoUrl!, - fit: BoxFit.contain, - errorWidget: (_, _, _) => Icon( - Icons.folder_rounded, - size: 19, - color: AppColors.textHint, - ), - ), + ), + child: Row( + children: [ + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - folder.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w700, - ), + child: Icon( + _glyphFor(folder.name), + size: 18, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + folder.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + height: 1.2, ), - const SizedBox(height: 2), - Text( - 'live_tv.channel_count'.plural(folder.count), - style: const TextStyle( - fontSize: 10.5, - color: AppColors.textHint, - ), + ), + const SizedBox(height: 3), + Text( + 'live_tv.channel_count'.plural(folder.count), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + height: 1.2, ), - ], - ), - ), - Icon( - Icons.chevron_right_rounded, - size: 18, - color: AppColors.textHint, + ), + ], ), - ], - ), + ), + ], ), ), - ); - }, - childCount: folders.length, - ), - ), - ); - } -} - -class _LivePill extends StatelessWidget { - const _LivePill(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 5, - height: 5, - decoration: BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, ), - ), - const SizedBox(width: 5), - Text( - 'LIVE', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w900, - letterSpacing: 0.9, - color: AppColors.primary, - ), - ), - ], + ); + }, childCount: folders.length), ), ); } } -/// The last few channels, as a rail rather than a grid. +/// The countries the line-up covers, as a strip you scroll sideways. /// -/// A rail because this is a shortcut, not a section to browse: four or five -/// entries wide is the whole of it, and it should never push the line-up itself -/// off the first screen. -class _RecentRow extends StatelessWidget { - const _RecentRow({required this.channels, required this.onPlay}); +/// A shortcut for somebody who already knows what they are after, not the main +/// navigation. Ordered by how much each country contributes, and cut at +/// sixteen — past that the codes stop resolving to names and the strip goes +/// ragged. +class _CountryRail extends StatelessWidget { + const _CountryRail({ + required this.countries, + required this.nameOf, + required this.flagOf, + required this.onOpen, + }); - final List<LiveChannel> channels; - final ValueChanged<LiveChannel> onPlay; + static const int _cap = 16; + + final List<LiveCountry> countries; + final String Function(String) nameOf; + final String Function(String) flagOf; + final ValueChanged<String> onOpen; @override Widget build(BuildContext context) { + // Regional-indicator pairs do not render on Windows, where a flag becomes + // two letterboxed letters. Phones, where this strip is actually used, get + // the picture. + final showFlags = !isDesktopPlatform; + return SliverToBoxAdapter( child: SizedBox( - height: 62, + height: 40, child: ListView.separated( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: channels.length, + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + itemCount: countries.length < _cap ? countries.length : _cap, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, i) { - final channel = channels[i]; + final country = countries[i]; + final flag = showFlags ? flagOf(country.code) : ''; return Material( color: AppColors.card, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () => onPlay(channel), + onTap: () => onOpen(country.code), child: Container( - width: 148, - padding: const EdgeInsets.symmetric(horizontal: 10), + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.06), + ), ), child: Row( + mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 34, - height: 34, - child: channel.logoUrl == null - ? Icon( - Icons.live_tv_rounded, - size: 18, - color: AppColors.textHint, - ) - : CachedNetworkImage( - imageUrl: channel.logoUrl!, - fit: BoxFit.contain, - errorWidget: (_, _, _) => Icon( - Icons.live_tv_rounded, - size: 18, - color: AppColors.textHint, - ), - ), + if (flag.isNotEmpty) ...[ + Text(flag, style: const TextStyle(fontSize: 15)), + const SizedBox(width: 7), + ], + Text( + nameOf(country.code), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), ), - const SizedBox(width: 9), - Expanded( - child: Text( - channel.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11.5, - height: 1.15, - fontWeight: FontWeight.w600, - ), + const SizedBox(width: 6), + Text( + '${country.count}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w700, ), ), ], @@ -795,62 +1184,39 @@ class _RecentRow extends StatelessWidget { } } -class _Header extends StatelessWidget { - const _Header({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 14, 18, 8), - child: Text( - label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0.8, - color: AppColors.textHint, - ), - ), - ), - ); - } -} - - -class _Grid extends StatelessWidget { - const _Grid({ +/// The channels themselves. +class _ChannelGrid extends StatelessWidget { + const _ChannelGrid({ required this.channels, required this.favourites, + required this.width, + required this.now, required this.onPlay, - required this.onFavourite, + required this.onMore, }); final List<LiveChannel> channels; final Set<String> favourites; + final double width; + final DateTime now; final ValueChanged<LiveChannel> onPlay; - final ValueChanged<LiveChannel> onFavourite; + final ValueChanged<LiveChannel> onMore; @override Widget build(BuildContext context) { // Channel logos are wide, not poster-shaped, so the cell is landscape and // the count follows the width rather than a fixed number. - const gutter = 14.0; - const spacing = 10.0; - final width = MediaQuery.sizeOf(context).width; - final columns = width >= 900 ? 5 : (width >= 620 ? 4 : 3); - final cell = (width - gutter * 2 - spacing * (columns - 1)) / columns; + final columns = _columnsFor(width); + final cell = (width - _kGutter * 2 - _kSpacing * (columns - 1)) / columns; final caption = _ChannelCard.reserveCaption(context); return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: gutter), + padding: const EdgeInsets.symmetric(horizontal: _kGutter), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, // The logo box is what the cell is for, so the caption's two lines // are added to it rather than taken out of it — a fixed ratio let a // long name eat the logo, and a one-line name grow it. @@ -861,8 +1227,10 @@ class _Grid extends StatelessWidget { channel: channels[i], favourite: favourites.contains(channels[i].id), captionHeight: caption, + cell: cell, + now: now, onPlay: () => onPlay(channels[i]), - onFavourite: () => onFavourite(channels[i]), + onMore: () => onMore(channels[i]), ), childCount: channels.length, ), @@ -876,12 +1244,15 @@ class _ChannelCard extends StatelessWidget { required this.channel, required this.favourite, required this.captionHeight, + required this.cell, + required this.now, required this.onPlay, - required this.onFavourite, + required this.onMore, }); static const double _fontSize = 11.5; static const double _lineHeight = 1.2; + static const double _slotFontSize = 10.5; /// Two lines, always. Channel names run from "TV1" to "Discovery Science HD", /// and letting the caption size itself left every logo in a row at a @@ -892,84 +1263,142 @@ class _ChannelCard extends StatelessWidget { final LiveChannel channel; final bool favourite; final double captionHeight; + final double cell; + final DateTime now; final VoidCallback onPlay; - final VoidCallback onFavourite; + final VoidCallback onMore; @override Widget build(BuildContext context) { - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(14), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onPlay, - onLongPress: onFavourite, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: favourite - ? AppColors.primary.withValues(alpha: 0.45) - : Colors.white.withValues(alpha: 0.06), + final slot = channel.slotAt(now); + final bar = _barFactor(slot, now); + final dpr = MediaQuery.devicePixelRatioOf(context); + final logoWidth = cell - 24; + + return Semantics( + container: true, + button: true, + label: slot == null ? channel.name : '${channel.name}. ${slot.title}', + child: Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPlay, + onLongPress: onMore, + onSecondaryTap: onMore, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: favourite + ? AppColors.primary.withValues(alpha: 0.45) + : Colors.white.withValues(alpha: 0.06), + ), ), - ), - child: Column( - children: [ - Expanded( - child: Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.all(12), - child: channel.logoUrl == null - ? _Fallback(name: channel.name) - : CachedNetworkImage( - imageUrl: channel.logoUrl!, - fit: BoxFit.contain, - // Logos come from wherever the playlist points, - // and a dead one is common — the initial reads - // better than a broken-image glyph. - errorWidget: (_, _, _) => - _Fallback(name: channel.name), - placeholder: (_, _) => - _Fallback(name: channel.name), - ), - ), - ), - if (favourite) - Positioned( - top: 6, - right: 6, - child: Icon( - Icons.star_rounded, - size: 15, - color: AppColors.primary, + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(12), + child: channel.logoUrl == null + ? _Fallback(name: channel.name) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + memCacheWidth: logoWidth > 0 + ? (logoWidth * dpr).round() + : null, + // Logos come from wherever the playlist + // points, and a dead one is common — the + // initial reads better than a broken-image + // glyph. + errorWidget: (_, _, _) => + _Fallback(name: channel.name), + placeholder: (_, _) => + const SizedBox.shrink(), + ), ), ), - ], + if (favourite) + Positioned( + top: 6, + right: 6, + child: Icon( + Icons.star_rounded, + size: 15, + color: AppColors.primary, + ), + ), + // Rides the bottom edge of the logo box, so it costs the + // cell no height at all. + if (bar != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SizedBox( + height: 2, + child: Container( + color: Colors.white.withValues(alpha: 0.08), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: bar, + heightFactor: 1, + child: Container(color: AppColors.primary), + ), + ), + ), + ), + ], + ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 9), - child: SizedBox( - height: captionHeight, - child: Center( - child: Text( - channel.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: _fontSize, - height: _lineHeight, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 9), + child: SizedBox( + height: captionHeight, + // One name line plus a slot line at 10.5 is strictly + // shorter than the two name lines the box reserves, so a + // guide costs the grid nothing and a channel without one + // simply keeps both lines for its name. + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + channel.name, + maxLines: slot == null ? 2 : 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: _fontSize, + height: _lineHeight, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (slot != null) + Text( + slot.title, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: _slotFontSize, + height: _lineHeight, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], ), ), ), - ), - ], + ], + ), ), ), ), @@ -997,6 +1426,123 @@ class _Fallback extends StatelessWidget { } } +class _LoadMoreFooter extends StatelessWidget { + const _LoadMoreFooter(); + + @override + Widget build(BuildContext context) { + return const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2.2), + ), + ), + ), + ); + } +} + +/// The channel grid's shape, before it has anything in it. +/// +/// The same delegate and the same aspect ratio as the real grid, so the +/// channels land exactly where the skeleton sat. +class _GridSkeleton extends StatelessWidget { + const _GridSkeleton({required this.width, required this.count}); + + final double width; + final int count; + + @override + Widget build(BuildContext context) { + final columns = _columnsFor(width); + final cell = (width - _kGutter * 2 - _kSpacing * (columns - 1)) / columns; + final caption = _ChannelCard.reserveCaption(context); + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, + childAspectRatio: cell / (cell * 0.73 + caption + 9), + ), + delegate: SliverChildBuilderDelegate( + (_, _) => const ShimmerWrapper( + child: HomeSkeletonBox( + width: double.infinity, + height: double.infinity, + radius: 14, + ), + ), + childCount: count, + ), + ), + ); + } +} + +class _CategorySkeleton extends StatelessWidget { + const _CategorySkeleton({required this.width}); + + final double width; + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _CategoryGrid.columnsFor(width), + mainAxisSpacing: _kSpacing, + crossAxisSpacing: _kSpacing, + mainAxisExtent: _CategoryGrid.extentFor(context), + ), + delegate: SliverChildBuilderDelegate( + (_, _) => const ShimmerWrapper( + child: HomeSkeletonBox( + width: double.infinity, + height: double.infinity, + radius: 14, + ), + ), + childCount: 6, + ), + ), + ); + } +} + +class _CountryRailSkeleton extends StatelessWidget { + const _CountryRailSkeleton(); + + /// Uneven on purpose: four identical pills read as a rendering artefact + /// rather than as country names on their way. + static const List<double> _widths = [96, 78, 110, 86]; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: _kGutter), + itemCount: _widths.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) => ShimmerWrapper( + child: HomeSkeletonBox(width: _widths[i], height: 40, radius: 20), + ), + ), + ), + ); + } +} + class _Empty extends StatelessWidget { const _Empty({ required this.icon, @@ -1018,13 +1564,17 @@ class _Empty extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 46, color: AppColors.textHint.withValues(alpha: 0.6)), + Icon( + icon, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), const SizedBox(height: 14), Text( text, textAlign: TextAlign.center, style: const TextStyle( - color: AppColors.textHint, + color: AppColors.textSecondary, fontSize: 13.5, height: 1.5, ), @@ -1039,86 +1589,3 @@ class _Empty extends StatelessWidget { ); } } - -/// The countries the line-up covers, as a strip you scroll sideways. -/// -/// A row rather than a grid, and below the folders: it is a shortcut for -/// somebody who already knows what they are after, not the main navigation. -/// Ordered by how much each country actually contributes — the line-up leads -/// with channels people recognise, and pinning a home country in front of that -/// puts a provincial station where a famous one should be. -class _CountryRow extends StatelessWidget { - const _CountryRow({ - required this.countries, - required this.nameOf, - required this.flagOf, - required this.onOpen, - }); - - final List<LiveCountry> countries; - final String Function(String) nameOf; - final String Function(String) flagOf; - final ValueChanged<String> onOpen; - - @override - Widget build(BuildContext context) { - final ordered = [...countries]..sort((a, b) => b.count.compareTo(a.count)); - - return SliverToBoxAdapter( - child: SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 14), - itemCount: ordered.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final country = ordered[i]; - final flag = flagOf(country.code); - return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => onOpen(country.code), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13), - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (flag.isNotEmpty) ...[ - Text(flag, style: const TextStyle(fontSize: 15)), - const SizedBox(width: 7), - ], - Text( - nameOf(country.code), - style: const TextStyle( - fontSize: 12.5, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(width: 6), - Text( - '${country.count}', - style: const TextStyle( - fontSize: 10.5, - fontWeight: FontWeight.w700, - color: AppColors.textHint, - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/features/live_tv/presentation/widgets/channel_sheet.dart b/lib/features/live_tv/presentation/widgets/channel_sheet.dart new file mode 100644 index 00000000..918393ee --- /dev/null +++ b/lib/features/live_tv/presentation/widgets/channel_sheet.dart @@ -0,0 +1,490 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; +import 'package:soplay/features/live_tv/data/live_tv_service.dart'; + +/// One channel, opened from a long press. +/// +/// The card can only ever carry a name and a line of guide; this is where the +/// rest of it lives — what is on, what follows, the day ahead, and the two +/// things anybody wants to do with a channel. +Future<void> showChannelSheet({ + required BuildContext context, + required LiveChannel channel, + required bool favourite, + required VoidCallback onPlay, + required VoidCallback onToggleFavourite, +}) { + return showAdaptiveModal<void>( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: (_) => ChannelSheet( + channel: channel, + favourite: favourite, + onPlay: onPlay, + onToggleFavourite: onToggleFavourite, + ), + ); +} + +class ChannelSheet extends StatefulWidget { + const ChannelSheet({ + super.key, + required this.channel, + required this.favourite, + required this.onPlay, + required this.onToggleFavourite, + }); + + final LiveChannel channel; + final bool favourite; + final VoidCallback onPlay; + final VoidCallback onToggleFavourite; + + @override + State<ChannelSheet> createState() => _ChannelSheetState(); +} + +class _ChannelSheetState extends State<ChannelSheet> { + late bool _favourite; + LiveSchedule? _schedule; + bool _loadingSchedule = true; + + @override + void initState() { + super.initState(); + _favourite = widget.favourite; + _loadSchedule(); + } + + /// Read once, here — a future created in `build()` is refetched every time + /// the favourite button rebuilds this widget. + Future<void> _loadSchedule() async { + try { + final schedule = await getIt<LiveTvService>().schedule( + widget.channel.id, + hours: 24, + ); + if (!mounted) return; + setState(() { + _schedule = schedule; + _loadingSchedule = false; + }); + } catch (_) { + if (!mounted) return; + // A missing guide is not worth saying out loud twice; the empty branch + // below already says it once, in the one place the user asked for it. + setState(() => _loadingSchedule = false); + } + } + + void _play() { + Navigator.of(context).pop(); + widget.onPlay(); + } + + void _toggleFavourite() { + setState(() => _favourite = !_favourite); + // The sheet stays open: favouriting is reversible and the guide underneath + // is what the user came for. + widget.onToggleFavourite(); + } + + @override + Widget build(BuildContext context) { + final channel = widget.channel; + final at = DateTime.now(); + final slot = channel.slotAt(at); + final bar = _barValue(slot, at); + final next = channel.next; + + return ConstrainedBox( + // Both of showAdaptiveModal's dialog paths hand down an unbounded height + // through their own scroll view; this is what makes it finite. + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.78, + ), + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB( + 18, + 6, + 18, + MediaQuery.paddingOf(context).bottom + 14, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _identity(context, channel), + if (slot != null) ..._nowBlock(slot, bar), + if (next != null) ..._nextBlock(next), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: _play, + icon: const Icon(Icons.play_arrow_rounded, size: 20), + label: Text('live_tv.watch'.tr()), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: _toggleFavourite, + icon: Icon( + _favourite ? Icons.star_rounded : Icons.star_border_rounded, + size: 20, + color: _favourite + ? AppColors.primary + : AppColors.textSecondary, + ), + label: Text( + _favourite + ? 'live_tv.favourite_remove'.tr() + : 'live_tv.favourite_add'.tr(), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + const Icon( + Icons.list_alt_rounded, + size: 18, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + 'live_tv.guide'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SizedBox(height: 10), + _guide(at), + ], + ), + ), + ), + ); + } + + Widget _identity(BuildContext context, LiveChannel channel) { + final dpr = MediaQuery.devicePixelRatioOf(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: channel.logoUrl == null + ? const Icon( + Icons.live_tv_rounded, + size: 22, + color: AppColors.textHint, + ) + : CachedNetworkImage( + imageUrl: channel.logoUrl!, + fit: BoxFit.contain, + memCacheWidth: (32 * dpr).round(), + errorWidget: (_, _, _) => const Icon( + Icons.live_tv_rounded, + size: 22, + color: AppColors.textHint, + ), + placeholder: (_, _) => const SizedBox.shrink(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + channel.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15.5, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + if (channel.category.isNotEmpty) ...[ + const SizedBox(height: 3), + // English, exactly as the line-up delivers it — the channel + // names beside it are English too. + Text( + channel.category, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ); + } + + List<Widget> _nowBlock(LiveProgramme slot, double? bar) { + final detail = [ + slot.episodeLabel, + slot.subtitle, + ].where((value) => value.isNotEmpty).join(' · '); + + return [ + const SizedBox(height: 18), + Text( + 'live_tv.now'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 5), + Text( + slot.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + height: 1.25, + ), + ), + if (detail.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w500, + ), + ), + ], + if (slot.hasWindow) ...[ + const SizedBox(height: 8), + Text( + '${_hhmm(slot.start!)} – ${_hhmm(slot.stop!)}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + ], + if (bar != null) ...[ + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: SizedBox( + // Explicitly full width: a column lays its children out loose, and + // a track measured from its own fill is no track at all. + width: double.infinity, + height: 3, + child: Container( + color: AppColors.surfaceVariant, + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: bar, + heightFactor: 1, + child: Container(color: AppColors.primary), + ), + ), + ), + ), + ], + if (slot.description.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + slot.description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + height: 1.45, + ), + ), + ], + ]; + } + + List<Widget> _nextBlock(LiveProgramme next) { + return [ + const SizedBox(height: 16), + Text( + 'live_tv.next'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 5), + Row( + children: [ + if (next.hasWindow) ...[ + Text( + _hhmm(next.start!), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 9), + ], + Expanded( + child: Text( + next.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ]; + } + + Widget _guide(DateTime at) { + if (_loadingSchedule) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: List<Widget>.generate( + 3, + (_) => const Padding( + padding: EdgeInsets.symmetric(vertical: 9), + child: ShimmerWrapper( + child: Row( + children: [ + HomeSkeletonBox(width: 46, height: 10), + SizedBox(width: 10), + HomeSkeletonBox(width: 160, height: 10), + ], + ), + ), + ), + ), + ); + } + + final schedule = _schedule; + // The live row is already the NOW block above, and a row with no time has + // nothing to put in its left column. + final rows = schedule == null + ? const <LiveProgramme>[] + : schedule + .from(at) + .where((p) => p.start != null && !p.isLiveAt(at)) + .take(8) + .toList(growable: false); + + if (rows.isEmpty) { + return Text( + 'live_tv.no_guide'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w500, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < rows.length; i++) ...[ + if (i > 0) Divider(height: 1, thickness: 1, color: AppColors.divider), + Padding( + padding: const EdgeInsets.symmetric(vertical: 9), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 46, + child: Text( + _hhmm(rows[i].start!), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + rows[i].title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +/// Width factor for the NOW progress bar, or null when there is nothing honest +/// to draw. The sheet is open for seconds, so this is read once per build and +/// never ticked. +double? _barValue(LiveProgramme? slot, DateTime at) { + if (slot == null || !slot.isBarWorthy) return null; + final value = slot.progressAt(at); + if (value <= 0) return null; + return value < 0.02 ? 0.02 : value; +} + +/// 24-hour, hand-formatted. +/// +/// `intl` is not a declared dependency — it reaches the app only through +/// easy_localization's re-export — and `DateFormat.Hm` throws without locale +/// data initialised for uz. Digits need no translation either way. +String _hhmm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; From 27507a041b1bbf3ab89265a27d2f36a6ab67c6ba Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 16:52:54 +0500 Subject: [PATCH 08/21] feat(sync): enhance history synchronization and prevent data leaks between accounts --- .../profile/data/models/provider_model.dart | 6 ++ .../domain/entities/provider_entity.dart | 14 +++++ .../presentation/pages/providers_page.dart | 55 ++++++++++++++++++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/features/profile/data/models/provider_model.dart b/lib/features/profile/data/models/provider_model.dart index 87a6f083..8ac30be1 100644 --- a/lib/features/profile/data/models/provider_model.dart +++ b/lib/features/profile/data/models/provider_model.dart @@ -11,6 +11,8 @@ class ProviderModel extends ProviderEntity { super.mode, super.category, super.requiresCfBypass, + super.browseOnly, + super.browseOnlyReason, super.nsfw, super.extractor, }); @@ -37,6 +39,8 @@ class ProviderModel extends ProviderEntity { ? json['category'] as String : 'other', requiresCfBypass: json['requiresCfBypass'] == true, + browseOnly: json['browseOnly'] == true, + browseOnlyReason: (json['browseOnlyReason'] as String?)?.trim(), nsfw: json['nsfw'] == true, extractor: _parseExtractor(json['extractor']), ); @@ -52,6 +56,8 @@ class ProviderModel extends ProviderEntity { 'mode': mode, 'category': category, 'requiresCfBypass': requiresCfBypass, + 'browseOnly': browseOnly, + if (browseOnlyReason != null) 'browseOnlyReason': browseOnlyReason, 'nsfw': nsfw, if (extractor != null) 'extractor': { diff --git a/lib/features/profile/domain/entities/provider_entity.dart b/lib/features/profile/domain/entities/provider_entity.dart index 5ccc1995..e2ca5b45 100644 --- a/lib/features/profile/domain/entities/provider_entity.dart +++ b/lib/features/profile/domain/entities/provider_entity.dart @@ -23,6 +23,18 @@ class ProviderEntity { final String category; final bool requiresCfBypass; + /// The source can be browsed but nothing on it will play. + /// + /// Set by the backend when a provider's media has gone away at the source + /// while its catalog still answers — mochitv, whose storage stopped serving, + /// is the current case. Surfacing it means a user learns the source is + /// read-only from the list, instead of by tapping an episode and watching a + /// player fail. + final bool browseOnly; + + /// Why [browseOnly] is set, in the user's language. Backend-supplied. + final String? browseOnlyReason; + final bool nsfw; final ExtractorRef? extractor; @@ -36,6 +48,8 @@ class ProviderEntity { this.mode = 'server', this.category = 'other', this.requiresCfBypass = false, + this.browseOnly = false, + this.browseOnlyReason, this.nsfw = false, this.extractor, }); diff --git a/lib/features/profile/presentation/pages/providers_page.dart b/lib/features/profile/presentation/pages/providers_page.dart index 11644667..0d82ed89 100644 --- a/lib/features/profile/presentation/pages/providers_page.dart +++ b/lib/features/profile/presentation/pages/providers_page.dart @@ -685,6 +685,10 @@ class _ProviderListTile extends StatelessWidget { const _ServerDownBadge() else _ProviderModeBadge(mode: provider.mode), + if (provider.browseOnly) ...[ + const SizedBox(width: 4), + const _BrowseOnlyBadge(), + ], if (provider.requiresCfBypass) ...[ const SizedBox(width: 4), const _CfBypassBadge(), @@ -695,7 +699,23 @@ class _ProviderListTile extends StatelessWidget { ], ], ), - if (provider.description.isNotEmpty) ...[ + // A browse-only source says why in place of its blurb: + // the reason is the one thing a user needs before they + // pick it, and the blurb is still one tap away. + if (provider.browseOnly && + (provider.browseOnlyReason?.isNotEmpty ?? false)) ...[ + const SizedBox(height: 2), + Text( + provider.browseOnlyReason!, + style: const TextStyle( + color: Color(0xFFD08A3E), + fontSize: 11, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ] else if (provider.description.isNotEmpty) ...[ const SizedBox(height: 2), Text( provider.description, @@ -917,6 +937,39 @@ class _ProviderModeBadge extends StatelessWidget { } } +class _BrowseOnlyBadge extends StatelessWidget { + const _BrowseOnlyBadge(); + + @override + Widget build(BuildContext context) { + const color = Color(0xFFD08A3E); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.visibility_outlined, size: 9, color: color), + SizedBox(width: 3), + Text( + 'FAQAT KO\'RISH', + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + class _CfBypassBadge extends StatelessWidget { const _CfBypassBadge(); From 6c4be4efdf67ba63f3fea0fa901b1737240fcf16 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 17:26:13 +0500 Subject: [PATCH 09/21] Present the User-Agent this device actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kSozoUserAgent existed for a good reason — Cloudflare binds cf_clearance to the exact agent that solved the challenge, so one string everywhere is right. But the string it settled on was invented: `Chrome/125` on a device whose WebView is some other version entirely. A managed challenge does not only read the header. It compares what the header claims against what the engine behind it is — client hints, userAgentData, platform, version. Measured against animepahe.pw, which sits behind one: driving a real browser through the challenge, the agent that matched its true platform cleared in about four seconds, while every agent that misdescribed the platform sat at "Just a moment..." until the watchdog gave up. Same browser, same flags, same window size; only the header differed. So the value is read from the device's own WebView at startup and reused everywhere, with the old constant kept only as a fallback. The `; wv` token is dropped — it is the one edit that does not make the string less true. channel_sheet.dart is unrelated: showAdaptiveModal was used without its import, which failed analysis and would have failed the build. Co-Authored-By: Azamov X <108933534+professorDeveloper@users.noreply.github.com> --- lib/core/js/dart_fetch.dart | 4 +- lib/core/network/user_agent.dart | 51 ++++++++++++++++++- .../extensions/data/mangayomi_repo_store.dart | 5 +- .../presentation/widgets/channel_sheet.dart | 1 + lib/main.dart | 6 +++ 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/core/js/dart_fetch.dart b/lib/core/js/dart_fetch.dart index 964ca071..5411123c 100644 --- a/lib/core/js/dart_fetch.dart +++ b/lib/core/js/dart_fetch.dart @@ -48,7 +48,7 @@ class DartFetch { responseType: ResponseType.plain, // Without this dart:io stamps `Dart/3.x (dart:io)` on every extension // request, which Cloudflare refuses outright. - headers: const {'User-Agent': kSozoUserAgent}, + headers: {'User-Agent': kSozoUserAgent}, ), )..interceptors.add(SafeCookieManager(CookieJar())); return DartFetch._(dio, cfService, backendDio); @@ -119,7 +119,7 @@ class DartFetch { // handed the challenge page. Pinning it here means a stale extractor // cannot reintroduce the mismatch, and the replay below sends the same // agent the clearance was earned under — which Cloudflare requires. - const solveAgent = kSozoUserAgent; + final solveAgent = kSozoUserAgent; final cookieHeader = await cf.solve( host: host, url: req.url, diff --git a/lib/core/network/user_agent.dart b/lib/core/network/user_agent.dart index 3e4def70..a60c5c42 100644 --- a/lib/core/network/user_agent.dart +++ b/lib/core/network/user_agent.dart @@ -1,3 +1,5 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + /// The single User-Agent this app presents. /// /// Cloudflare binds cf_clearance to the exact User-Agent that solved the @@ -8,6 +10,53 @@ /// plenty of ordinary WAFs refuse that outright. /// /// The Android TV app had the same disease and the same cure. -const String kSozoUserAgent = +/// +/// One string, but not a *made-up* one. A managed challenge does not only read +/// the header: it compares what the header claims against what the engine +/// actually is — client hints, `navigator.userAgentData`, platform, engine +/// version. A hard-coded `Chrome/125` on a device whose WebView is 130-something +/// is exactly that kind of mismatch, and the challenge then never clears no +/// matter how long the WebView is left running. Measured against animepahe.pw, +/// which is behind a managed challenge: a browser presenting a User-Agent that +/// matched its real platform cleared in ~4s, while every UA that misdescribed +/// the platform sat at "Just a moment..." until the 35s watchdog gave up. +/// +/// So the value is the device's OWN WebView User-Agent, read once at startup +/// and reused everywhere. [kSozoUserAgentFallback] only applies when that read +/// fails (a platform with no WebView, or an early call). +const String kSozoUserAgentFallback = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36'; + +String _resolved = kSozoUserAgentFallback; + +/// The agent every request in this app must carry. +/// +/// Synchronous on purpose — it is read from header maps all over the codebase. +/// Before [initSozoUserAgent] completes it returns the fallback, which is +/// harmless: nothing that needs a clearance runs that early. +String get kSozoUserAgent => _resolved; + +/// Whether the value in use came from the device rather than the fallback. +bool get sozoUserAgentIsDeviceReported => _resolved != kSozoUserAgentFallback; + +/// Adopt the device's real WebView User-Agent. Call once, during startup. +/// +/// Safe to call more than once and safe to fail: on any error the fallback +/// stays in place and the app behaves exactly as it did before. +Future<void> initSozoUserAgent() async { + try { + final ua = (await InAppWebViewController.getDefaultUserAgent()).trim(); + // A WebView that reports something implausible is worse than the fallback: + // the whole point is that the string matches the engine behind it. + if (ua.length >= 40 && ua.startsWith('Mozilla/')) { + // Android's default carries `; wv` to mark it as a WebView rather than + // Chrome. Dropping that token is the one edit worth making: it keeps the + // platform and engine version truthful — which is what the challenge + // checks — while not announcing an embedded browser to every WAF. + _resolved = ua.replaceAll('; wv)', ')').replaceAll(' wv)', ')'); + } + } catch (_) { + // Keep the fallback. + } +} diff --git a/lib/features/extensions/data/mangayomi_repo_store.dart b/lib/features/extensions/data/mangayomi_repo_store.dart index 5d52cc62..8172cab1 100644 --- a/lib/features/extensions/data/mangayomi_repo_store.dart +++ b/lib/features/extensions/data/mangayomi_repo_store.dart @@ -38,9 +38,8 @@ class MangayomiRepoStore { connectTimeout: const Duration(seconds: 20), receiveTimeout: const Duration(seconds: 30), responseType: ResponseType.plain, - headers: const { - 'User-Agent': - kSozoUserAgent, + headers: { + 'User-Agent': kSozoUserAgent, }, // GitHub raw serves 404s for missing sibling indexes; treat any status as // a response so a missing anime_index.json isn't an exception. diff --git a/lib/features/live_tv/presentation/widgets/channel_sheet.dart b/lib/features/live_tv/presentation/widgets/channel_sheet.dart index 918393ee..94adc38b 100644 --- a/lib/features/live_tv/presentation/widgets/channel_sheet.dart +++ b/lib/features/live_tv/presentation/widgets/channel_sheet.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/live_tv/data/live_tv_service.dart'; diff --git a/lib/main.dart b/lib/main.dart index 484aa878..b1e79d90 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,6 +33,7 @@ import 'package:soplay/core/js/provider_registry.dart'; import 'package:soplay/features/download/data/download_service.dart'; import 'package:soplay/features/notifications/data/services/notification_service.dart'; +import 'package:soplay/core/network/user_agent.dart'; import 'app.dart'; @@ -54,6 +55,11 @@ void main() async { await Future.wait([ EasyLocalization.ensureInitialized(), _initHive(), + // Adopt the device's own WebView User-Agent before anything makes a + // request. A Cloudflare managed challenge compares the header against the + // engine behind it, so a hard-coded version that does not match this + // device's WebView is a mismatch the challenge never clears. + initSozoUserAgent(), ]); if (isDesktopPlatform) { final native = Hive.box(AppConstants.settingsBox) From daa174e15ed3554f50834de6a65c668cc602339b Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 24 Aug 2026 19:41:42 +0500 Subject: [PATCH 10/21] Let a source say which url is worth playing, not just which one was seen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sniffer keeps whatever url the page requested. For a player that asks for one rendition and never for the master, that pins playback to a ladder of one — the qualities exist, the page simply never asks for them, and the master sits under a name derivable from the variant's. So the extractor directive can now carry a `rewrite`: a regex the server sends and this applies to the sniffed url. No provider name reaches the app; the rule travels with the source, the same way blockHosts and urlPatterns already do. Verified before it is trusted. A derived url is a guess about someone else's naming, and a guess that 404s must not cost a user their playback, so the candidate is fetched and kept only if it answers as a playlist. One request, and only when a rule was sent at all. Co-Authored-By: Azamov X <108933534+professorDeveloper@users.noreply.github.com> --- .../data/models/media_resolve_model.dart | 13 +++++ .../entities/extractor_config_entity.dart | 41 +++++++++++++++ .../presentation/pages/player_page.media.dart | 50 +++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/lib/features/detail/data/models/media_resolve_model.dart b/lib/features/detail/data/models/media_resolve_model.dart index fbe1b1b6..3d3e137c 100644 --- a/lib/features/detail/data/models/media_resolve_model.dart +++ b/lib/features/detail/data/models/media_resolve_model.dart @@ -58,6 +58,7 @@ class MediaResolveModel extends MediaResolveEntity { final loginUrl = raw['loginUrl'] as String?; final playType = (raw['playType'] as String?)?.toLowerCase(); return ExtractorConfigEntity( + rewrite: _parseRewrite(raw['rewrite']), mode: raw['mode'] as String? ?? 'shouldInterceptRequest', hostPattern: hostPattern, urlPatterns: patterns, @@ -69,6 +70,18 @@ class MediaResolveModel extends MediaResolveEntity { ); } + static UrlRewrite? _parseRewrite(dynamic raw) { + if (raw is! Map) return null; + final pattern = raw['pattern'] as String?; + final replace = raw['replace'] as String?; + if (pattern == null || pattern.isEmpty || replace == null) return null; + return UrlRewrite( + pattern: pattern, + replace: replace, + verify: raw['verify'] != false, + ); + } + static Map<String, String> _parseHeaders(dynamic raw) { if (raw is! Map) return const {}; final out = <String, String>{}; diff --git a/lib/features/detail/domain/entities/extractor_config_entity.dart b/lib/features/detail/domain/entities/extractor_config_entity.dart index fff54e52..fd9593ee 100644 --- a/lib/features/detail/domain/entities/extractor_config_entity.dart +++ b/lib/features/detail/domain/entities/extractor_config_entity.dart @@ -12,6 +12,17 @@ class ExtractorConfigEntity { final String? loginUrl; final String playType; + /// How to turn the url that was sniffed into the one worth playing. + /// + /// Some players ask for a single rendition and never for the master, so what + /// the sniffer sees is one fixed quality — the ladder exists, the page just + /// never requests it. When the master's url is derivable from the variant's, + /// the server says so here rather than the app knowing which site it is. + /// + /// Server-supplied and always optional; without it the sniffed url is played + /// exactly as captured. + final UrlRewrite? rewrite; + const ExtractorConfigEntity({ required this.hostPattern, required this.urlPatterns, @@ -21,5 +32,35 @@ class ExtractorConfigEntity { this.timeoutMs = 20000, this.loginUrl, this.playType = 'hls', + this.rewrite, }); } + +/// A regex substitution applied to a sniffed url. +class UrlRewrite { + final String pattern; + final String replace; + + /// Fetch the rewritten url before trusting it, and fall back to the original + /// unless it answers as a playlist. A derived url is a guess about someone + /// else's naming; a guess that 404s must not cost the user their playback. + final bool verify; + + const UrlRewrite({ + required this.pattern, + required this.replace, + this.verify = true, + }); + + /// The rewritten url, or null when the pattern does not apply. + String? apply(String url) { + try { + final re = RegExp(pattern); + if (!re.hasMatch(url)) return null; + final out = url.replaceFirst(re, replace); + return out == url ? null : out; + } catch (_) { + return null; + } + } +} diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index b88bcee4..ce5245fa 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -248,6 +248,55 @@ extension _PlayerMedia on _PlayerPageState { } } + /// Swap a sniffed url for the one the server says is worth playing. + /// + /// Players that request a single rendition leave the sniffer holding one + /// fixed quality while the master — and with it the whole ladder — sits under + /// a derivable name. The rule comes from the server so the app never learns + /// which site it is talking to. + /// + /// A derived url is a guess about someone else's naming, so it is fetched + /// before it is trusted: anything that is not a playlist leaves the sniffed + /// url in place. One request, and only when a rule was sent at all. + Future<String> _applyRewrite( + UrlRewrite? rule, + String url, + Map<String, String> headers, + ) async { + if (rule == null) return url; + final candidate = rule.apply(url); + if (candidate == null) return url; + if (!rule.verify) { + _plog('rewrite (unverified) -> $candidate'); + return candidate; + } + try { + final res = await getIt<Dio>().get<String>( + candidate, + options: Options( + headers: headers, + responseType: ResponseType.plain, + validateStatus: (_) => true, + receiveTimeout: const Duration(seconds: 10), + extra: const {'skipAuthInterceptor': true}, + ), + ); + final body = res.data ?? ''; + if (res.statusCode == 200 && body.trimLeft().startsWith('#EXTM3U')) { + _plog('rewrite ok -> $candidate'); + return candidate; + } + _plog( + 'rewrite rejected (${res.statusCode}) — playing the sniffed url', + level: LogLevel.warn, + ); + } catch (e) { + _plog('rewrite check failed: $e — playing the sniffed url', + level: LogLevel.warn); + } + return url; + } + /// Entry point for every playback start. Resolves a page-url source to its /// real manifest first, then hands off to [_initializeResolved]. /// @@ -284,6 +333,7 @@ extension _PlayerMedia on _PlayerPageState { effHeaders = {...headers, ...sniffed.headers}; effType = sniffed.playType; _plog('sniff ok in ${sw.elapsedMilliseconds}ms -> $effUrl'); + effUrl = await _applyRewrite(cfg.rewrite, effUrl, effHeaders); } else { // The url in hand is the embed PAGE. Handing that to the player used to // cost two doomed retries and a minute of spinner before an error that From 363c3e1daaf33ebb60cb6e350c0ea222d1ea0883 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 7 Sep 2026 23:26:28 +0500 Subject: [PATCH 11/21] feat: one place to change the app language, and a picker on the sign-in screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language lived 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 `AppLanguage.set`, and `syncFromDevice` repairs accounts already in the split state: on a first launch the resolved locale comes from the device, and Hive's own default is the string `'en'`, which is a real answer rather than an absent one, so nothing downstream could tell "English" from "never asked". `useFallbackTranslations` is enabled. Without it 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 it should never fire; it is here because the day it does, English is a far better answer. The picker is a chip on the sign-in screen rather than a screen of its own. The app already resolves the device locale by itself, so a dedicated first-run language step would charge every user a step to serve the minority whose phone is set to something they do not want to read. The chip costs nobody a step, and the slides re-render in the chosen language immediately — which a picker placed after the introduction cannot do, because by then the introduction has been read in the wrong one. Registering the FCM token now carries the language up with it, keyed on the language as well as the token so switching language re-registers instead of being swallowed as a duplicate. --- lib/app.dart | 6 + lib/core/localization/app_language.dart | 65 ++++++++ lib/core/localization/language_picker.dart | 146 ++++++++++++++++++ .../notifications_data_source.dart | 3 +- .../notifications_repository_impl.dart | 8 +- .../data/services/notification_service.dart | 29 +++- .../notifications_repository.dart | 1 + .../presentation/pages/onboarding_page.dart | 3 + .../presentation/pages/settings_page.dart | 19 +-- lib/main.dart | 20 +++ 10 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 lib/core/localization/app_language.dart create mode 100644 lib/core/localization/language_picker.dart 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<MyApp> { super.initState(); _theme.addListener(_onThemeChanged); getIt<NotificationService>().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/localization/app_language.dart b/lib/core/localization/app_language.dart new file mode 100644 index 00000000..995c1a15 --- /dev/null +++ b/lib/core/localization/app_language.dart @@ -0,0 +1,65 @@ +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 = <String, String>{ + '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; + + /// Switches the interface, and everything downstream of it. + static Future<void> set(BuildContext context, String code) async { + if (code == context.locale.languageCode) return; + await context.setLocale(Locale(code)); + await getIt<HiveService>().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<NotificationService>().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<void> syncFromDevice(BuildContext context) async { + final hive = getIt<HiveService>(); + 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..a3026ca6 --- /dev/null +++ b/lib/core/localization/language_picker.dart @@ -0,0 +1,146 @@ +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'; + +/// The language control on the sign-in screen. +/// +/// ## Why it is here and not on a screen of its own +/// +/// The app already picks the right language by itself: with no saved choice, +/// `easy_localization` resolves the device locale, so a German phone opens in +/// German the moment `de.json` exists. A dedicated first-run language screen +/// would therefore charge every user a step to serve the minority whose phone +/// is set to something they do not want to read. +/// +/// A chip costs nobody a step, and the slides re-render in the chosen language +/// straight away — which a picker placed *after* the introduction cannot do, +/// because by then the introduction has already been read in the wrong one. +class LanguageChip extends StatelessWidget { + const LanguageChip({super.key}); + + @override + Widget build(BuildContext context) { + final code = context.locale.languageCode; + return Material( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(999), + child: InkWell( + onTap: () => showLanguageSheet(context), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 12, 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.language_rounded, + size: 18, + color: AppColors.textPrimary, + ), + const SizedBox(width: 6), + // The code, not the native name: "Bahasa Indonesia" is four times + // the width of "EN" and the chip sits next to Skip in a row that + // has no room to grow. + Text( + code.toUpperCase(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Lists every language the app ships, in its own name. +Future<void> showLanguageSheet(BuildContext context) { + return showAdaptiveModal<void>( + 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, + 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/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<void> 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<Result<void>> 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<Result<void>> 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<String, dynamic> data); @@ -49,6 +51,7 @@ class NotificationService { bool _initialized = false; String? _registeredToken; + String? _registeredLanguage; StreamSubscription<RemoteMessage>? _foregroundSub; StreamSubscription<RemoteMessage>? _openedSub; StreamSubscription<String>? _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<void> 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<void> _registerToken(String token) async { - if (_registeredToken == token) return; + final language = getIt<HiveService>().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<Result<void>> registerFcmToken({ required String token, required String platform, + required String language, }); Future<Result<void>> 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..13c5e940 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'; @@ -118,6 +119,8 @@ class _OnboardingPageState extends State<OnboardingPage> { ), ), const Spacer(), + const LanguageChip(), + const SizedBox(width: 8), _SkipChip(onTap: () => _leave('/main')), ], ), 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<SettingsPage> { - /// Native names on purpose: a person looking for their own language finds it - /// fastest written the way they write it. - static const _languageNames = <String, String>{ - 'en': 'English', - 'uz': "O'zbekcha", - 'ru': 'Русский', - 'ar': 'العربية', - }; - - Future<void> _setLanguage(String code) async { - if (code == context.locale.languageCode) return; - await context.setLocale(Locale(code)); - await getIt<HiveService>().saveLanguage(code); - } + Future<void> _setLanguage(String code) => AppLanguage.set(context, code); @override Widget build(BuildContext context) { @@ -49,7 +36,7 @@ class _SettingsPageState extends State<SettingsPage> { 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/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) { From 4116b2bf5f19deb25b437cc499477e5e00252906 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Mon, 7 Sep 2026 23:26:55 +0500 Subject: [PATCH 12/21] feat: German, Dutch, Spanish, Portuguese, French, Turkish and Indonesian MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven languages now, up from four. All seven new ones are Latin-script: the app bundles no font, so the system one covers them, and none needs the mirrored layout Arabic does. `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 subtitle target. The copy is machine-generated and has NOT been reviewed by a native speaker. German and Dutch are the two markets this was asked for; treat reports about their wording as expected rather than surprising. `localization_test.dart` reads the locale list off disk instead of naming four files. A hard-coded list silently stops covering the locale nobody updated it for, which is the exact failure the file exists to catch. `check_translations.dart` now blocks only on `en`, `uz` and `ru` — the three the team writes. Blocking a PR on the other eight 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 the fallback added alongside means a gap renders as English rather than as raw dot-notation. `check_expansion.dart` ranks short strings by how much longer they got. Overflow is not random — it happens where the most-expanded string meets the tightest box — so the ranking names the screens worth opening in German instead of auditing all sixty. Five sites it found are fixed here: three retry buttons pinned to 156px that "Tentar de novo" does not fit, and two fixed-width label columns in the player and the source tester. The player's info labels were shortened in four locales rather than ellipsised, because "Almacenado por…" hides the very word that says which row you are reading. --- assets/translations/de.json | 1698 +++++++++++++++++ assets/translations/es.json | 1698 +++++++++++++++++ assets/translations/fr.json | 1698 +++++++++++++++++ assets/translations/id.json | 1698 +++++++++++++++++ assets/translations/nl.json | 1698 +++++++++++++++++ assets/translations/pt.json | 1698 +++++++++++++++++ assets/translations/tr.json | 1698 +++++++++++++++++ .../pages/player_page.widgets.dart | 6 + .../widgets/home_state_views.dart | 16 +- .../widgets/view_all_widgets.dart | 8 +- .../pages/provider_test_page.dart | 4 + test/core/localization_test.dart | 16 +- tool/check_expansion.dart | 128 ++ tool/check_translations.dart | 30 +- 14 files changed, 12079 insertions(+), 15 deletions(-) create mode 100644 assets/translations/de.json create mode 100644 assets/translations/es.json create mode 100644 assets/translations/fr.json create mode 100644 assets/translations/id.json create mode 100644 assets/translations/nl.json create mode 100644 assets/translations/pt.json create mode 100644 assets/translations/tr.json create mode 100644 tool/check_expansion.dart diff --git a/assets/translations/de.json b/assets/translations/de.json new file mode 100644 index 00000000..ac88a7d0 --- /dev/null +++ b/assets/translations/de.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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" + }, + "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 wählen", + "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" + }, + "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" + }, + "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" + } +} diff --git a/assets/translations/es.json b/assets/translations/es.json new file mode 100644 index 00000000..d6190c52 --- /dev/null +++ b/assets/translations/es.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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" + }, + "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": "Elegir 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" + }, + "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" + }, + "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" + } +} diff --git a/assets/translations/fr.json b/assets/translations/fr.json new file mode 100644 index 00000000..15e7a088 --- /dev/null +++ b/assets/translations/fr.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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" + }, + "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": "Choisir une 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" + }, + "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" + }, + "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" + } +} diff --git a/assets/translations/id.json b/assets/translations/id.json new file mode 100644 index 00000000..d862715d --- /dev/null +++ b/assets/translations/id.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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" + }, + "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": "Pilih 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" + }, + "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" + }, + "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" + } +} diff --git a/assets/translations/nl.json b/assets/translations/nl.json new file mode 100644 index 00000000..4639eec1 --- /dev/null +++ b/assets/translations/nl.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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" + }, + "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 kiezen", + "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" + }, + "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" + }, + "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" + } +} diff --git a/assets/translations/pt.json b/assets/translations/pt.json new file mode 100644 index 00000000..12f1926e --- /dev/null +++ b/assets/translations/pt.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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" + }, + "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": "Escolher 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" + }, + "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" + }, + "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" + } +} diff --git a/assets/translations/tr.json b/assets/translations/tr.json new file mode 100644 index 00000000..c07d9a38 --- /dev/null +++ b/assets/translations/tr.json @@ -0,0 +1,1698 @@ +{ + "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" + }, + "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)" + }, + "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ı" + }, + "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": "Kaynak seç", + "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" + }, + "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" + }, + "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" + } +} 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/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<HomeBloc>().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/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/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/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<File>() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.json')) + .map((n) => n.substring(0, n.length - 5)) + .toList() + ..sort(); Map<String, dynamic> load(String locale) => jsonDecode( File('assets/translations/$locale.json').readAsStringSync(), 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<String> 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<File>() + .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 = <String, int>{}; + 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<String, String>? _load(String locale) { + final file = File('$translationsDir/$locale.json'); + if (!file.existsSync()) return null; + final out = <String, String>{}; + void walk(Map<String, dynamic> node, String prefix) { + node.forEach((key, value) { + final path = prefix.isEmpty ? key : '$prefix.$key'; + if (value is Map<String, dynamic>) { + walk(value, path); + } else { + out[path] = value.toString(); + } + }); + } + + walk(jsonDecode(file.readAsStringSync()) as Map<String, dynamic>, ''); + 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<String> blockingLocales = {'en', 'uz', 'ru'}; + const String translationsDir = 'assets/translations'; const String sourceDir = 'lib'; @@ -84,13 +97,20 @@ void main(List<String> 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<String> 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, From c221497f0f33a170278cb646f0054c509e635925 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Tue, 8 Sep 2026 14:40:31 +0500 Subject: [PATCH 13/21] fix: an all-source search that finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as the app freezing once the installed source list passes a couple of thousand. Two separate causes, and they compound. **The screen stopped responding.** `_remerge()` ran on every arriving leg, and `mergeSearchResults` rebuilds from scratch — it regroups every item of every leg collected so far and re-scores each one against the query, then sorts. Per arrival that is quadratic in the number of sources: at 2000 legs, tens of millions of item comparisons over a run, all on the UI isolate, plus 2000 full rebuilds of the results list. The engine's network work was already off-thread; this was not. Arriving legs are now coalesced into one merge per 250ms window, with an immediate flush when the run ends so the last legs are never stranded in an unflushed window. The test that guards it fails at 1000 notifications without the change and passes under 5 with it. **The run took over an hour.** Nothing capped the set. The pool is bounded at 5 with a 10s per-provider budget, so a run costs roughly `legs / concurrency * timeout` in the worst case — about 67 minutes for 2000 sources. A search nobody waits for is a search that did not happen. `planLegs` caps an all-source run at 60 legs, taken after the existing health ordering, so the ones kept are the ones most likely to answer. It is applied where the set is built rather than inside `search`, so the counters the UI shows — pending, answered, timed out — still describe the run that is really happening. A scope the user picked by hand is never trimmed: then the count is theirs. The summary line says which 60 of how many, because a number that quietly disagrees with the source list is worse than a long search. `SearchFanOut` is a narrow interface over the two methods a search surface actually uses. The engine reaches WebViews, a Dio client and the Mangayomi bridge; standing all three up to assert that legs are batched would have tested the fakes rather than the batching. --- assets/translations/ar.json | 3 +- assets/translations/de.json | 3 +- assets/translations/en.json | 3 +- assets/translations/es.json | 3 +- assets/translations/fr.json | 3 +- assets/translations/id.json | 3 +- assets/translations/nl.json | 3 +- assets/translations/pt.json | 3 +- assets/translations/ru.json | 3 +- assets/translations/tr.json | 3 +- assets/translations/uz.json | 3 +- .../domain/services/cross_search_engine.dart | 56 +++++- .../blocs/cross_search_controller.dart | 36 +++- .../presentation/pages/cross_search_page.dart | 28 ++- .../search/cross_search_controller_test.dart | 161 ++++++++++++++++++ 15 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 test/features/search/cross_search_controller_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index e538b4d8..395154bf 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -632,7 +632,8 @@ "voice_listening": "جارٍ الاستماع…", "voice_done": "توقّف الاستماع", "voice_hint": "قل اسم فيلم أو مسلسل", - "try_torrents": "البحث في التورنت" + "try_torrents": "البحث في التورنت", + "capped_note": "تم البحث في أكثر {} مصدرًا موثوقية من أصل {} — ضيّق النطاق للوصول إلى مصدر بعينه." }, "movie": { "rating": "التقييم", diff --git a/assets/translations/de.json b/assets/translations/de.json index ac88a7d0..31c592a9 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -632,7 +632,8 @@ "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" + "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", diff --git a/assets/translations/en.json b/assets/translations/en.json index 403bf228..2e4dcb7d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -632,7 +632,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", diff --git a/assets/translations/es.json b/assets/translations/es.json index d6190c52..e2286544 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -632,7 +632,8 @@ "voice_listening": "Escuchando…", "voice_done": "Dejé de escuchar", "voice_hint": "Di el nombre de una película o serie", - "try_torrents": "Buscar torrents" + "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", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 15e7a088..b6c9c543 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -632,7 +632,8 @@ "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" + "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", diff --git a/assets/translations/id.json b/assets/translations/id.json index d862715d..f2644ce5 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -632,7 +632,8 @@ "voice_listening": "Mendengarkan…", "voice_done": "Berhenti mendengarkan", "voice_hint": "Sebutkan nama film atau serial", - "try_torrents": "Cari torrent" + "try_torrents": "Cari torrent", + "capped_note": "Mencari di {} sumber paling andal dari {} — persempit cakupan untuk menjangkau sumber tertentu." }, "movie": { "rating": "Rating", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 4639eec1..37c22e01 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -632,7 +632,8 @@ "voice_listening": "Luisteren…", "voice_done": "Gestopt met luisteren", "voice_hint": "Zeg de naam van een film of serie", - "try_torrents": "Torrents zoeken" + "try_torrents": "Torrents zoeken", + "capped_note": "De {} betrouwbaarste van {} bronnen doorzocht — beperk de selectie om een specifieke te bereiken." }, "movie": { "rating": "Beoordeling", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 12f1926e..ef7f1ad9 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -632,7 +632,8 @@ "voice_listening": "Ouvindo…", "voice_done": "Parei de ouvir", "voice_hint": "Diga o nome de um filme ou série", - "try_torrents": "Buscar torrents" + "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", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 55afec7e..06d333df 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -632,7 +632,8 @@ "voice_listening": "Слушаю…", "voice_done": "Прослушивание остановлено", "voice_hint": "Назовите фильм или сериал", - "try_torrents": "Искать в торрентах" + "try_torrents": "Искать в торрентах", + "capped_note": "Искали в {} самых надёжных из {} источников — сузьте область, чтобы дойти до конкретного." }, "movie": { "rating": "Рейтинг", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index c07d9a38..d59be603 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -632,7 +632,8 @@ "voice_listening": "Dinleniyor…", "voice_done": "Dinleme durduruldu", "voice_hint": "Bir film ya da dizi adı söyle", - "try_torrents": "Torrent ara" + "try_torrents": "Torrent ara", + "capped_note": "{} kaynaktan en güvenilir {} tanesinde arandı — belirli birine ulaşmak için kapsamı daralt." }, "movie": { "rating": "Puan", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index d31c6a7d..e51efc04 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -632,7 +632,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", 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<MovieEntity> 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<ProviderRef> planLegs(List<ProviderRef> set, {int limit}); + + Stream<ProviderSearchResult> search({ + required List<ProviderRef> set, + required String query, + int page, + int concurrency, + Duration perProviderTimeout, + }); + + Future<ProviderSearchResult> 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<ProviderRef> planLegs(List<ProviderRef> set, {int limit = maxLegs}) { + if (set.length <= limit) return set; + return health.order(List<ProviderRef>.of(set), (r) => r.id).take(limit).toList(); + } + + @override Stream<ProviderSearchResult> search({ required List<ProviderRef> 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<ProviderSearchResult> 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<ProviderRef> set}) : _set = set; - final CrossSearchEngine engine; + final SearchFanOut engine; List<ProviderRef> _set; final QueryDebouncer _debouncer = QueryDebouncer(); @@ -34,6 +34,20 @@ class CrossSearchController extends ChangeNotifier { final Set<String> _retrying = {}; List<MergedSearchTitle> _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<String, _CachedRun> _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..d4d89027 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -138,8 +138,17 @@ class _CrossSearchPageState extends State<CrossSearchPage> { return [...selected, ...rest]; } - List<ProviderRef> _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<ProviderRef> _refs() => getIt<CrossSearchEngine>().planLegs( + _scope.resolve(_providers).map(ProviderRef.fromEntity).toList(), + ); void _applyScope(CrossSearchScope scope, {bool reorder = false}) { if (scope == _scope) return; @@ -500,6 +509,21 @@ class _CrossSearchPageState extends State<CrossSearchPage> { ], ), _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( + 'search.capped_note'.tr( + args: ['${c.expectedLegs}', '$_scopedCount'], + ), + style: const TextStyle( + color: AppColors.textHint, fontSize: 11.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<ProviderSearchResult> out) emit; + + @override + Stream<ProviderSearchResult> search({ + required List<ProviderRef> set, + required String query, + int page = 1, + int concurrency = 5, + Duration perProviderTimeout = const Duration(seconds: 10), + }) { + final out = StreamController<ProviderSearchResult>(); + 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<ProviderRef> planLegs(List<ProviderRef> set, {int limit = 60}) => set; + + @override + Future<ProviderSearchResult> 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<ProviderSearchResult> 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<ProviderSearchResult> 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 = <StreamController<ProviderSearchResult>>[]; + 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(); + }); + }); +} From 45874ad4799b1e9e771b29f6707fc348840fab2b Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Wed, 9 Sep 2026 11:48:21 +0500 Subject: [PATCH 14/21] fix: download the best rendition, not the first one listed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as films downloading blurry with no way to pick a quality — on vidapi, which is worth naming because it explains why the setting could not be found. vidapi's `stream_urls` are servers, not qualities: same picture, different mirror, as its own comment says. So there is no 1080p entry anywhere in the provider's response to choose — the renditions live inside the HLS master playlist, one level down. The player already read them: it parses the master and builds its quality menu from it. The downloader parsed the same file and took whichever variant was listed first. Packagers commonly order a master lowest-bitrate-first, because a client is meant to start conservatively and adapt upward — so "first" was the 480p rendition of the stream the player was showing at 1080p. The download completed, sat in the list, and looked wrong, with nothing on screen to suggest a quality had been picked at all. The ranking now lives in `parseHlsVariants` and both callers use it. Keeping two copies is what let them disagree, and the subtle half is not obvious enough to survive being written twice: the packager's own name for a variant beats RESOLUTION, because a 2.40:1 film encoded at 1080p carries RESOLUTION=1920x800 and ranking on pixel height alone puts it below a true 1080p and labels it 800p. Nobody downloads a film to keep the smallest copy of it, so the best rendition is the answer rather than a new setting. The parser also splits on CRLF, which the downloader's did not — a master served with Windows line endings left a stray carriage return on every url it built. --- lib/core/player/hls_variants.dart | 48 +++++++ .../presentation/pages/player_page.dart | 1 + .../presentation/pages/player_page.media.dart | 29 +---- .../download_transfer_data_source.dart | 19 +++ test/core/player/hls_variants_test.dart | 120 ++++++++++++++++++ 5 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 lib/core/player/hls_variants.dart create mode 100644 test/core/player/hls_variants_test.dart 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<HlsVariant> parseHlsVariants(String playlist, Uri base) { + final out = <HlsVariant>[]; + 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/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index b1048de6..8ad7f2d1 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1,5 +1,6 @@ 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'; diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 32143e90..549c9f4e 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 = <int>{}; final auto = _videoSources.isNotEmpty ? _videoSources.first : null; 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/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); + }); +} From 6212f4ed9bbb54face529578d159129a383ad625 Mon Sep 17 00:00:00 2001 From: azamov <azamovhud007@outlook.com> Date: Wed, 9 Sep 2026 12:15:32 +0500 Subject: [PATCH 15/21] feat: add funding and configuration files, implement download use cases, and update UI alignment --- .../repositories/detail_repository_impl.dart | 20 ++++++++++++++----- lib/features/mal/data/mal_service.dart | 2 +- tool/torrent_live_check.dart | 6 ++++++ 3 files changed, 22 insertions(+), 6 deletions(-) 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<Result<MediaResolveEntity>> _postProcess( MediaResolveEntity media, ) async { 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/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 From 173a0ee184d4975d1e3a6f6c1ca66413ba4614ab Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:25:30 +0500 Subject: [PATCH 16/21] Put switching source one screen from wherever you are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as the provider picker being buried, and it was: from Profile it took Sources, then Active source, then the full providers page. Three screens for the thing this app asks people to do most often, and the last of them is really for installing extension repos rather than picking a source. The home screen already had the answer — a quick-switch sheet with the modes, the favourites, a filter once the list is long enough to need one, and the home screen still visible behind it. Its own comment records why it exists: the report before this one was "I have to go to settings every time I want to change source". It was just nailed to the top bar. So it moves out of `home_top_bar.dart` into its own file and both entry points open the same sheet. Building a second picker for Profile was the alternative, and two controls for one decision drift apart — this one already carries enough judgement (offline sources dropped, mode narrowing, the switch animation covering the reload) that a rebuild would have quietly lost some of it. Profile now shows two rows where it showed one, because they are two jobs: `Provider`, which names the current source and opens the switcher, and `Extension sources`, which goes to the management screen. The rule is the same everywhere now — a row that shows the current source is the row that changes it, including the one on the sources screen, which used to push the providers page. `home_top_bar.dart` drops from 1152 lines to 676. What is left is the top bar: the other buttons, the indicators, the shortcuts. Also localised `Mangayomi Sources`, a title that was still a Dart string literal — the extraction pass looked at layouts and `.text =` assignments, and this one was neither. --- assets/translations/ar.json | 3 +- assets/translations/de.json | 3 +- assets/translations/en.json | 3 +- assets/translations/es.json | 3 +- assets/translations/fr.json | 3 +- assets/translations/id.json | 3 +- assets/translations/nl.json | 3 +- assets/translations/pt.json | 3 +- assets/translations/ru.json | 3 +- assets/translations/tr.json | 3 +- assets/translations/uz.json | 3 +- .../presentation/widgets/home_top_bar.dart | 489 +--------------- .../presentation/pages/profile_page.dart | 1 + .../presentation/pages/profile_page.hub.dart | 45 +- .../presentation/pages/sources_page.dart | 10 +- .../widgets/provider_quick_switch.dart | 527 ++++++++++++++++++ 16 files changed, 593 insertions(+), 512 deletions(-) create mode 100644 lib/features/profile/presentation/widgets/provider_quick_switch.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 395154bf..c7862ffc 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -513,7 +513,8 @@ "activity_subtitle": "السجل والتنزيلات والمتابعة والإحصاءات", "connections_subtitle": "المتتبعات، ديسكورد، التلفاز، سطح المكتب", "trackers": "المتتبعات", - "stat_days": "أيام مشاهدة" + "stat_days": "أيام مشاهدة", + "mangayomi_sources": "مصادر Mangayomi" }, "appearance": { "title": "المظهر", diff --git a/assets/translations/de.json b/assets/translations/de.json index 31c592a9..07b18df0 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -513,7 +513,8 @@ "activity_subtitle": "Verlauf, Downloads, Verfolgen, Statistik", "connections_subtitle": "Tracker, Discord, TV, Desktop", "trackers": "Tracker", - "stat_days": "Tage gesehen" + "stat_days": "Tage gesehen", + "mangayomi_sources": "Mangayomi-Quellen" }, "appearance": { "title": "Darstellung", diff --git a/assets/translations/en.json b/assets/translations/en.json index 2e4dcb7d..cdbff89c 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -513,7 +513,8 @@ "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" }, "appearance": { "title": "Appearance", diff --git a/assets/translations/es.json b/assets/translations/es.json index e2286544..d60cd022 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -513,7 +513,8 @@ "activity_subtitle": "Historial, descargas, seguimiento, estadísticas", "connections_subtitle": "Trackers, Discord, TV, escritorio", "trackers": "Trackers", - "stat_days": "días vistos" + "stat_days": "días vistos", + "mangayomi_sources": "Fuentes de Mangayomi" }, "appearance": { "title": "Apariencia", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index b6c9c543..fbef4cc3 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -513,7 +513,8 @@ "activity_subtitle": "Historique, téléchargements, suivi, statistiques", "connections_subtitle": "Trackers, Discord, TV, bureau", "trackers": "Trackers", - "stat_days": "jours regardés" + "stat_days": "jours regardés", + "mangayomi_sources": "Sources Mangayomi" }, "appearance": { "title": "Apparence", diff --git a/assets/translations/id.json b/assets/translations/id.json index f2644ce5..0de404c4 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -513,7 +513,8 @@ "activity_subtitle": "Riwayat, unduhan, pelacakan, statistik", "connections_subtitle": "Pelacak, Discord, TV, desktop", "trackers": "Pelacak", - "stat_days": "hari ditonton" + "stat_days": "hari ditonton", + "mangayomi_sources": "Sumber Mangayomi" }, "appearance": { "title": "Tampilan", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 37c22e01..bcd7083b 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -513,7 +513,8 @@ "activity_subtitle": "Geschiedenis, downloads, volgen, statistieken", "connections_subtitle": "Trackers, Discord, tv, desktop", "trackers": "Trackers", - "stat_days": "dagen gekeken" + "stat_days": "dagen gekeken", + "mangayomi_sources": "Mangayomi-bronnen" }, "appearance": { "title": "Weergave", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index ef7f1ad9..e1097cb1 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -513,7 +513,8 @@ "activity_subtitle": "Histórico, downloads, acompanhamento, estatísticas", "connections_subtitle": "Rastreadores, Discord, TV, desktop", "trackers": "Rastreadores", - "stat_days": "dias assistidos" + "stat_days": "dias assistidos", + "mangayomi_sources": "Fontes do Mangayomi" }, "appearance": { "title": "Aparência", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 06d333df..2356c32c 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -513,7 +513,8 @@ "activity_subtitle": "История, загрузки, отслеживание, статистика", "connections_subtitle": "Трекеры, Discord, ТВ, десктоп", "trackers": "Трекеры", - "stat_days": "дней просмотра" + "stat_days": "дней просмотра", + "mangayomi_sources": "Источники Mangayomi" }, "appearance": { "title": "Внешний вид", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index d59be603..116644a5 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -513,7 +513,8 @@ "activity_subtitle": "Geçmiş, indirilenler, takip, istatistikler", "connections_subtitle": "Takipçiler, Discord, TV, masaüstü", "trackers": "Takipçiler", - "stat_days": "gün izlendi" + "stat_days": "gün izlendi", + "mangayomi_sources": "Mangayomi kaynakları" }, "appearance": { "title": "Görünüm", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index e51efc04..ff07c3df 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -513,7 +513,8 @@ "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" }, "appearance": { "title": "Ko'rinish", diff --git a/lib/features/home/presentation/widgets/home_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index 542a55f7..5ec121af 100644 --- a/lib/features/home/presentation/widgets/home_top_bar.dart +++ b/lib/features/home/presentation/widgets/home_top_bar.dart @@ -16,16 +16,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/profile/presentation/widgets/provider_quick_switch.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'; @@ -185,7 +180,7 @@ class _ProviderSwitcher extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(20), - onTap: () => _openSwitcher(context, state), + onTap: () => openProviderQuickSwitch(context), child: Container( padding: const EdgeInsetsDirectional.fromSTEB(5, 4, 7, 4), decoration: BoxDecoration( @@ -196,7 +191,7 @@ class _ProviderSwitcher extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _ProviderLogo(image: current?.image ?? '', size: 22), + ProviderLogo(image: current?.image ?? '', size: 22), const SizedBox(width: 6), Flexible( child: Text( @@ -225,486 +220,8 @@ class _ProviderSwitcher extends StatelessWidget { ); } - /// 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<ProviderEntity> _resolveFavorites(ProviderLoaded state) { - final favIds = getIt<HiveService>().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<void> _switchMode( - BuildContext context, - ProviderBloc bloc, - ProviderLoaded state, - ContentMode mode, - ) async { - final hive = getIt<HiveService>(); - 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<void>? loaded = pick.id == state.currentProviderId - ? null - : context.read<HomeBloc>().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<void> _openSwitcher(BuildContext context, ProviderLoaded state) async { - final bloc = context.read<ProviderBloc>(); - 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<HiveService>(); - 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<String>( - 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<ProviderEntity> favorites; - final List<ProviderEntity> 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<ProviderEntity> 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<ProviderEntity> 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, - ), - ), - ), - ), - 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: Text( - 'profile.no_providers_in_category'.tr(), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 13, - ), - ), - ), - ], - ), - ), - 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, - ), - ), - ), - ), - ), - ), - ); - } -} - -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. 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..97ea8f26 100644 --- a/lib/features/profile/presentation/pages/profile_page.hub.dart +++ b/lib/features/profile/presentation/pages/profile_page.hub.dart @@ -243,25 +243,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<ProviderBloc, ProviderState>( - builder: (context, state) { - final loaded = state is ProviderLoaded ? state : null; - final current = loaded?.currentProvider; - return SettingsNavTile( + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + BlocBuilder<ProviderBloc, ProviderState>( + 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/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..ac080f04 --- /dev/null +++ b/lib/features/profile/presentation/widgets/provider_quick_switch.dart @@ -0,0 +1,527 @@ +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<void> openProviderQuickSwitch(BuildContext context) async { + final bloc = context.read<ProviderBloc>(); + final state = bloc.state; + if (state is! ProviderLoaded) 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<ProviderEntity> _resolveFavorites(ProviderLoaded state) { + final favIds = getIt<HiveService>().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<void> _switchMode( + BuildContext context, + ProviderBloc bloc, + ProviderLoaded state, + ContentMode mode, + ) async { + final hive = getIt<HiveService>(); + 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<void>? loaded = pick.id == state.currentProviderId + ? null + : context.read<HomeBloc>().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<void> _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<HiveService>(); + 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<String>( + 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<ProviderEntity> favorites; + final List<ProviderEntity> 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<ProviderEntity> 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<ProviderEntity> 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, + ), + ), + ), + ), + 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: Text( + 'profile.no_providers_in_category'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13, + ), + ), + ), + ], + ), + ), + 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, + ), + ), + ), + ), + ), + ), + ); + } +} + +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, + 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, + ), + ); + } +} From 9e37abeba2f04d2b5efb3949787024a57812226a Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:48 +0500 Subject: [PATCH 17/21] Give the viewer a real quality choice, and stop picking the worst one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related defects, all from the same wrong assumption: that a provider's source order is already ranked. SourceLadder ranked by remembered quality, then isDefault, then backend order, documented as "already ranked". It is not — asilmedia and animefenix mark nothing default and list 360p ahead of 720p, so the ladder opened on the worst rendition. Resolution is now rule 3, parsed from the label; a label with no resolution in it scores 0 and keeps the backend order, so vidapi's "Server 1/2/3" is unaffected. detail_page's _pickMovieUrl had its own hand-rolled version of the same walk, with the same defect, feeding the downloader. It calls the ladder now. _expandMasterPlaylist could only be reached through _applyRewrite — a provider with a rewrite rule that verifies, and a source list of exactly one. vidapi has no such rule and returns three masters, so the sheet listed servers, the renditions inside them were never surfaced, and only the connection speed chose between 480p and 1080p. Playback now reads the current server's master once and inserts its heights after it, leaving that entry untouched as the adaptive choice; other servers expand when switched to. --- lib/core/player/source_ladder.dart | 27 ++++- .../presentation/pages/detail_page.dart | 25 ++-- .../presentation/pages/player_page.dart | 4 + .../presentation/pages/player_page.media.dart | 108 ++++++++++++++++++ .../pages/player_page.panels.dart | 12 +- test/core/player/source_ladder_test.dart | 28 ++++- 6 files changed, 190 insertions(+), 14 deletions(-) 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<int> ordered() { final candidates = <int>[]; 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/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index f85de977..36c0cc55 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -5,6 +5,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'; @@ -888,18 +889,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<void> _playMovieDirect(PlaybackEntity playback) async { diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 8ad7f2d1..d708c47e 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -221,6 +221,10 @@ class _PlayerPageState extends State<PlayerPage> 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<String> _expandedMasters = <String>{}; + /// Mirrors already attempted for what is on screen, by url. /// /// This was a single `_autoFallbackUsed` bool, which is why a five-mirror diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 549c9f4e..3d339715 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -671,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, @@ -680,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<void> _maybeExpandQualities( + String url, + Map<String, String> 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<Dio>().get<String>( + 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 = <int>{}; + final rows = <VideoSourceEntity>[ + 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<void> _initializeResolved({ required String url, required Map<String, String> 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/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( From ec59916c3ac018a9d0bee50062d0bd123d92e95f Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:48 +0500 Subject: [PATCH 18/21] Localize the screens the first pass missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scan for string literals reaching a widget's visible slots found 60-odd that never went through .tr(): the whole app-updater flow, the CloudStream, Aniyomi and Mangayomi source pages, the repo-file import dialog, the log viewer, and a "FAQAT KO'RISH" badge sitting hardcoded in Uzbek on the providers page — which read as Uzbek to a German user. 82 keys across all 11 locales. Numeric interpolations, brand names and the font-preview pangram are left alone deliberately. --- assets/translations/ar.json | 94 ++++++++++++++++++- assets/translations/de.json | 94 ++++++++++++++++++- assets/translations/en.json | 94 ++++++++++++++++++- assets/translations/es.json | 94 ++++++++++++++++++- assets/translations/fr.json | 94 ++++++++++++++++++- assets/translations/id.json | 94 ++++++++++++++++++- assets/translations/nl.json | 94 ++++++++++++++++++- assets/translations/pt.json | 94 ++++++++++++++++++- assets/translations/ru.json | 94 ++++++++++++++++++- assets/translations/tr.json | 94 ++++++++++++++++++- assets/translations/uz.json | 94 ++++++++++++++++++- lib/core/diagnostics/log_viewer_sheet.dart | 20 ++-- .../pages/aniyomi_sources_page.dart | 60 ++++++------ .../presentation/pages/force_update_page.dart | 19 ++-- .../widgets/install_progress_dialog.dart | 7 +- .../presentation/widgets/update_dialog.dart | 30 +++--- .../presentation/pages/otp_verify_page.dart | 6 +- .../pages/cloudstream_plugins_page.dart | 37 +++++--- .../pages/cloudstream_sources_page.dart | 67 +++++++------ .../pages/mangayomi_sources_page.dart | 68 +++++++------- .../presentation/repo_file_import.dart | 59 ++++++------ .../widgets/recommended_repos_section.dart | 13 +-- .../pages/manga_sources_page.dart | 2 +- .../manga/presentation/pages/reader_page.dart | 2 +- .../presentation/pages/providers_page.dart | 10 +- 25 files changed, 1203 insertions(+), 231 deletions(-) diff --git a/assets/translations/ar.json b/assets/translations/ar.json index c7862ffc..cfa74742 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": "حسابي", @@ -514,7 +526,8 @@ "connections_subtitle": "المتتبعات، ديسكورد، التلفاز، سطح المكتب", "trackers": "المتتبعات", "stat_days": "أيام مشاهدة", - "mangayomi_sources": "مصادر Mangayomi" + "mangayomi_sources": "مصادر Mangayomi", + "view_only": "للمشاهدة فقط" }, "appearance": { "title": "المظهر", @@ -1700,5 +1713,78 @@ "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": "رواية" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 07b18df0..e15478bb 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -26,7 +26,15 @@ "off": "Aus", "on": "An", "new_badge": "NEU", - "reset": "Zurücksetzen" + "reset": "Zurücksetzen", + "clear": "Leeren", + "copy": "Kopieren", + "share": "Teilen", + "install": "Installieren", + "remove": "Entfernen", + "show": "Anzeigen", + "hide": "Ausblenden", + "installed": "Installiert" }, "auth": { "login": "Anmelden", @@ -78,7 +86,8 @@ "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)" + "avatar_bad_format": "Bildformat nicht unterstützt (jpg, png, webp)", + "verification_code": "Bestätigungscode" }, "onboarding": { "skip": "Überspringen", @@ -348,7 +357,10 @@ "fit": "Anpassen", "pip": "Bild im Bild", "layout_pinned_badge": "Angeheftet", - "info_fields_title": "Zeilen der Info-Einblendung" + "info_fields_title": "Zeilen der Info-Einblendung", + "logs_title": "Player-Logs ({})", + "logs_copied": "Logs kopiert", + "logs_empty": "Noch keine Logs" }, "profile": { "title": "Profil", @@ -514,7 +526,8 @@ "connections_subtitle": "Tracker, Discord, TV, Desktop", "trackers": "Tracker", "stat_days": "Tage gesehen", - "mangayomi_sources": "Mangayomi-Quellen" + "mangayomi_sources": "Mangayomi-Quellen", + "view_only": "NUR ANSEHEN" }, "appearance": { "title": "Darstellung", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index cdbff89c..cba06464 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", @@ -514,7 +526,8 @@ "connections_subtitle": "Trackers, Discord, TV, desktop", "trackers": "Trackers", "stat_days": "days watched", - "mangayomi_sources": "Mangayomi Sources" + "mangayomi_sources": "Mangayomi Sources", + "view_only": "VIEW ONLY" }, "appearance": { "title": "Appearance", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index d60cd022..567b0a2b 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -26,7 +26,15 @@ "off": "No", "on": "Sí", "new_badge": "NUEVO", - "reset": "Restablecer" + "reset": "Restablecer", + "clear": "Borrar", + "copy": "Copiar", + "share": "Compartir", + "install": "Instalar", + "remove": "Quitar", + "show": "Mostrar", + "hide": "Ocultar", + "installed": "Instalado" }, "auth": { "login": "Iniciar sesión", @@ -78,7 +86,8 @@ "strong": "Fuerte", "code_resent": "Código reenviado. Revisa tu correo.", "verify": "Verificar", - "avatar_bad_format": "Formato de imagen no admitido (jpg, png, webp)" + "avatar_bad_format": "Formato de imagen no admitido (jpg, png, webp)", + "verification_code": "Código de verificación" }, "onboarding": { "skip": "Omitir", @@ -348,7 +357,10 @@ "fit": "Ajustar", "pip": "Imagen en imagen", "layout_pinned_badge": "Fijado", - "info_fields_title": "Filas del panel de información" + "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", @@ -514,7 +526,8 @@ "connections_subtitle": "Trackers, Discord, TV, escritorio", "trackers": "Trackers", "stat_days": "días vistos", - "mangayomi_sources": "Fuentes de Mangayomi" + "mangayomi_sources": "Fuentes de Mangayomi", + "view_only": "SOLO VER" }, "appearance": { "title": "Apariencia", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index fbef4cc3..1cd09a19 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -26,7 +26,15 @@ "off": "Désactivé", "on": "Activé", "new_badge": "NOUVEAU", - "reset": "Réinitialiser" + "reset": "Réinitialiser", + "clear": "Effacer", + "copy": "Copier", + "share": "Partager", + "install": "Installer", + "remove": "Retirer", + "show": "Afficher", + "hide": "Masquer", + "installed": "Installé" }, "auth": { "login": "Connexion", @@ -78,7 +86,8 @@ "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)" + "avatar_bad_format": "Format d’image non pris en charge (jpg, png, webp)", + "verification_code": "Code de vérification" }, "onboarding": { "skip": "Passer", @@ -348,7 +357,10 @@ "fit": "Ajuster", "pip": "Image dans l’image", "layout_pinned_badge": "Épinglé", - "info_fields_title": "Lignes de l’encart d’informations" + "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", @@ -514,7 +526,8 @@ "connections_subtitle": "Trackers, Discord, TV, bureau", "trackers": "Trackers", "stat_days": "jours regardés", - "mangayomi_sources": "Sources Mangayomi" + "mangayomi_sources": "Sources Mangayomi", + "view_only": "LECTURE SEULE" }, "appearance": { "title": "Apparence", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 0de404c4..e56a5c7d 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -26,7 +26,15 @@ "off": "Mati", "on": "Nyala", "new_badge": "BARU", - "reset": "Setel ulang" + "reset": "Setel ulang", + "clear": "Bersihkan", + "copy": "Salin", + "share": "Bagikan", + "install": "Pasang", + "remove": "Hapus", + "show": "Tampilkan", + "hide": "Sembunyikan", + "installed": "Terpasang" }, "auth": { "login": "Masuk", @@ -78,7 +86,8 @@ "strong": "Kuat", "code_resent": "Kode dikirim ulang. Periksa emailmu.", "verify": "Verifikasi", - "avatar_bad_format": "Format gambar tidak didukung (jpg, png, webp)" + "avatar_bad_format": "Format gambar tidak didukung (jpg, png, webp)", + "verification_code": "Kode verifikasi" }, "onboarding": { "skip": "Lewati", @@ -348,7 +357,10 @@ "fit": "Sesuaikan", "pip": "Gambar dalam gambar", "layout_pinned_badge": "Disematkan", - "info_fields_title": "Baris lapisan info" + "info_fields_title": "Baris lapisan info", + "logs_title": "Log pemutar ({})", + "logs_copied": "Log disalin", + "logs_empty": "Belum ada log" }, "profile": { "title": "Profil", @@ -514,7 +526,8 @@ "connections_subtitle": "Pelacak, Discord, TV, desktop", "trackers": "Pelacak", "stat_days": "hari ditonton", - "mangayomi_sources": "Sumber Mangayomi" + "mangayomi_sources": "Sumber Mangayomi", + "view_only": "HANYA TONTON" }, "appearance": { "title": "Tampilan", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index bcd7083b..c982c692 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -26,7 +26,15 @@ "off": "Uit", "on": "Aan", "new_badge": "NIEUW", - "reset": "Herstellen" + "reset": "Herstellen", + "clear": "Wissen", + "copy": "Kopiëren", + "share": "Delen", + "install": "Installeren", + "remove": "Verwijderen", + "show": "Tonen", + "hide": "Verbergen", + "installed": "Geïnstalleerd" }, "auth": { "login": "Inloggen", @@ -78,7 +86,8 @@ "strong": "Sterk", "code_resent": "Code opnieuw verstuurd. Kijk in je mail.", "verify": "Bevestigen", - "avatar_bad_format": "Beeldformaat wordt niet ondersteund (jpg, png, webp)" + "avatar_bad_format": "Beeldformaat wordt niet ondersteund (jpg, png, webp)", + "verification_code": "Verificatiecode" }, "onboarding": { "skip": "Overslaan", @@ -348,7 +357,10 @@ "fit": "Passend maken", "pip": "Beeld in beeld", "layout_pinned_badge": "Vastgezet", - "info_fields_title": "Rijen van de info-overlay" + "info_fields_title": "Rijen van de info-overlay", + "logs_title": "Spelerlogboek ({})", + "logs_copied": "Logboek gekopieerd", + "logs_empty": "Nog geen logboek" }, "profile": { "title": "Profiel", @@ -514,7 +526,8 @@ "connections_subtitle": "Trackers, Discord, tv, desktop", "trackers": "Trackers", "stat_days": "dagen gekeken", - "mangayomi_sources": "Mangayomi-bronnen" + "mangayomi_sources": "Mangayomi-bronnen", + "view_only": "ALLEEN KIJKEN" }, "appearance": { "title": "Weergave", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index e1097cb1..216a3f9f 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -26,7 +26,15 @@ "off": "Desligado", "on": "Ligado", "new_badge": "NOVO", - "reset": "Redefinir" + "reset": "Redefinir", + "clear": "Limpar", + "copy": "Copiar", + "share": "Partilhar", + "install": "Instalar", + "remove": "Remover", + "show": "Mostrar", + "hide": "Ocultar", + "installed": "Instalado" }, "auth": { "login": "Entrar", @@ -78,7 +86,8 @@ "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)" + "avatar_bad_format": "Formato de imagem não suportado (jpg, png, webp)", + "verification_code": "Código de verificação" }, "onboarding": { "skip": "Pular", @@ -348,7 +357,10 @@ "fit": "Ajustar", "pip": "Picture in picture", "layout_pinned_badge": "Fixado", - "info_fields_title": "Linhas do painel de informações" + "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", @@ -514,7 +526,8 @@ "connections_subtitle": "Rastreadores, Discord, TV, desktop", "trackers": "Rastreadores", "stat_days": "dias assistidos", - "mangayomi_sources": "Fontes do Mangayomi" + "mangayomi_sources": "Fontes do Mangayomi", + "view_only": "SÓ VER" }, "appearance": { "title": "Aparência", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 2356c32c..b496e58e 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": "Профиль", @@ -514,7 +526,8 @@ "connections_subtitle": "Трекеры, Discord, ТВ, десктоп", "trackers": "Трекеры", "stat_days": "дней просмотра", - "mangayomi_sources": "Источники Mangayomi" + "mangayomi_sources": "Источники Mangayomi", + "view_only": "ТОЛЬКО ПРОСМОТР" }, "appearance": { "title": "Внешний вид", @@ -1697,5 +1710,78 @@ "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": "Новелла" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 116644a5..2c1d464b 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -26,7 +26,15 @@ "off": "Kapalı", "on": "Açık", "new_badge": "YENİ", - "reset": "Sıfırla" + "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", @@ -78,7 +86,8 @@ "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)" + "avatar_bad_format": "Görsel biçimi desteklenmiyor (jpg, png, webp)", + "verification_code": "Doğrulama kodu" }, "onboarding": { "skip": "Atla", @@ -348,7 +357,10 @@ "fit": "Sığdır", "pip": "Görüntü içinde görüntü", "layout_pinned_badge": "Sabit", - "info_fields_title": "Bilgi katmanı satırları" + "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", @@ -514,7 +526,8 @@ "connections_subtitle": "Takipçiler, Discord, TV, masaüstü", "trackers": "Takipçiler", "stat_days": "gün izlendi", - "mangayomi_sources": "Mangayomi kaynakları" + "mangayomi_sources": "Mangayomi kaynakları", + "view_only": "SALT İZLEME" }, "appearance": { "title": "Görünüm", @@ -1696,5 +1709,78 @@ "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" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index ff07c3df..5c6fcaf3 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", @@ -514,7 +526,8 @@ "connections_subtitle": "Trackerlar, Discord, TV, desktop", "trackers": "Trackerlar", "stat_days": "kun ko'rildi", - "mangayomi_sources": "Mangayomi manbalari" + "mangayomi_sources": "Mangayomi manbalari", + "view_only": "FAQAT KO‘RISH" }, "appearance": { "title": "Ko'rinish", @@ -1696,5 +1709,78 @@ "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" } } 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<LogViewerSheet> { 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<LogViewerSheet> { ), 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<LogViewerSheet> { 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/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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { if (!mounted) return; setState(() { _statusError = false; - _status = 'Source removed.'; + _status = 'ext.source_removed'.tr(); }); } @@ -155,9 +158,9 @@ class _AniyomiSourcesPageState extends State<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { ? 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<AniyomiSourcesPage> { 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<AniyomiSourcesPage> { 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<OtpVerifyPage> ), ), 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/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<CloudStreamPluginsPage> { 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<CloudStreamPluginsPage> { ), 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<CloudStreamPluginsPage> { height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Install all'), + : Text('ext.install_all'.tr()), ), ], ), @@ -225,22 +230,22 @@ class _CloudStreamPluginsPageState extends State<CloudStreamPluginsPage> { 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<CloudStreamPluginsPage> { 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<CloudStreamPluginsPage> { 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<CloudStreamPluginsPage> { 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<CloudStreamPluginsPage> { ) : 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { if (!mounted) return; setState(() { _statusError = false; - _status = 'Source removed.'; + _status = 'ext.source_removed'.tr(); }); } @@ -104,7 +106,7 @@ class _CloudStreamSourcesPageState extends State<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { surfaceTintColor: Colors.transparent, scrolledUnderElevation: 0, elevation: 0, - title: const Text('CloudStream Sources'), + title: Text('ext.cloudstream_title'.tr()), actions: [ PopupMenuButton<String>( enabled: !_busy && _repos.isNotEmpty, @@ -159,16 +164,16 @@ class _CloudStreamSourcesPageState extends State<CloudStreamSourcesPage> { onSelected: (v) { if (v == 'updates') _checkUpdates(); }, - itemBuilder: (_) => const [ + itemBuilder: (_) => [ PopupMenuItem<String>( 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { ? 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<CloudStreamSourcesPage> { 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<CloudStreamSourcesPage> { 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/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<MangayomiSourcesPage> { setState(() { _busy = true; _statusError = false; - _status = 'Installing extensions…'; + _status = 'ext.installing_extensions'.tr(); }); var added = 0; @@ -104,12 +105,14 @@ class _MangayomiSourcesPageState extends State<MangayomiSourcesPage> { _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<MangayomiSourcesPage> { _reloadProviders(); setState(() { _statusError = false; - _status = 'Repo removed.'; + _status = 'ext.repo_removed'.tr(); }); } @@ -129,7 +132,7 @@ class _MangayomiSourcesPageState extends State<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { )), ), 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { 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<MangayomiSourcesPage> { ); 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<MangayomiRepoStore>().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<RecommendedReposSection> { 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/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<MangaSourcesPage> { ? 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<ReaderPage> { 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/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, From 9bfc9927f3559edbb9932b34c038aef8e225a2a0 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:14:48 +0500 Subject: [PATCH 19/21] Declare fake_async cross_search_controller_test imports it and it only resolved as a transitive dependency of flutter_test. --- pubspec.lock | 2 +- pubspec.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pubspec.lock b/pubspec.lock index c2c3d7af..ac5ae595 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" diff --git a/pubspec.yaml b/pubspec.yaml index 7570a8eb..8cd07b42 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -172,6 +172,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 From 857a7b1ccf0f9f01213bcc1af7162b61bec2ed4b Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:45:03 +0500 Subject: [PATCH 20/21] Improve mobile navigation, source selection and download quality UX --- assets/translations/ar.json | 28 +- assets/translations/de.json | 22 +- assets/translations/en.json | 28 +- assets/translations/es.json | 22 +- assets/translations/fr.json | 22 +- assets/translations/id.json | 22 +- assets/translations/nl.json | 22 +- assets/translations/pt.json | 22 +- assets/translations/ru.json | 26 +- assets/translations/tr.json | 22 +- assets/translations/uz.json | 26 +- lib/core/localization/app_language.dart | 5 +- lib/core/localization/language_picker.dart | 71 ++- lib/core/navigation/app_tab.dart | 2 +- lib/core/theme/app_colors.dart | 21 +- .../presentation/widgets/auth_widgets.dart | 11 +- .../presentation/pages/detail_page.dart | 108 ++-- .../presentation/pages/episodes_page.dart | 296 +++++----- .../presentation/pages/player_page.dart | 202 ++++--- .../pages/player_page.history.dart | 57 +- .../presentation/widgets/detail_info.dart | 50 +- .../data/models/download_item_model.dart | 96 +-- .../download_repository_impl.dart | 114 ++-- .../domain/entities/download_item.dart | 73 +-- .../domain/entities/download_request.dart | 80 ++- .../presentation/pages/downloads_page.dart | 55 +- .../widgets/download_choice_sheet.dart | 274 +++++++++ .../widgets/download_group_tile.dart | 28 +- .../presentation/widgets/home_banner.dart | 36 +- .../presentation/widgets/home_top_bar.dart | 294 +++------- .../main/presentation/pages/main_page.dart | 349 +++++++---- .../presentation/pages/onboarding_page.dart | 173 +++--- .../presentation/widgets/anime_ribbons.dart | 11 + .../presentation/widgets/poster_wall.dart | 11 + .../presentation/widgets/tv_showcase.dart | 20 + .../presentation/pages/profile_page.hub.dart | 6 + .../widgets/provider_quick_switch.dart | 549 ++++++++---------- .../presentation/widgets/settings_tiles.dart | 22 +- .../domain/entities/cross_search_scope.dart | 28 +- .../presentation/pages/cross_search_page.dart | 452 ++++++++------ .../presentation/pages/search_page.dart | 125 ++-- .../presentation/widgets/search_header.dart | 240 +++----- .../widgets/search_set_sheet.dart | 124 ++-- .../widgets/source_scope_bar.dart | 155 ++--- pubspec.lock | 2 +- pubspec.yaml | 1 + test/features/mobile_ux_test.dart | 397 +++++++++++++ .../search/cross_search_scope_test.dart | 34 +- 48 files changed, 3030 insertions(+), 1804 deletions(-) create mode 100644 lib/features/download/presentation/widgets/download_choice_sheet.dart create mode 100644 test/features/mobile_ux_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index cfa74742..2375969c 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -381,7 +381,7 @@ "guest_subtitle": "سجّل الدخول لمزامنة المفضّلة وسجل المشاهدة.", "auth_provider": "مزوّد تسجيل الدخول", "joined": "تاريخ الانضمام", - "providers": "المزوّدون", + "providers": "المصادر", "providers_error": "تعذّر تحميل المزوّدين.", "offline_title": "تعذّر الوصول إلى الخادم", "offline_local_available": "المصادر المثبَّتة على هذا الجهاز والبالغ عددها {} ما زالت تعمل. المصادر السحابية غير متاحة حتى يعود الخادم.", @@ -394,7 +394,7 @@ "connections": "الاتصالات", "coming_soon": "قريباً", "favorites": "المفضّلة", - "all_providers": "كل المزوّدين", + "all_providers": "كل المصادر", "add_favorite": "إضافة إلى المفضّلة", "cloudstream_sources": "مصادر CloudStream", "aniyomi_sources": "مصادر Aniyomi", @@ -403,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": "لا يوجد مزوّدون في هذا التصنيف", @@ -1786,5 +1786,23 @@ "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 index e15478bb..9922feaf 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -403,12 +403,12 @@ "sign_in": "Anmelden", "sign_out": "Abmelden", "sign_out_confirm": "Möchtest du dich wirklich abmelden?", - "section_providers": "QUELLEN", + "section_providers": "Quellen", "section_content": "INHALTE", "sources_row": "Erweiterungsquellen", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Quelle", - "choose_provider": "Quelle wählen", + "choose_provider": "Quelle wechseln", "search_providers_hint": "Quellen durchsuchen …", "count_of_total_shown": "{} von {} angezeigt", "no_providers_in_category": "Keine Quellen in dieser Kategorie", @@ -1782,5 +1782,23 @@ "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 cba06464..3afc5955 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -381,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.", @@ -394,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", @@ -403,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", @@ -1782,5 +1782,23 @@ "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 index 567b0a2b..2f9c3fdc 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -403,12 +403,12 @@ "sign_in": "Iniciar sesión", "sign_out": "Cerrar sesión", "sign_out_confirm": "¿Seguro que quieres cerrar sesión?", - "section_providers": "FUENTES", + "section_providers": "Fuentes", "section_content": "CONTENIDO", "sources_row": "Fuentes de extensión", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Fuente", - "choose_provider": "Elegir 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", @@ -1782,5 +1782,23 @@ "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 index 1cd09a19..00d78bf0 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -403,12 +403,12 @@ "sign_in": "Se connecter", "sign_out": "Se déconnecter", "sign_out_confirm": "Veux-tu vraiment te déconnecter ?", - "section_providers": "SOURCES", + "section_providers": "Sources", "section_content": "CONTENU", "sources_row": "Sources d’extension", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Source", - "choose_provider": "Choisir une 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", @@ -1782,5 +1782,23 @@ "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 index e56a5c7d..868d5e85 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -403,12 +403,12 @@ "sign_in": "Masuk", "sign_out": "Keluar", "sign_out_confirm": "Yakin ingin keluar?", - "section_providers": "SUMBER", + "section_providers": "Sumber", "section_content": "KONTEN", "sources_row": "Sumber ekstensi", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Sumber", - "choose_provider": "Pilih 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", @@ -1782,5 +1782,23 @@ "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 index c982c692..15cf718a 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -403,12 +403,12 @@ "sign_in": "Inloggen", "sign_out": "Uitloggen", "sign_out_confirm": "Weet je zeker dat je wilt uitloggen?", - "section_providers": "BRONNEN", + "section_providers": "Bronnen", "section_content": "INHOUD", "sources_row": "Extensiebronnen", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Bron", - "choose_provider": "Bron kiezen", + "choose_provider": "Bron wijzigen", "search_providers_hint": "Bronnen zoeken…", "count_of_total_shown": "{} van {} getoond", "no_providers_in_category": "Geen bronnen in deze categorie", @@ -1782,5 +1782,23 @@ "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 index 216a3f9f..fa677bdf 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -403,12 +403,12 @@ "sign_in": "Entrar", "sign_out": "Sair", "sign_out_confirm": "Tem certeza de que quer sair?", - "section_providers": "FONTES", + "section_providers": "Fontes", "section_content": "CONTEÚDO", "sources_row": "Fontes de extensão", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Fonte", - "choose_provider": "Escolher fonte", + "choose_provider": "Trocar fonte", "search_providers_hint": "Buscar fontes…", "count_of_total_shown": "{} de {} exibidas", "no_providers_in_category": "Nenhuma fonte nesta categoria", @@ -1782,5 +1782,23 @@ "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 b496e58e..eb55a3a8 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -381,7 +381,7 @@ "guest_subtitle": "Войдите, чтобы сохранять избранное и историю просмотров.", "auth_provider": "Тип входа", "joined": "Дата регистрации", - "providers": "Providers", + "providers": "Источники", "providers_error": "Не удалось загрузить провайдеров.", "offline_title": "Сервер недоступен", "offline_local_available": "Источников на этом устройстве, которые продолжают работать: {}. Облачные источники недоступны, пока сервер не вернётся.", @@ -394,7 +394,7 @@ "connections": "Подключения", "coming_soon": "Скоро", "favorites": "Избранное", - "all_providers": "Все провайдеры", + "all_providers": "Все источники", "add_favorite": "Добавить в избранное", "cloudstream_sources": "Источники CloudStream", "aniyomi_sources": "Источники Aniyomi", @@ -403,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": "В этой категории нет источников", @@ -1783,5 +1783,23 @@ "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 index 2c1d464b..6035e319 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -403,12 +403,12 @@ "sign_in": "Giriş yap", "sign_out": "Çıkış yap", "sign_out_confirm": "Çıkmak istediğine emin misin?", - "section_providers": "KAYNAKLAR", + "section_providers": "Kaynaklar", "section_content": "İÇERİK", "sources_row": "Eklenti kaynakları", "sources_row_subtitle": "CloudStream, Aniyomi, Mangayomi", "provider": "Kaynak", - "choose_provider": "Kaynak seç", + "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", @@ -1782,5 +1782,23 @@ "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 5c6fcaf3..a9cfa0ba 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -381,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.", @@ -394,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", @@ -403,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", @@ -1782,5 +1782,23 @@ "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/core/localization/app_language.dart b/lib/core/localization/app_language.dart index 995c1a15..49f2dc4e 100644 --- a/lib/core/localization/app_language.dart +++ b/lib/core/localization/app_language.dart @@ -41,8 +41,9 @@ abstract final class AppLanguage { /// Switches the interface, and everything downstream of it. static Future<void> set(BuildContext context, String code) async { - if (code == context.locale.languageCode) return; - await context.setLocale(Locale(code)); + if (code != context.locale.languageCode) { + await context.setLocale(Locale(code)); + } await getIt<HiveService>().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 diff --git a/lib/core/localization/language_picker.dart b/lib/core/localization/language_picker.dart index a3026ca6..5bd4001c 100644 --- a/lib/core/localization/language_picker.dart +++ b/lib/core/localization/language_picker.dart @@ -29,8 +29,9 @@ class LanguageChip extends StatelessWidget { child: InkWell( onTap: () => showLanguageSheet(context), borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 12, 8), + child: Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsetsDirectional.fromSTEB(12, 12, 12, 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -60,6 +61,72 @@ class LanguageChip extends StatelessWidget { } } +/// First-run choice, after the introduction and before entering the app/auth. +Future<bool> confirmIntroLanguage(BuildContext context) async { + final code = await Navigator.of( + context, + ).push<String>(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<IntroLanguagePage> createState() => _IntroLanguagePageState(); +} + +class _IntroLanguagePageState extends State<IntroLanguagePage> { + String? _selected; + + @override + Widget build(BuildContext context) { + final selected = _selected ?? context.locale.languageCode; + return Scaffold( + appBar: AppBar(title: Text('profile.language'.tr())), + body: SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'ux.interface_language_note'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 16, + ), + ), + ), + for (final locale in context.supportedLocales) + _LanguageRow( + code: locale.languageCode, + selected: selected == locale.languageCode, + onTap: () => + setState(() => _selected = locale.languageCode), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton( + onPressed: () => Navigator.pop(context, selected), + child: Text('ux.continue'.tr()), + ), + ), + ], + ), + ), + ); + } +} + /// Lists every language the app ships, in its own name. Future<void> showLanguageSheet(BuildContext context) { return showAdaptiveModal<void>( 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/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/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/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 36c0cc55..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'; @@ -144,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)), @@ -167,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<DetailBloc>().add( @@ -547,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<ResolveMediaUseCase>()( ref: ref, @@ -577,6 +575,8 @@ class _DetailViewState extends State<_DetailView> } url = result.value.videoUrl; headers = result.value.headers; + sources = result.value.videoSources; + type = result.value.type; } } @@ -585,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<EnqueueDownloadUseCase>()( 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; @@ -1119,37 +1129,36 @@ class _DetailViewState extends State<_DetailView> // header handed to it as `child`. background: ValueListenableBuilder<bool>( valueListenable: _heroActive, - builder: (_, heroActive, _) => - ValueListenableBuilder<double>( - 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<double>( + 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( @@ -1598,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<EpisodesPage> { ); if (!mounted) return false; - if (result is! Success<MediaResolveEntity> || result.value.videoUrl.isEmpty) { + if (result is! Success<MediaResolveEntity> || + result.value.videoUrl.isEmpty) { if (!quiet) _toast('detail.download_resolve_failed'.tr()); return false; } @@ -721,14 +723,25 @@ class _EpisodesPageState extends State<EpisodesPage> { 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<EpisodesPage> { } } 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<EpisodesPage> { } Future<bool?> _confirmLargeBatch(List<int> indices) => showDialog<bool>( - 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<bool> _downloadChapterQuietly(int index) async { @@ -1006,13 +1023,19 @@ class _EpisodesPageState extends State<EpisodesPage> { 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<EpisodesPage> { 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<String> 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<int>( - 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<int>( - 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 d708c47e..c15f2e3b 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1,3 +1,4 @@ +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'; @@ -196,6 +197,7 @@ class _PlayerPageState extends State<PlayerPage> Map<String, String> _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 @@ -237,6 +239,7 @@ class _PlayerPageState extends State<PlayerPage> /// 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 @@ -261,8 +264,9 @@ class _PlayerPageState extends State<PlayerPage> /// 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<String> _infoFields = - PlayerInfoFields.fromStored(getIt<HiveService>().getPlayerInfoFields()); + Set<String> _infoFields = PlayerInfoFields.fromStored( + getIt<HiveService>().getPlayerInfoFields(), + ); /// The viewer's bar arrangement. Read once at open: it can only change from /// the editor, and returning from that re-reads it. @@ -434,7 +438,6 @@ class _PlayerPageState extends State<PlayerPage> /// 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; @@ -469,7 +472,9 @@ class _PlayerPageState extends State<PlayerPage> // 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). /// @@ -480,8 +485,9 @@ class _PlayerPageState extends State<PlayerPage> /// 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'); @@ -529,8 +535,12 @@ class _PlayerPageState extends State<PlayerPage> // 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( @@ -684,93 +694,102 @@ class _PlayerPageState extends State<PlayerPage> 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(), - ], + ), ), - )), + ), ), - )), + ), ), ); } @@ -816,7 +835,8 @@ class _PlayerPageState extends State<PlayerPage> 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 <Future<int?> 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/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<DetailContentHeader> { 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<DetailContentHeader> { 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/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<String, dynamic> 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<String, dynamic> 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<void> _requeue(DownloadItem item, {required bool resetAttempts}) async { + Future<void> _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<void> _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<DownloadItem?> _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<bool> _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/<id>/index.m3u8`, `downloads/<id>/video.mkv`, or for a manga /// chapter the folder `downloads/<id>`. @@ -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<String, String> 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<DownloadLocationUseCase>(), hive: getIt<HiveService>(), )..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<Object>( 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<Object>( 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<String, String> headers; + final int? height; +} + +Future<DownloadSelection?> chooseDownload( + BuildContext context, { + required String url, + required Map<String, String> headers, + String? type, + int? height, + List<VideoSourceEntity> sources = const [], +}) => showModalBottomSheet<DownloadSelection>( + 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<String, String> headers; + final String? type; + final int? height; + final List<VideoSourceEntity> sources; + + /// Optional transport for deterministic manifest tests; owned by this sheet. + final Dio? manifestClient; + + @override + State<DownloadChoiceSheet> createState() => _DownloadChoiceSheetState(); +} + +class _DownloadChoiceSheetState extends State<DownloadChoiceSheet> { + late final _dio = + widget.manifestClient ?? + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + late final List<VideoSourceEntity> _sources; + int _source = 0; + int _variant = 0; + List<HlsVariant> _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<void> _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<String>( + 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/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<HomeBanner> { 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<double>(begin: 1.05, end: 1.16) - .animate(CurvedAnimation(parent: _kb, curve: Curves.easeOut)); + _zoom = Tween<double>( + 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_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index 5ec121af..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'; @@ -21,9 +18,6 @@ import 'package:soplay/features/notifications/domain/repositories/notifications_ 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/provider_quick_switch.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'; class HomeTopBar extends StatelessWidget { const HomeTopBar({super.key, required this.blurProgress}); @@ -35,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 = <Widget>[ - // 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<HomeBloc>().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), ]; @@ -86,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, ], ), ); @@ -181,37 +142,44 @@ class _ProviderSwitcher extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(20), onTap: () => openProviderQuickSwitch(context), - 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, + // 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), + ), + ), + 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, - ), - ], + const SizedBox(width: 2), + const Icon( + Icons.keyboard_arrow_down_rounded, + color: AppColors.textHint, + size: 18, + ), + ], + ), ), ), ), @@ -219,90 +187,6 @@ class _ProviderSwitcher extends StatelessWidget { }, ); } - -} - - -/// 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<StreakService>(); - final loggedIn = getIt<HiveService>().isLoggedIn; - return ValueListenableBuilder<StreakState>( - 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<NavController>().goToId(TabId.search)) { - context.push('/cross-search'); - } - }, - child: const Icon( - Icons.search_rounded, - color: Colors.white, - size: 24, - ), - ); - }, - ); - } } class _NotificationsIndicator extends StatefulWidget { @@ -410,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, @@ -476,6 +360,7 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> final GetDownloadsUseCase _downloads = getIt<GetDownloadsUseCase>(); late final AnimationController _pulse; bool _hasActive = false; + bool _reducedMotion = false; int _activeCount = 0; @override @@ -489,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); @@ -508,7 +404,7 @@ class _DownloadIndicatorState extends State<_DownloadIndicator> _hasActive = hasActive; _activeCount = active; }); - if (hasActive) { + if (hasActive && !_reducedMotion) { _pulse.repeat(); } else { _pulse.stop(); @@ -519,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, @@ -539,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, + ), ), ), ), ), - ), ], ), ), @@ -579,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 @@ -651,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/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<MainPage> 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<MainPage> 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<MainPage> with WidgetsBindingObserver { _shortsShowcaseStarted = false; return; } - ShowcaseView.getNamed(_showcaseScope).startShowCase([_shortsRefreshShowcaseKey]); + ShowcaseView.getNamed( + _showcaseScope, + ).startShowCase([_shortsRefreshShowcaseKey]); }); }); } @@ -317,10 +321,12 @@ class _MainPageState extends State<MainPage> 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<MainPage> 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<SystemUiOverlayStyle>( value: const SystemUiOverlayStyle( @@ -382,7 +389,14 @@ class _MainPageState extends State<MainPage> 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<MainPage> with WidgetsBindingObserver { Positioned.fill( child: IndexedStack(index: _index, children: tabs), ), - if (!keyboardOpen) + if (!keyboardOpen && !readableNav) Positioned( - left: 0, - right: 0, - bottom: 0, - child: ValueListenableBuilder<String>( - 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<String>( + 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<int> 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<AppTabDef> items; + final ValueChanged<int> 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/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 13c5e940..8bb261f3 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -38,6 +38,8 @@ class _OnboardingPageState extends State<OnboardingPage> { final _pageController = PageController(); int _page = 0; bool _googlePending = false; + bool _languageConfirmed = false; + bool _leaving = false; @override void dispose() { @@ -48,12 +50,28 @@ class _OnboardingPageState extends State<OnboardingPage> { /// Marked on the way out rather than on entry: a first launch killed halfway /// through should still get the introduction next time. Future<void> _leave(String route) async { + if (_leaving) return; + _leaving = true; + if (!_languageConfirmed) { + _languageConfirmed = await confirmIntroLanguage(context); + if (!mounted || !_languageConfirmed) { + _leaving = false; + return; + } + } await getIt<HiveService>().markOnboardingSeen(); if (!mounted) return; context.go(route); } - void _continueWithGoogle() { + Future<void> _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<AuthBloc>().add(const AuthGoogleRequested()); } @@ -101,74 +119,98 @@ class _OnboardingPageState extends State<OnboardingPage> { ), ), 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, + 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, ), - ), - const Spacer(), - const LanguageChip(), - const SizedBox(width: 8), - _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<AuthBloc, AuthState>( - 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, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + Text( + 'app_name'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.primary, + fontSize: 22, + fontWeight: FontWeight.w900, + letterSpacing: 3, + ), ), - const SizedBox(height: 10), + const LanguageChip(), + const SizedBox(width: 8), + _SkipChip(onTap: () => _leave('/main')), ], - 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'), - ), - ], - ); - }, + ), + ), + 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<AuthBloc, AuthState>( + 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'), + ), + ], + ); + }, + ), + ), + ], ), ), - ], + ), ), ), ], @@ -192,8 +234,9 @@ class _SkipChip extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(999), - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(14, 8, 10, 8), + child: Container( + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsetsDirectional.fromSTEB(14, 12, 10, 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ 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<AnimeRibbons> 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<PosterWall> 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<TvShowcase> 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.hub.dart b/lib/features/profile/presentation/pages/profile_page.hub.dart index 97ea8f26..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(), diff --git a/lib/features/profile/presentation/widgets/provider_quick_switch.dart b/lib/features/profile/presentation/widgets/provider_quick_switch.dart index ac080f04..e01d4bff 100644 --- a/lib/features/profile/presentation/widgets/provider_quick_switch.dart +++ b/lib/features/profile/presentation/widgets/provider_quick_switch.dart @@ -34,132 +34,151 @@ import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; Future<void> openProviderQuickSwitch(BuildContext context) async { final bloc = context.read<ProviderBloc>(); final state = bloc.state; - if (state is! ProviderLoaded) return; + 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. +/// 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<ProviderEntity> _resolveFavorites(ProviderLoaded state) { - final favIds = getIt<HiveService>().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]!, - ]; - } + final favIds = getIt<HiveService>().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. +/// 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<void> _switchMode( - BuildContext context, - ProviderBloc bloc, - ProviderLoaded state, - ContentMode mode, - ) async { - final hive = getIt<HiveService>(); - 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, + BuildContext context, + ProviderBloc bloc, + ProviderLoaded state, + ContentMode mode, +) async { + final hive = getIt<HiveService>(); + 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; + 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<void>? loaded = pick.id == state.currentProviderId - ? null - : context.read<HomeBloc>().stream - .firstWhere((s) => s is HomeLoaded || s is HomeError) - .then((_) {}); + // 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<void>? loaded = pick.id == state.currentProviderId + ? null + : context + .read<HomeBloc>() + .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); - } + // 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<void> _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<HiveService>(); - 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<String>( - 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)); + 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<HiveService>(); + 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<String>( + 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__'; @@ -175,8 +194,9 @@ const String _kModePrefix = '__mode__:'; /// 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({ +class ProviderQuickSwitchSheet extends StatefulWidget { + const ProviderQuickSwitchSheet({ + super.key, required this.favorites, required this.all, required this.mode, @@ -195,11 +215,11 @@ class _ProviderQuickSwitchSheet extends StatefulWidget { final String currentProviderId; @override - State<_ProviderQuickSwitchSheet> createState() => - _ProviderQuickSwitchSheetState(); + State<ProviderQuickSwitchSheet> createState() => + ProviderQuickSwitchSheetState(); } -class _ProviderQuickSwitchSheetState extends State<_ProviderQuickSwitchSheet> { +class ProviderQuickSwitchSheetState extends State<ProviderQuickSwitchSheet> { final TextEditingController _filter = TextEditingController(); String _query = ''; @@ -238,184 +258,126 @@ class _ProviderQuickSwitchSheetState extends State<_ProviderQuickSwitchSheet> { @override Widget build(BuildContext context) { - final bottomPad = MediaQuery.paddingOf(context).bottom; + 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 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, - ), - ), - ), - ), - 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: Text( - 'profile.no_providers_in_category'.tr(), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 13, - ), + 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()), + ), + ], + ], ), - ), - ], - ), - ), - 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, + 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), ), - 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. @@ -441,9 +403,12 @@ class _ModeChip extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + 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( @@ -463,8 +428,9 @@ class _ModeChip extends StatelessWidget { Widget _favoriteProviderTile( BuildContext context, ProviderEntity p, - String currentProviderId, -) { + String currentProviderId, { + bool favorite = false, +}) { final selected = p.id == currentProviderId; return ListTile( leading: ProviderLogo(image: p.image, size: 36), @@ -480,18 +446,15 @@ Widget _favoriteProviderTile( ), 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, - }); + const ProviderLogo({required this.image, required this.size, super.key}); final String image; final double size; 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<String> 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<ProviderEntity> resolve(List<ProviderEntity> providers) => - ids == null ? providers : providers.where((p) => ids!.contains(p.id)).toList(); + List<ProviderEntity> resolve(List<ProviderEntity> providers) => ids == null + ? providers + : providers.where((p) => ids!.contains(p.id)).toList(); - int selectedCount(List<ProviderEntity> providers) => resolve(providers).length; + int selectedCount(List<ProviderEntity> 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<ProviderEntity> 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/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index d4d89027..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<CrossSearchPage> { /// 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<CrossSearchPage> { /// 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<ProviderEntity> 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<HiveService>().getCrossSearchProviders()); + final stored = CrossSearchScope.fromStored( + getIt<HiveService>().getCrossSearchProviders(), + ); return providers.isEmpty ? stored : stored.pruned(providers); } - List<String> _orderedIds(List<ProviderEntity> providers, CrossSearchScope scope) { + List<String> _orderedIds( + List<ProviderEntity> providers, + CrossSearchScope scope, + ) { final selected = <String>[]; final rest = <String>[]; for (final p in providers) { @@ -146,9 +157,15 @@ class _CrossSearchPageState extends State<CrossSearchPage> { /// hand is theirs, and is never trimmed. bool get _capped => _scope.isAll && _scopedCount > _refs().length; - List<ProviderRef> _refs() => getIt<CrossSearchEngine>().planLegs( - _scope.resolve(_providers).map(ProviderRef.fromEntity).toList(), - ); + List<ProviderRef> _refs() { + final selected = _scope + .resolve(_providers) + .map(ProviderRef.fromEntity) + .toList(); + return _scope.isAll + ? getIt<CrossSearchEngine>().planLegs(selected) + : selected; + } void _applyScope(CrossSearchScope scope, {bool reorder = false}) { if (scope == _scope) return; @@ -212,16 +229,24 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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); @@ -268,9 +293,10 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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 @@ -284,29 +310,58 @@ class _CrossSearchPageState extends State<CrossSearchPage> { ), ], ), - 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(), + ), + ), + ], + ), ), ), ); @@ -329,8 +384,11 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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<TextEditingValue>( @@ -376,8 +434,9 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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) { @@ -413,8 +472,9 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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, @@ -436,22 +496,19 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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), ), ); @@ -466,12 +523,13 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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), @@ -494,7 +552,9 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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) @@ -517,11 +577,13 @@ class _CrossSearchPageState extends State<CrossSearchPage> { Padding( padding: const EdgeInsets.only(top: 6), child: Text( - 'search.capped_note'.tr( + 'ux.quick_search_note'.tr( args: ['${c.expectedLegs}', '$_scopedCount'], ), style: const TextStyle( - color: AppColors.textHint, fontSize: 11.5), + color: AppColors.textSecondary, + fontSize: 13, + ), ), ), ], @@ -532,20 +594,30 @@ class _CrossSearchPageState extends State<CrossSearchPage> { Widget _statusPills(CrossSearchController c) { final pills = <Widget>[ 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( @@ -555,15 +627,16 @@ class _CrossSearchPageState extends State<CrossSearchPage> { } 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. @@ -579,8 +652,9 @@ class _CrossSearchPageState extends State<CrossSearchPage> { ? '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(); } @@ -636,9 +710,10 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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( @@ -647,11 +722,14 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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, + ), + ), ), ], ), @@ -713,85 +791,94 @@ class _CrossSearchPageState extends State<CrossSearchPage> { List<String> 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, @@ -804,16 +891,19 @@ class _CrossSearchPageState extends State<CrossSearchPage> { 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<SearchBloc, SearchState>( listener: (context, state) => _maybeAutoFill(state), - builder: (context, state) => Stack( - children: [ - SearchContentView( - state: state, - scrollController: _scrollController, - topPad: headerHeight, - bottomPad: bottomPad, - onRetry: () => context.read<SearchBloc>().add(const SearchRetry()), - onSuggestion: _runQuery, - onGenre: (genre) => - context.read<SearchBloc>().add(SearchGenreSelected(genre)), - onRemoveRecent: (query) => - context.read<SearchBloc>().add(SearchRecentRemoved(query)), - onClearRecents: () => - context.read<SearchBloc>().add(const SearchRecentsCleared()), - onTryAllSources: () => _openCrossSearch(state.criteria.text), - onSearchTorrents: _torrentsAvailable - ? () => _openTorrents(state.criteria.text) - : null, - ), - ValueListenableBuilder<double>( - 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<SearchBloc>().add(SearchQueryChanged(q)), - onSubmitted: (q) => - context.read<SearchBloc>().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<SearchBloc>().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<double>( + 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<SearchBloc>().add(SearchQueryChanged(q)), + onSubmitted: (q) => + context.read<SearchBloc>().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<SearchBloc>().add(SearchQueryChanged(t)); + }, + onSubmit: _runQuery, + ), + ), + ), + ), + ), + Expanded( + child: SearchContentView( + state: state, + scrollController: _scrollController, + topPad: 0, + bottomPad: bottomPad, + onRetry: () => + context.read<SearchBloc>().add(const SearchRetry()), + onSuggestion: _runQuery, + onGenre: (genre) => context.read<SearchBloc>().add( + SearchGenreSelected(genre), + ), + onRemoveRecent: (query) => context.read<SearchBloc>().add( + SearchRecentRemoved(query), + ), + onClearRecents: () => context.read<SearchBloc>().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<TextEditingValue>( - 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<SearchSetSheet> { }; 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<SearchSetSheet> { 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<SearchSetSheet> { 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<SearchSetSheet> { 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<SearchSetSheet> { 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<SearchSetSheet> { 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<SearchSetSheet> { 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<SearchSetSheet> { 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/pubspec.lock b/pubspec.lock index ac5ae595..9e61d340 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 8cd07b42..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: diff --git a/test/features/mobile_ux_test.dart b/test/features/mobile_ux_test.dart new file mode 100644 index 00000000..e705f19c --- /dev/null +++ b/test/features/mobile_ux_test.dart @@ -0,0 +1,397 @@ +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/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<Map<String, dynamic>> load(String path, Locale locale) async => + jsonDecode( + File( + 'assets/translations/${locale.languageCode}.json', + ).readAsStringSync(), + ) + as Map<String, dynamic>; +} + +class _ManifestAdapter implements HttpClientAdapter { + RequestOptions? request; + @override + Future<ResponseBody> fetch( + RequestOptions options, + Stream<Uint8List>? body, + Future<void>? 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 main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + Future<void> 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.tap(find.text('Bahasa Indonesia')); + 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<DownloadSelection>( + 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_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, ); }); From 084c9ec522f2973c8f73d901d79a1e1912407fa4 Mon Sep 17 00:00:00 2001 From: Azamov X <108933534+professorDeveloper@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:56:38 +0500 Subject: [PATCH 21/21] Refine onboarding language selection flow --- lib/core/localization/app_language.dart | 16 ++ lib/core/localization/language_picker.dart | 177 +++++++++--------- .../presentation/pages/onboarding_page.dart | 113 ++++++----- test/features/mobile_ux_test.dart | 26 +++ 4 files changed, 187 insertions(+), 145 deletions(-) diff --git a/lib/core/localization/app_language.dart b/lib/core/localization/app_language.dart index 49f2dc4e..05d187ce 100644 --- a/lib/core/localization/app_language.dart +++ b/lib/core/localization/app_language.dart @@ -39,6 +39,22 @@ abstract final class AppLanguage { static String labelOf(String code) => names[code] ?? code; + static const flags = <String, String>{ + '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<void> set(BuildContext context, String code) async { if (code != context.locale.languageCode) { diff --git a/lib/core/localization/language_picker.dart b/lib/core/localization/language_picker.dart index 5bd4001c..03cbfb7c 100644 --- a/lib/core/localization/language_picker.dart +++ b/lib/core/localization/language_picker.dart @@ -4,63 +4,6 @@ import 'package:soplay/core/localization/app_language.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; -/// The language control on the sign-in screen. -/// -/// ## Why it is here and not on a screen of its own -/// -/// The app already picks the right language by itself: with no saved choice, -/// `easy_localization` resolves the device locale, so a German phone opens in -/// German the moment `de.json` exists. A dedicated first-run language screen -/// would therefore charge every user a step to serve the minority whose phone -/// is set to something they do not want to read. -/// -/// A chip costs nobody a step, and the slides re-render in the chosen language -/// straight away — which a picker placed *after* the introduction cannot do, -/// because by then the introduction has already been read in the wrong one. -class LanguageChip extends StatelessWidget { - const LanguageChip({super.key}); - - @override - Widget build(BuildContext context) { - final code = context.locale.languageCode; - return Material( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(999), - child: InkWell( - onTap: () => showLanguageSheet(context), - borderRadius: BorderRadius.circular(999), - child: Container( - constraints: const BoxConstraints(minHeight: 48), - padding: const EdgeInsetsDirectional.fromSTEB(12, 12, 12, 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.language_rounded, - size: 18, - color: AppColors.textPrimary, - ), - const SizedBox(width: 6), - // The code, not the native name: "Bahasa Indonesia" is four times - // the width of "EN" and the chip sits next to Skip in a row that - // has no room to grow. - Text( - code.toUpperCase(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ], - ), - ), - ), - ); - } -} - /// First-run choice, after the introduction and before entering the app/auth. Future<bool> confirmIntroLanguage(BuildContext context) async { final code = await Navigator.of( @@ -84,43 +27,95 @@ class _IntroLanguagePageState extends State<IntroLanguagePage> { @override Widget build(BuildContext context) { final selected = _selected ?? context.locale.languageCode; - return Scaffold( - appBar: AppBar(title: Text('profile.language'.tr())), - body: SafeArea( - top: false, - child: Column( - children: [ - Expanded( - child: ListView( - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: Text( - 'ux.interface_language_note'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 16, + 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, ), ), - ), - for (final locale in context.supportedLocales) - _LanguageRow( + ], + ), + ), + 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.all(16), - child: FilledButton( - onPressed: () => Navigator.pop(context, selected), - child: Text('ux.continue'.tr()), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: FilledButton( + onPressed: () => Navigator.pop(context, selected), + child: Text('ux.continue'.tr()), + ), ), - ), - ], + ], + ), ), ), ); @@ -197,6 +192,16 @@ class _LanguageRow extends StatelessWidget { 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( diff --git a/lib/features/onboarding/presentation/pages/onboarding_page.dart b/lib/features/onboarding/presentation/pages/onboarding_page.dart index 8bb261f3..dc292f11 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_page.dart @@ -130,36 +130,7 @@ class _OnboardingPageState extends State<OnboardingPage> { ), 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: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - Text( - 'app_name'.tr().toUpperCase(), - style: TextStyle( - color: AppColors.primary, - fontSize: 22, - fontWeight: FontWeight.w900, - letterSpacing: 3, - ), - ), - const LanguageChip(), - const SizedBox(width: 8), - _SkipChip(onTap: () => _leave('/main')), - ], - ), - ), + OnboardingHeader(onSkip: () => _leave('/main')), Expanded( child: PageView.builder( controller: _pageController, @@ -220,7 +191,42 @@ class _OnboardingPageState extends State<OnboardingPage> { } } -/// Skip, made to look like a control rather than a stray word on a poster. +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), + ], + ), + ), + ); + } +} + +/// A quiet secondary action anchored to the top-right of the intro. class _SkipChip extends StatelessWidget { const _SkipChip({required this.onTap}); @@ -228,35 +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: Container( - constraints: const BoxConstraints(minHeight: 48), - padding: const EdgeInsetsDirectional.fromSTEB(14, 12, 10, 12), - 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/test/features/mobile_ux_test.dart b/test/features/mobile_ux_test.dart index e705f19c..d8264779 100644 --- a/test/features/mobile_ux_test.dart +++ b/test/features/mobile_ux_test.dart @@ -19,6 +19,7 @@ import 'package:soplay/features/download/presentation/widgets/download_choice_sh 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'; @@ -59,6 +60,8 @@ class _ManifestAdapter implements HttpClientAdapter { void close({bool force = false}) {} } +void _noop() {} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { @@ -218,10 +221,33 @@ void main() { 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 {