diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b8362bb..c3e1f8bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v4 + - name: Release notes composer + run: python3 tool/compose_release_notes_test.py - uses: subosito/flutter-action@v2 with: channel: stable @@ -55,12 +57,52 @@ jobs: - name: Analyzer run: flutter analyze --no-fatal-infos - # Runs before the tests on purpose: a missing translation key is the one - # failure that reaches users looking like a rendering bug rather than a - # crash, and it costs a second to catch. + # Stays with the analyzer rather than with the tests, because it is the + # same kind of check: it reads the source and compares it against the arb + # files. A missing translation key is the one failure that reaches users + # looking like a rendering bug rather than a crash, and it costs a second + # to catch. - name: Translation keys run: dart run tool/check_translations.dart + # The tests used to be the last step of `analyze`, and that was misleading in + # a way that cost real time: the check list on a pull request said "analyze", + # so "CI is green" was the only thing a reader could conclude, and whether + # the suite had run at all was invisible. Worse, an analyzer error ended the + # job before `flutter test` was reached, so a red "analyze" could mean the + # tests failed, or that they never ran — the same signal for two different + # situations. + # + # A job of its own fixes both: the check is named "test", a green one means + # the suite passed, and the tests now run even when the analyzer is unhappy, + # so one push gets both verdicts instead of two pushes getting one each. + # + # THE TRADE, since it is not free: this job pays for its own checkout and its + # own `flutter pub get`, roughly a minute and a half of runner time that the + # single job did not spend. It is not a second SDK download — flutter-action's + # `cache: true` restores the same cache `analyze` populates — and because the + # two jobs now run side by side rather than one after the other, the wall + # clock a person waits on a pull request goes DOWN by about as much as the + # billed minutes go up. Paying ~1.5 runner-minutes to make a test result + # legible is the right side of that trade; paying it to re-download Flutter + # would not have been, which is why `cache: true` is not optional here. + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + # The same reason as in `analyze`: `.env` is a declared pubspec asset and + # its absence fails the build before a single test runs. + - name: Create an empty .env + run: touch .env + + - run: flutter pub get + - name: Tests run: flutter test --reporter expanded diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d026761..89625ad4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1027,8 +1027,21 @@ jobs: V: ${{ needs.version.outputs.name }} BUILD: ${{ needs.version.outputs.build }} NOTES: ${{ inputs.notes }} + SHA: ${{ needs.version.outputs.sha }} + TAG: ${{ needs.version.outputs.tag }} run: | set -euo pipefail + # Hand-written notes and screenshots, when this version has them. + status=0 + python3 tool/compose_release_notes.py --version "${V}" --sha "${SHA}" \ + --repo "${GITHUB_REPOSITORY}" --tag "${TAG}" --assets rel \ + --out RELEASE_NOTES.md || status=$? + if [ "${status}" -eq 0 ]; then + echo "── notes ──"; cat RELEASE_NOTES.md + exit 0 + elif [ "${status}" -ne 3 ]; then + exit "${status}" + fi { if [ -n "${NOTES}" ]; then printf '%s\n\n' "${NOTES}" diff --git a/.gitignore b/.gitignore index edd247a4..8617344f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ +/android/build/ /coverage/ # Symbolication related @@ -61,3 +62,6 @@ tool/lnreader_deps/package-lock.json /docs/* !/docs/images/ !/docs/releases/ + +# Exported chat transcripts (e.g. 2026-09-02-221911-....txt) stay local. +/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt diff --git a/2026-09-02-221911-qara-qiladigan-ishimiz-quyidagicha-sozoni-toliq.txt b/2026-09-02-221911-qara-qiladigan-ishimiz-quyidagicha-sozoni-toliq.txt deleted file mode 100644 index 7b01803d..00000000 --- a/2026-09-02-221911-qara-qiladigan-ishimiz-quyidagicha-sozoni-toliq.txt +++ /dev/null @@ -1,193 +0,0 @@ - ▐▛███▛█ Claude Code v2.1.258 -▝▜██████▀ Fable 5.1 · Claude Max - ▝▝ ▝▝ ~/Sozo-Full/sozo - - -❯ qara qiladigan ishimiz quyidagicha sozoni to`liq mobil tomonini oqib chiq - profile qismini qaytadan moslashtiramiz - -⏺ Sozo mobil kodini o'qib chiqishdan boshlayman. Avval repo tuzilishi haqidagi - xotira yozuvlarini ko'rib, keyin mobil qismini to'liq o'rganaman. - - Listed 1 directory, ran 2 shell commands (ctrl+o to expand) - -⏺ Profil qismini o'zim to'liq o'qib chiqaman, umumiy arxitektura, mobil layout - konventsiyalari va foydalanuvchi ma'lumotlari manbalarini tekshirishni esa - parallel agentlarga bo'lib beraman. · summarized - -⏺ 3 background agents launched (↓ to manage) - ├ Map Sozo app architecture - ├ Survey mobile page conventions - └ Map user data sources - - Read 2 files (ctrl+o to expand) - -⏺ Hozir profile sahifasining mobil qismini to'liq o'qib chiqdim. Endi yordamchi - vidjetlar, tahrirlash sahifasi va streak kartasini o'qiyman. - - Searched for 3 patterns, read 3 files, ran 5 shell commands (ctrl+o to expand) - -⏺ Agentlar hali ishlayapti. Bu orada til sozlamasi qayerda boshqarilishini - tekshiryapman. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Profile qismini to'liq o'qib bo'ldim. Uchta agent hali umumiy arxitektura, - boshqa mobil sahifalar va foydalanuvchi ma'lumot manbalari bo'yicha hisobot - tayyorlayapti. Ular tugagach, to'liq tahlil va qayta ishlash taklifini - beraman. - -✻ Crunched for 3m 35s · done 9:46 PM - -⏺ Agent "Map Sozo app architecture" finished · 3m 59s - -⏺ Birinchi agent (arxitektura xaritasi) tugadi. Qolgan ikkitasini kutyapman. - -✻ Baked for 4s · done 9:48 PM - -⏺ Agent "Map user data sources" finished · 3m 55s - -⏺ Ikkinchi agent (foydalanuvchi ma'lumot manbalari) ham tugadi. Oxirgisi, mobil - sahifa konventsiyalari, hali ishlayapti. - -✻ Churned for 4s · done 9:48 PM - -⏺ Agent "Survey mobile page conventions" finished · 4m 13s - -⏺ O'qish tugadi. Uchala agent hisoboti va o'zim o'qigan profil kodidan to'liq - manzara hosil bo'ldi. - - Hozirgi mobil profil sahifasi - (lib/features/profile/presentation/pages/profile_page.dart + 5 ta part-fayl) - aslida sozlamalar markazi. Foydalanuvchi haqida faqat avatar, ism va email - ko'rinadi. Qolgani 8 ta kartada 25 ga yaqin qator: - - - Header karta (avatar 66px, ism, email, edit tugmasi) - - Streak kompakt qator - - Ulanishlar: AniList/MAL, Link TV, Desktop share, Backup - - Kontent: Provider, kengaytma manbalari (yig'iladigan) - - Kutubxona: Tarix (soni), Yuklamalar (soni), Kuzatuv, Statistika - - Ko'rish: Live TV, Watch Party, Torrent - - Sozlamalar: Ko'rinish, Navigatsiya, Pleyer, Discord, App lock, Maxfiy - ro'yxat - - Haqida: versiya, dasturchi, server countdown, ijtimoiy ikonkalar - - Chiqish - - Topilgan kamchiliklar va bo'sh imkoniyatlar: - - - Ilova tilini o'zgartirib bo'lmaydi. Tarjimalar en/uz/ru/ar tayyor, Hive'da - saveLanguage bor, lekin hech qayerdan chaqirilmaydi. - - Bildirishnomalar sahifasi profildan ochilmaydi, o'qilmagan soni ham - ko'rinmaydi. - - Statistika (soatlar, tugallanganlar), My List / Watch Later / Watched - sonlari, trivia reytingi, AniList/MAL avatarlari. Bularning hammasi kodda - tayyor, lekin profilda faqat "Stats" degan bitta qator bor. - - Backup "Ulanishlar" ichida, Provider "Kontent" ichida. Guruhlash mantiqi - chalkash. - - Guest holati faqat "Kirish" kartasi. Foydalanuvchi nima yo'qotayotganini - ko'rmaydi. - - Karta va qator vidjetlari ikki nusxada: profil ichida _Tile/_SectionCard - (private), sub-sahifalar uchun settings_tiles.dart. - - NavbarPage profil faylining ichida turibdi, profile_page.sections.dart.moved - esa ortiqcha qoldiq fayl. - - Backend banned holatini qaytaradi, klient modeli uni tashlab yuboradi. - - Ilovaning umumiy konventsiyalari (yangi profil shularga mos bo'lishi kerak): - tab-root sahifalarda AppBar yo'q, katta sarlavha 28/w900 va scroll bilan blur - bo'ladigan header (My List namunasi). Kartalar 16/14/12 radius, bo'lim - sarlavhasi 17/w800 va chevron bilan (Home, Trivia). Pastki bo'shliq bottomPad - + 96. Ranglar faqat AppColors getterlari orqali. Platforma - isMobilePlatform/isDesktopPlatform bilan ajratiladi, kenglik bo'yicha emas. - - Git holati: master branchda 231 ta commit qilinmagan fayl bor, shu jumladan - profil fayllarida ham WIP (profile_page.sections.dart +214 qator, yangi - discord_settings_page.dart). Men hech narsa commit qilmayman, faqat ishchi - daraxtda tahrirlayman, sizning o'zgarishlaringiz ustida. - - Endi yo'nalishni aniqlab olishim kerak, chunki variantlar butunlay boshqa - hajmdagi ish. - -⏺ User declined to answer questions - ⎿  · Profil qismini qanday yo'nalishda qayta ishlaymiz? (Profil + alohida - Sozlamalar (Tavsiya) / Bitta sahifa, tartib va vizual yangilanadi / Faqat - vizual polish) - · Guest (kirmagan) foydalanuvchi uchun profil qanday bo'lsin? (Kirish - kartasi + afzalliklar ro'yxati (Tavsiya) / Hozirgidek minimal) - -⏺ Nimani aniqlashtirmoqchisiz? Yo'nalish bo'yicha savolim bormi, yoki profil - haqida boshqa fikringiz, talabingiz bormi? Aytib bering, shunga qarab davom - etamiz. -✻ Baked for 7m 46s · done 9:51 PM - -❯ xullas qara hammasi alohida va umumiy bolishi kerka profile bir tekkisda - tushunarli bolish kerak har bir narsa ichida va mosroq bolishi kerak togri - click kopayadi lekin tartibli bolishini xoxlayman - -⏺ Tushundim. Yo'nalish aniq: profil sahifasi tekis va tushunarli bo'ladi, har - bir guruh o'z alohida sahifasiga ochiladi (Kutubxona, Ulanishlar, Manbalar, - Sozlamalar, Haqida), click ko'paysa ham tartib saqlanadi. Endi kerakli - API'larni tekshirib, qurishni boshlayman. - - Ran 3 shell commands (ctrl+o to expand) - -❯ VIDAPI da oddiy search ishlamayapti lol koplab sourcelarda ham shunday lol - fck tekshir ultrahink bilan har birini toliq cloudstreamdanmi nimadan - -⏺ VIDAPI va boshqa manbalarda search ishlamayotgani bo'yicha alohida tekshiruv - agenti yuboraman, o'zim esa profil ishini davom ettirib, avval VIDAPI - joylashuvi va prod server xotirasini ko'raman. · summarized - - Ran 1 shell command (ctrl+o to expand) - -⏺ Agent(Diagnose broken provider search) Fable 5.1 - ⎿  Backgrounded agent (↓ to manage · ctrl+o to expand) - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Kerakli ma'lumotlar yig'ildi. Endi qurishni boshlayman: avval mavjud tarjima - kalitlarini va flutter mavjudligini tekshiraman, keyin kodni yozaman. - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Endi kodni yozaman. Birinchi: umumiy tile vidjetlarini kengaytiraman - (settings_tiles.dart), keyin yangi sahifalar. - - Read 6 files, ran 2 shell commands (ctrl+o to expand) - ⎿  You've hit your session limit · resets 2am (Asia/Tashkent) - Continuing automatically at 2am · esc to cancel - /usage-credits to continue now - -⏺ Usage limit reached · continuing automatically at 2am · esc or type to - cancel - -✻ Cogitated for 14m 58s · done 10:06 PM - -⏺ Agent "Diagnose broken provider search" failed: Agent terminated early due to -an API error: You've hit your session limit · resets 2am (Asia/Tashkent) (error -type rate_limit, HTTP 429, request id req_011CeeyiACqm7ZyWrz13pivF, model sent -to the API: claude-fable-5-1) - ⎿  You've hit your session limit · resets 2am (Asia/Tashkent) - Continuing automatically at 2am · esc to cancel - /usage-credits to continue now - -✻ Baked for 15m 16s · done 10:06 PM - -❯ /export - ⎿ Conversation copied to clipboard - -❯ /export - ⎿ Export cancelled - -❯ /teleport - ⎿ Teleport cancelled - -❯ /wizard - -⏺ Usage limit reached again after you continued · continuing - automatically at 2am · the automatic-continue setting no longer ends - this wait (esc or /rate-limit-options still can) - ⎿  You've hit your session limit · resets 2am (Asia/Tashkent) - Continuing automatically at 2am · esc to cancel - /usage-credits to continue now - -✻ Worked for 1s · done 10:19 PM \ No newline at end of file diff --git a/2026-09-22-124652-this-session-is-being-continued-from-a-previous-c.txt b/2026-09-22-124652-this-session-is-being-continued-from-a-previous-c.txt deleted file mode 100644 index 52c90819..00000000 --- a/2026-09-22-124652-this-session-is-being-continued-from-a-previous-c.txt +++ /dev/null @@ -1,13435 +0,0 @@ - ▐▛███▛█ Claude Code v2.1.278 -▝▜██████▀ Opus 5 · Claude Max - ▝▝ ▝▝ ~/Sozo-Full/sozo - - -⏺ 2-band — natijalar tepasida "nima ko'rsatilyapti" degan qator: - - Searched for 4 patterns, read 1 file, ran 10 shell commands (ctrl+o to expand) - ⎿  Referenced file - lib/features/home/presentation/widgets/view_all_widgets.dart - ⎿  Read lib/features/extensions/domain/entities/mangayomi_source.dart (222 - lines) - ⎿  Referenced file lib/features/search/data/source_health_store.dart - ⎿  Read lib/features/detail/data/models/episode_model.dart (46 lines) - ⎿  Referenced file - android/app/src/main/kotlin/com/soplay/sozo/aniyomi/AniyomiHost.kt - ⎿  Skills restored (run) - -⏺ Bash(python3 - <<'PY' - import io,re…) - ⎿  ok - ⎿  Updated lib/features/search/presentation/blocs/search_state.dart (+38 - -0) - 111 bool get isBusy => - 112 status == SearchStatus.loading || status == SearchStatus - .refreshing; - 113 - 114 + /// The selected genre in words, or an empty string when non - +e is selected. - 115 + /// - 116 + /// [SearchCriteria.genre] holds the source's *slug* — `acti - +on-adventure`, or - 117 + /// a bare TMDB id like `10759` — because that is what a bro - +wse request takes. - 118 + /// Every screen that needed to name the active filter reach - +ed for that slug - 119 + /// and printed it, so a genre that found nothing said "No r - +esults for - 120 + /// 10759". The names are already in [genres]; nothing was l - +ooking them up. - 121 + /// - 122 + /// A slug with no entry is humanised rather than dropped: t - +he genre list can - 123 + /// arrive after the browse (or fail outright), and a filter - + that names - 124 + /// itself "Action adventure" is still better than one that - +names itself - 125 + /// nothing at all. - 126 + String get genreName { - 127 + final slug = criteria.genre; - 128 + if (slug.isEmpty) return ''; - 129 + for (final g in genres) { - 130 + if (g.slug != slug) continue; - 131 + return g.name.isNotEmpty ? g.name : _humanise(slug); - 132 + } - 133 + return _humanise(slug); - 134 + } - 135 + - 136 + /// What the user asked for, in words. Never a slug — see [g - +enreName]. - 137 + String get criteriaLabel => - 138 + criteria.text.isNotEmpty ? criteria.text : genreName; - 139 + - 140 + static String _humanise(String slug) { - 141 + final words = slug - 142 + .replaceAll(RegExp(r'[-_+]+'), ' ') - 143 + .split(' ') - 144 + .where((w) => w.isNotEmpty) - 145 + .toList(); - 146 + if (words.isEmpty) return slug; - 147 + return words - 148 + .map((w) => w[0].toUpperCase() + w.substring(1)) - 149 + .join(' '); - 150 + } - … 4 more lines (ctrl+o to expand) - -⏺ Now the strip itself and the wiring in search_state_views.dart: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - ⎿  Updated - lib/features/search/presentation/widgets/search_state_views.dart (+40 - -2) - 133 hasScrollBody: false, - 134 child: _SearchEmptyView( - 135 criteria: state.criteria, - 136 + label: state.criteriaLabel, - 137 suggestions: state.suggestions, - 138 onSuggestion: onSuggestion, - 139 onTryAllSources: onTryAllSources, - 140 onSearchTorrents: onSearchTorrents, - 141 + onClearGenre: () => onGenre(''), - 142 ), - 143 ), - 144 ]; - ... - 167 if (state.weakResults) - 168 SliverToBoxAdapter( - 169 child: _WeakResultsBanner( - 168 - query: state.criteria.label, - 170 + query: state.criteriaLabel, - 171 suggestions: state.suggestions, - 172 onSuggestion: onSuggestion, - 173 onTryAllSources: onTryAllSources, - 174 onSearchTorrents: onSearchTorrents, - 175 ), - 176 ), - 177 + // What the grid below is, said once: how many rows - +answered, and - 178 + // which filter produced them. A genre browse used t - +o be unlabelled - 179 + // and unescapable — the chip in the header lit up, - +the grid changed, - 180 + // and the only way back was to guess that the filte - +r sheet had a - 181 + // Clear button in it. - 182 + SliverToBoxAdapter( - 183 + child: _SearchCriteriaStrip( - 184 + count: state.items.length, - 185 + hasMore: state.hasMore, - 186 + genre: state.genreName, - 187 + onClearGenre: () => onGenre(''), - 188 + ), - 189 + ), - 190 SearchResultsGrid(items: state.items), - 191 if (state.isLoadingMore) - 192 SliverToBoxAdapter( - ... - 369 class _SearchEmptyView extends StatelessWidget { - … 52 more lines (ctrl+o to expand) - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(cat >> lib/features/search/presentation/widgets/search_state_views.dart - <<'DART'…) - ⎿  Updated - lib/features/search/presentation/widgets/search_state_views.dart (+131 - -0) - 719 return GestureDetector(onTap: onTap, child: content); - 720 } - 721 } - 722 + - 723 +/// One line between the header and the grid: how many rows th - +ere are, and the - 724 +/// filter that produced them. - 725 +/// - 726 +/// Both halves earn their place. The count is the only acknow - +ledgement the - 727 +/// search gets — the grid just appears, and on a source that - +returns four rows - 728 +/// for a popular title the honest reading is "this source has - + four", not "the - 729 +/// search is still going". The pill is the way back: a genre - +browse changes the - 730 +/// whole screen from a control in a modal sheet, and until no - +w nothing on the - 731 +/// screen it changed said which genre, or offered to undo it. - 732 +class _SearchCriteriaStrip extends StatelessWidget { - 733 + const _SearchCriteriaStrip({ - 734 + required this.count, - 735 + required this.hasMore, - 736 + required this.genre, - 737 + required this.onClearGenre, - 738 + }); - 739 + - 740 + final int count; - 741 + - 742 + /// More pages exist, so [count] is what has arrived rather - +than what there - 743 + /// is. Worth the separate phrasing: "12 results" under a li - +st that grows to - 744 + /// 200 as you scroll is a number that was never true. - 745 + final bool hasMore; - 746 + - 747 + /// The active genre, in words. Empty when the search is a p - +lain text search. - 748 + final String genre; - 749 + - 750 + final VoidCallback onClearGenre; - 751 + - 752 + @override - 753 + Widget build(BuildContext context) { - 754 + final label = hasMore - 755 + ? 'search.results_so_far'.tr(args: ['$count']) - 756 + : 'search.results_n'.tr(args: ['$count']); - 757 + - 758 + return Padding( - … 94 more lines (ctrl+o to expand) - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - ⎿  Updated - lib/features/search/presentation/widgets/search_state_views.dart (+8 - -1) - 1 import 'package:easy_localization/easy_localization.dart'; - 2 import 'package:flutter/material.dart'; - 3 import 'package:go_router/go_router.dart'; - 4 +import 'package:soplay/core/system/responsive.dart'; - 5 import 'package:soplay/core/theme/app_colors.dart'; - 6 import 'package:soplay/core/widgets/item_appear.dart'; - 7 import 'package:soplay/core/tv/tv.dart'; - ... - 847 return Semantics( - 848 button: true, - 849 label: 'search.clear_genre'.tr(), - 849 - child: HoverTap(onTap: onClear, borderRadius: 999, child - -: content), - 850 + child: HoverTap( - 851 + onTap: onClear, - 852 + borderRadius: 999, - 853 + scale: 1.03, - 854 + haptic: true, - 855 + child: content, - 856 + ), - 857 ); - 858 } - 859 } - -⏺ Now the two new l10n keys across all 12 locales: - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/keys_criteria.json <<'JSON' - {…) - ⎿  added 2 keys x 12 locales - ⎿  Updated assets/translations/ar.json (+2 -0) - 632 "type": "النوع", - 633 "apply": "تطبيق", - 634 "clear_filter": "مسح", - 635 + "clear_genre": "إزالة تصفية النوع", - 636 "sources": "المصادر", - 637 "sources_n": "{} مصادر", - 638 "all_sources": "تحديد الكل", - ... - 650 "type_to_search_n": "اكتب للبحث في {} مصادر دفعة واحدة.", - 651 "found_in": "وُجدت في {} من أصل {} مصادر", - 652 "results_n": "{} نتيجة", - 653 + "results_so_far": "{} نتيجة حتى الآن", - 654 "waiting_for": "جارٍ البحث في {}…", - 655 "no_results_any": "لا توجد نتائج في أي مصدر محدَّد.", - 656 "offline_note": "غير متّصل — يمكن البحث في مصادر الجهاز فقط - .", - ⎿  Updated assets/translations/de.json (+2 -0) - 632 "type": "Typ", - 633 "apply": "Anwenden", - 634 "clear_filter": "Löschen", - 635 + "clear_genre": "Genre-Filter entfernen", - 636 "sources": "Quellen", - 637 "sources_n": "{} Quellen", - 638 "all_sources": "Alle auswählen", - ... - 650 "type_to_search_n": "Tippe, um {} Quellen gleichzeitig zu - durchsuchen.", - 651 "found_in": "In {} von {} Quellen gefunden", - 652 "results_n": "{} Ergebnisse", - 653 + "results_so_far": "bisher {} Ergebnisse", - 654 "waiting_for": "{} wird durchsucht …", - 655 "no_results_any": "Keine Ergebnisse in einer der gewählten - Quellen.", - 656 "offline_note": "Offline — es lassen sich nur Quellen auf - dem Gerät durchsuchen.", - ⎿  Updated assets/translations/en.json (+2 -0) - 632 "type": "Type", - 633 "apply": "Apply", - 634 "clear_filter": "Clear", - 635 + "clear_genre": "Clear genre filter", - 636 "sources": "Sources", - 637 "sources_n": "{} sources", - 638 "all_sources": "Select all", - ... - 650 "type_to_search_n": "Type to search {} sources at once.", - 651 "found_in": "Found in {} of {} sources", - 652 "results_n": "{} results", - 653 + "results_so_far": "{} results so far", - 654 "waiting_for": "Searching {}…", - 655 "no_results_any": "No results in any selected source.", - 656 "offline_note": "Offline — only on-device sources can be s - earched.", - ⎿  Updated assets/translations/es.json (+2 -0) - 632 "type": "Tipo", - 633 "apply": "Aplicar", - 634 "clear_filter": "Borrar", - 635 + "clear_genre": "Quitar el filtro de género", - 636 "sources": "Fuentes", - 637 "sources_n": "{} fuentes", - 638 "all_sources": "Seleccionar todo", - ... - 650 "type_to_search_n": "Escribe para buscar en {} fuentes a l - a vez.", - 651 "found_in": "Encontrado en {} de {} fuentes", - 652 "results_n": "{} resultados", - 653 + "results_so_far": "{} resultados hasta ahora", - 654 "waiting_for": "Buscando en {}…", - 655 "no_results_any": "Sin resultados en ninguna de las fuente - s elegidas.", - 656 "offline_note": "Sin conexión: solo se pueden buscar las f - uentes del dispositivo.", - ⎿  Updated assets/translations/fr.json (+2 -0) - 632 "type": "Type", - 633 "apply": "Appliquer", - 634 "clear_filter": "Effacer", - 635 + "clear_genre": "Retirer le filtre de genre", - 636 "sources": "Sources", - 637 "sources_n": "{} sources", - 638 "all_sources": "Tout sélectionner", - ... - 650 "type_to_search_n": "Tape pour chercher dans {} sources à - la fois.", - 651 "found_in": "Trouvé dans {} sources sur {}", - 652 "results_n": "{} résultats", - 653 + "results_so_far": "{} résultats pour l'instant", - 654 "waiting_for": "Recherche dans {}…", - 655 "no_results_any": "Aucun résultat dans les sources sélecti - onnées.", - 656 "offline_note": "Hors ligne — seules les sources de l’appa - reil peuvent être interrogées.", - ⎿  … 7 more files changed - - Searched for 4 patterns, ran 5 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+33 -30) - 70 SliverPadding( - 71 padding: const EdgeInsets.symmetric(horizontal: 16), - 72 sliver: SliverGrid( - 73 - // One more column than the posters use, and a shorte - -r tile. - 74 - // - 75 - // At two columns and 1.85 each genre was a landscape - - card the size of - 76 - // a small poster, and a source with forty-one genres - - filled several - 77 - // screens with them — a browsing aid taking more roo - -m than the thing - 78 - // it helps you browse. Three across at 1.5 keeps the - - artwork legible - 79 - // while the whole set fits in a screen and a half. - 80 - gridDelegate: SliverGridDelegateWithFixedCrossAxisCou - -nt( - 81 - crossAxisCount: columns + 1, - 82 - mainAxisSpacing: 8, - 83 - crossAxisSpacing: 8, - 84 - childAspectRatio: 1.5, - 85 - ), - 73 + gridDelegate: genreGridDelegate(columns), - 74 delegate: SliverChildBuilderDelegate((context, i) { - 75 final g = genres[i]; - 76 return ItemAppear( - ... - 94 ), - 95 ), - 96 ] else if (genresLoading) - 97 + // The same heading and the same grid, greyed out. - 98 + // - 99 + // It used to be two columns at 1.85 with ten points of - +spacing and no - 100 + // heading, against three columns at 1.5 with eight and - +a heading — so - 101 + // the moment the genres landed the section changed colu - +mn count, tile - 102 + // shape and height at once, and everything above it tha - +t the reader had - 103 + // started on moved. A skeleton is only worth having if - +what replaces it - 104 + // lands in the same place. - 105 SliverToBoxAdapter( - 106 child: Padding( - 111 - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - 107 + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), - 108 child: ShimmerWrapper( - 113 - child: GridView.count( - 114 - shrinkWrap: true, - 115 - physics: const NeverScrollableScrollPhysics(), - 116 - padding: EdgeInsets.zero, - … 39 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+3 -2) - 102 // shape and height at once, and everything above it tha - t the reader had - 103 // started on moved. A skeleton is only worth having if - what replaces it - 104 // lands in the same place. - 105 + ...[ - 106 SliverToBoxAdapter( - 107 child: Padding( - 108 padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), - ... - 117 ), - 118 ), - 119 ), - 119 - if (genres.isEmpty && genresLoading) - 120 SliverPadding( - 121 padding: const EdgeInsets.symmetric(horizontal: 16), - 122 sliver: SliverGrid( - ... - 132 childCount: (columns + 1) * 3, - 133 ), - 134 ), - 135 - ) - 135 + ), - 136 + ] - 137 else if (genresFailed) - 138 // The state that had nowhere to go. `genresFailed` was - computed and put - 139 // on the state and then read by nothing at all, so a fa - iled genre call - -⏺ Now the shared delegate, the title-height helper, and the rail skeleton - geometry: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - xml 6.6.1 (7.0.1 available) - yaml 3.1.3 (3.1.4 available) - … +7 lines (ctrl+o to expand) - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+80 -29) - 148 ]; - 149 } - 150 - 151 +/// The genre grid's shape, used by the grid and by the skelet - +on that stands in - 152 +/// for it. - 153 +/// - 154 +/// One more column than the posters use, and a shorter tile. - +At two columns and - 155 +/// 1.85 each genre was a landscape card the size of a small p - +oster, and a - 156 +/// source with forty-one genres filled several screens with t - +hem — a browsing - 157 +/// aid taking more room than the thing it helps you browse. T - +hree across at 1.5 - 158 +/// keeps the artwork legible while the whole set fits in a sc - +reen and a half. - 159 +/// - 160 +/// Shared rather than written twice because it was written tw - +ice, with - 161 +/// different numbers, and the difference was a layout jump. - 162 +SliverGridDelegate genreGridDelegate(int columns) => - 163 + SliverGridDelegateWithFixedCrossAxisCount( - 164 + crossAxisCount: columns + 1, - 165 + mainAxisSpacing: 8, - 166 + crossAxisSpacing: 8, - 167 + childAspectRatio: 1.5, - 168 + ); - 169 + - 170 +/// The height one [_SectionTitle] occupies, so a skeleton sta - +nding in for a - 171 +/// heading reserves exactly the line the heading will take — - +at whatever text - 172 +/// size the reader has chosen, which is the part a hard-coded - + number misses. - 173 +double _sectionTitleHeight(BuildContext context) => - 174 + MediaQuery.textScalerOf(context).scale(18) * 1.15; - 175 + - 176 class _RecentSearches extends StatelessWidget { - 177 const _RecentSearches({ - 178 required this.recent, - ... - 248 - 249 @override - 250 Widget build(BuildContext context) { - 251 + // Every number here is the loaded rail's number, not an a - +pproximation of - 252 + // it: same top padding, same heading line, same gap, same - + card width, same - 253 + // card height, same trailing gap. A skeleton that is clos - +e but not equal is - 254 + // a layout jump with a shimmer in front of it — 112-wide - +cards 168 tall - 255 + // under a 13-point line stood in for 118-wide cards whose - + height is - 256 + // computed from the caption, and the genre grid below mov - +ed by the - … 102 more lines (ctrl+o to expand) - - Searched for 4 patterns, ran 4 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 401: SkeletonLine(width: double.infinity), - 404: SkeletonLine(width: 46), - 413: child: SkeletonLine(width: 24), - ⎿  Updated - lib/features/home/presentation/widgets/home_shared_widgets.dart (+18 - -0) - 200 } - 201 } - 202 - 203 +/// One line of caption in a skeleton, at the weight a line of - + small text - 204 +/// actually reads as. - 205 +/// - 206 +/// A 10px bar against an 11px line looked like a heading; 8 s - +its where the - 207 +/// x-height of the real text does. Shared, because every grid - + of cards in the - 208 +/// app has a caption under it and each of them was drawing th - +ese bars at its - 209 +/// own height — so two skeletons for two grids of the same ca - +rd read as two - 210 +/// different kinds of loading. - 211 +class SkeletonLine extends StatelessWidget { - 212 + const SkeletonLine({super.key, required this.width}); - 213 + - 214 + final double width; - 215 + - 216 + @override - 217 + Widget build(BuildContext context) => - 218 + HomeSkeletonBox(width: width, height: 8, radius: 2); - 219 +} - 220 + - 221 class HomeSkeletonBox extends StatelessWidget { - 222 const HomeSkeletonBox({ - 223 super.key, - ⎿  Updated lib/features/home/presentation/widgets/view_all_widgets.dart - (+3 -17) - 398 child: Column( - 399 crossAxisAlignment: CrossAxisAlignment.start, - 400 children: [ - 401 - _SkeletonLine(width: double.infinity), - 401 + SkeletonLine(width: double.infinity), - 402 SizedBox(height: 4), - 403 // Short, because a title that wraps rarely fill - s its second line. - 404 - _SkeletonLine(width: 46), - 404 + SkeletonLine(width: 46), - 405 ], - 406 ), - 407 ), - ... - 410 lineHeight: 1.3, - 411 child: Align( - 412 alignment: AlignmentDirectional.centerStart, - 413 - child: _SkeletonLine(width: 24), - 413 + child: SkeletonLine(width: 24), - 414 ), - 415 ), - 416 ], - ... - 418 } - 419 } - 420 - 421 - - 422 -/// One line of caption, at the weight a line of small text ac - -tually reads as. - 423 -/// A 10px bar against an 11px line looked like a heading; 8 s - -its where the - 424 -/// x-height of the real text does. - 425 -class _SkeletonLine extends StatelessWidget { - 426 - const _SkeletonLine({required this.width}); - 427 - - 428 - final double width; - 429 - - 430 - @override - 431 - Widget build(BuildContext context) => - 432 - HomeSkeletonBox(width: width, height: 8, radius: 2); - 433 -} - 434 - - 421 class ViewAllErrorView extends StatelessWidget { - 422 const ViewAllErrorView({super.key, required this.message, re - quired this.onRetry}); - 423 - -⏺ Now the card-shaped skeleton and a single shimmer across the grid: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - xml 6.6.1 (7.0.1 available) - yaml 3.1.3 (3.1.4 available) - … +7 lines (ctrl+o to expand) - ⎿  Updated - lib/features/search/presentation/widgets/search_result_card.dart (+56 - -0) - 69 double searchCardHeight(double tileWidth, [BuildContext? conte - xt]) => - 70 tileWidth / _posterRatio + _captionHeight(context); - 71 - 72 +/// [SearchResultCard]'s shape, greyed out. - 73 +/// - 74 +/// The old skeleton was one rectangle filling the whole grid - +cell, caption - 75 +/// space included — a solid slab where the card has a 2:3 pos - +ter and three - 76 +/// lines of text. So the wait looked nothing like the result: - + the poster - 77 +/// appeared to shrink as the real cards landed, and the capti - +on area went from - 78 +/// filled to empty, which is a change of shape on every tile - +at once. - 79 +/// - 80 +/// The caption is built out of the same [FixedTextLines] boxe - +s and the same - 81 +/// numbers as the card's own — 12pt at 1.15 over two lines, t - +hen 10.5 at 1.2 — - 82 +/// so it reserves what the card reserves at any text size. - 83 +class SearchCardSkeleton extends StatelessWidget { - 84 + const SearchCardSkeleton({super.key}); - 85 + - 86 + @override - 87 + Widget build(BuildContext context) { - 88 + return const Column( - 89 + crossAxisAlignment: CrossAxisAlignment.start, - 90 + children: [ - 91 + Expanded( - 92 + child: HomeSkeletonBox( - 93 + width: double.infinity, - 94 + height: double.infinity, - 95 + radius: 10, - 96 + ), - 97 + ), - 98 + SizedBox(height: 6), - 99 + FixedTextLines( - 100 + fontSize: 12, - 101 + lineHeight: 1.15, - 102 + lines: 2, - 103 + child: Column( - 104 + crossAxisAlignment: CrossAxisAlignment.start, - 105 + children: [ - 106 + SkeletonLine(width: double.infinity), - 107 + SizedBox(height: 5), - 108 + // Short: a title that wraps rarely fills its se - +cond line, and a - … 22 more lines (ctrl+o to expand) - ⎿  Updated - lib/features/search/presentation/widgets/search_state_views.dart (+22 - -10) - 348 Widget build(BuildContext context) { - 349 final width = MediaQuery.sizeOf(context).width; - 350 final columns = searchGridColumns(width); - 351 + // One shimmer over the whole grid, not one per tile. - 352 + // - 353 + // Twenty-one [ShimmerWrapper]s meant twenty-one Animation - +Controllers and - 354 + // twenty-one shader layers, each sweeping its own tile on - + its own clock — - 355 + // so the screen read as a field of separately blinking re - +ctangles rather - 356 + // than as one surface with a highlight crossing it. A sin - +gle wrapper is one - 357 + // controller, one sweep, and it travels the way a sweep i - +s supposed to: - 358 + // across the grid. - 359 + // - 360 + // The grid is shrink-wrapped, which for real content woul - +d be wrong — it - 361 + // lays every child out at once — but there are three rows - + of it, they hold - 362 + // no images and they run no entrance animations, and layi - +ng them out is the - 363 + // price of the single sweep. - 364 return SliverPadding( - 365 padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), - 353 - sliver: SliverGrid( - 354 - delegate: SliverChildBuilderDelegate( - 355 - (context, i) => const ShimmerWrapper( - 356 - child: HomeSkeletonBox( - 357 - width: double.infinity, - 358 - height: double.infinity, - 359 - radius: 10, - 360 - ), - 366 + sliver: SliverToBoxAdapter( - 367 + child: ShimmerWrapper( - 368 + child: GridView.builder( - 369 + shrinkWrap: true, - 370 + physics: const NeverScrollableScrollPhysics(), - 371 + padding: EdgeInsets.zero, - 372 + gridDelegate: searchGridDelegate(context), - 373 + itemCount: columns * 3, - 374 + itemBuilder: (_, _) => const SearchCardSkeleton(), - 375 ), - 362 - childCount: columns * 3, - 376 ), - 364 - gridDelegate: searchGridDelegate(context), - 377 ), - … 2 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) ⎿  ok - 1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+31 -27) - 102 // shape and height at once, and everything above it tha - t the reader had - 103 // started on moved. A skeleton is only worth having if - what replaces it - 104 // lands in the same place. - 105 - ...[ - 105 + // One sweep across the heading and the tiles together, - +rather than one - 106 + // per tile on its own clock — see [SearchCardSkeleton]' - +s grid. - 107 SliverToBoxAdapter( - 107 - child: Padding( - 108 - padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), - 109 - child: ShimmerWrapper( - 110 - child: SizedBox( - 111 - height: _sectionTitleHeight(context), - 112 - child: const Align( - 113 - alignment: AlignmentDirectional.centerStart, - 114 - child: HomeSkeletonBox(width: 96, height: 14, - -radius: 4), - 108 + child: ShimmerWrapper( - 109 + child: Column( - 110 + crossAxisAlignment: CrossAxisAlignment.start, - 111 + children: [ - 112 + Padding( - 113 + padding: const EdgeInsets.fromLTRB(16, 20, 16, - + 12), - 114 + child: SizedBox( - 115 + height: _sectionTitleHeight(context), - 116 + child: const Align( - 117 + alignment: AlignmentDirectional.centerStar - +t, - 118 + child: HomeSkeletonBox(width: 96, height: - +14, radius: 4), - 119 + ), - 120 + ), - 121 ), - 116 - ), - 117 - ), - 118 - ), - 119 - ), - 120 - SliverPadding( - 121 - padding: const EdgeInsets.symmetric(horizontal: 16), - 122 - sliver: SliverGrid( - 123 - gridDelegate: genreGridDelegate(columns), - 124 - delegate: SliverChildBuilderDelegate( - 125 - (context, i) => const ShimmerWrapper( - 126 - child: HomeSkeletonBox( - … 29 more lines (ctrl+o to expand) - -⏺ Now the widget tests the plan calls for — the filter sheet at 41 vs 3 genres, - and the new criteria strip: - - Listed 1 directory, ran 4 shell commands (ctrl+o to expand) - -⏺ Bash(cat > test/features/search/search_criteria_and_filter_test.dart <<'DART' - // Two things the search tab could not say: which filter is on, and — on - a…) - ⎿   vector_graphics 1.2.2 (1.2.3 available) - vector_graphics_compiler 1.2.3 (1.3.0 available) - vector_math 2.2.0 (2.4.3 available) - … +37 lines (ctrl+o to expand) - ⎿  Created test/features/search/search_criteria_and_filter_test.dart - (+310 -0) - 1 +// Two things the search tab could not say: which filter is on - +, and — on a - 2 +// source with forty-one genres — where the button that applie - +s one went. - 3 +// - 4 +// The filter sheet put its Apply and Clear row inside the sam - +e scroll view as - 5 +// the chips, so past about twenty genres the commit row sat b - +elow the fold of a - 6 +// sheet that gave no sign it scrolled. And once a genre WAS a - +pplied, nothing on - 7 +// the screen it changed named it: the state carries the sourc - +e's slug, because - 8 +// that is what a browse request takes, and every screen that - +needed to name the - 9 +// filter printed the slug — "No results for 10759". - 10 +import 'dart:convert'; - 11 +import 'dart:io'; - 12 + - 13 +import 'package:easy_localization/easy_localization.dart'; - 14 +import 'package:flutter/material.dart'; - 15 +import 'package:flutter_test/flutter_test.dart'; - 16 +import 'package:shared_preferences/shared_preferences.dart'; - 17 +import 'package:soplay/features/home/domain/entities/movie.dar - +t'; - 18 +import 'package:soplay/features/search/domain/entities/genre_e - +ntity.dart'; - 19 +import 'package:soplay/features/search/presentation/blocs/sear - +ch_bloc.dart'; - 20 +import 'package:soplay/features/search/presentation/widgets/se - +arch_filter_sheet.dart'; - 21 +import 'package:soplay/features/search/presentation/widgets/se - +arch_state_views.dart'; - 22 + - 23 +/// The real en.json, so the copy under test is the copy that - +ships. - 24 +class _Translations extends AssetLoader { - 25 + const _Translations(); - 26 + @override - 27 + Future> load(String path, Locale locale - +) async => - 28 + jsonDecode(File('assets/translations/en.json').readAsStr - +ingSync()) - 29 + as Map; - 30 +} - 31 + - 32 +List _genres(int n) => [ - 33 + for (var i = 0; i < n; i++) - 34 + GenreEntity( - 35 + provider: 'p', - 36 + slug: 'genre-$i', - 37 + url: '', - 38 + image: '', - 39 + name: 'Genre $i', - 40 + ), - … 270 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   url_launcher_android 6.3.30 (6.3.33 available) - url_launcher_ios 6.4.1 (6.4.2 available) - url_launcher_linux 3.2.2 (3.2.3 available) - … +37 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_criteria_and_filter_test.dart (+8 - -0) - 41 ]; - 42 - 43 MovieEntity _movie(int i) => MovieEntity( - 44 + externalId: '$i', - 45 title: 'Title $i', - 46 + description: '', - 47 + slug: 'title-$i', - 48 url: 'https://example.test/$i', - 49 + provider: 'p', - 50 thumbnail: '', - 51 + year: '2024', - 52 + rating: null, - 53 + qualities: const [], - 54 + category: '', - 55 ); - 56 - 57 Widget _app(Widget child) => EasyLocalization( - -⏺ Bash(sed -i '' "s/ year: '2024',/ year: 2024,/" - test/features/search/search_criteria_and_filter_test.dart && export - PATH="/Users/azamov/dev/flutter/bin:/opt/homebr…) - ⎿  ══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═══════════════════════════ - ══════════════════════════════ - The following assertion was thrown during layout: - … +42 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_criteria_and_filter_test.dart (+1 - -1) - 48 url: 'https://example.test/$i', - 49 provider: 'p', - 50 thumbnail: '', - 51 - year: '2024', - 51 + year: 2024, - 52 rating: null, - 53 qualities: const [], - 54 category: '', - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - [🌎 Easy Localization] [DEBUG] Build - [🌎 Easy Localization] [DEBUG] Init Localization Delegate - … +28 lines (ctrl+o to expand) - ⎿  Updated - lib/features/search/presentation/widgets/search_state_views.dart (+16 - -6) - 707 children: [ - 708 Icon(icon, size: 16, color: AppColors.primary), - 709 const SizedBox(width: 8), - 710 - Text( - 711 - label, - 712 - style: TextStyle( - 713 - color: AppColors.primary, - 714 - fontSize: 14, - 715 - fontWeight: FontWeight.w600, - 710 + // Flexible, because the label is a translation. The - +se chips sit in a - 711 + // 32-point-inset column on a 320-point phone, which - + leaves a little - 712 + // over 200 for the text — and the app ships twelve - +languages, one of - 713 + // which routinely needs half again what English doe - +s. An overflow - 714 + // stripe across the only control on an empty screen - + is worse than a - 715 + // clipped word. - 716 + Flexible( - 717 + child: Text( - 718 + label, - 719 + maxLines: 1, - 720 + overflow: TextOverflow.ellipsis, - 721 + style: TextStyle( - 722 + color: AppColors.primary, - 723 + fontSize: 14, - 724 + fontWeight: FontWeight.w600, - 725 + ), - 726 ), - 727 ), - 728 ], - ⎿  Updated test/features/search/search_criteria_and_filter_test.dart (+3 - -0) - 116 ), - 117 ), - 118 ); - 119 + // EasyLocalization loads its bundle before it builds an - +ything, so the - 120 + // first frame is a placeholder and there is nothing to - +tap yet. - 121 + await tester.pumpAndSettle(); - 122 await tester.tap(find.text('open')); - 123 await tester.pumpAndSettle(); - 124 return applied; - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +1: the filter sheet keeps its commit row on screen and at three - 00:00 +2: the filter sheet keeps its commit row on screen typing narro - ws the chips - … +15 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_criteria_and_filter_test.dart (+4 - -1) - 151 await open(tester, count: 41); - 152 expect(find.byType(TextField), findsOneWidget); - 153 - 154 - await tester.enterText(find.byType(TextField), 'Genre 17 - -'); - 154 + // '17' rather than 'Genre 17': the field's own contents - + are text on the - 155 + // screen too, and matching the full label would find bo - +th. - 156 + await tester.enterText(find.byType(TextField), '17'); - 157 await tester.pumpAndSettle(); - 158 - 159 expect(find.text('Genre 17'), findsOneWidget); - 160 expect(find.text('Genre 18'), findsNothing); - 161 + expect(find.text('Genre 1'), findsNothing); - 162 expectCommitRowVisible(tester); - 163 }); - 164 - -⏺ All 11 pass. Now a test for the landing not jumping, then the full suite: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(cat > test/features/search/search_landing_no_jump_test.dart <<'DART' - // The search tab used to land twice.…) - ⎿  1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - nts. - … +32 lines (ctrl+o to expand) - ⎿  Created test/features/search/search_landing_no_jump_test.dart (+241 - -0) - 1 +// The search tab used to land twice. - 2 +// - 3 +// The first frame drew no source rail at all — `_SourceRail` - +returned an empty - 4 +// box while the home was in flight — so the genres started at - + the top of the - 5 +// screen. A second later the posters arrived and pushed every - +thing down by a - 6 +// quarter of a screen, under a thumb that was already reachin - +g for a genre. The - 7 +// genre skeleton did the same thing again on its own account: - + two columns at - 8 +// 1.85 with ten points of spacing standing in for three colum - +ns at 1.5 with - 9 +// eight, so the section changed column count, tile shape and - +height the moment - 10 +// the genres landed. - 11 +// - 12 +// Both are measured here rather than described: the skeleton' - +s height is - 13 +// compared against the height of the thing that replaces it. - 14 +import 'dart:async'; - 15 +import 'dart:convert'; - 16 +import 'dart:io'; - 17 + - 18 +import 'package:easy_localization/easy_localization.dart'; - 19 +import 'package:flutter/material.dart'; - 20 +import 'package:flutter_bloc/flutter_bloc.dart'; - 21 +import 'package:flutter_test/flutter_test.dart'; - 22 +import 'package:shared_preferences/shared_preferences.dart'; - 23 +import 'package:soplay/core/error/result.dart'; - 24 +import 'package:soplay/features/home/domain/entities/home_data - +_entity.dart'; - 25 +import 'package:soplay/features/home/domain/entities/home_sect - +ion_entity.dart'; - 26 +import 'package:soplay/features/home/domain/entities/movie.dar - +t'; - 27 +import 'package:soplay/features/home/domain/repositories/home_ - +repository.dart'; - 28 +import 'package:soplay/features/home/domain/usecase/home_useca - +se.dart'; - 29 +import 'package:soplay/features/home/presentation/bloc/home/ho - +me_bloc.dart'; - 30 +import 'package:soplay/features/home/presentation/bloc/home/ho - +me_event.dart'; - 31 +import 'package:soplay/features/search/domain/entities/genre_e - +ntity.dart'; - 32 +import 'package:soplay/features/search/presentation/widgets/se - +arch_landing.dart'; - 33 + - 34 +class _Translations extends AssetLoader { - 35 + const _Translations(); - 36 + @override - 37 + Future> load(String path, Locale locale - +) async => - 38 + jsonDecode(File('assets/translations/en.json').readAsStr - +ingSync()) - 39 + as Map; - 40 +} - … 201 more lines (ctrl+o to expand) - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:01 +0 -2: the genre skeleton is the genre grid, greyed out - ══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞══════════════════════ - ══════════════════════════════ - … +32 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+5 -0) - 24 import 'package:soplay/features/home/domain/entities/home_data_ - entity.dart'; - 25 import 'package:soplay/features/home/domain/entities/home_secti - on_entity.dart'; - 26 import 'package:soplay/features/home/domain/entities/movie.dart - '; - 27 +import 'package:soplay/features/home/domain/entities/view_all.d - +art'; - 28 import 'package:soplay/features/home/domain/repositories/home_r - epository.dart'; - 29 import 'package:soplay/features/home/domain/usecase/home_usecas - e.dart'; - 30 import 'package:soplay/features/home/presentation/bloc/home/hom - e_bloc.dart'; - ... - 74 return Success( - 75 HomeDataEntity( - 76 provider: 'p', - 77 + banner: const [], - 78 + categories: const [], - 79 + genres: const [], - 80 sections: [ - 81 HomeSectionEntity( - 82 key: 'trending', - 83 label: 'Trending now', - 84 + viewAll: ViewAllEntity(slug: 'trending', type: 'sec - +tion'), - 85 items: [for (var i = 0; i < 8; i++) _movie(i)], - 86 ), - 87 ], - - Searched for 3 patterns, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - ^ - test/features/search/search_landing_no_jump_test.dart:111:38: Error: - … +30 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+45 -5) - 30 import 'package:soplay/features/home/presentation/bloc/home/hom - e_bloc.dart'; - 31 import 'package:soplay/features/home/presentation/bloc/home/hom - e_event.dart'; - 32 import 'package:soplay/features/search/domain/entities/genre_en - tity.dart'; - 33 +import 'package:soplay/core/services/hive_service.dart'; - 34 +import 'package:soplay/features/profile/domain/usecases/get_pro - +viders_usecase.dart'; - 35 +import 'package:soplay/features/profile/presentation/bloc/provi - +der_bloc.dart'; - 36 import 'package:soplay/features/search/presentation/widgets/sea - rch_landing.dart'; - 37 +import 'package:soplay/features/sources/domain/provider_manager - +.dart'; - 38 +import 'package:soplay/features/sources/domain/provider_registr - +y.dart'; - 39 - 40 class _Translations extends AssetLoader { - 41 const _Translations(); - ... - 98 Future>> loadGenres() async => cons - t Success([]); - 99 } - 100 - 101 +/// The rail's heading names the source, which it looks up in - +the provider list. - 102 +/// None of that is what these tests are about, so the bloc is - + built and never - 103 +/// asked anything: with no `ProviderLoad` there is no name, a - +nd the heading - 104 +/// falls back to the app's own sentence. - 105 +class _Fake { - 106 + dynamic noSuchMethod(Invocation i) => null; - 107 +} - 108 + - 109 +class _UseCase extends _Fake implements GetProvidersUseCase {} - 110 + - 111 +class _Hive extends _Fake implements HiveService {} - 112 + - 113 +class _Manager extends _Fake implements ProviderManager {} - 114 + - 115 +class _Registry extends _Fake implements ProviderRegistry {} - 116 + - 117 void main() { - 118 TestWidgetsFlutterBinding.ensureInitialized(); - 119 - ... - 144 child: Builder( - 145 builder: (context) => MaterialApp( - 146 locale: context.locale, - 147 + // The skeletons shimmer on an infinite repeat, so - + `pumpAndSettle` - 148 + // can never settle while one is on screen. - 149 + builder: (inner, child) => MediaQuery( - … 49 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +1: and gives the space back when the home fails - [HomeBloc] home: fail: Exception: this source has no home - 00:00 +2: the genre skeleton is the genre grid, greyed out - … +27 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+3 -3) - 30 import 'package:soplay/features/home/presentation/bloc/home/hom - e_bloc.dart'; - 31 import 'package:soplay/features/home/presentation/bloc/home/hom - e_event.dart'; - 32 import 'package:soplay/features/search/domain/entities/genre_en - tity.dart'; - 33 -import 'package:soplay/core/services/hive_service.dart'; - 33 +import 'package:soplay/core/extractor/provider_manager.dart'; - 34 +import 'package:soplay/core/js/provider_registry.dart'; - 35 +import 'package:soplay/core/storage/hive_service.dart'; - 36 import 'package:soplay/features/profile/domain/usecases/get_pro - viders_usecase.dart'; - 37 import 'package:soplay/features/profile/presentation/bloc/provi - der_bloc.dart'; - 38 import 'package:soplay/features/search/presentation/widgets/sea - rch_landing.dart'; - 37 -import 'package:soplay/features/sources/domain/provider_manager - -.dart'; - 38 -import 'package:soplay/features/sources/domain/provider_registr - -y.dart'; - 39 - 40 class _Translations extends AssetLoader { - 41 const _Translations(); - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Resolving dependencies... - Downloading packages... - palette_generator 0.3.3+7 (discontinued) - … +17 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+22 -7) - 29 import 'package:soplay/features/home/domain/usecase/home_usecas - e.dart'; - 30 import 'package:soplay/features/home/presentation/bloc/home/hom - e_bloc.dart'; - 31 import 'package:soplay/features/home/presentation/bloc/home/hom - e_event.dart'; - 32 +import 'package:soplay/features/home/presentation/widgets/home_ - +shared_widgets.dart'; - 33 import 'package:soplay/features/search/domain/entities/genre_en - tity.dart'; - 34 +import 'package:soplay/features/search/presentation/widgets/gen - +re_tile.dart'; - 35 import 'package:soplay/core/extractor/provider_manager.dart'; - 36 import 'package:soplay/core/js/provider_registry.dart'; - 37 import 'package:soplay/core/storage/hive_service.dart'; - ... - 261 testWidgets('the genre skeleton is the genre grid, greyed ou - t', ( - 262 tester, - 263 ) async { - 262 - final home = _HeldHome(); - 263 - await pump(tester, home: home, genresLoading: true); - 264 - final loading = tester.getSize(find.byType(GridView)).heig - -ht; - 264 + // Both trees hold the same unreleased home, so the rail a - +bove the genres is - 265 + // the same height in each and the grid below it starts in - + the same place. - 266 + await pump(tester, home: _HeldHome(), genresLoading: true) - +; - 267 + final placeholder = tester.getRect( - 268 + find - 269 + .descendant( - 270 + of: find.byType(GridView), - 271 + matching: find.byType(HomeSkeletonBox), - 272 + ) - 273 + .first, - 274 + ); - 275 - 276 await pump( - 277 tester, - ... - 287 ), - 288 ], - 289 ); - 280 - final loaded = tester.getSize(find.byType(SliverGrid)).hei - -ght; - 290 + final real = tester.getRect(find.byType(GenreTile).first); - 291 - 282 - // Nine genres, three columns, three rows either way — so - -the section is - 283 - // the same height before and after, and nothing below it - -moves. - 284 - expect(loading, closeTo(loaded, 1.0)); - 292 + // Same size and same position: three columns at 1.5 eithe - +r way, under a - 293 + // heading that occupies the line the real heading occupie - +s. It used to be - … 8 more lines (ctrl+o to expand) - -⏺ Now the full suite: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - flutter analyze 2>&1 | grep -v "available)" | tail -6 && dart format - --set-exit-if-change…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - … +14 lines (ctrl+o to expand) - ⎿  Updated - lib/features/home/presentation/widgets/home_shared_widgets.dart (+3 - -2) - 101 // published at. A 500px poster in a 110px tile co - sts twenty times - 102 // the memory it needs, and the cost is paid per t - ile. - 103 memCacheWidth: (w != null && w > 0) ? w : null, - 104 - filterQuality: - 105 - isDesktopPlatform ? FilterQuality.medium : Fil - -terQuality.low, - 104 + filterQuality: isDesktopPlatform - 105 + ? FilterQuality.medium - 106 + : FilterQuality.low, - 107 // Loading and broken are different states and now - look it. Both - 108 // used to be the same grey icon, so a poster that - was never going - 109 // to arrive was indistinguishable from one still - on its way — the - ⎿  Updated lib/features/search/presentation/blocs/search_state.dart (+36 - -41) - 27 - 28 String get label => text.isNotEmpty ? text : genre; - 29 - 30 - SearchCriteria copyWith({String? text, String? genre}) => Sea - -rchCriteria( - 31 - text: text ?? this.text, - 32 - genre: genre ?? this.genre, - 33 - ); - 30 + SearchCriteria copyWith({String? text, String? genre}) => - 31 + SearchCriteria(text: text ?? this.text, genre: genre ?? t - +his.genre); - 32 - 33 @override - 34 List get props => [text, genre]; - ... - 142 .where((w) => w.isNotEmpty) - 143 .toList(); - 144 if (words.isEmpty) return slug; - 147 - return words - 148 - .map((w) => w[0].toUpperCase() + w.substring(1)) - 149 - .join(' '); - 145 + return words.map((w) => w[0].toUpperCase() + w.substring(1 - +)).join(' '); - 146 } - 147 - 148 SearchState copyWith({ - ... - 162 bool? weakResults, - 163 List? suggestions, - 164 bool clearError = false, - 169 - }) => - 170 - SearchState( - 171 - criteria: criteria ?? this.criteria, - 172 - status: status ?? this.status, - 173 - items: items ?? this.items, - 174 - page: page ?? this.page, - 175 - totalPages: totalPages ?? this.totalPages, - 176 - isLoadingMore: isLoadingMore ?? this.isLoadingMore, - 177 - failure: clearError ? null : (failure ?? this.failure) - -, - 178 - loadMoreFailure: clearLoadMoreFailure - 179 - ? null - 180 - : (loadMoreFailure ?? this.loadMoreFailure), - 181 - genres: genres ?? this.genres, - 182 - genresLoading: genresLoading ?? this.genresLoading, - 183 - genresFailed: genresFailed ?? this.genresFailed, - … 56 more lines (ctrl+o to expand) - ⎿  Updated - lib/features/search/presentation/widgets/search_state_views.dart (+5 - -15) - 122 return [ - 123 SliverFillRemaining( - 124 hasScrollBody: false, - 125 - child: _SearchErrorView( - 126 - failure: state.failure, - 127 - onRetry: onRetry, - 128 - ), - 125 + child: _SearchErrorView(failure: state.failure, on - +Retry: onRetry), - 126 ), - 127 ]; - 128 case SearchStatus.empty: - ... - 653 ), - 654 textAlign: TextAlign.center, - 655 ), - 659 - if (message.isNotEmpty && - 660 - kind != SourceFailureKind.unreachable) ...[ - 656 + if (message.isNotEmpty && kind != SourceFailureKind. - +unreachable) ...[ - 657 const SizedBox(height: 8), - 658 Text( - 659 message, - ... - 791 ), - 792 ), - 793 ), - 798 - if (genre.isNotEmpty) _GenreCriteriaPill( - 799 - genre: genre, - 800 - onClear: onClearGenre, - 801 - ), - 794 + if (genre.isNotEmpty) - 795 + _GenreCriteriaPill(genre: genre, onClear: onClearG - +enre), - 796 ], - 797 ), - 798 ); - ... - 854 ); - 855 - 856 if (isTvPlatform) { - 863 - return TvFocusable( - 864 - onPressed: onClear, - 865 - borderRadius: 999, - 866 - child: content, - 867 - ); - … 4 more lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_criteria_and_filter_test.dart (+7 - -2) - 211 - 212 test('and the text query wins when there is one', () { - 213 expect( - 214 - state.copyWith(criteria: const SearchCriteria(text: 'n - -aruto')).genreName, - 214 + state - 215 + .copyWith(criteria: const SearchCriteria(text: 'na - +ruto')) - 216 + .genreName, - 217 '', - 218 ); - 219 expect( - ... - 294 ), - 295 ); - 296 - 295 - expect(find.text('search.results_so_far'.tr(args: ['4']) - -), findsOneWidget); - 297 + expect( - 298 + find.text('search.results_so_far'.tr(args: ['4'])), - 299 + findsOneWidget, - 300 + ); - 301 // No filter is on, so there is no pill to remove. - 302 expect(find.byIcon(Icons.filter_alt_rounded), findsNothi - ng); - 303 }); - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+5 -1) - 225 // The posters are in, and the heading below them has not - moved. A point of - 226 // slack for the heading's own text metrics; the defect wa - s a quarter of a - 227 // screen. - 228 - expect(find.text('Trending now'), findsNothing, reason: 'a - -pp heading, not the rail label'); - 228 + expect( - 229 + find.text('Trending now'), - 230 + findsNothing, - 231 + reason: 'app heading, not the rail label', - 232 + ); - 233 expect(genresTop(tester), closeTo(before, 1.0)); - 234 }); - 235 - -❯ oxiri bitta androidda ishga tushurvor - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +5 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+1 -0) - 105 /// asked anything: with no `ProviderLoad` there is no name, a - nd the heading - 106 /// falls back to the app's own sentence. - 107 class _Fake { - 108 + @override - 109 dynamic noSuchMethod(Invocation i) => null; - 110 } - 111 - - Pushed to work/2026-09-14, ran 2 shell commands (ctrl+o to expand) - -⏺ Now running it on the emulator: - - Ran 2 shell commands (ctrl+o to expand) -⏺ Background command "Launch the app on the emulator" completed (exit code 0) - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo debug build on emulator-5554) - ⎿  Monitor started · task byk5h29h0 · timeout 600s - - Searched for 3 patterns, listed 2 directories (ctrl+o to expand) - -⏺ Monitor event: "Sozo debug build on emulator-5554" - -⏺ The app is up. Let me look at it: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo debug build on emulator-5554" - -⏺ Monitor event: "Sozo debug build on emulator-5554" - - Read 1 file, ran 3 shell commands (ctrl+o to expand) - -⏺ Monitor event: "Sozo debug build on emulator-5554" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Emulatorda ishga tushdi — lekin app lock PIN so'rayapti, PIN menda yo'q, - data'ni tozalamayman. PIN'ni kiriting, keyin Search tabining screenshot'ini - olaman. - - Shu turnda qilinganlar (3aeb168, work/2026-09-14 ga push qilindi): - - 1. Qidiruv nima ko'rsatayotganini aytadi - - Holat genre'ning slug'ini saqlardi (action-adventure, yoki TMDB'da 10759), - va uni nomlash kerak bo'lgan har bir ekran o'sha slug'ni chop etardi — "No - results for 10759". genreName / criteriaLabel nomni genres ro'yxatidan - topadi; ro'yxat hali kelmagan bo'lsa slug'ni o'qiladigan holga keltiradi. - - Grid ustida yangi qator: nechta natija bor va qaysi filtr ularni chiqargani, - yonida ×. Ilgari genre browse butun ekranni modal sheet ichidagi tugmadan - o'zgartirardi va o'zgargan ekranda na qaysi genre ekani, na uni bekor qilish - yo'li bor edi. - - Natijasiz genre browse butunlay tupik edi — bo'sh ekrandagi barcha takliflar - matnli qidiruv uchun. Endi "Clear genre filter" bor. - - 2. Landing ikki marta "tushishi" to'xtatildi - - Source rail home yuklanayotganda umuman chizilmasdi → bir soniyadan keyin - posterlar chiqib, janrlarni chorak ekran pastga surardi. Endi skeleton - yuklangan rail bilan bir xil geometriyada turadi (caption'dan hisoblangan - card balandligigacha). - - Home xato bersa, joy bo'shaydi — ilgari buni ko'rib bo'lmasdi: a is - HomeLoaded != b is HomeLoaded HomeLoading→HomeError uchun false, ya'ni - skeleton sessiya oxirigacha shimmer qilib turardi. - - Genre skeleton 2 ustun / 1.85 edi, haqiqiysi 3 ustun / 1.5 — endi bitta - umumiy genreGridDelegate. - - 3. Skeletonlar sifati - - Bitta gridda 21 ta ShimmerWrapper = 21 ta AnimationController, har biri o'z - soatida — ekran alohida-alohida o'chib-yonadigan to'rtburchaklar maydoniga - o'xshardi. Endi grid'ga bitta sweep. - - Plitalar endi SearchResultCard shaklida (poster + 2 sarlavha qatori + - subtitle), ilgari caption joyini ham to'ldirgan bitta plita edi. - - _ActionChip tor ekranda overflow berardi — 12 ta til uchun Flexible + - ellipsis. - - Tekshiruv: flutter analyze — toza; flutter test — 1525 ta o'tdi (14 tasi - yangi: 41 va 3 janrda filter sheet, tanlangan chip narrow qilinganda ham - birinchi turishi, slug→nom, criteria strip, landing geometriyasi). - -✻ Sautéed for 26m 20s · done Monday 1:47 PM - -⏺ Monitor event: "Sozo debug build on emulator-5554" - - Read 3 files, ran 4 shell commands (ctrl+o to expand) - -❯ [Image #8] shu qismda scroll qilinganda qandaydir nimadir effectlarni - qoshishing kerakda s ababi ko`p sourcelar kichik yerda bolib qolgan shuni - oldini olish uchun flip clock keyin about sectionda bolsin + secondsni ham - qosh flip bolishi toza emas keyin - ⎿  [Image #8] - - Searched for 4 patterns, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo debug build on emulator-5554" - - Searched for 1 pattern, ran 2 shell commands (ctrl+o to expand) - -⏺ Now the flip clock itself: - - Searched for 2 patterns, read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Now the flip clock — a true split-flap, seconds always, and moved into About: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing flip_clock.dart... - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/core/widgets/flip_clock.dart (+184 -73) - 129 label: 'detail.cd_minutes'.tr(), - 130 compact: widget.compact, - 131 ), - 132 - // Seconds are dropped when the wait is measured in da - -ys: a digit - 133 - // flipping sixty times a minute next to a number that - - moves once a day - 134 - // is noise, and it keeps a phone's screen busy for no - -thing. - 135 - if (days == 0) ...[ - 136 - _Separator(compact: widget.compact), - 137 - _Group( - 138 - value: seconds, - 139 - label: 'detail.cd_seconds'.tr(), - 140 - compact: widget.compact, - 141 - ), - 142 - ], - 132 + // Seconds are always on, days or no days. - 133 + // - 134 + // They were dropped past a day on the grounds that a - +digit flipping - 135 + // sixty times a minute next to one that moves once a - +day is noise. It - 136 + // is not: the second card is the only part of this th - +at is visibly - 137 + // alive, and without it a clock four days out is a st - +atic row of - 138 + // numbers that could as easily be a stopped one. The - +cost is a repaint - 139 + // a second, of four cards. - 140 + _Separator(compact: widget.compact), - 141 + _Group( - 142 + value: seconds, - 143 + label: 'detail.cd_seconds'.tr(), - 144 + compact: widget.compact, - 145 + ), - 146 ], - 147 ); - 148 } - ... - 218 - 219 /// One card, which turns over when its digit changes. - 220 /// - 218 -/// The turn is the real thing rather than a cross-fade: the c - -ard rotates away - 219 -/// carrying the old digit and back carrying the new one, abou - -t its own centre, - 220 -/// with a perspective so the far edge narrows. A fade would r - -ead as a glitch at - 221 -/// this size; the rotation reads as a mechanism. - 221 +/// A split-flap, built the way one is built: the card is two - +leaves meeting at - 222 +/// a fold. The top leaf carrying the OLD digit falls forward - +to the fold, and - … 259 more lines (ctrl+o to expand) - -⏺ Bash(sed -i '' 's/ gradient: const LinearGradient(/ gradient: - LinearGradient(/' lib/core/widgets/flip_clock.dart && export - PATH="/Users/azamov/dev/flut…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/core/widgets/flip_clock.dart (+1 -1) - 403 decoration: BoxDecoration( - 404 // A vertical seam, because a split-flap has one: the - card is lighter - 405 // above the fold and darker below it. - 406 - gradient: const LinearGradient( - 406 + gradient: LinearGradient( - 407 begin: Alignment.topCenter, - 408 end: Alignment.bottomCenter, - 409 colors: [AppColors.surfaceVariant, AppColors.card], - -⏺ Now move the countdown into the About tab: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   // two cards plus their separators do not fit there at full - // size. The compact set does. - compact: width < 380, - … +13 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/widgets/detail_info.dart (+0 - -100) - 132 // came as a block with its progress bar and caption - attached — so - 133 // starting a title showed three controls on one lin - e and coming back - 134 // to it showed one, with the other two stranded bel - ow the caption. - 135 - if (widget.detail.record?.nextAiringAt != null) ...[ - 136 - ItemAppear( - 137 - index: 4, - 138 - child: _NextEpisode(record: widget.detail.record - -!), - 139 - ), - 140 - const SizedBox(height: 12), - 141 - ], - 135 if (Catalogue.isId(widget.detail.provider)) - 136 // A catalogue title nothing installed carries. No - t a Play that - 137 // fails; the honest button is the one that goes a - nd looks. - ... - 862 } - 863 } - 864 - 872 -/// The next episode, alone. It is the one fact on the page wi - -th a date on - 873 -/// it, and the one worth glancing back for. - 874 -/// When the next episode lands, as a clock that is actually r - -unning. - 875 -/// - 876 -/// This was one line of text — "next episode in 3d 4h" — comp - -uted once when the - 877 -/// page was built and never again. It looked the same whether - - it had been - 878 -/// worked out a second ago or an hour ago, which for the one - -number on the page - 879 -/// whose whole value is that it is counting down is the wrong - - shape entirely. - 880 -class _NextEpisode extends StatefulWidget { - 881 - const _NextEpisode({required this.record}); - 882 - - 883 - final RecordInfo record; - 884 - - 885 - @override - 886 - State<_NextEpisode> createState() => _NextEpisodeState(); - 887 -} - 888 - - 889 -class _NextEpisodeState extends State<_NextEpisode> { - 890 - /// Set when the clock reaches zero. - 891 - /// - 892 - /// The page does not reload itself: the record came from An - -iList and the - 893 - /// episode number will not change the instant it airs, so r - -efetching would - 894 - /// spend a request to redraw the same thing. Saying it is a - -iring is both - 895 - /// true and the whole of what a viewer wants at that moment - -. - … 69 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(sed -i '' "\#^import 'package:soplay/core/widgets/flip_clock.dart';\$#d; - \#^import - 'package:soplay/features/detail/domain/entities/record_info.dart';\$#d" - lib/f…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/widgets/detail_info.dart (+0 - -2) - 1 -import 'package:soplay/core/widgets/flip_clock.dart'; - 2 -import 'package:soplay/features/detail/domain/entities/record_in - -fo.dart'; - 1 import 'package:soplay/features/detail/presentation/widgets/trac - king_row.dart'; - 2 import 'package:soplay/core/content/catalogue.dart'; - 3 import 'package:soplay/core/widgets/item_appear.dart'; - -⏺ Now it goes into the About tab, at the top where it reads as the page's one - live fact: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Got dependencies! - 1 package is discontinued. - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/widgets/detail_about_tab.dart - (+111 -1) - 2 import 'package:flutter/material.dart'; - 3 - 4 import 'package:soplay/core/theme/app_colors.dart'; - 5 +import 'package:soplay/core/widgets/flip_clock.dart'; - 6 import 'package:soplay/features/detail/domain/entities/record_in - fo.dart'; - 7 - 8 /// What the catalogue knows and a source does not, as its own t - ab. - ... - 76 final cells = [ - 77 for (final f in record.facts) (f.labelKey, f.labelKey.tr( - ), f.value), - 78 ]; - 78 - if (cells.isEmpty && record.tags.isEmpty) return const Size - -dBox.shrink(); - 79 + final airing = record.nextAiringAt != null; - 80 + if (cells.isEmpty && record.tags.isEmpty && !airing) { - 81 + return const SizedBox.shrink(); - 82 + } - 83 return Padding( - 84 padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - 85 child: Column( - 86 crossAxisAlignment: CrossAxisAlignment.start, - 87 children: [ - 88 + // First, because it is the only fact here that chang - +es while it is - 89 + // being looked at. Everything below it was true last - + year and will be - 90 + // true next year. - 91 + if (airing) ...[ - 92 + _NextEpisode(record: record), - 93 + const SizedBox(height: 12), - 94 + ], - 95 LayoutBuilder( - 96 builder: (context, c) { - 97 final columns = c.maxWidth >= 520 ? 3 : 2; - ... - 155 } - 156 - 157 /// One fact: what kind of thing it is, what it is called, and - what it says. - 158 +/// When the next episode lands, as a clock that is actually r - +unning. - 159 +/// - 160 +/// It began as one line of text — "next episode in 3d 4h" — c - +omputed once when - 161 +/// the page was built and never again. It looked the same whe - +ther it had been - 162 +/// worked out a second ago or an hour ago, which for the one - +number on the page - 163 +/// whose whole value is that it is counting down is the wrong - + shape entirely. - 164 +/// - … 95 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing detail... - No issues found! (ran in 3.2s) - … +7 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/widgets/detail_about_tab.dart - (+22 -9) - 234 ), - 235 if (!_aired) ...[ - 236 const SizedBox(height: 10), - 237 - FlipClock( - 238 - target: at, - 239 - // Narrow phones in the wild are 320pt, an - -d four groups of - 240 - // two cards plus their separators do not - -fit there at full - 241 - // size. The compact set does. - 242 - compact: width < 380, - 243 - onFinished: () { - 244 - if (mounted) setState(() => _aired = tru - -e); - 245 - }, - 237 + // Scaled down rather than overflowed, whate - +ver is left. - 238 + // - 239 + // Four groups of two cards is the widest th - +is gets, and the - 240 + // width it needs is not something the card - +can know: the - 241 + // labels under the digits are translated, a - + hundred-day wait - 242 + // adds a third digit, and the whole row gro - +ws with the - 243 + // system text size. [FittedBox] makes the a - +nswer arithmetic - 244 + // instead of a threshold that is right on t - +he phones it was - 245 + // measured on. - 246 + FittedBox( - 247 + fit: BoxFit.scaleDown, - 248 + alignment: AlignmentDirectional.centerStar - +t, - 249 + child: FlipClock( - 250 + target: at, - 251 + // Narrow phones in the wild are 320pt, - +and the full-size - 252 + // cards do not fit there. The compact s - +et does, and - 253 + // starting compact beats being scaled d - +own to it. - 254 + compact: width < 420, - 255 + onFinished: () { - 256 + if (mounted) setState(() => _aired = t - +rue); - 257 + }, - 258 + ), - 259 ), - 260 ], - 261 ], - -⏺ Monitor event: "Sozo debug build on emulator-5554" - Searched for 1 pattern, listed 1 directory, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +3: a long wait grows a third card rather than clamping - 00:00 +4: a target in the past reads zero, not a negative - 00:00 +5: finishing is announced once, not once a tick - … +3 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/flip_clock_test.dart (+13 -5) - 68 expect(digits(tester), hasLength(6), reason: 'hours, minute - s, seconds'); - 69 }); - 70 - 71 - testWidgets('and seconds are dropped when the wait is days', - -(tester) async { - 72 - // A digit flipping sixty times a minute beside a number th - -at moves once a - 73 - // day is noise, and it keeps the screen busy for nothing. - 71 + testWidgets('and seconds are kept even when the wait is days' - +, ( - 72 + tester, - 73 + ) async { - 74 + // They used to be dropped past a day, on the grounds that - +a digit flipping - 75 + // sixty times a minute beside a number that moves once a d - +ay is noise. The - 76 + // second card is the only part of this that is visibly ali - +ve; without it a - 77 + // clock four days out is a row of numbers that could as ea - +sily be stopped. - 78 await pump(tester, const Duration(days: 3, hours: 2)); - 75 - expect(digits(tester), hasLength(6), reason: 'days, hours, - -minutes'); - 79 + expect( - 80 + digits(tester), - 81 + hasLength(8), - 82 + reason: 'days, hours, minutes, seconds', - 83 + ); - 84 }); - 85 - 86 testWidgets('a long wait grows a third card rather than clamp - ing', ( - ... - 88 ) async { - 89 // A clamped "99" would be a lie a viewer cannot see throug - h. - 90 await pump(tester, const Duration(days: 140, hours: 3)); - 83 - expect(digits(tester), hasLength(7), reason: '3 day digits - -+ 4'); - 91 + expect(digits(tester), hasLength(9), reason: '3 day digits - ++ 6'); - 92 expect(digits(tester).take(3).join(), '140'); - 93 }); - 94 - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Downloading packages... - palette_generator 0.3.3+7 (discontinued) - Got dependencies! - … +18 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/flip_clock_test.dart (+51 -0) - 92 expect(digits(tester).take(3).join(), '140'); - 93 }); - 94 - 95 + group('the flip is a split-flap, not a card turning over', ( - +) { - 96 + /// Every glyph painted for one card. A card at rest paint - +s one; a card - 97 + /// mid-flip paints three — the new digit's top, the old d - +igit's bottom and - 98 + /// the leaf in flight between them. - 99 + List facesOfUnits(WidgetTester tester) => tester - 100 + .widgetList( - 101 + find.descendant( - 102 + of: find.byType(FlipDigit).last, - 103 + matching: find.byType(Text), - 104 + ), - 105 + ) - 106 + .map((t) => t.data ?? '') - 107 + .toList(); - 108 + - 109 + testWidgets('a settled card paints one face', (tester) asy - +nc { - 110 + await pump(tester, const Duration(minutes: 5)); - 111 + expect(facesOfUnits(tester), hasLength(1)); - 112 + }); - 113 + - 114 + testWidgets('and mid-flip the new digit is already on scre - +en', ( - 115 + tester, - 116 + ) async { - 117 + // This is the difference from what it replaced. A whole - + card rotating a - 118 + // half-turn about its middle takes the entire glyph edg - +e-on, so the digit - 119 + // vanishes into a line and reappears; here the new digi - +t's top half is up - 120 + // from the first frame and the old leaf falls off it. - 121 + await pump(tester, const Duration(minutes: 5, seconds: 8 - +)); - 122 + expect(facesOfUnits(tester), ['8']); - 123 + - 124 + clock = clock.add(const Duration(seconds: 1)); - 125 + await tester.pump(const Duration(milliseconds: 250)); - 126 + // A third of the way into a 340ms turn: the leaf is sti - +ll falling. - 127 + await tester.pump(const Duration(milliseconds: 110)); - 128 + - 129 + final faces = facesOfUnits(tester); - 130 + expect(faces, hasLength(3), reason: 'new top, old bottom - +, falling leaf'); - 131 + expect(faces, contains('7'), reason: 'the digit being ar - +rived at'); - … 17 more lines (ctrl+o to expand) - -⏺ Now the source sheet — 372 rows crammed into a small box with the top row - sliced in half: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(cat > lib/core/widgets/edge_fade.dart <<'DART' - import 'package:flutter/material.dart';…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Created lib/core/widgets/edge_fade.dart (+151 -0) - 1 +import 'package:flutter/material.dart'; - 2 + - 3 +/// Dissolves a scrollable into its own edges instead of slici - +ng it. - 4 +/// - 5 +/// A list inside a sheet ends where the sheet's box ends, whi - +ch means a row cut - 6 +/// in half across the middle: half an icon, half a name, hard - + against the - 7 +/// filter field above it. That cut is what makes a long list - +feel crammed - 8 +/// rather than long — nothing says the list continues, only t - +hat it was - 9 +/// truncated, and the half-row reads as a rendering fault. - 10 +/// - 11 +/// The fade is only ever on the side there is something beyon - +d. At rest at the - 12 +/// top of a list, a fade over the first row would be dimming - +a row for no - 13 +/// reason; it comes in over the first [extent] scrolled and g - +oes out the same - 14 +/// way, so the edge is soft exactly while there is something - +behind it. - 15 +/// - 16 +/// One `saveLayer` for the whole scrollable, which is why the - + rows themselves - 17 +/// must not each be given an [Opacity] — see the scale-only t - +reatment callers - 18 +/// use for per-row falloff. - 19 +class EdgeFade extends StatefulWidget { - 20 + const EdgeFade({ - 21 + super.key, - 22 + required this.child, - 23 + this.extent = 28, - 24 + this.axis = Axis.vertical, - 25 + }); - 26 + - 27 + final Widget child; - 28 + - 29 + /// How deep the fade runs, and the distance over which it c - +omes in. - 30 + final double extent; - 31 + - 32 + final Axis axis; - 33 + - 34 + @override - 35 + State createState() => _EdgeFadeState(); - 36 +} - 37 + - 38 +class _EdgeFadeState extends State { - 39 + /// How much of each edge's fade is showing, 0 to 1. - 40 + double _before = 0; - … 111 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 1:import 'dart:ui' as ui; - 3:import 'package:easy_localization/easy_localization.dart'; - … +18 lines (ctrl+o to expand) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+46 -15) - 24 import 'package:soplay/features/profile/presentation/bloc/provi - der_event.dart'; - 25 import 'package:soplay/features/profile/presentation/bloc/provi - der_state.dart'; - 26 import 'package:soplay/core/widgets/mode_switch_overlay.dart'; - 27 +import 'package:soplay/core/widgets/edge_fade.dart'; - 28 import 'package:soplay/core/widgets/item_appear.dart'; - 29 import 'package:soplay/features/home/presentation/bloc/home/hom - e_bloc.dart'; - 30 import 'package:soplay/features/home/presentation/bloc/home/hom - e_state.dart'; - ... - 552 ) - 553 else - 554 Flexible( - 554 - child: ListView.builder( - 555 - shrinkWrap: true, - 555 + // Three hundred and seventy-two sources - + in a box eight - 556 + // rows tall. What made that feel cramme - +d was not the - 557 + // number — it was that the box gave no - +sign of being a - 558 + // window onto it: rows were sliced in h - +alf at the top and - 559 + // bottom edges, and with the list jumpe - +d to the current - 560 + // source on open, there was nothing to - +say whether it - 561 + // held ten entries or four hundred, or - +where in them you - 562 + // were. - 563 + // - 564 + // Three things, none of which is decora - +tion. The rows - 565 + // dissolve into the edges instead of be - +ing cut, so the - 566 + // list reads as continuing rather than - +as clipped. They - 567 + // settle to full size as they reach the - + middle, so a - 568 + // scroll has a direction and a rhythm t - +o it. And the - 569 + // thumb says how far down four hundred - +rows you are, - 570 + // which no amount of scrolling could ot - +herwise tell you. - 571 + child: Scrollbar( - 572 controller: controller, - 557 - keyboardDismissBehavior: - 558 - ScrollViewKeyboardDismissBehavior. - -onDrag, - 559 - // Fixed rows are what make the openin - -g jump land on the - 560 - // right one. - 561 - itemExtent: _tileExtent, - 562 - itemCount: items.length, - 563 - itemBuilder: (context, index) => ItemA - -ppear( - 564 - index: index, - 565 - child: _favoriteProviderTile( - 566 - context, - … 34 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Formatted lib/features/detail/presentation/widgets/detail_about_tab.da - rt - Formatted lib/features/detail/presentation/widgets/detail_info.dart - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/core/widgets/edge_fade.dart (+7 -6) - 128 animation: controller, - 129 child: child, - 130 builder: (context, built) { - 131 - if (!controller.hasClients || !controller.position.hav - -eDimensions) { - 132 - return built!; - 133 - } - 131 + // `position` throws outright when a controller is att - +ached to more - 132 + // than one scrollable, which is a thing that happens - +for a frame while - 133 + // a sheet's list is being replaced. A row at full siz - +e for that frame - 134 + // is the right answer; an exception is not. - 135 + if (controller.positions.length != 1) return built!; - 136 final position = controller.position; - 137 + if (!position.haveDimensions) return built!; - 138 final top = index * extent - position.pixels; - 139 final bottom = position.viewportDimension - (top + ext - ent); - 140 // Distance from whichever edge is nearer, in rows. A - row fully inside - 141 // is untouched; the treatment is entirely about the t - wo rows at the - 142 // ends. - 140 - final t = ( - 141 - (top < bottom ? top : bottom) / extent - 142 - ).clamp(0.0, 1.0); - 143 + final t = ((top < bottom ? top : bottom) / extent).cla - +mp(0.0, 1.0); - 144 final k = Curves.easeOut.transform(t); - 145 return Transform.scale( - 146 scale: minScale + (1 - minScale) * k, - ⎿  Updated lib/features/detail/presentation/widgets/detail_about_tab.dart - (+9 -3) - 59 v.contains('returning')) { - 60 return const Color(0xFF3FB950); - 61 } - 62 - if (v.contains('finished') || v.contains('ended') || v.cont - -ains('complete')) { - 62 + if (v.contains('finished') || - 63 + v.contains('ended') || - 64 + v.contains('complete')) { - 65 return const Color(0xFF8B949E); - 66 } - 65 - if (v.contains('cancelled') || v.contains('canceled') || v. - -contains('hiatus')) { - 67 + if (v.contains('cancelled') || - 68 + v.contains('canceled') || - 69 + v.contains('hiatus')) { - 70 return const Color(0xFFE5484D); - 71 } - 68 - if (v.contains('not yet') || v.contains('upcoming') || v.co - -ntains('planned')) { - 72 + if (v.contains('not yet') || - 73 + v.contains('upcoming') || - 74 + v.contains('planned')) { - 75 return const Color(0xFFD29922); - 76 } - 77 return null; - ⎿  Updated lib/features/detail/presentation/widgets/detail_info.dart (+0 - -1) - 859 ); - 860 } - 861 } - 862 - - -⏺ Now a test for the sheet's new scroll behaviour, then the full suite: - -⏺ Bash(cat > test/core/widgets/edge_fade_test.dart <<'DART' - // A long list in a short box used to be a list with its rows cut in - half.…) - ⎿  placeholder written - ⎿  Created test/core/widgets/edge_fade_test.dart (+128 -0) - 1 +// A long list in a short box used to be a list with its rows - +cut in half. - 2 +// - 3 +// The source sheet holds several hundred entries in a window - +eight rows tall, - 4 +// and the window gave no sign of being one: rows were sliced - +flat against the - 5 +// filter field above and the footer below, which reads as a r - +endering fault - 6 +// rather than as "there is more". The fade has to be on the s - +ide there is - 7 +// something beyond, and only there — a fade over the first ro - +w of a list that - 8 +// is already at its top is dimming a row for no reason. - 9 +import 'package:flutter/material.dart'; - 10 +import 'package:flutter_test/flutter_test.dart'; - 11 +import 'package:soplay/core/widgets/edge_fade.dart'; - 12 + - 13 +void main() { - 14 + const extent = 40.0; - 15 + - 16 + /// The alphas the mask is currently painting at its two end - +s, which is the - 17 + /// whole of what this widget does. Read off the gradient th - +e shader callback - 18 + /// builds rather than off pixels: the mask is composited wi - +th `dstIn`, so - 19 + /// these ARE the edges' opacities. - 20 + ({double before, double after}) edges(WidgetTester tester) { - 21 + final mask = tester.widget(find.byType(ShaderM - +ask)); - 22 + late LinearGradient captured; - 23 + // The callback is pure, so calling it is a read rather th - +an a side effect. - 24 + mask.shaderCallback(const Rect.fromLTWH(0, 0, 100, 400)); - 25 + // Rebuilt here because a Shader cannot be inspected; the - +callback is - 26 + // deterministic on the same rect, so the second build is - +the first. - 27 + captured = _lastGradient!; - 28 + return ( - 29 + before: captured.colors.first.a, - 30 + after: captured.colors.last.a, - 31 + ); - 32 + } - 33 + - 34 + Future pump(WidgetTester tester, int rows) - + async { - 35 + final controller = ScrollController(); - 36 + addTearDown(controller.dispose); - 37 + await tester.pumpWidget( - 38 + MaterialApp( - 39 + home: Scaffold( - 40 + body: Center( - … 88 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(cat > test/core/widgets/edge_fade_test.dart <<'DART' - // A long list in a short box used to be a list with its rows cut in - half.…) - ⎿  When the exception was thrown, this was the stack: - #4 main. (file:///Users/azamov/Sozo-Full/sozo/ - test/core/widgets/edge_fade_test.dart:159:5) - … +21 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/edge_fade_test.dart (+89 -55) - 6 // rather than as "there is more". The fade has to be on the si - de there is - 7 // something beyond, and only there — a fade over the first row - of a list that - 8 // is already at its top is dimming a row for no reason. - 9 +// - 10 +// Read off the rendered pixels rather than off the widget tree - +, because the - 11 +// tree is identical either way: the mask is composited, and on - +ly the composite - 12 +// says what it did. - 13 +import 'dart:ui' as ui; - 14 + - 15 import 'package:flutter/material.dart'; - 16 +import 'package:flutter/rendering.dart'; - 17 import 'package:flutter_test/flutter_test.dart'; - 18 import 'package:soplay/core/widgets/edge_fade.dart'; - 19 - 20 void main() { - 14 - const extent = 40.0; - 21 + const rowExtent = 40.0; - 22 + const boxHeight = 200.0; - 23 + const boxWidth = 100.0; - 24 + const fade = 20.0; - 25 - 16 - /// The alphas the mask is currently painting at its two ends - -, which is the - 17 - /// whole of what this widget does. Read off the gradient the - - shader callback - 18 - /// builds rather than off pixels: the mask is composited wit - -h `dstIn`, so - 19 - /// these ARE the edges' opacities. - 20 - ({double before, double after}) edges(WidgetTester tester) { - 21 - final mask = tester.widget(find.byType(ShaderMa - -sk)); - 22 - late LinearGradient captured; - 23 - // The callback is pure, so calling it is a read rather tha - -n a side effect. - 24 - mask.shaderCallback(const Rect.fromLTWH(0, 0, 100, 400)); - 25 - // Rebuilt here because a Shader cannot be inspected; the c - -allback is - 26 - // deterministic on the same rect, so the second build is t - -he first. - 27 - captured = _lastGradient!; - 28 - return ( - 29 - before: captured.colors.first.a, - 30 - after: captured.colors.last.a, - 31 - ); - 26 + final key = GlobalKey(); - 27 + - 28 + /// Opacity of the white rows at a given row from the top of - +the box, 0 to 1. - … 160 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  DEBUG 10=1.0 11=1.0 12=1.0 13=1.0 - 00:00 +1: All tests passed! - ⎿  Updated test/core/widgets/edge_fade_test.dart (+2 -2) - 156 - 157 // Row 10 sits hard against the top edge at this offset; r - ow 12 is two rows - 158 // in, which is past the falloff. - 159 - expect(scaleOf(10), lessThan(scaleOf(12))); - 160 - expect(scaleOf(12), closeTo(1.0, 0.001)); - 159 + // ignore: avoid_print - 160 + print('DEBUG 10=${scaleOf(10)} 11=${scaleOf(11)} 12=${scal - +eOf(12)} 13=${scaleOf(13)}'); - 161 }); - 162 } - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  DEBUG all=[1.0, 1.0, 1.0, 1.0, 1.0] count=5 settles=5 - 00:00 +1: All tests passed! - ⎿  Updated test/core/widgets/edge_fade_test.dart (+6 -1) - 156 - 157 // Row 10 sits hard against the top edge at this offset; r - ow 12 is two rows - 158 // in, which is past the falloff. - 159 + final all = tester - 160 + .widgetList(find.byType(Transform)) - 161 + .map((t) => t.transform.getMaxScaleOnAxis()) - 162 + .toList(); - 163 // ignore: avoid_print - 160 - print('DEBUG 10=${scaleOf(10)} 11=${scaleOf(11)} 12=${scal - -eOf(12)} 13=${scaleOf(13)}'); - 164 + print('DEBUG all=$all count=${all.length} ' - 165 + 'settles=${tester.widgetList(find.byType(ScrollSettle) - +).length}'); - 166 }); - 167 } - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +1: All tests passed! - ⎿  Updated test/core/widgets/edge_fade_test.dart (+5 -8) - 151 ) - 152 .first, - 153 ); - 154 - return t.transform.getMaxScaleOnAxis(); - 154 + // The x scale, not `getMaxScaleOnAxis`: `Transform.scal - +e` leaves z at - 155 + // 1, and the max across all three axes is therefore alw - +ays 1. - 156 + return t.transform.storage[0]; - 157 } - 158 - 159 // Row 10 sits hard against the top edge at this offset; r - ow 12 is two rows - 160 // in, which is past the falloff. - 159 - final all = tester - 160 - .widgetList(find.byType(Transform)) - 161 - .map((t) => t.transform.getMaxScaleOnAxis()) - 162 - .toList(); - 163 - // ignore: avoid_print - 164 - print('DEBUG all=$all count=${all.length} ' - 165 - 'settles=${tester.widgetList(find.byType(ScrollSettle) - -).length}'); - 161 + expect(scaleOf(10), lessThan(scaleOf(12))); - 162 + expect(scaleOf(12), closeTo(1.0, 0.001)); - 163 }); - 164 } - -⏺ Now the fade tests, which my own settle was interfering with: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   palette_generator 0.3.3+7 (discontinued) - Got dependencies! - 1 package is discontinued. - … +11 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/edge_fade_test.dart (+20 -8) - 41 return value; - 42 } - 43 - 44 - Future pump(WidgetTester tester, int rows) - -async { - 44 + /// [settle] off for the fade tests: the two treatments are i - +ndependent, and - 45 + /// a row scaled down at the edge leaves a gap of background - +exactly where - 46 + /// the fade is being read, so measuring one through the othe - +r measures - 47 + /// neither. - 48 + Future pump( - 49 + WidgetTester tester, - 50 + int rows, { - 51 + bool settle = false, - 52 + }) async { - 53 final controller = ScrollController(); - 54 addTearDown(controller.dispose); - 55 await tester.pumpWidget( - ... - 71 itemCount: rows, - 72 // Solid white rows, edge to edge: anything - less than the - 73 // full opacity read back is the mask's doi - ng. - 66 - itemBuilder: (_, i) => ScrollSettle( - 67 - controller: controller, - 68 - index: i, - 69 - extent: rowExtent, - 70 - child: const ColoredBox(color: Color(0xFF - -FFFFFF)), - 71 - ), - 74 + itemBuilder: (_, i) { - 75 + const row = ColoredBox(color: Color(0xFFF - +FFFFF)); - 76 + if (!settle) return row; - 77 + return ScrollSettle( - 78 + controller: controller, - 79 + index: i, - 80 + extent: rowExtent, - 81 + child: row, - 82 + ); - 83 + }, - 84 ), - 85 ), - 86 ), - ... - 144 }); - 145 - … 6 more lines (ctrl+o to expand) - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format test/core/widgets/edge_fade_test.dart - test/core/widgets/flip_clock_test.dart…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 9.3s) - … +6 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/edge_fade_test.dart (+1 -4) - 137 // suggestion that there is more. - 138 await pump(tester, 4); - 139 expect(await brightnessAt(tester, 1), greaterThan(0.95)); - 140 - expect( - 141 - await brightnessAt(tester, 4 * rowExtent - 1), - 142 - greaterThan(0.95), - 143 - ); - 140 + expect(await brightnessAt(tester, 4 * rowExtent - 1), grea - +terThan(0.95)); - 141 }); - 142 - 143 testWidgets('rows settle to full size away from the edges', - (tester) async { - -⏺ 1533 passing. Committing and hot-restarting so you can see it: - - Pushed to work/2026-09-14, ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild with the new flip clock) - ⎿  Monitor started · task b76jk0atu · timeout 900s - -⏺ Qildim — build ketyapti, tayyor bo'lganda aytaman. - - 1. Flip clock → About sectionga ko'chdi - Header'da u source qatori bilan Play tugmasi orasida turib, har bir efirdagi - serialda ikkalasini ham pastga surardi — sahifani ochgan odam birinchi bo'lib - eshitmoqchi bo'lgan narsa emas. About'da esa u studio va rank bilan bir - qatorda: katalogdan kelgan fakt, Play/Download kabi harakat emas. Faktlar - ichida eng tepada — chunki qaralayotgan paytda o'zgaradigan yagona fakt shu. - - 2. Seconds har doim ko'rinadi - Ilgari kun bo'lsa sekundlar olib tashlanardi ("kuniga bir marta o'zgaradigan - raqam yonida minutiga 60 marta aylanadigani shovqin" degan mantiq bilan). - Aslida aksincha: sekund kartasi — bu soatning tirikligini ko'rsatadigan yagona - narsa. 4 kun qolganda sekundsiz u to'xtab qolgan raqamlar qatoridan farq - qilmaydi. - - 3. Flip endi haqiqiy split-flap - Oldin bitta qattiq karta o'z markazi atrofida yarim aylanardi — butun raqam - bir vaqtda qirrasiga kelib, chiziqqa aylanib yo'qolar va qayta paydo bo'lardi. - Ya'ni "siqilish va otilib chiqish", almashtirish esa ko'rinmaydigan lahzada - yashiringan. Haqiqiy split-flap'da ikkita yaproq va bitta buklama bor: - - eski raqamning yuqori yaprog'i buklamagacha oldinga yiqiladi (easeIn — - tashlanadi), - - keyin yangi raqamning pastki yaprog'i buklamadan yuqoriga ko'tariladi - (easeOut — ushlab qolinadi), - - ular ortida yangi raqamning yuqori yarmi birinchi kadrdanoq ekranda turadi — - mexanizm mexanizmdek o'qilishining sababi shu. - - Yaproq burilganda yorug'likni yo'qotadi va ko'tarilganda qaytarib oladi — bu - kartaga qalinlik beradi. - - Tinch holatdagi karta yana bitta qatlam: aks holda har bir karta sahifa - ochiq turgan davr mobaynida 4 qatlam + perspektiva matritsasini ushlab - turardi. - - 4. Source sheet — 372 ta manba endi "deraza" - Rasmdagi muammo: ro'yxat sakkiz qator balandlikdagi qutida, qatorlar tepa va - pastda kesilgan — bu "davomi bor" emas, "render buzilgan" deb o'qiladi; ustiga - ochilganda joriy manbaga sakraydi, shuning uchun 10 tami yo 400 tami, - qayerdaligingiz — hech narsa aytmaydi. Uchta narsa (hech biri bezak emas): - - qatorlar chetlarga erib kiradi — faqat ortida narsa bor tomonda, shuning - uchun eng tepada turgan ro'yxatning birinchi qatori sababsiz xiralashmaydi; - - qatorlar markazga yetganda to'liq o'lchamiga joylashadi — scroll'da - yo'nalish va ritm paydo bo'ladi; - - scrollbar qo'shildi. - - Fade butun ro'yxat uchun bitta saveLayer; shuning uchun qatorlarga opacity - emas, faqat scale berilgan — opacity har bir qator uchun alohida qatlam bo'lar - edi. - - flutter analyze toza, 1533 ta test o'tdi (18 tasi yangi: split-flap'ning uch - holati, sekundlar, fade'ning to'rt holati, settle). Commit 072ee6b, - work/2026-09-14 ga push qilindi. - -✻ Baked for 16m 15s · done Monday 2:05 PM - -⏺ Monitor event: "Sozo rebuild with the new flip clock" - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Yangi build emulatorda ishga tushdi (emulator-5554), PIN ekranida turibdi. - PIN'ni kiriting — ko'rmoqchi bo'lsangiz: - - - Flip clock: efirdagi anime detaliga kiring → About tab. One Piece - "Releasing" edi, u yerda ko'rinadi. Sekund kartasi har soniyada yaproqdek - yiqilib-ko'tariladi. - - Source list: Change source sheet'ini oching — 372 ta manba endi chetlarda - kesilmaydi, erib kiradi; o'ngda scrollbar bor; qatorlar markazga kelganda - to'liq o'lchamiga joylashadi. - - Bitta kuzatuv, o'zgartirmadim: logda CloudStream host'idan qaytgan javob JSON - emas, oddiy UUID (50584590-3c7c-…) bo'lib, safeApiCall uni parse qilishga - urinib xato berayapti — Expected quotation mark '"', but had '5'. Bu qaysidir - manbaning javobi, xohlasangiz keyingi navbatda qarayman. - -✻ Sautéed for 57s · done Monday 2:06 PM - -❯ flip clockni animationi unchalik yaxhsimas ultrathink bilan togirla yani - to`g`ri darajada emas - -⏺ Let me look hard at what's actually being rendered, because I think there are - real bugs rather than taste issues. - - Ran 1 shell command (ctrl+o to expand) - -❯ sliver narsa qo`sh stream servicelar ham qani umuman yoqatib qoyibsan - [Image #9] shunda catalouge va nimadirlarni mosroq darajada scrollda - animation bilan ishlaydigan qilishimiz kerak tushunyapsanmi shu tomonlariga - qaramagansan to`liq qilib ber ultrathink darajada qiberishing kerak to`liq - qilib ber - ⎿  [Image #9] - -⏺ Three things: the flip clock (bugs I found), the source sheet as real slivers, - and the streaming services that have no way in. Flip clock first — I found - two bugs that make it render wrong, not just look wrong. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(cat > /tmp/flip_tail.dart <<'DART' - /// One card, which turns over when its digit changes.…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  1 file changed (binary, mode only or too large to show) - -⏺ Now tests that would have caught both bugs — geometry, and painted pixels: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +16 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/flip_clock_test.dart (+103 -7) - 4 // when the page was built and never again. It looked identical - whether it had - 5 // been computed a second ago or an hour ago, which for the one - number on the - 6 // page whose entire value is that it is running is the wrong s - hape. - 7 +import 'dart:ui' as ui; - 8 + - 9 import 'package:easy_localization/easy_localization.dart'; - 10 import 'package:flutter/material.dart'; - 11 +import 'package:flutter/rendering.dart'; - 12 import 'package:flutter_test/flutter_test.dart'; - 13 import 'package:shared_preferences/shared_preferences.dart'; - 14 import 'package:soplay/core/widgets/flip_clock.dart'; - ... - 96 }); - 97 - 98 group('the flip is a split-flap, not a card turning over', ( - ) { - 96 - /// Every glyph painted for one card. A card at rest paint - -s one; a card - 99 + const w = 26.0; - 100 + const h = 36.0; - 101 + - 102 + /// One card, on white, with room around it for a leaf tha - +t overflows. - 103 + const pad = 12.0; - 104 + final boundary = GlobalKey(); - 105 + - 106 + Future pumpDigit(WidgetTester tester, String digit) - +=> - 107 + tester.pumpWidget( - 108 + Directionality( - 109 + textDirection: TextDirection.ltr, - 110 + child: Center( - 111 + child: RepaintBoundary( - 112 + key: boundary, - 113 + child: ColoredBox( - 114 + color: const Color(0xFFFFFFFF), - 115 + child: Padding( - 116 + padding: const EdgeInsets.all(pad), - 117 + child: FlipDigit(digit: digit), - 118 + ), - 119 + ), - 120 + ), - 121 + ), - 122 + ), - 123 + ); - … 105 more lines (ctrl+o to expand) - -⏺ Bash(sed -i '' 's/ textDirection: TextDirection.ltr,/ - \/\/ Prefixed: easy_localization pulls in intl, which has a\n - \/\/ TextDirecti…) - ⎿  00:00 +0: loading /Users/azamov/Sozo-Full/sozo/test/core/widgets/flip_ - clock_test.dart - 00:00 +0: (setUpAll) - … +15 lines (ctrl+o to expand) - ⎿  Updated test/core/widgets/flip_clock_test.dart (+3 -1) - 106 Future pumpDigit(WidgetTester tester, String digit) - => - 107 tester.pumpWidget( - 108 Directionality( - 109 - textDirection: TextDirection.ltr, - 109 + // Prefixed: easy_localization pulls in intl, whic - +h has a - 110 + // TextDirection of its own with different members - +. - 111 + textDirection: ui.TextDirection.ltr, - 112 child: Center( - 113 child: RepaintBoundary( - 114 key: boundary, - -⏺ Let me verify the new tests actually fail against the old bugs — the previous - test passed either way, which is why the bug shipped: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the new flip clock" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Both bugs caught, fixed code passes. Now the source sheet — the real problem - in your screenshot is that the header is pinned and eats the sheet, leaving - the list a sliver: - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing edge_fade.dart... - No issues found! (ran in 0.7s) - ⎿  Updated lib/core/widgets/edge_fade.dart (+31 -3) - 22 required this.child, - 23 this.extent = 28, - 24 this.axis = Axis.vertical, - 25 + this.before = true, - 26 + this.after = true, - 27 }); - 28 - 29 final Widget child; - 30 - 31 + /// Whether to fade the leading edge — the top of a vertical - +list. - 32 + /// - 33 + /// Off where something opaque already covers it. A pinned he - +ader is its own - 34 + /// boundary: rows slide under it and are hidden rather than - +sliced, and - 35 + /// fading there would dim the header itself, which is the on - +e thing on the - 36 + /// screen that must stay legible. - 37 + final bool before; - 38 + - 39 + /// Whether to fade the trailing edge. - 40 + final bool after; - 41 + - 42 /// How deep the fade runs, and the distance over which it co - mes in. - 43 final double extent; - 44 - ... - 57 // A scrollable that is not the one being wrapped — a horiz - ontal row inside - 58 // a row, say — must not drive this one's edges. - 59 if (m.axis != widget.axis) return false; - 47 - final before = (m.extentBefore / widget.extent).clamp(0.0, - -1.0); - 48 - final after = (m.extentAfter / widget.extent).clamp(0.0, 1. - -0); - 60 + final before = widget.before - 61 + ? (m.extentBefore / widget.extent).clamp(0.0, 1.0) - 62 + : 0.0; - 63 + final after = widget.after - 64 + ? (m.extentAfter / widget.extent).clamp(0.0, 1.0) - 65 + : 0.0; - 66 if (before == _before && after == _after) return false; - 67 setState(() { - 68 _before = before; - ... - 127 required this.index, - 128 required this.extent, - 129 required this.child, - … 28 more lines (ctrl+o to expand) - -⏺ Now the sheet itself — one scroll view, catalogue scrolls away with a fade, - search pins: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Analyzing provider_quick_switch.dart... - … +23 lines (ctrl+o to expand) - ⎿  1 file changed (binary, mode only or too large to show) - -⏺ Now the two new widgets, and the hasGeometry guard: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 145 packages have newer versions incompatible with dependency constrai - nts. - … +15 lines (ctrl+o to expand) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+142 -4) - 1 +import 'dart:math' as math; - 2 import 'dart:ui' as ui; - 3 - 4 import 'package:easy_localization/easy_localization.dart'; - ... - 353 /// Used on TV and desktop, where there is no sheet to suppl - y one. - 354 final ScrollController _flat = ScrollController(); - 355 - 355 - static const double _pinnedPad = 12; - 356 - - 356 bool get _hasFilter => widget.all.length >= _filterThreshold - ; - 357 - 358 /// Where a chip is on screen. Zero when it has not been lai - d out, which the - ... - 380 WidgetsBinding.instance.addPostFrameCallback((_) { - 381 if (!mounted) return; - 382 final sliver = _listKey.currentContext?.findRenderObject - (); - 384 - if (sliver is! RenderSliver || !sliver.hasGeometry) retu - -rn; - 383 + if (sliver is! RenderSliver || sliver.geometry == null) - +return; - 384 final leading = sliver.constraints.precedingScrollExtent - ; - 385 if (leading == _leading || !leading.isFinite) return; - 386 setState(() => _leading = leading); - ... - 518 // scrolls above the list, which is read off the sliver - rather than - 519 // guessed. - 520 final sliver = _listKey.currentContext?.findRenderObject - (); - 522 - final leading = sliver is RenderSliver && sliver.hasGeom - -etry - 521 + final leading = sliver is RenderSliver && sliver.geometr - +y != null - 522 ? sliver.constraints.precedingScrollExtent - 523 : 0.0; - 524 final target = leading + (index - 2) * _tileExtent; - ... - 1320 ); - 1321 } - 1322 } - 1323 + - 1324 + - 1325 +/// The section above the list, going quietly as it leaves. - 1326 +/// - 1327 +/// It is not enough for the catalogue cards to scroll away — - + they have to - 1328 +/// leave like something being put down, or a sheet whose top - + third suddenly - 1329 +/// slides under a row of chips reads as a layout glitch. The - +y fade, shrink a - 1330 +/// little and travel up slower than the finger, so the list - +arrives over them - 1331 +/// rather than shoving them off. - … 130 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing profile... - No issues found! (ran in 3.0s) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+2 -2) - 1362 alignment: Alignment.topCenter, - 1363 transform: Matrix4.identity() - 1364 // Slower than the scroll, so it recedes rather - than races off. - 1365 - ..translate(0.0, position.pixels * 0.22) - 1366 - ..scale(1 - k * 0.05), - 1365 + ..translateByDouble(0, position.pixels * 0.22, - +0, 1) - 1366 + ..scaleByDouble(1 - k * 0.05, 1 - k * 0.05, 1, - +1), - 1367 child: built, - 1368 ), - 1369 ); - - Ran 3 shell commands (ctrl+o to expand) - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format - lib/features/profile/presentation/widgets/provider_quick_switch.dart - >/dev/nu…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing profile... - No issues found! (ran in 3.2s) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+0 -1) - 1321 } - 1322 } - 1323 - 1324 - - 1324 /// The section above the list, going quietly as it leaves. - 1325 /// - 1326 /// It is not enough for the catalogue cards to scroll away — - they have to - - Searched for 1 pattern, ran 6 shell commands (ctrl+o to expand) - -⏺ Bash(cp /tmp/pqs.bak - lib/features/profile/presentation/widgets/provider_quick_switch.dart && - python3 - <<'PY' - import io…) - ⎿  patched - Actual: FlutterError: - 00:00 +0 -1: Some tests failed. - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+1 -1) - 574 keyboardDismissBehavior: - 575 ScrollViewKeyboardDismissBehavior.onDrag, - 576 slivers: [ - 577 - if (catalogues.isNotEmpty) - 577 + if (false) - 578 SliverToBoxAdapter( - 579 child: _FadeOnScroll( - 580 controller: controller, - -⏺ Bash(cp /tmp/pqs.bak - lib/features/profile/presentation/widgets/provider_quick_switch.dart && - S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb4…) - ⎿   fallbackLocale: const Locale('en'), - saveLocale: false, - path: 'assets/translations', - … +26 lines (ctrl+o to expand) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+1 -1) - 574 keyboardDismissBehavior: - 575 ScrollViewKeyboardDismissBehavior.onDrag, - 576 slivers: [ - 577 - if (false) - 577 + if (catalogues.isNotEmpty) - 578 SliverToBoxAdapter( - 579 child: _FadeOnScroll( - 580 controller: controller, -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  written - ==== A RenderFlex overflowed by 95 pixels on the bottom. - debugCreator: Column ← SizedBox ← Padding ← ProviderQuickSwitchSheet ← - … +15 lines (ctrl+o to expand) - ⎿  Created test/features/zz_dbg_overflow_test.dart (+346 -0) - 1 +import 'dart:convert'; - 2 +import 'dart:io'; - 3 +import 'dart:typed_data'; - 4 + - 5 +import 'package:dio/dio.dart'; - 6 +import 'package:easy_localization/easy_localization.dart'; - 7 +import 'package:flutter/material.dart'; - 8 +import 'package:flutter_test/flutter_test.dart'; - 9 +import 'package:shared_preferences/shared_preferences.dart'; - 10 +import 'package:soplay/core/localization/language_picker.dart' - +; - 11 +import 'package:soplay/core/content/content_mode.dart'; - 12 +import 'package:soplay/core/navigation/app_tab.dart'; - 13 +import 'package:soplay/core/theme/app_colors.dart'; - 14 +import 'package:soplay/core/theme/app_theme.dart'; - 15 +import 'package:soplay/features/detail/domain/entities/video_s - +ource_entity.dart'; - 16 +import 'package:soplay/features/download/data/models/download_ - +item_model.dart'; - 17 +import 'package:soplay/features/download/domain/entities/downl - +oad_item.dart'; - 18 +import 'package:soplay/features/download/presentation/widgets/ - +download_choice_sheet.dart'; - 19 +import 'package:soplay/features/profile/domain/entities/provid - +er_entity.dart'; - 20 +import 'package:soplay/features/profile/presentation/widgets/p - +rovider_quick_switch.dart'; - 21 +import 'package:soplay/features/main/presentation/pages/main_p - +age.dart'; - 22 +import 'package:soplay/features/onboarding/presentation/pages/ - +onboarding_page.dart'; - 23 +import 'package:soplay/features/search/domain/entities/cross_s - +earch_scope.dart'; - 24 +import 'package:soplay/features/search/presentation/widgets/se - +arch_header.dart'; - 25 +import 'package:soplay/features/search/presentation/widgets/so - +urce_scope_bar.dart'; - 26 + - 27 +class _Translations extends AssetLoader { - 28 + const _Translations(); - 29 + @override - 30 + Future> load(String path, Locale locale - +) async => - 31 + jsonDecode( - 32 + File( - 33 + 'assets/translations/${locale.languageCode}.json - +', - 34 + ).readAsStringSync(), - 35 + ) - 36 + as Map; - 37 +} - 38 + - 39 +class _ManifestAdapter implements HttpClientAdapter { - 40 + RequestOptions? request; - … 306 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ The nesting fix never applied — that Bash call errored before running. - Applying it properly: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing profile... - No issues found! (ran in 3.1s) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+141 -97) - 549 // that got whatever was left. None of it scrolled, be - cause the list - 550 // was a separate scrollable underneath it. - 551 _grabber(context), - 552 - _title(context), - 553 - Padding( - 554 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - 555 - child: _ModeSegments( - 556 - keys: _chipKeys, - 557 - active: widget.mode, - 558 - pending: _pendingMode, - 559 - onTap: _pickMode, - 560 - ), - 561 - ), - 552 + // One flexible box holding both the title block and t - +he list, not one - 553 + // each. - 554 + // - 555 + // A Column shares its spare room between flexible chi - +ldren by flex, so - 556 + // a flexible header beside a flexible list takes a sh - +are whether it - 557 + // needs one or not — and what a loose fit leaves unus - +ed becomes dead - 558 + // space rather than going to the list. Nested, the ou - +ter box takes - 559 + // what the inner Column needs and the list inside it - +is the only thing - 560 + // that stretches. - 561 + // - 562 + // The cap is for the case that was overflowing: a 320 - +-point screen at - 563 + // 200% text with the keyboard up, where the title and - + the mode chips - 564 + // together are taller than what is left of the sheet. - + They scroll. - 565 Flexible( - 563 - child: Scrollbar( - 564 - controller: controller, - 565 - // Outside the fade: a position indicator that dis - -solves at the - 566 - // edge it exists to mark is no indicator. - 567 - child: EdgeFade( - 568 - extent: _tileExtent * 0.55, - 569 - // The search bar is pinned and opaque, so it is - - already the top - 570 - // boundary — rows pass under it rather than bei - -ng cut by it. - 571 - before: false, - 572 - child: CustomScrollView( - 573 - controller: controller, - 574 - keyboardDismissBehavior: - 575 - ScrollViewKeyboardDismissBehavior.onDrag, - … 213 more lines (ctrl+o to expand) - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  DBG textfields=0 listtiles=1 scrollviews=1 - DBG size type "CustomScrollView" = Size(320.0, 60.6) - DBG size type "Scrollbar" = Size(320.0, 60.6) - ⎿  Updated test/features/zz_dbg_overflow_test.dart (+13 -0) - 334 scale: 2, - 335 keyboard: 250, - 336 ); - 337 + // ignore: avoid_print - 338 + print('DBG textfields=${find.byType(TextField).evaluate(). - +length} ' - 339 + 'listtiles=${find.byType(ListTile).evaluate().length} - +' - 340 + 'scrollviews=${find.byType(CustomScrollView).evaluate( - +).length}'); - 341 + for (final f in [ - 342 + find.byType(CustomScrollView), - 343 + find.byType(Scrollbar), - 344 + ]) { - 345 + if (f.evaluate().isNotEmpty) { - 346 + // ignore: avoid_print - 347 + print('DBG size ${f.description} = ${tester.getSize(f. - +first)}'); - 348 + } - 349 + } - 350 for (final d in caught) { - 351 // ignore: avoid_print - 352 print('==== ${d.exception}'); - -⏺ The pinned bar must come before anything taller than the viewport, or it's - never built. Restructuring the slivers: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  edge_fade ok - warning • The value of the local variable 'uncovered' isn't used. Try - removing the variable or using it • lib/core/widgets/edge_fade.dart:17 - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/core/widgets/edge_fade.dart (+8 -0) - 128 required this.extent, - 129 required this.child, - 130 this.leading = 0, - 131 + this.topInset = 0, - 132 this.minScale = 0.94, - 133 }); - 134 - ... - 145 /// that section. - 146 final double leading; - 147 - 148 + /// How much of the viewport's top edge is covered by someth - +ing pinned over - 149 + /// the list. Rows pass UNDER a pinned bar, so without this - +they would start - 150 + /// shrinking while they are still behind it and arrive at f - +ull size a bar's - 151 + /// height too late. - 152 + final double topInset; - 153 + - 154 final Widget child; - 155 - 156 /// How small a row gets at the very edge. Small differences - read as the list - ... - 172 if (!position.haveDimensions) return built!; - 173 final top = leading + index * extent - position.pixels - ; - 174 final bottom = position.viewportDimension - (top + ext - ent); - 175 + final uncovered = top - topInset; - 176 // Distance from whichever edge is nearer, in rows. A - row fully inside - 177 // is untouched; the treatment is entirely about the t - wo rows at the - 178 // ends. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing edge_fade.dart... - No issues found! (ran in 0.5s) - ⎿  Updated lib/core/widgets/edge_fade.dart (+4 -1) - 176 // Distance from whichever edge is nearer, in rows. A - row fully inside - 177 // is untouched; the treatment is entirely about the t - wo rows at the - 178 // ends. - 179 - final t = ((top < bottom ? top : bottom) / extent).cla - -mp(0.0, 1.0); - 179 + final t = ((uncovered < bottom ? uncovered : bottom) / - + extent).clamp( - 180 + 0.0, - 181 + 1.0, - 182 + ); - 183 final k = Curves.easeOut.transform(t); - 184 return Transform.scale( - 185 scale: minScale + (1 - minScale) * k, - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+74 -38) - 530 final favorites = _shownFavorites; - 531 final items = [...favorites, ..._rest]; - 532 final catalogues = Catalogue.forMode(widget.mode); - 533 + final searchExtent = _SearchBarHeader.extentFor(context); - 534 _alignToCurrent(controller, items); - 535 _measureLeading(); - 536 - ... - 604 keyboardDismissBehavior: - 605 ScrollViewKeyboardDismissBehavior. - onDrag, - 606 slivers: [ - 606 - if (catalogues.isNotEmpty) - 607 - SliverToBoxAdapter( - 608 - child: _FadeOnScroll( - 609 - controller: controller, - 610 - over: 120, - 611 - child: Padding( - 612 - padding: const EdgeInsets.fr - -omLTRB( - 613 - 16, - 614 - 0, - 615 - 16, - 616 - 12, - 617 - ), - 618 - child: _catalogueSection(con - -text, catalogues), - 619 - ), - 607 + // The filter first, and pinned. - 608 + // - 609 + // Not because it belongs above the - +catalogue by - 610 + // rights, but because a sliver outs - +ide the viewport - 611 + // is not built at all: placed after - + a section taller - 612 + // than the box, the field does not - +exist — and on a - 613 + // short screen with the keyboard up - +, which is to say - 614 + // while somebody is typing in it, t - +here was no field - 615 + // and no way to get one back. It is - + also the only - 616 + // practical way into a list of seve - +ral hundred, so - 617 + // it is the one thing here that mus - +t never leave. - 618 + if (_hasFilter) - 619 + SliverPersistentHeader( - 620 + pinned: true, - 621 + delegate: _SearchBarHeader( - 622 + controller: _filter, - … 111 more lines (ctrl+o to expand) - - Ran 2 shell commands (ctrl+o to expand) - -⏺ The gate is unnecessary now that the search bar is first — removing it: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing profile... - … +6 lines (ctrl+o to expand) - ⎿  Updated - lib/features/profile/presentation/widgets/provider_quick_switch.dart - (+14 -30) - 630 ), - 631 ), - 632 if (catalogues.isNotEmpty) - 633 - SliverLayoutBuilder( - 634 - // Dropped outright where the bo - -x is too small to - 635 - // hold it and anything else: tw - -o catalogue cards - 636 - // in a viewport sixty points ta - -ll is a shortcut - 637 - // taking the room from the thin - -g it is a - 638 - // shortcut past. - 639 - builder: (context, sliver) => - 640 - sliver.viewportMainAxisExten - -t < - 641 - searchExtent + _tileExte - -nt * 2.5 - 642 - ? const SliverToBoxAdapter( - 643 - child: SizedBox.shrink() - -, - 644 - ) - 645 - : SliverToBoxAdapter( - 646 - child: _FadeOnScroll( - 647 - controller: controller - -, - 648 - over: 120, - 649 - child: Padding( - 650 - padding: const EdgeI - -nsets.fromLTRB( - 651 - 16, - 652 - 12, - 653 - 16, - 654 - 12, - 655 - ), - 656 - child: _catalogueSec - -tion( - 657 - context, - 658 - catalogues, - 659 - ), - 660 - ), - 661 - ), - 662 - ), - 633 + SliverToBoxAdapter( - 634 + child: _FadeOnScroll( - 635 + controller: controller, - 636 + over: 120, - 637 + child: Padding( - 638 + padding: const EdgeInsets.fr - +omLTRB( - 639 + 16, - … 10 more lines (ctrl+o to expand) - -⏺ Bash(rm -f test/features/zz_dbg_overflow_test.dart && export - PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && flutter - analyze 2>&1 | tail -3 && flutte…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 10.5s) - … +12 lines (ctrl+o to expand) - ⎿  Deleted test/features/zz_dbg_overflow_test.dart (+0 -359) - 1 -import 'dart:convert'; - 2 -import 'dart:io'; - 3 -import 'dart:typed_data'; - 4 - - 5 -import 'package:dio/dio.dart'; - 6 -import 'package:easy_localization/easy_localization.dart'; - 7 -import 'package:flutter/material.dart'; - 8 -import 'package:flutter_test/flutter_test.dart'; - 9 -import 'package:shared_preferences/shared_preferences.dart'; - 10 -import 'package:soplay/core/localization/language_picker.dart' - -; - 11 -import 'package:soplay/core/content/content_mode.dart'; - 12 -import 'package:soplay/core/navigation/app_tab.dart'; - 13 -import 'package:soplay/core/theme/app_colors.dart'; - 14 -import 'package:soplay/core/theme/app_theme.dart'; - 15 -import 'package:soplay/features/detail/domain/entities/video_s - -ource_entity.dart'; - 16 -import 'package:soplay/features/download/data/models/download_ - -item_model.dart'; - 17 -import 'package:soplay/features/download/domain/entities/downl - -oad_item.dart'; - 18 -import 'package:soplay/features/download/presentation/widgets/ - -download_choice_sheet.dart'; - 19 -import 'package:soplay/features/profile/domain/entities/provid - -er_entity.dart'; - 20 -import 'package:soplay/features/profile/presentation/widgets/p - -rovider_quick_switch.dart'; - 21 -import 'package:soplay/features/main/presentation/pages/main_p - -age.dart'; - 22 -import 'package:soplay/features/onboarding/presentation/pages/ - -onboarding_page.dart'; - 23 -import 'package:soplay/features/search/domain/entities/cross_s - -earch_scope.dart'; - 24 -import 'package:soplay/features/search/presentation/widgets/se - -arch_header.dart'; - 25 -import 'package:soplay/features/search/presentation/widgets/so - -urce_scope_bar.dart'; - 26 - - 27 -class _Translations extends AssetLoader { - 28 - const _Translations(); - 29 - @override - 30 - Future> load(String path, Locale locale - -) async => - 31 - jsonDecode( - 32 - File( - 33 - 'assets/translations/${locale.languageCode}.json - -', - 34 - ).readAsStringSync(), - 35 - ) - 36 - as Map; - 37 -} - 38 - - 39 -class _ManifestAdapter implements HttpClientAdapter { - 40 - RequestOptions? request; - … 319 more lines (ctrl+o to expand) - -⏺ 1535 pass. Now the streaming services — they're unreachable, which is why they - look "removed": - - Ran 3 shell commands (ctrl+o to expand) - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/keys_services_entry.json <<'JSON' - {…) - ⎿  added 1 keys x 12 locales - ⎿  Updated assets/translations/ar.json (+1 -0) - 1992 }, - 1993 "watch": { - 1994 "services_title": "خدمات البث", - 1995 + "services_entry_hint": "اطّلع على ما تقدّمه نتفليكس وديزني+ - + وغيرها في منطقتك", - 1996 "services_search_hint": "ابحث عن خدمة", - 1997 "top_services": "الأكثر استخدامًا هنا", - 1998 "all_services": "كل الخدمات", - ⎿  Updated assets/translations/de.json (+1 -0) - 1988 }, - 1989 "watch": { - 1990 "services_title": "Streamingdienste", - 1991 + "services_entry_hint": "Sieh, was Netflix, Disney+ und Co - +. bei dir im Angebot haben", - 1992 "services_search_hint": "Dienste suchen", - 1993 "top_services": "Hier am meisten genutzt", - 1994 "all_services": "Alle Dienste", - ⎿  Updated assets/translations/en.json (+1 -0) - 1988 }, - 1989 "watch": { - 1990 "services_title": "Streaming services", - 1991 + "services_entry_hint": "See what Netflix, Disney+ and the - + rest carry where you are", - 1992 "services_search_hint": "Search services", - 1993 "top_services": "Most used here", - 1994 "all_services": "All services", - ⎿  Updated assets/translations/es.json (+1 -0) - 1988 }, - 1989 "watch": { - 1990 "services_title": "Servicios de streaming", - 1991 + "services_entry_hint": "Mira qué ofrecen Netflix, Disney+ - + y los demás donde estás", - 1992 "services_search_hint": "Buscar servicios", - 1993 "top_services": "Los más usados aquí", - 1994 "all_services": "Todos los servicios", - ⎿  Updated assets/translations/fr.json (+1 -0) - 1988 }, - 1989 "watch": { - 1990 "services_title": "Services de streaming", - 1991 + "services_entry_hint": "Voyez ce que proposent Netflix, D - +isney+ et les autres chez vous", - 1992 "services_search_hint": "Rechercher un service", - 1993 "top_services": "Les plus utilisés ici", - 1994 "all_services": "Tous les services", - ⎿  … 7 more files changed - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - - error • Invalid constant value • lib/features/search/presentation/wi - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+104 -0) - 5 import 'package:go_router/go_router.dart'; - 6 - 7 import 'package:soplay/core/content/catalogue.dart'; - 8 +import 'package:soplay/core/system/responsive.dart'; - 9 import 'package:soplay/core/theme/app_colors.dart'; - 10 import 'package:soplay/core/tv/tv.dart'; - 11 import 'package:soplay/core/widgets/item_appear.dart'; - ... - 61 ), - 62 ), - 63 const SliverToBoxAdapter(child: _SourceRail()), - 64 + // The way in to the streaming services. - 65 + // - 66 + // They had none. The page and its browse screen were built - +, routed and - 67 + // translated, and then the rail that used to reach them ca - +me off Home — - 68 + // correctly, because a rail is not where a whole catalogue - + belongs — and - 69 + // nothing replaced it. A feature no screen links to is a f - +eature that has - 70 + // been removed. - 71 + // - 72 + // Here rather than on Home because of what it answers: "wh - +ere can I watch - 73 + // this" is a search, not a shelf. It is one row and it fet - +ches nothing — - 74 + // the services and the region load when the page opens, no - +t when the - 75 + // search tab does. - 76 + const SliverToBoxAdapter(child: _StreamingServicesEntry()), - 77 if (genres.isNotEmpty) ...[ - 78 SliverToBoxAdapter( - 79 child: Padding( - ... - 191 double _sectionTitleHeight(BuildContext context) => - 192 MediaQuery.textScalerOf(context).scale(18) * 1.15; - 193 - 194 +/// One row, into the streaming services. - 195 +/// - 196 +/// Built out of the same parts as the rest of this screen — a - + section title in - 197 +/// the same weight, a surface card, the app's own press feedb - +ack — so it reads - 198 +/// as another place to start rather than as an advertisement - +for a feature. - 199 +class _StreamingServicesEntry extends StatelessWidget { - 200 + const _StreamingServicesEntry(); - 201 + - 202 + @override - 203 + Widget build(BuildContext context) { - 204 + final card = Container( - … 82 more lines (ctrl+o to expand) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 9.7s) - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+1 -1) - 218 color: AppColors.primary.withValues(alpha: 0.12) - , - 219 borderRadius: BorderRadius.circular(11), - 220 ), - 221 - child: const Icon( - 221 + child: Icon( - 222 Icons.subscriptions_rounded, - 223 size: 20, - 224 color: AppColors.primary, - -⏺ Bash(cat >> test/features/search/search_landing_no_jump_test.dart <<'DART' - DART…) - ⎿  00:00 +0: loading /Users/azamov/Sozo-Full/sozo/test/features/search/se - arch_landing_no_jump_test.dart - 00:00 +0: (setUpAll) - … +6 lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+12 -0) - 263 expect(genresTop(tester), lessThan(waiting)); - 264 }); - 265 - 266 + testWidgets('the streaming services have a way in', (tester) - + async { - 267 + // They had none. The page, its browse screen and its tran - +slations were all - 268 + // built and routed, and then the rail that reached them c - +ame off Home and - 269 + // nothing replaced it — so the whole feature was unreacha - +ble from any - 270 + // screen in the app, which from the outside is indistingu - +ishable from - 271 + // having deleted it. - 272 + await pump(tester, home: _HeldHome()); - 273 + - 274 + expect(find.text('watch.services_title'.tr()), findsOneWid - +get); - 275 + expect(find.text('watch.services_entry_hint'.tr()), findsO - +neWidget); - 276 + }); - 277 + - 278 testWidgets('the genre skeleton is the genre grid, greyed ou - t', ( - 279 tester, - 280 ) async { - - Pushed to work/2026-09-14, ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild with the sheet and flip fixes) - ⎿  Monitor started · task b59mpajpc · timeout 900s - -⏺ Build ketyapti. Uchchalasi ham qilindi — 16422e5, push qilindi. - - 1. Flip clock — ikkita haqiqiy bug bor edi, "did" emas - - Ikkalasi ham widget-tree testiga ko'rinmaydi, shuning uchun testlar o'tib - turib, ekranda buzuq edi: - - - Yarimliklar yarim emas edi. Stack ichida StackFit.expand har bir - non-positioned bolaga tight constraint beradi — ichidagi SizedBox(height: - h/2) bekor qilinadi, Align.heightFactor esa shrink-wrap qiladigan narsa - topolmaydi. Natijada ikkala statik qatlam ham butun kartani chizardi: eski - raqam yangisini butunlay yopib turardi, yaproq ortida hech narsa - o'zgarmasdi, va raqam oxirida bitta kadrda almashardi. Ya'ni animatsiya, eng - muhim lahzada, oddiy kesim edi. - - Yaproq teskari aylanardi. rotateX musbat burchakda widget tepasini - tomoshabin tomon olib keladi; men uni manfiy yozgandim — flap kartaning - ichiga orqaga buklanardi. Buni ko'rish qiyin edi, chunki perspektiva 36pt - balandlikdagi kartada 0.0016 edi — chekkada taxminan yarim piksel. Ya'ni - perspektiva umuman yo'q, perspektivasiz aylanish esa vertikal siqilishdan - farq qilmaydi. - - Endi: perspektiva kartaga moslangan (erkin qirrasi chekkada eniga ~1/5 - kattalashadi va kartadan tashqariga chiqadi — Clip.none), yugurish 50/50 emas - 58/42 (flap qo'yib yuboriladi, keyin qo'nadi), yorug'lik tepadan — yaproq - qirraga kelganda qorayadi, erkin qirrasi yorug'lik ushlaydi, pastdagi yarimga - soya tashlaydi. Uchalasi bitta kattalik: burilishning sinusi. - - Uchta yangi test yozdim va har birini o'z bug'iga qarshi tekshirdim — bug - qaytarilsa test yiqiladi, to'g'ri kodda o'tadi. - - 2. Source sheet — sliverlar - - Rasmdagi muammoning sababi: header Column bolasi bo'lib, qutining 0.6 qismiga - cheklangan edi va telefonda o'sha 0.6 ni to'liq egallardi (sarlavha + rejim - chiplari + 2 ta katalog kartasi + bo'lim yorlig'i + scope + qidiruv), - ro'yxatga esa qolgani. Hech biri scroll qilmasdi, chunki ro'yxat alohida - scrollable edi. - - Endi bitta CustomScrollView: - - katalog bo'limi scroll qilinganda so'nadi, kichrayadi va sekinroq ketadi - (barmoqdan 0.22 tezlikda) — itarib yuborilmaydi, orqaga chekinadi; - - SOURCES · 372 + scope ham u bilan ketadi; - - qidiruv maydoni — pinned sliver, va u eng birinchi turadi. Sababi muhim: - viewport'dan tashqaridagi sliver umuman qurilmaydi — katalogdan keyin - qo'yilganda, past ekranda klaviatura ochiqligida (ya'ni aynan kimdir unga - yozayotganda) maydon mavjud bo'lmasdi. Buni test ham tutdi. - - qatorlar pinned bar ostidan o'tadi, shuning uchun settle effekti endi bar - balandligini hisobga oladi. - - 3. Streaming servicelar - - Haq edingiz — umuman yo'qolgan edi. Sahifa, browse ekrani, region tanlovi va - 12 ta tilga tarjimasi qurilgan va routerga ulangan, lekin Home'dagi rail olib - tashlangach (siz aytganingizdek — rail katalog uchun joy emas) o'rniga hech - narsa qo'yilmagan. Hech bir ekran havola qilmaydigan funksiya — tashqaridan - qaraganda o'chirilgan funksiya. - - Endi Search landing'da bitta qator: "Streaming services / Netflix, Disney+ va - boshqalarda o'z hududingizda nima borligini ko'ring". Home'da emas, chunki - "buni qayerda ko'rsam bo'ladi" — bu qidiruv, javon emas. Va u hech narsa - yuklamaydi — servicelar va region faqat sahifa ochilganda so'raladi. - - flutter analyze toza, 1536 test o'tdi. - -✻ Cogitated for 38m 58s · done Monday 2:47 PM -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - - Read 2 files, ran 2 shell commands (ctrl+o to expand) - -⏺ The services page is reachable now. But its skeleton is poster-shaped at 3 - columns while the real tiles are square marks at 4 — the same shape-jump I - just fixed elsewhere: - - Searched for 1 pattern (ctrl+o to expand) - -❯ ajoyib dashboardga ham qoshsih haqida qosh streaming serviceni shu - tomonlariga ham qara - - Searched for 1 pattern, listed 1 directory, ran 2 shell commands (ctrl+o to expand) - -❯ yani xozir umuman ishlamayapti - -⏺ "Umuman ishlamayapti" — to'g'ri, skeletonda qotib qolgan. Sababini topay: - - Searched for 2 patterns, read 1 file, listed 3 directories, ran 9 shell commands (ctrl+o to expand) - -❯ shu tomonlarini qara umuman ishlamayaptiku u - -⏺ Topdim. TMDB'da O'zbekiston uchun umuman ro'yxat yo'q — server to'g'ri javob - beryapti, lekin bo'sh: - - Ran 2 shell commands (ctrl+o to expand) - -❯ searchga bunaqa qilib qoyma searchdan ob tashla bolmasam streaming - servicelarni dashboardga suggestion qosh streaming service haqida ha - qoshaman desa qoshadi keyin remove qiladigan ham bolsinda u kabi narsalari - ham amplitudega to`liq borsin - -⏺ Tushundim — Search'dan olib tashlayman, dashboardga taklif qilib qo'yaman. - Avval analytics va Hive tomonini ko'ray: - - Searched for 3 patterns, ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - - Searched for 2 patterns, ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Right architecture exists already — Home has configurable rails. Adding it as - an opt-in band: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  rail ok - 61: static const String homeRailHiddenKey = 'home_rail_hidden'; - ⎿  Updated lib/features/home/domain/home_rail.dart (+24 -0) - 27 /// Live TV. - 28 liveTv('live_tv', 'home_rails.live_tv', Icons.live_tv_outline - d), - 29 - 30 + /// What the streaming services carry, in the viewer's countr - +y. - 31 + /// - 32 + /// Off until somebody says yes. Every other band here is som - +ething the app - 33 + /// already knows about you — what you were watching, what th - +is source - 34 + /// serves. This one is a whole second catalogue, and putting - + it on Home - 35 + /// uninvited is how Home stops being yours. - 36 + watchServices( - 37 + 'watch_services', - 38 + 'home_rails.watch_services', - 39 + Icons.subscriptions_outlined, - 40 + ), - 41 + - 42 /// Everything the current source serves, in the order it ser - ves it. - 43 catalogue('catalogue', 'home_rails.catalogue', Icons.grid_vie - w_outlined); - 44 - ... - 63 resume, - 64 genres, - 65 liveTv, - 66 + watchServices, - 67 catalogue, - 68 ]; - 69 + - 70 + /// Bands that are in [defaults] — so they have a place in th - +e order and a - 71 + /// row in the customizer — but are switched OFF until asked - +for. - 72 + /// - 73 + /// The distinction matters on upgrade as much as on a fresh - +install: a band - 74 + /// added in a new version arrives in everybody's order, and - +without this - 75 + /// every existing install would find something new on Home t - +hat nobody put - 76 + /// there. - 77 + static const Set optIn = {'watch_services'}; - 78 + - 79 + bool get isOptIn => optIn.contains(id); - 80 } - 81 - 82 /// Repairs a stored order into one that can actually be render - ed. - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  hive ok - - error • Undefined name 'HomeRail'. Try correcting the name to one th - … +7 lines (ctrl+o to expand) - ⎿  Updated lib/core/constants/app_constants.dart (+6 -0) - 60 static const String homeRailOrderKey = 'home_rail_order'; - 61 static const String homeRailHiddenKey = 'home_rail_hidden'; - 62 - 63 + /// Which Home suggestions have been answered, either way. Se - +parate from the - 64 + /// hidden set: "you said no" and "it is switched off" are di - +fferent facts, - 65 + /// and conflating them means somebody who turns a band off i - +n the customizer - 66 + /// gets asked about it again on the next launch. - 67 + static const String homeSuggestionsAnsweredKey = 'home_sugges - +tions_answered'; - 68 + - 69 /// Accumulated watch time and completions. See [WatchStatsSt - ore] — it cannot - 70 /// be derived from history, which is a rolling fifty-item wi - ndow. - 71 static const String watchStatsKey = 'watch_stats'; - ⎿  Updated lib/core/storage/hive_service.dart (+48 -0) - 321 return raw.map((e) => e.toString()).toList(); - 322 } - 323 - 324 + /// The bands switched off. - 325 + /// - 326 + /// With nothing stored this is not empty: the opt-in bands - +start off. A band - 327 + /// added in a new version arrives in everybody's rail order - +, and an empty - 328 + /// hidden set would mean every existing install found somet - +hing new on Home - 329 + /// that nobody asked for. - 330 Set getHomeRailHidden() { - 331 final raw = _settingsBox.get(AppConstants.homeRailHiddenKe - y); - 332 + if (raw is! List) return HomeRail.optIn; - 333 + final stored = raw.map((e) => e.toString()).toSet(); - 334 + // An opt-in band that predates this install's stored set - +has never been - 335 + // answered, so it is still off. Once the question is answ - +ered the band's - 336 + // id is written either way, which is what takes it out of - + this branch. - 337 + final answered = getAnsweredHomeSuggestions(); - 338 + return { - 339 + ...stored, - 340 + for (final id in HomeRail.optIn) - 341 + if (!answered.contains(id)) id, - 342 + }; - 343 + } - 344 + - 345 + /// Home suggestions that have been put to the viewer and an - +swered. - 346 + Set getAnsweredHomeSuggestions() { - 347 + final raw = _settingsBox.get(AppConstants.homeSuggestionsA - +nsweredKey); - 348 if (raw is! List) return const {}; - 349 return raw.map((e) => e.toString()).toSet(); - 350 } - 351 - 352 + bool hasAnsweredHomeSuggestion(String id) => - 353 + getAnsweredHomeSuggestions().contains(id); - 354 + - 355 + /// Records the answer and applies it, in one write. - 356 + /// - 357 + /// Both halves together, because they are one decision: a y - +es that recorded - 358 + /// the answer without switching the band on would ask once - +and do nothing. - 359 + Future answerHomeSuggestion(String id, {required bool - +accepted}) async { - 360 + final answered = {...getAnsweredHomeSuggestions(), id}; - … 20 more lines (ctrl+o to expand) - - Read 1 file (ctrl+o to expand) - -⏺ Bash(sed -i '' "s|import - '../../features/auth/data/models/user_model.dart';|import - '../../features/auth/data/models/user_model.dart';\nimport - '../../features/home/do…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing hive_service.dart... - No issues found! (ran in 0.8s) - ⎿  Updated lib/core/storage/hive_service.dart (+1 -0) - 4 import '../constants/app_constants.dart'; - 5 import '../subtitles/subtitle_languages.dart'; - 6 import '../../features/auth/data/models/user_model.dart'; - 7 +import '../../features/home/domain/home_rail.dart'; - 8 import '../../features/detail/domain/entities/subtitle_style.da - rt'; - 9 - 10 class HiveService { - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Now the analytics events, the Home section + suggestion, and removing the - Search entry: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  events ok - search entry removed - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/core/analytics/analytics_event.dart (+23 -0) - 38 playbackFailed('playback_failed'), - 39 sourceSwitched('source_switched'), - 40 - 41 + // --- Home, and what people let onto it -------------------- - +--------------- - 42 + // - 43 + // A suggestion is the app asking for room on somebody's Home - + screen, so the - 44 + // whole exchange is worth counting: how often it is put, how - + often it is - 45 + // taken, and how often what was taken is later given back. T - +he band's id - 46 + // rides along as a property rather than being baked into the - + event name, so - 47 + // the next suggestion reuses these three charts instead of s - +tarting its own. - 48 + homeSuggestionShown('home_suggestion_shown'), - 49 + homeSuggestionAccepted('home_suggestion_accepted'), - 50 + homeSuggestionDismissed('home_suggestion_dismissed'), - 51 + - 52 + /// A band switched off from the band itself. Against - 53 + /// [homeSuggestionAccepted] this is the honest measure of wh - +ether the - 54 + /// suggestion was worth making. - 55 + homeRailRemoved('home_rail_removed'), - 56 + - 57 + /// The streaming line-up opened, from wherever. - 58 + watchServicesOpened('watch_services_opened'), - 59 + - 60 + /// The country had no line-up at all and a populated one was - + shown instead. - 61 + /// If this is common the feature is being sold to people it - +cannot serve. - 62 + watchServicesRegionFellBack('watch_services_region_fell_back' - +), - 63 + - 64 // --- Everything else -------------------------------------- - --------------- - 65 downloadQueued('download_queued'), - 66 extensionRepoAdded('extension_repo_added'), - ⎿  Updated lib/features/search/presentation/widgets/search_landing.dart - (+0 -104) - 5 import 'package:go_router/go_router.dart'; - 6 - 7 import 'package:soplay/core/content/catalogue.dart'; - 8 -import 'package:soplay/core/system/responsive.dart'; - 8 import 'package:soplay/core/theme/app_colors.dart'; - 9 import 'package:soplay/core/tv/tv.dart'; - 10 import 'package:soplay/core/widgets/item_appear.dart'; - ... - 60 ), - 61 ), - 62 const SliverToBoxAdapter(child: _SourceRail()), - 64 - // The way in to the streaming services. - 65 - // - 66 - // They had none. The page and its browse screen were built - -, routed and - 67 - // translated, and then the rail that used to reach them ca - -me off Home — - 68 - // correctly, because a rail is not where a whole catalogue - - belongs — and - 69 - // nothing replaced it. A feature no screen links to is a f - -eature that has - 70 - // been removed. - 71 - // - 72 - // Here rather than on Home because of what it answers: "wh - -ere can I watch - 73 - // this" is a search, not a shelf. It is one row and it fet - -ches nothing — - 74 - // the services and the region load when the page opens, no - -t when the - 75 - // search tab does. - 76 - const SliverToBoxAdapter(child: _StreamingServicesEntry()), - 63 if (genres.isNotEmpty) ...[ - 64 SliverToBoxAdapter( - 65 child: Padding( - ... - 177 double _sectionTitleHeight(BuildContext context) => - 178 MediaQuery.textScalerOf(context).scale(18) * 1.15; - 179 - 194 -/// One row, into the streaming services. - 195 -/// - 196 -/// Built out of the same parts as the rest of this screen — a - - section title in - 197 -/// the same weight, a surface card, the app's own press feedb - -ack — so it reads - 198 -/// as another place to start rather than as an advertisement - -for a feature. - 199 -class _StreamingServicesEntry extends StatelessWidget { - 200 - const _StreamingServicesEntry(); - 201 - - 202 - @override - 203 - Widget build(BuildContext context) { - 204 - final card = Container( - … 82 more lines (ctrl+o to expand) - ⎿  Updated test/features/search/search_landing_no_jump_test.dart (+0 -12) - 263 expect(genresTop(tester), lessThan(waiting)); - 264 }); - 265 - 266 - testWidgets('the streaming services have a way in', (tester) - - async { - 267 - // They had none. The page, its browse screen and its tran - -slations were all - 268 - // built and routed, and then the rail that reached them c - -ame off Home and - 269 - // nothing replaced it — so the whole feature was unreacha - -ble from any - 270 - // screen in the app, which from the outside is indistingu - -ishable from - 271 - // having deleted it. - 272 - await pump(tester, home: _HeldHome()); - 273 - - 274 - expect(find.text('watch.services_title'.tr()), findsOneWid - -get); - 275 - expect(find.text('watch.services_entry_hint'.tr()), findsO - -neWidget); - 276 - }); - 277 - - 266 testWidgets('the genre skeleton is the genre grid, greyed ou - t', ( - 267 tester, - 268 ) async { - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Now the Home section and the one-time suggestion: - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/keys_home_services.json <<'JSON' - {…) - ⎿  added 7 keys x 12 locales - ⎿  Updated assets/translations/ar.json (+9 -2) - 136 "empty_title": "لا شيء هنا بعد", - 137 "empty_subtitle": "اختر مصدراً آخر، أو اسحب للتحديث.", - 138 "list_empty_title": "هذه القائمة فارغة", - 139 - "list_empty_subtitle": "لا يوجد ما يُعرض في هذا القسم حالياً - -." - 139 + "list_empty_subtitle": "لا يوجد ما يُعرض في هذا القسم حالياً - +.", - 140 + "suggest_services_title": "إضافة خدمات البث إلى الشاشة الر - +ئيسية؟", - 141 + "suggest_services_body": "اطّلع على ما تقدّمه نتفليكس وديزني - ++ وغيرها في منطقتك. يمكنك إزالتها في أي وقت.", - 142 + "suggest_add": "إضافة", - 143 + "suggest_no": "لا، شكرًا", - 144 + "remove_from_home": "إزالة من الشاشة الرئيسية", - 145 + "removed_undo_hint": "تمت الإزالة. يمكنك إعادتها من المظهر - + ← الشاشة الرئيسية." - 146 }, - 147 "player": { - 148 "needs_media_kit": "يتطلب محرّك media_kit", - ... - 1778 "genres": "الأنواع", - 1779 "live_tv": "البث المباشر", - 1780 "catalogue": "صفوف الفهرس", - 1775 - "services": "خدمات البث" - 1781 + "services": "خدمات البث", - 1782 + "watch_services": "خدمات البث" - 1783 }, - 1784 "stats": { - 1785 "title": "الإحصائيات", - ⎿  Updated assets/translations/de.json (+9 -2) - 136 "empty_title": "Hier ist noch nichts", - 137 "empty_subtitle": "Wähle eine andere Quelle oder zieh zum - Aktualisieren.", - 138 "list_empty_title": "Diese Liste ist leer", - 139 - "list_empty_subtitle": "Für diesen Bereich gibt es gerade - -nichts zu zeigen." - 139 + "list_empty_subtitle": "Für diesen Bereich gibt es gerade - +nichts zu zeigen.", - 140 + "suggest_services_title": "Streamingdienste zur Startseite - + hinzufügen?", - 141 + "suggest_services_body": "Sieh, was Netflix, Disney+ und C - +o. bei dir im Angebot haben. Du kannst es jederzeit wieder ent - +fernen.", - 142 + "suggest_add": "Hinzufügen", - 143 + "suggest_no": "Nein danke", - 144 + "remove_from_home": "Von der Startseite entfernen", - 145 + "removed_undo_hint": "Entfernt. Über Darstellung → Startse - +ite wieder hinzufügen." - 146 }, - 147 "player": { - 148 "needs_media_kit": "Benötigt die media_kit-Engine", - ... - 1774 "genres": "Genres", - 1775 "live_tv": "Live-TV", - 1776 "catalogue": "Katalogreihen", - 1771 - "services": "Streamingdienste" - 1777 + "services": "Streamingdienste", - 1778 + "watch_services": "Streamingdienste" - 1779 }, - 1780 "stats": { - 1781 "title": "Statistik", - ⎿  Updated assets/translations/en.json (+9 -2) - 136 "empty_title": "Nothing here yet", - 137 "empty_subtitle": "Pick another source, or pull to refresh - .", - 138 "list_empty_title": "This list is empty", - 139 - "list_empty_subtitle": "Nothing to show for this section r - -ight now." - 139 + "list_empty_subtitle": "Nothing to show for this section r - +ight now.", - 140 + "suggest_services_title": "Add streaming services to Home? - +", - 141 + "suggest_services_body": "See what Netflix, Disney+ and th - +e rest carry where you are. You can remove it again at any tim - +e.", - 142 + "suggest_add": "Add to Home", - 143 + "suggest_no": "No thanks", - 144 + "remove_from_home": "Remove from Home", - 145 + "removed_undo_hint": "Removed. Add it back from Appearance - + → Home layout." - 146 }, - 147 "player": { - 148 "needs_media_kit": "Needs the media_kit engine", - ... - 1774 "genres": "Genres", - 1775 "live_tv": "Live TV", - 1776 "catalogue": "Catalogue rows", - 1771 - "services": "Streaming services" - 1777 + "services": "Streaming services", - 1778 + "watch_services": "Streaming services" - 1779 }, - 1780 "stats": { - 1781 "title": "Statistics", - ⎿  Updated assets/translations/es.json (+9 -2) - 136 "empty_title": "Aquí no hay nada todavía", - 137 "empty_subtitle": "Elige otra fuente, o desliza hacia abaj - o para actualizar.", - 138 "list_empty_title": "Esta lista está vacía", - 139 - "list_empty_subtitle": "Ahora mismo no hay nada que mostra - -r en esta sección." - 139 + "list_empty_subtitle": "Ahora mismo no hay nada que mostra - +r en esta sección.", - 140 + "suggest_services_title": "¿Añadir servicios de streaming - +al inicio?", - 141 + "suggest_services_body": "Mira qué ofrecen Netflix, Disney - ++ y los demás donde estás. Puedes quitarlo cuando quieras.", - 142 + "suggest_add": "Añadir", - 143 + "suggest_no": "No, gracias", - 144 + "remove_from_home": "Quitar del inicio", - 145 + "removed_undo_hint": "Quitado. Puedes volver a añadirlo en - + Apariencia → Inicio." - 146 }, - 147 "player": { - 148 "needs_media_kit": "Necesita el motor media_kit", - ... - 1774 "genres": "Géneros", - 1775 "live_tv": "TV en vivo", - 1776 "catalogue": "Filas del catálogo", - 1771 - "services": "Servicios de streaming" - 1777 + "services": "Servicios de streaming", - 1778 + "watch_services": "Servicios de streaming" - 1779 }, - 1780 "stats": { - 1781 "title": "Estadísticas", - ⎿  Updated assets/translations/fr.json (+9 -2) - 136 "empty_title": "Rien ici pour l’instant", - 137 "empty_subtitle": "Choisis une autre source, ou tire vers - le bas pour actualiser.", - 138 "list_empty_title": "Cette liste est vide", - 139 - "list_empty_subtitle": "Rien à afficher dans cette section - - pour le moment." - 139 + "list_empty_subtitle": "Rien à afficher dans cette section - + pour le moment.", - 140 + "suggest_services_title": "Ajouter les services de streami - +ng à l'accueil ?", - 141 + "suggest_services_body": "Voyez ce que proposent Netflix, - +Disney+ et les autres chez vous. Vous pouvez le retirer à tout - + moment.", - 142 + "suggest_add": "Ajouter", - 143 + "suggest_no": "Non merci", - 144 + "remove_from_home": "Retirer de l'accueil", - 145 + "removed_undo_hint": "Retiré. Vous pouvez le remettre dans - + Apparence → Accueil." - 146 }, - 147 "player": { - 148 "needs_media_kit": "Nécessite le moteur media_kit", - ... - 1774 "genres": "Genres", - 1775 "live_tv": "TV en direct", - 1776 "catalogue": "Rangées du catalogue", - 1771 - "services": "Services de streaming" - 1777 + "services": "Services de streaming", - 1778 + "watch_services": "Services de streaming" - 1779 }, - 1780 "stats": { - 1781 "title": "Statistiques", - ⎿  … 7 more files changed - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" -⏺ Bash(cat > - lib/features/home/presentation/widgets/home_watch_services_section.dart - <<'DART' - import 'package:easy_localization/easy_localization.dart';…) - ⎿  Analyzing home_watch_services_section.dart... - - info • The import of 'package:soplay/core/analytics/analytics_event - … +6 lines (ctrl+o to expand) - ⎿  Created lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+262 -0) - 1 +import 'package:easy_localization/easy_localization.dart'; - 2 +import 'package:flutter/material.dart'; - 3 +import 'package:go_router/go_router.dart'; - 4 + - 5 +import 'package:soplay/core/analytics/analytics.dart'; - 6 +import 'package:soplay/core/analytics/analytics_event.dart'; - 7 +import 'package:soplay/core/di/injection.dart'; - 8 +import 'package:soplay/core/storage/hive_service.dart'; - 9 +import 'package:soplay/core/system/responsive.dart'; - 10 +import 'package:soplay/core/theme/app_colors.dart'; - 11 +import 'package:soplay/core/tv/tv.dart'; - 12 +import 'package:soplay/features/home/domain/home_rail.dart'; - 13 + - 14 +/// The way in to the streaming line-up, once somebody has ask - +ed for it. - 15 +/// - 16 +/// One row, not a rail of logos. A rail would have to fetch a - + hundred and - 17 +/// thirty marks to fill itself before anybody had shown any i - +nterest, and the - 18 +/// answer it gives — which services carry what, here — is a p - +age, not a shelf. - 19 +/// This costs nothing until it is tapped. - 20 +/// - 21 +/// It carries its own way off Home, because a band that can o - +nly be removed - 22 +/// from a settings screen somewhere else is a band people put - + up with. - 23 +class HomeWatchServicesSection extends StatelessWidget { - 24 + const HomeWatchServicesSection({super.key}); - 25 + - 26 + @override - 27 + Widget build(BuildContext context) { - 28 + void open() { - 29 + getIt().track( - 30 + AnalyticsEvent.watchServicesOpened, - 31 + props: const {'from': 'home'}, - 32 + ); - 33 + context.push('/watch-services'); - 34 + } - 35 + - 36 + Future remove() async { - 37 + final hive = getIt(); - 38 + await hive.hideHomeRail(HomeRail.watchServices.id); - 39 + getIt().track( - 40 + AnalyticsEvent.homeRailRemoved, - … 222 more lines (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Bash(sed -i '' "/^import - 'package:soplay\/core\/analytics\/analytics_event.dart';$/d" - lib/features/home/presentation/widgets/home_watch_services_section.dart && - pyth…) - ⎿  content ok - 1:import 'package:easy_localization/easy_localization.dart'; - 2:import 'package:flutter/material.dart'; - … +18 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+7 - -0) - 275 yield const SliverToBoxAdapter( - 276 child: RepaintBoundary(child: LiveTvSection( - )), - 277 ); - 278 + case HomeRail.watchServices: - 279 + // Only ever here because somebody said yes: t - +he band is in - 280 + // [HomeRail.optIn], so it is off until the su - +ggestion below is - 281 + // answered the other way. - 282 + yield const SliverToBoxAdapter( - 283 + child: RepaintBoundary(child: HomeWatchServi - +cesSection()), - 284 + ); - 285 case HomeRail.catalogue: - 286 if (loaded != null && loaded.collectionLoading - ) { - 287 yield const SliverToBoxAdapter(child: Collec - tionLoadingRow()); - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+0 -1) - 3 import 'package:go_router/go_router.dart'; - 4 - 5 import 'package:soplay/core/analytics/analytics.dart'; - 6 -import 'package:soplay/core/analytics/analytics_event.dart'; - 6 import 'package:soplay/core/di/injection.dart'; - 7 import 'package:soplay/core/storage/hive_service.dart'; - 8 import 'package:soplay/core/system/responsive.dart'; - -⏺ Bash(grep -n "home_watch_services_section" - lib/features/home/presentation/widgets/home_content.dart || sed -i '' - "s|import 'package:soplay/features/home/domain/entit…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+1 - -0) - 16 import 'package:soplay/features/history/domain/entities/history - _item.dart'; - 17 import 'package:url_launcher/url_launcher.dart'; - 18 import 'package:soplay/features/home/domain/entities/hero_slide - .dart'; - 19 +import 'package:soplay/features/home/presentation/widgets/home_ - +watch_services_section.dart'; - 20 import 'package:soplay/features/home/domain/entities/home_secti - on_entity.dart'; - 21 import 'package:soplay/features/home/presentation/bloc/home/hom - e_bloc.dart'; - 22 import 'package:soplay/features/home/presentation/bloc/home/hom - e_event.dart'; - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Now the suggestion card has to actually appear on Home: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 237: final hive = getIt(); - 239: sanitizeRailOrder(hive.getHomeRailOrder()), - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+18 - -0) - 315 // missing. Everything below it is local and sti - ll works. - 316 if (catalogue case CatalogueFailed(:final messag - e)) - 317 SliverToBoxAdapter(child: HomeErrorStrip(messa - ge: message)), - 318 + // Asked once, at the top, where it can be answe - +red and be done - 319 + // with. Not a dialog: Home is not a screen anyb - +ody came to in - 320 + // order to be interrupted, and a card that can - +be ignored until - 321 + // it is convenient is a question rather than a - +demand. - 322 + if (!hive.hasAnsweredHomeSuggestion(HomeRail.wat - +chServices.id)) - 323 + SliverToBoxAdapter( - 324 + child: HomeSuggestionCard( - 325 + rail: HomeRail.watchServices, - 326 + title: 'home.suggest_services_title'.tr(), - 327 + body: 'home.suggest_services_body'.tr(), - 328 + icon: Icons.subscriptions_rounded, - 329 + // The bands are read at build time, so th - +e answer has to - 330 + // reach this widget the same way the cust - +omizer's does. - 331 + onAnswered: () => - 332 + hive.homeRailsChanged.value = - 333 + !hive.homeRailsChanged.value, - 334 + ), - 335 + ), - 336 for (final rail in rails) ...sliversFor(rail), - 337 SliverToBoxAdapter( - 338 child: SizedBox( - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Good — the page already rebuilds on that. Now the "shown" event, fired once: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 145 packages have newer versions incompatible with dependency constrai - nts. - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+30 -10) - 146 /// at all: a suggestion that comes back after "no thanks" is - not a suggestion. - 147 /// The yes is reversible from the band itself and from Appear - ance; the no is - 148 /// reversible from Appearance. Neither is reversed by the app - . - 149 -class HomeSuggestionCard extends StatelessWidget { - 149 +class HomeSuggestionCard extends StatefulWidget { - 150 const HomeSuggestionCard({ - 151 super.key, - 152 required this.rail, - ... - 162 final IconData icon; - 163 final VoidCallback onAnswered; - 164 - 165 - Future _answer(BuildContext context, {required bool ac - -cepted}) async { - 166 - await getIt().answerHomeSuggestion(rail.id, a - -ccepted: accepted); - 165 + @override - 166 + State createState() => _HomeSuggestionCa - +rdState(); - 167 +} - 168 + - 169 +class _HomeSuggestionCardState extends State { - 170 + @override - 171 + void initState() { - 172 + super.initState(); - 173 + // Counted where it is put, not where it is built: Home re - +builds on every - 174 + // source switch and every pull, and an event per rebuild - +would make the - 175 + // question look a hundred times more popular than it was - +ever asked. - 176 + getIt().track( - 177 + AnalyticsEvent.homeSuggestionShown, - 178 + props: {'rail': widget.rail.id}, - 179 + ); - 180 + } - 181 + - 182 + Future _answer({required bool accepted}) async { - 183 + await getIt().answerHomeSuggestion( - 184 + widget.rail.id, - 185 + accepted: accepted, - 186 + ); - 187 getIt().track( - 188 accepted - 189 ? AnalyticsEvent.homeSuggestionAccepted - 190 : AnalyticsEvent.homeSuggestionDismissed, - 171 - props: {'rail': rail.id}, - … 47 more lines (ctrl+o to expand) - -⏺ Now the real bug — UZ has no line-up at all, so the page is empty whatever you - do: - - Read 1 file (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - - Ran 1 shell command (ctrl+o to expand) - -⏺ UZ TMDB'ning ro'yxatida umuman yo'q — shuning uchun bo'sh. Tuzatyapman: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the sheet and flip fixes" - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  state ok - bloc ok - 1 package is discontinued. - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/bloc/watch_services/w - atch_services_bloc.dart (+37 -3) - 55 state.regions, - 56 deviceCountry: _deviceCountry, - 57 ); - 58 - await _fetch(region, emit); - 58 + // Explicit when it came from storage: that is a country so - +mebody picked, - 59 + // and an empty answer to it is an answer, not a failure to - + find one. - 60 + await _fetch(region, emit, explicit: hive.getWatchRegion(). - +isNotEmpty); - 61 } - 62 - 63 Future _onRegionChanged( - ... - 67 final region = event.region.trim().toUpperCase(); - 68 if (region.isEmpty) return; - 69 await hive.setWatchRegion(region); - 68 - await _fetch(region, emit); - 70 + await _fetch(region, emit, explicit: true); - 71 } - 72 - 73 /// The country list, fetched the first time somebody opens t - he picker. - ... - 96 emit(state.copyWith(loadingRegions: false)); - 97 } - 98 - 97 - Future _fetch(String region, Emitter emit) async { - 99 + /// The country shown when the viewer's own has no line-up a - +t all. TMDB lists - 100 + /// more for it than for anywhere else, so it is the one tha - +t always has - 101 + /// something to show. - 102 + static const String fallbackRegion = 'US'; - 103 + - 104 + Future _fetch( - 105 + String region, - 106 + Emitter emit, { - 107 + required bool explicit, - 108 + String fellBackFrom = '', - 109 + }) async { - 110 final token = ++_runToken; - 111 emit( - 112 state.copyWith( - 113 status: WatchServicesStatus.loading, - 114 region: region, - 115 + fellBackFrom: fellBackFrom, - 116 clearError: true, - … 34 more lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/bloc/watch_services/w - atch_services_state.dart (+21 -3) - 9 this.services = const [], - 10 this.regions = const [], - 11 this.loadingRegions = false, - 12 + this.fellBackFrom = '', - 13 this.error, - 14 }); - 15 - ... - 24 final List regions; - 25 final bool loadingRegions; - 26 - 27 + /// The country that was asked for and had nothing, when [reg - +ion] is a - 28 + /// stand-in the app chose instead. Empty the rest of the tim - +e. - 29 + /// - 30 + /// TMDB lists providers for 139 countries and Uzbekistan is - +not one of them, - 31 + /// which for a viewer there meant opening this screen and fi - +nding an empty - 32 + /// page — the feature reading as broken rather than as unava - +ilable. Falling - 33 + /// back silently would be worse: a grid of services nobody h - +ere can - 34 + /// subscribe to, under no explanation at all. So the app fal - +ls back AND says - 35 + /// which country it is showing. - 36 + final String fellBackFrom; - 37 + - 38 final String? error; - 39 - 40 bool get hasServices => services.isNotEmpty; - 41 - 30 - String get regionName { - 42 + String get regionName => nameOf(region); - 43 + - 44 + /// A country's name, or its code while the list has not been - + fetched. - 45 + String nameOf(String code) { - 46 for (final r in regions) { - 32 - if (r.code == region) return r.name; - 47 + if (r.code == code) return r.name; - 48 } - 34 - return region; - 49 + return code; - 50 } - 51 - 52 WatchServicesState copyWith({ - ... - 55 List? services, - … 20 more lines (ctrl+o to expand) - -⏺ Now the page has to say which country it fell back to, and record it: - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/keys_fellback.json <<'JSON' - {…) - ⎿  added 1 keys x 12 locales - 129: SliverToBoxAdapter(child: _regionMenu(state, bloc)), - 251: SliverToBoxAdapter(child: _regionMenu(state, bloc)), - ⎿  Updated assets/translations/ar.json (+1 -0) - 2012 "type_series": "مسلسلات", - 2013 "empty_region_title": "لا توجد خدمات هنا", - 2014 "empty_region_subtitle": "لا توجد لدى TMDB قائمة بث لهذه - المنطقة بعد. جرّب منطقة مجاورة.", - 2015 + "region_fell_back": "لا توجد قائمة بث لـ {from}، لذا هذه - +{to}. اختر بلدًا آخر من الأعلى.", - 2016 "load_failed": "تعذّر تحميل خدمات البث." - 2017 } - 2018 } - ⎿  Updated assets/translations/de.json (+1 -0) - 2008 "type_series": "Serien", - 2009 "empty_region_title": "Hier sind keine Dienste gelistet", - 2010 "empty_region_subtitle": "TMDB hat für diese Region noch - kein Streaming-Angebot. Versuche eine benachbarte.", - 2011 + "region_fell_back": "Für {from} ist kein Streaming-Angebo - +t gelistet, das hier ist {to}. Oben lässt sich ein anderes La - +nd wählen.", - 2012 "load_failed": "Streamingdienste konnten nicht geladen we - rden." - 2013 } - 2014 } - ⎿  Updated assets/translations/en.json (+1 -0) - 2008 "type_series": "Series", - 2009 "empty_region_title": "No services listed here", - 2010 "empty_region_subtitle": "TMDB has no streaming line-up f - or this region yet. Try a neighbouring one.", - 2011 + "region_fell_back": "No streaming line-up is listed for { - +from}, so this is {to}. Pick another country above.", - 2012 "load_failed": "Streaming services could not be loaded." - 2013 } - 2014 } - ⎿  Updated assets/translations/es.json (+1 -0) - 2008 "type_series": "Series", - 2009 "empty_region_title": "No hay servicios aquí", - 2010 "empty_region_subtitle": "TMDB aún no tiene servicios par - a esta región. Prueba una vecina.", - 2011 + "region_fell_back": "No hay catálogo de streaming para {f - +rom}, así que esto es {to}. Elige otro país arriba.", - 2012 "load_failed": "No se pudieron cargar los servicios." - 2013 } - 2014 } - ⎿  Updated assets/translations/fr.json (+1 -0) - 2008 "type_series": "Séries", - 2009 "empty_region_title": "Aucun service ici", - 2010 "empty_region_subtitle": "TMDB n’a pas encore d’offre pou - r cette région. Essayez une région voisine.", - 2011 + "region_fell_back": "Aucune offre de streaming n'est répe - +rtoriée pour {from}, ceci est donc {to}. Choisissez un autre - +pays ci-dessus.", - 2012 "load_failed": "Impossible de charger les services." - 2013 } - 2014 } - ⎿  … 7 more files changed - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 145 packages have newer versions incompatible with dependency constrai - nts. - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/pages/watch_services_ - page.dart (+47 -0) - 127 slivers: [ - 128 SliverToBoxAdapter(child: SizedBox(height: appBarH - + 8)), - 129 SliverToBoxAdapter(child: _regionMenu(state, bloc) - ), - 130 + if (state.fellBackFrom.isNotEmpty) - 131 + SliverToBoxAdapter(child: _fellBackNote(state)), - 132 + if (state.fellBackFrom.isNotEmpty) - 133 + SliverToBoxAdapter(child: _fellBackNote(state)), - 134 SliverToBoxAdapter(child: _search(state.services.l - ength)), - 135 if (showTop) ...[ - 136 _label('watch.top_services'.tr()), - ... - 243 ); - 244 } - 245 - 246 + /// Said plainly, where the grid would be: this is somewhere - + else's line-up. - 247 + /// - 248 + /// Without it the fallback is a lie by omission — a screen - +of services - 249 + /// nobody in the viewer's country can subscribe to, under a - + flag that is not - 250 + /// theirs, with nothing to say why. - 251 + Widget _fellBackNote(WatchServicesState state) => Padding( - 252 + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), - 253 + child: Container( - 254 + padding: const EdgeInsetsDirectional.fromSTEB(12, 10, 12 - +, 10), - 255 + decoration: BoxDecoration( - 256 + color: AppColors.surface, - 257 + borderRadius: BorderRadius.circular(12), - 258 + border: Border.all(color: Colors.white.withValues(alph - +a: 0.06)), - 259 + ), - 260 + child: Row( - 261 + crossAxisAlignment: CrossAxisAlignment.start, - 262 + children: [ - 263 + const Icon( - 264 + Icons.info_outline_rounded, - 265 + size: 16, - 266 + color: AppColors.textHint, - 267 + ), - 268 + const SizedBox(width: 10), - 269 + Expanded( - 270 + child: Text( - 271 + 'watch.region_fell_back'.tr( - 272 + namedArgs: { - … 19 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   error • The type 'HomeRail' isn't exhaustively matched by the switch - cases since it doesn't match the pattern 'HomeRail.watchServices'. Tr - y adding a wildcard pattern or cases that match 'HomeRail.watchService - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/bloc/watch_services/w - atch_services_bloc.dart (+6 -0) - 2 - 3 import 'package:equatable/equatable.dart'; - 4 import 'package:flutter_bloc/flutter_bloc.dart'; - 5 +import 'package:soplay/core/analytics/analytics.dart'; - 6 +import 'package:soplay/core/di/injection.dart'; - 7 import 'package:soplay/core/error/result.dart'; - 8 import 'package:soplay/core/storage/hive_service.dart'; - 9 import 'package:soplay/features/watch_services/domain/entities/w - atch_region_entity.dart'; - ... - 132 !explicit && - 133 region != fallbackRegion && - 134 fellBackFrom.isEmpty) { - 135 + getIt().track( - 136 + AnalyticsEvent.watchServicesRegionFellBack, - 137 + props: {'from': region, 'to': fallbackRegion}, - 138 + ); - 139 // So the picker, and the resolver, know the real li - st next time. - 140 add(const WatchServicesRegionsRequested()); - 141 await _fetch( - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 11.1s) - ⎿  Updated lib/features/profile/presentation/widgets/home_rail_customizer - _sheet.dart (+24 -0) - 356 ), - 357 ), - 358 - 359 + // One wide row with a mark on the left. It is a way in - +rather than a - 360 + // shelf, and the preview has to say so, or somebody tur - +ns it on - 361 + // expecting a rail of logos. - 362 + HomeRail.watchServices => SizedBox( - 363 + height: 34, - 364 + child: Row( - 365 + children: [ - 366 + _block(width: 34, height: 34, radius: 9), - 367 + const SizedBox(width: 10), - 368 + Expanded( - 369 + child: Column( - 370 + crossAxisAlignment: CrossAxisAlignment.start - +, - 371 + mainAxisAlignment: MainAxisAlignment.center, - 372 + children: [ - 373 + _block(width: 84, height: 7, radius: 3), - 374 + const SizedBox(height: 6), - 375 + _block(width: 130, height: 6, radius: 3), - 376 + ], - 377 + ), - 378 + ), - 379 + ], - 380 + ), - 381 + ), - 382 + - 383 // Tall posters, twice — the catalogue is several rails, - not one. - 384 HomeRail.catalogue => Column( - 385 children: [ - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) ⎿  00:00 +13: hiding the order survives hiding - 00:00 +14: hiding hiding everything still leaves something to look at - 00:00 +15: hiding hiding nothing shows everything - … +2 lines (ctrl+o to expand) - ⎿  Updated test/features/home/home_rail_test.dart (+27 -1) - 21 test('the defaults are the order Home has always had', () { - 22 expect( - 23 HomeRail.defaults.map((r) => r.id).toList(), - 24 - ['hero', 'resume', 'genres', 'live_tv', 'catalogue'], - 24 + ['hero', 'resume', 'genres', 'live_tv', 'watch_services - +', 'catalogue'], - 25 ); - 26 }); - 27 }); - 28 - 29 + group('bands nobody asked for', () { - 30 + test('the streaming line-up is one of them', () { - 31 + expect(HomeRail.watchServices.isOptIn, isTrue); - 32 + expect(HomeRail.catalogue.isOptIn, isFalse); - 33 + expect(HomeRail.liveTv.isOptIn, isFalse); - 34 + }); - 35 + - 36 + test('and an opt-in band is not visible just for being in t - +he order', () { - 37 + // The whole point: adding a band in a new version puts i - +t in everybody's - 38 + // stored order, and without the hidden set carrying it t - +hat is a thing - 39 + // appearing on Home that nobody put there. - 40 + final shown = visibleRails(HomeRail.defaults, HomeRail.op - +tIn); - 41 + expect(shown.contains(HomeRail.watchServices), isFalse); - 42 + expect(shown.contains(HomeRail.catalogue), isTrue); - 43 + }); - 44 + - 45 + test('and it shows once it is taken out of the hidden set', - + () { - 46 + final shown = visibleRails(HomeRail.defaults, const {}); - 47 + expect(shown.contains(HomeRail.watchServices), isTrue); - 48 + }); - 49 + }); - 50 + - 51 group('repairing a stored order', () { - 52 test('nothing stored gives the defaults', () { - 53 expect(sanitizeRailOrder(const []), HomeRail.defaults); - ... - 67 'hero', - 68 'genres', - 69 'live_tv', - 70 + // Appended, because it is newer than this stored order - +. Being in the - 71 + // order is not being on Home: it is an opt-in band, so - + it arrives - 72 + // switched off and the customizer has a row for it eit - +her way. - … 4 more lines (ctrl+o to expand) - -⏺ Now a test for the fallback — the bug that made it "umuman ishlamayapti": - - Searched for 1 pattern, listed 1 directory, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(cat > test/features/watch_services/watch_services_fallback_test.dart - <<'DART' - // A country TMDB has never heard of used to be an empty screen.…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +18 lines (ctrl+o to expand) - ⎿  Created test/features/watch_services/watch_services_fallback_test.dart - (+161 -0) - 1 +// A country TMDB has never heard of used to be an empty scree - +n. - 2 +// - 3 +// TMDB publishes streaming line-ups for 139 countries. Uzbeki - +stan is not one of - 4 +// them, and neither is Kazakhstan — so a viewer in either ope - +ned Streaming - 5 +// services and found a blank grid with no explanation, which - +reads as the - 6 +// feature being broken rather than as their country not being - + covered. - 7 +// - 8 +// The region list would have said so, but it is only fetched - +when the picker is - 9 +// opened, so the first thing in the app that knows is the emp - +ty answer itself. - 10 +import 'package:flutter_test/flutter_test.dart'; - 11 +import 'package:get_it/get_it.dart'; - 12 +import 'package:soplay/core/analytics/analytics.dart'; - 13 +import 'package:soplay/core/error/result.dart'; - 14 +import 'package:soplay/core/storage/hive_service.dart'; - 15 +import 'package:soplay/features/watch_services/domain/entities - +/watch_region_entity.dart'; - 16 +import 'package:soplay/features/watch_services/domain/entities - +/watch_service_entity.dart'; - 17 +import 'package:soplay/features/watch_services/domain/reposito - +ries/watch_services_repository.dart'; - 18 +import 'package:soplay/features/watch_services/domain/usecase/ - +watch_services_usecase.dart'; - 19 +import 'package:soplay/features/watch_services/presentation/bl - +oc/watch_services/watch_services_bloc.dart'; - 20 + - 21 +/// Only the countries TMDB actually lists have anything. - 22 +class _Repo implements WatchServicesRepository { - 23 + _Repo({this.lists = const {'US': 3, 'GB': 2}}); - 24 + - 25 + final Map lists; - 26 + final asked = []; - 27 + var regionsAsked = 0; - 28 + - 29 + @override - 30 + Future>> loadServices({ - 31 + required String region, - 32 + }) async { - 33 + asked.add(region); - 34 + return Success([ - 35 + for (var i = 0; i < (lists[region] ?? 0); i++) - 36 + WatchServiceEntity( - 37 + id: i, - 38 + name: '$region service $i', - 39 + slug: '$region-$i', - 40 + logo: '', - … 121 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - TimeoutException after 0:00:05.000000: Future not completed - dart:async _startMicrotaskLoop - … +16 lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/bloc/watch_services/w - atch_services_bloc.dart (+18 -6) - 27 /// region can change while a load is out, and the answer that - lands last is not - 28 /// necessarily the one that was asked for last. - 29 class WatchServicesBloc extends Bloc { - 30 - WatchServicesBloc({required this.useCase, required this.hive} - -) - 31 - : super(const WatchServicesState()) { - 30 + WatchServicesBloc({ - 31 + required this.useCase, - 32 + required this.hive, - 33 + String Function()? deviceCountry, - 34 + }) : _country = deviceCountry ?? _platformCountry, - 35 + super(const WatchServicesState()) { - 36 on(_onLoad); - 37 on(_onRegionChanged); - 38 on(_onRegionsRequested); - ... - 41 final WatchServicesUseCase useCase; - 42 final HiveService hive; - 43 - 44 + /// Where the viewer is, when they have not said. - 45 + /// - 46 + /// A seam rather than a direct read of [PlatformDispatcher], - + for the same - 47 + /// reason the countdown takes its clock: the whole of the fa - +llback behaviour - 48 + /// depends on this value, and `PlatformDispatcher.instance` - +is a global a - 49 + /// test cannot move — `localeTestValue` sets the binding's d - +ispatcher, not - 50 + /// the static one the app would otherwise read. - 51 + final String Function() _country; - 52 + - 53 int _runToken = 0; - 54 - 42 - /// The device's country, for a viewer who has never chosen o - -ne. - 43 - static String get _deviceCountry => - 55 + static String _platformCountry() => - 56 PlatformDispatcher.instance.locale.countryCode ?? ''; - 57 - 58 Future _onLoad( - ... - 67 final region = resolveRegion( - 68 hive.getWatchRegion(), - 69 state.regions, - 58 - deviceCountry: _deviceCountry, - 70 + deviceCountry: _country(), - 71 ); - … 10 more lines (ctrl+o to expand) - ⎿  Updated test/features/watch_services/watch_services_fallback_test.dart - (+10 -6) - 82 - 83 tearDown(getIt.reset); - 84 - 85 - WatchServicesBloc blocFor(_Repo repo, _Hive hive) => WatchSer - -vicesBloc( - 86 - useCase: WatchServicesUseCase(repo), - 87 - hive: hive, - 88 - ); - 85 + /// Uzbekistan, which is exactly the case this is about: a re - +al country that - 86 + /// TMDB does not publish a line-up for. - 87 + WatchServicesBloc blocFor(_Repo repo, _Hive hive, {String dev - +ice = 'UZ'}) => - 88 + WatchServicesBloc( - 89 + useCase: WatchServicesUseCase(repo), - 90 + hive: hive, - 91 + deviceCountry: () => device, - 92 + ); - 93 - 94 Future settle(WatchServicesBloc bloc) => - bloc.stream - 95 .firstWhere( - ... - 111 .firstWhere((s) => s.status == WatchServicesStatus.loa - ded && s.hasServices) - 112 .timeout(const Duration(seconds: 5)); - 113 - 110 - expect(repo.asked.first, isNot('US'), reason: 'it tried th - -e device first'); - 114 + expect(repo.asked, ['UZ', 'US'], reason: 'the device first - +, then the stand-in'); - 115 expect(state.region, 'US'); - 112 - expect(state.fellBackFrom, repo.asked.first); - 116 + expect(state.fellBackFrom, 'UZ'); - 117 expect(state.services, hasLength(3)); - 118 // And the real country list is fetched, so the picker and - the resolver are - 119 // right from here on rather than repeating this every ope - n. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  DBG WatchServicesStatus.loading region=UZ n=0 fell= - 00:05 +0 -1: Some tests failed. - ⎿  Updated test/features/watch_services/watch_services_fallback_test.dart - (+5 -0) - 104 final bloc = blocFor(repo, _Hive()); - 105 addTearDown(bloc.close); - 106 - 107 + bloc.stream.listen((s) { - 108 + // ignore: avoid_print - 109 + print('DBG ${s.status} region=${s.region} n=${s.services - +.length} ' - 110 + 'fell=${s.fellBackFrom}'); - 111 + }); - 112 bloc.add(const WatchServicesLoad()); - 113 // The first answer is the fallback's, because the empty o - ne is not an - 114 // answer anybody can use. - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  DBG WatchServicesStatus.loading region=UZ n=0 fell= - DBG-ERR Bad state: GetIt: Object/factory with type Analytics is not re - gistered inside GetIt. - 00:05 +0 -1: Some tests failed. - ⎿  Updated test/features/watch_services/watch_services_fallback_test.dart - (+10 -0) - 7 // - 8 // The region list would have said so, but it is only fetched w - hen the picker is - 9 // opened, so the first thing in the app that knows is the empt - y answer itself. - 10 +import 'package:bloc/bloc.dart'; - 11 import 'package:flutter_test/flutter_test.dart'; - 12 import 'package:get_it/get_it.dart'; - 13 import 'package:soplay/core/analytics/analytics.dart'; - ... - 71 dynamic noSuchMethod(Invocation i) => null; - 72 } - 73 - 74 +class _Obs extends BlocObserver { - 75 + @override - 76 + void onError(BlocBase bloc, Object error, StackTrace - + st) { - 77 + // ignore: avoid_print - 78 + print('DBG-ERR $error'); - 79 + } - 80 +} - 81 + - 82 void main() { - 83 + Bloc.observer = _Obs(); - 84 final getIt = GetIt.instance; - 85 - 86 setUp(() { - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +14: /Users/azamov/Sozo-Full/sozo/test/features/watch_services/w - atch_services_fallback_test.dart: a country with no line-up falls back - , and says so - … +13 lines (ctrl+o to expand) - ⎿  Updated test/features/watch_services/watch_services_fallback_test.dart - (+5 -18) - 7 // - 8 // The region list would have said so, but it is only fetched w - hen the picker is - 9 // opened, so the first thing in the app that knows is the empt - y answer itself. - 10 -import 'package:bloc/bloc.dart'; - 10 import 'package:flutter_test/flutter_test.dart'; - 11 import 'package:get_it/get_it.dart'; - 12 import 'package:soplay/core/analytics/analytics.dart'; - ... - 70 dynamic noSuchMethod(Invocation i) => null; - 71 } - 72 - 74 -class _Obs extends BlocObserver { - 75 - @override - 76 - void onError(BlocBase bloc, Object error, StackTrace - - st) { - 77 - // ignore: avoid_print - 78 - print('DBG-ERR $error'); - 79 - } - 80 -} - 81 - - 73 void main() { - 83 - Bloc.observer = _Obs(); - 74 final getIt = GetIt.instance; - 75 - 86 - setUp(() { - 87 - getIt.reset(); - 76 + // Awaited: `reset` is asynchronous, and an un-awaited one la - +nds AFTER the - 77 + // registration below and quietly wipes it. - 78 + setUp(() async { - 79 + await getIt.reset(); - 80 // The bloc records the fallback. Suppressed, so nothing is - sent and the - 81 // call is still exercised. - 82 getIt.registerSingleton(Analytics(suppressed: () - => true)); - 83 }); - 84 - 93 - tearDown(getIt.reset); - 85 + tearDown(() async => getIt.reset()); - 86 - 87 /// Uzbekistan, which is exactly the case this is about: a re - al country that - 88 /// TMDB does not publish a line-up for. - ... - 106 final bloc = blocFor(repo, _Hive()); - 107 addTearDown(bloc.close); - … 9 more lines (ctrl+o to expand) - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/features/watch_services lib/features/home - lib/core/storage/hive_service.d…) - ⎿  Formatted test/features/home/home_rail_test.dart - Formatted 67 files (16 changed) in 0.13 seconds. - warning • A value for optional parameter 'lists' isn't ever given. Try - … +13 lines (ctrl+o to expand) - ⎿  Updated lib/core/storage/hive_service.dart (+3 -1) - 116 /// OLD source's slugs. - 117 /// - 118 /// So the notification lives at the write, where it cannot - be forgotten. - 119 - final ValueNotifier currentProviderChanged = ValueNo - -tifier(''); - 119 + final ValueNotifier currentProviderChanged = ValueNo - +tifier( - 120 + '', - 121 + ); - 122 - 123 Future saveCurrentProvider(String providerId) async { - 124 final before = getCurrentProvider(); - ⎿  Updated lib/features/home/domain/usecase/view_all_usecase.dart (+2 -6) - 8 - 9 ViewAllUseCase(this.repository); - 10 - 11 - Future> call(String key ,String s - -lug ,int page) { - 12 - return repository.loadViewAll( - 13 - key: key, - 14 - slug: slug, - 15 - page: page, - 16 - ); - 11 + Future> call(String key, String s - +lug, int page) { - 12 + return repository.loadViewAll(key: key, slug: slug, page: p - +age); - 13 } - 14 } - ⎿  Updated - lib/features/home/presentation/bloc/view_all/view_all_bloc.dart (+36 - -24) - 25 final Result result = await useCase(_k - ey, _slug, 1); - 26 if (result.isSuccess) { - 27 final data = result.getOrNull()!; - 28 - emit(ViewAllLoaded( - 29 - items: data.items, - 30 - currentPage: data.page, - 31 - totalPages: data.totalPages, - 32 - )); - 28 + emit( - 29 + ViewAllLoaded( - 30 + items: data.items, - 31 + currentPage: data.page, - 32 + totalPages: data.totalPages, - 33 + ), - 34 + ); - 35 } else { - 36 emit(ViewAllError(mesage: result.getErrorOrNull()!.toStri - ng())); - 37 } - 38 } - 39 - 38 - Future _onLoadMore(ViewAllLoadMore event, Emitter emit) async { - 40 + Future _onLoadMore( - 41 + ViewAllLoadMore event, - 42 + Emitter emit, - 43 + ) async { - 44 final current = state; - 40 - if (current is! ViewAllLoaded || !current.hasMore || curren - -t.isLoadingMore) return; - 45 + if (current is! ViewAllLoaded || !current.hasMore || curren - +t.isLoadingMore) - 46 + return; - 47 - 42 - emit(ViewAllLoaded( - 43 - items: current.items, - 44 - currentPage: current.currentPage, - 45 - totalPages: current.totalPages, - 46 - isLoadingMore: true, - 47 - )); - 48 + emit( - 49 + ViewAllLoaded( - 50 + items: current.items, - 51 + currentPage: current.currentPage, - … 40 more lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/pages/home_view_all_page.dart - (+6 -7) - 30 @override - 31 Widget build(BuildContext context) { - 32 return BlocProvider( - 33 - create: (_) => getIt() - 34 - ..add(ViewAllLoad(key: keyCat, slug: slug)), - 33 + create: (_) => - 34 + getIt()..add(ViewAllLoad(key: keyCat, sl - +ug: slug)), - 35 child: _HomeViewAllView(keyCat: keyCat, slug: slug, title - : title), - 36 ); - 37 } - ... - 73 - 74 void _maybeAutoFill(ViewAllState state) { - 75 if (!isDesktopPlatform) return; - 76 - if (state is! ViewAllLoaded || !state.hasMore || state.isLo - -adingMore) return; - 76 + if (state is! ViewAllLoaded || !state.hasMore || state.isLo - +adingMore) - 77 + return; - 78 WidgetsBinding.instance.addPostFrameCallback((_) { - 79 if (!mounted || !_scroll.hasClients) return; - 80 if (_scroll.position.maxScrollExtent <= 0) { - ... - 131 right: 0, - 132 child: ValueListenableBuilder( - 133 valueListenable: _blurProgress, - 133 - builder: (_, progress, _) => ViewAllAppBar( - 134 - title: widget.title, - 135 - blurProgress: progress, - 136 - ), - 134 + builder: (_, progress, _) => - 135 + ViewAllAppBar(title: widget.title, blurProgr - +ess: progress), - 136 ), - 137 ), - 138 ], - ⎿  Updated - lib/features/home/presentation/widgets/backend_outage_banner.dart (+17 - -8) - 49 decoration: BoxDecoration( - 50 color: Colors.orange.withValues(alpha: 0.14), - 51 borderRadius: BorderRadius.circular(12), - 52 - border: - 53 - Border.all(color: Colors.orange.withValues(al - -pha: 0.35)), - 52 + border: Border.all(color: Colors.orange.withValue - +s(alpha: 0.35)), - 53 ), - 54 child: Row( - 55 children: [ - 57 - const Icon(Icons.cloud_off_rounded, - 58 - size: 16, color: Colors.orange), - 56 + const Icon( - 57 + Icons.cloud_off_rounded, - 58 + size: 16, - 59 + color: Colors.orange, - 60 + ), - 61 const SizedBox(width: 8), - 62 Expanded( - 63 child: Text( - ... - 85 child: Text( - 86 'outage.pick_source'.tr(), - 87 style: const TextStyle( - 86 - fontSize: 11.5, fontWeight: FontWeigh - -t.w700), - 88 + fontSize: 11.5, - 89 + fontWeight: FontWeight.w700, - 90 + ), - 91 ), - 92 ) - 93 else - ... - 102 child: Text( - 103 'general.retry'.tr(), - 104 style: const TextStyle( - 101 - fontSize: 11.5, fontWeight: FontWeig - -ht.w700), - 105 + fontSize: 11.5, - 106 + fontWeight: FontWeight.w700, - 107 + ), - 108 ), - 109 ), - 110 IconButton( - ... - 117 minHeight: 48, - … 12 more lines (ctrl+o to expand) - ⎿  … 11 more files changed - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(git checkout -- lib/features/home/domain/usecase/view_all_usecase.dart - lib/features/home/presentation/bloc/view_all/view_all_bloc.dart - lib/features/home/present…) - ⎿   .../home/presentation/widgets/view_all_widgets.dart | 18 +++++++++ - +-------- - 1 file changed, 10 insertions(+), 8 deletions(-) - ⎿  Updated lib/features/home/domain/usecase/view_all_usecase.dart (+6 -2) - 8 - 9 ViewAllUseCase(this.repository); - 10 - 11 - Future> call(String key, String s - -lug, int page) { - 12 - return repository.loadViewAll(key: key, slug: slug, page: p - -age); - 11 + Future> call(String key ,String s - +lug ,int page) { - 12 + return repository.loadViewAll( - 13 + key: key, - 14 + slug: slug, - 15 + page: page, - 16 + ); - 17 } - 18 } - ⎿  Updated - lib/features/home/presentation/bloc/view_all/view_all_bloc.dart (+24 - -36) - 25 final Result result = await useCase(_k - ey, _slug, 1); - 26 if (result.isSuccess) { - 27 final data = result.getOrNull()!; - 28 - emit( - 29 - ViewAllLoaded( - 30 - items: data.items, - 31 - currentPage: data.page, - 32 - totalPages: data.totalPages, - 33 - ), - 34 - ); - 28 + emit(ViewAllLoaded( - 29 + items: data.items, - 30 + currentPage: data.page, - 31 + totalPages: data.totalPages, - 32 + )); - 33 } else { - 34 emit(ViewAllError(mesage: result.getErrorOrNull()!.toStri - ng())); - 35 } - 36 } - 37 - 40 - Future _onLoadMore( - 41 - ViewAllLoadMore event, - 42 - Emitter emit, - 43 - ) async { - 38 + Future _onLoadMore(ViewAllLoadMore event, Emitter emit) async { - 39 final current = state; - 45 - if (current is! ViewAllLoaded || !current.hasMore || curren - -t.isLoadingMore) - 46 - return; - 40 + if (current is! ViewAllLoaded || !current.hasMore || curren - +t.isLoadingMore) return; - 41 - 48 - emit( - 49 - ViewAllLoaded( - 50 - items: current.items, - 51 - currentPage: current.currentPage, - 52 - totalPages: current.totalPages, - 53 - isLoadingMore: true, - 54 - ), - 55 - ); - 42 + emit(ViewAllLoaded( - 43 + items: current.items, - … 40 more lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/pages/home_view_all_page.dart - (+7 -6) - 30 @override - 31 Widget build(BuildContext context) { - 32 return BlocProvider( - 33 - create: (_) => - 34 - getIt()..add(ViewAllLoad(key: keyCat, sl - -ug: slug)), - 33 + create: (_) => getIt() - 34 + ..add(ViewAllLoad(key: keyCat, slug: slug)), - 35 child: _HomeViewAllView(keyCat: keyCat, slug: slug, title - : title), - 36 ); - 37 } - ... - 73 - 74 void _maybeAutoFill(ViewAllState state) { - 75 if (!isDesktopPlatform) return; - 76 - if (state is! ViewAllLoaded || !state.hasMore || state.isLo - -adingMore) - 77 - return; - 76 + if (state is! ViewAllLoaded || !state.hasMore || state.isLo - +adingMore) return; - 77 WidgetsBinding.instance.addPostFrameCallback((_) { - 78 if (!mounted || !_scroll.hasClients) return; - 79 if (_scroll.position.maxScrollExtent <= 0) { - ... - 130 right: 0, - 131 child: ValueListenableBuilder( - 132 valueListenable: _blurProgress, - 134 - builder: (_, progress, _) => - 135 - ViewAllAppBar(title: widget.title, blurProgr - -ess: progress), - 133 + builder: (_, progress, _) => ViewAllAppBar( - 134 + title: widget.title, - 135 + blurProgress: progress, - 136 + ), - 137 ), - 138 ), - 139 ], - ⎿  Updated - lib/features/home/presentation/widgets/backend_outage_banner.dart (+8 - -17) - 49 decoration: BoxDecoration( - 50 color: Colors.orange.withValues(alpha: 0.14), - 51 borderRadius: BorderRadius.circular(12), - 52 - border: Border.all(color: Colors.orange.withValue - -s(alpha: 0.35)), - 52 + border: - 53 + Border.all(color: Colors.orange.withValues(al - +pha: 0.35)), - 54 ), - 55 child: Row( - 56 children: [ - 56 - const Icon( - 57 - Icons.cloud_off_rounded, - 58 - size: 16, - 59 - color: Colors.orange, - 60 - ), - 57 + const Icon(Icons.cloud_off_rounded, - 58 + size: 16, color: Colors.orange), - 59 const SizedBox(width: 8), - 60 Expanded( - 61 child: Text( - ... - 83 child: Text( - 84 'outage.pick_source'.tr(), - 85 style: const TextStyle( - 88 - fontSize: 11.5, - 89 - fontWeight: FontWeight.w700, - 90 - ), - 86 + fontSize: 11.5, fontWeight: FontWeigh - +t.w700), - 87 ), - 88 ) - 89 else - ... - 98 child: Text( - 99 'general.retry'.tr(), - 100 style: const TextStyle( - 105 - fontSize: 11.5, - 106 - fontWeight: FontWeight.w700, - 107 - ), - 101 + fontSize: 11.5, fontWeight: FontWeig - +ht.w700), - 102 ), - 103 ), - 104 IconButton( - ... - 111 minHeight: 48, - … 12 more lines (ctrl+o to expand) - ⎿  Updated - lib/features/home/presentation/widgets/home_downloads_section.dart (+1 - -2) - 94 scrollDirection: Axis.horizontal, - 95 padding: const EdgeInsets.symmetric(horizontal: - 12), - 96 itemCount: _items.length > 20 ? 20 : _items.leng - th, - 97 - itemBuilder: (_, i) => - 98 - _DownloadCard(item: _items[i], siblings: _it - -ems), - 97 + itemBuilder: (_, i) => _DownloadCard(item: _item - +s[i], siblings: _items), - 98 ), - 99 ), - 100 ], - ⎿  … 4 more files changed - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Updated test/features/watch_services/watch_services_fallback_test.dart - (+3 -2) - 20 - 21 /// Only the countries TMDB actually lists have anything. - 22 class _Repo implements WatchServicesRepository { - 23 - _Repo({this.lists = const {'US': 3, 'GB': 2}}); - 23 + /// What TMDB publishes: a line-up for some countries and not - +hing for the - 24 + /// rest. Uzbekistan and Kazakhstan are real examples of the - +rest. - 25 + static const Map lists = {'US': 3, 'GB': 2}; - 26 - 25 - final Map lists; - 27 final asked = []; - 28 var regionsAsked = 0; - 29 - -⏺ Now the services page skeleton — it's poster-shaped at 3 columns while the - real tiles are square marks at 4: - - Searched for 2 patterns, ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Analyzing watch_services... - … +8 lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/pages/watch_services_ - page.dart (+69 -1) - 4 import 'package:soplay/core/di/injection.dart'; - 5 import 'package:soplay/core/theme/app_colors.dart'; - 6 import 'package:soplay/core/widgets/item_appear.dart'; - 7 +import 'package:soplay/core/widgets/shimmer_wrapper.dart'; - 8 +import 'package:soplay/features/home/presentation/widgets/home_ - +shared_widgets.dart'; - 9 import 'package:soplay/features/home/presentation/widgets/view_ - all_widgets.dart'; - 10 import 'package:soplay/features/watch_services/domain/entities/ - watch_service_entity.dart'; - 11 import 'package:soplay/features/watch_services/presentation/blo - c/watch_services/watch_services_bloc.dart'; - ... - 112 if (!state.hasServices) { - 113 if (state.status == WatchServicesStatus.loading || - 114 state.status == WatchServicesStatus.initial) { - 113 - return ViewAllSkeleton(appBarH: appBarH); - 115 + return _skeleton(context, appBarH); - 116 } - 117 return _empty(context, state, bloc, appBarH); - 118 } - ... - 250 /// Without it the fallback is a lie by omission — a screen - of services - 251 /// nobody in the viewer's country can subscribe to, under a - flag that is not - 252 /// theirs, with nothing to say why. - 253 + /// The grid, greyed out — the same marks in the same places - +. - 254 + /// - 255 + /// It used to borrow [ViewAllSkeleton], which is three colu - +mns of tall - 256 + /// posters with two caption lines under each. This grid is - +four columns of - 257 + /// square marks with one short name, so the wait looked not - +hing like the - 258 + /// answer: the moment the services landed, every tile chang - +ed shape, size - 259 + /// and column, and the whole page reflowed under whoever wa - +s looking at it. - 260 + Widget _skeleton(BuildContext context, double appBarH) { - 261 + final size = WatchServiceTile.sizeFor(); - 262 + return LayoutBuilder( - 263 + builder: (context, constraints) { - 264 + final columns = columnsFor(constraints.maxWidth); - 265 + return CustomScrollView( - 266 + // Not the page's own controller: this view is repla - +ced wholesale by - 267 + // the real grid, and two scrollables sharing one co - +ntroller is an - 268 + // exception rather than a layout. - 269 + physics: const NeverScrollableScrollPhysics(), - 270 + slivers: [ - 271 + SliverToBoxAdapter(child: SizedBox(height: appBarH - + + 8)), - 272 + SliverPadding( - 273 + padding: const EdgeInsets.fromLTRB(14, 10, 14, 0 - +), - … 48 more lines (ctrl+o to expand) - -⏺ Bash(sed -i '' "/^import - 'package:soplay\/core\/widgets\/shimmer_wrapper.dart';$/d" - lib/features/watch_services/presentation/pages/watch_services_page.dart && - export…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 9.4s) - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/features/watch_services/presentation/pages/watch_services_ - page.dart (+0 -1) - 4 import 'package:soplay/core/di/injection.dart'; - 5 import 'package:soplay/core/theme/app_colors.dart'; - 6 import 'package:soplay/core/widgets/item_appear.dart'; - 7 -import 'package:soplay/core/widgets/shimmer_wrapper.dart'; - 7 import 'package:soplay/features/home/presentation/widgets/home_ - shared_widgets.dart'; - 8 import 'package:soplay/features/home/presentation/widgets/view_ - all_widgets.dart'; - 9 import 'package:soplay/features/watch_services/domain/entities/ - watch_service_entity.dart'; - - Pushed to work/2026-09-14, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild with the Home suggestion) - ⎿  Monitor started · task bkrb4qcq0 · timeout 900s - -⏺ Build ketyapti. 559296b push qilindi. - - Nega "umuman ishlamayapti" edi — topdim - - TMDB striming ro'yxatini 139 ta davlat uchun chop etadi. O'zbekiston ularning - ichida yo'q (Qozog'iston ham). Backend to'g'ri javob beryapti: - {"region":"UZ","total":0,"items":[]}. Davlatlar ro'yxati esa faqat region - tanlagich ochilganda yuklanadi — shuning uchun ilova UZ'ni so'rab, bo'sh javob - olib, hech qanday izohsiz bo'sh grid ko'rsatardi. Bu "davlatingiz qamrab - olinmagan" emas, "funksiya buzuq" bo'lib o'qiladi. - - Endi: hech kim tanlamagan region bo'sh javob bersa — ilova TMDB eng ko'p - ma'lumotga ega bo'lgan regionga o'tadi, haqiqiy davlatlar ro'yxatini yuklaydi - (keyingi safar to'g'ri bo'lishi uchun) va ekranda ochiq aytadi: "O'zbekiston - uchun ro'yxat yo'q, bu — Qo'shma Shtatlar. Yuqoridan boshqa davlatni tanlang." - Agar foydalanuvchi o'zi tanlagan region bo'sh bo'lsa — o'sha holida qoladi: - bu savolga berilgan javob, uni almashtirish tanlovni bekor qilish bo'lardi. - - Search'dan olib tashlandi, dashboardga taklif sifatida qo'yildi - - - HomeRail.watchServices — Home bandlari qatoriga qo'shildi, lekin yangi - HomeRail.optIn to'plamiga ham: yangi versiyada qo'shilgan band hammaning - saqlangan tartibiga tushadi, va bu to'plamsiz har bir mavjud o'rnatishda - Home'da hech kim qo'ymagan narsa paydo bo'lardi. - - Home tepasida bir marta so'raladigan karta: "Striming servicelarni Home'ga - qo'shaymizmi?" — dialog emas, e'tiborsiz qoldirsa ham bo'ladigan karta. - Ikkala javob ham yakuniy: "kerak emas" degandan keyin qaytib chiqadigan - taklif — taklif emas. - - "Ha"ni qaytarish: bandning o'zida ⋮ → Home'dan olib tashlash, va Appearance - → Home layout'da (u yerda endi qatori va preview'i bor). "Yo'q"ni qaytarish: - Appearance orqali. Ilova o'zi hech birini qaytarmaydi. - - Amplitude — to'liq - - home_suggestion_shown / _accepted / _dismissed / home_rail_removed / - watch_services_opened / watch_services_region_fell_back. Band id'si event - nomiga emas, property'ga yoziladi — keyingi taklif shu uchta grafikni qayta - ishlatadi, o'ziniki boshlamaydi. accepted ga qarshi removed — taklif - arziganini ko'rsatadigan halol o'lchov. "Shown" initState'da, build'da emas: - Home har source almashganda qayta quriladi, build'da hisoblansa savol yuz - marta ko'p berilgandek ko'rinardi. - - Yana: sahifaning loading holati 3 ustun baland poster edi, haqiqiysi esa 4 - ustun kvadrat belgi — servicelar kelganda hamma plita shakli, o'lchami va - ustuni bir vaqtda o'zgarardi. Endi mos. - - flutter analyze toza, 1542 test o'tdi (yangi: region fallback'ning 4 holati, - opt-in bandning 3 holati). - -✻ Churned for 33m 6s · done Monday 3:20 PM - -⏺ Monitor event: "Sozo rebuild with the Home suggestion" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Card chiqdi, lekin ikkita kamchilik bor: top bar ostida qolgan va tugma qizil - — aynan siz aytgan narsa: - - Searched for 1 pattern (ctrl+o to expand) - -❯ [Image #10] bu darajada chiqmasin service iconlari netflix appletv iconlar - yana nimadir rasmlarni movie styleda qoshib keyin qoshishni taklif qiladigak - kichikroq va mosroq bo`lsin - ⎿  [Image #10] - - Ran 1 shell command (ctrl+o to expand) -❯ [Image #11] bu qism ochilmayapti shu tomoniga ham qara - ⎿  [Image #11] - -⏺ Ikkinchisi muhimroq — sahifa umuman yuklanmayapti. Kim WatchServicesLoad - yuboradi? - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Topdim: WatchServicesLoad hech qayerdan yuborilmaydi — men olib tashlagan Home - rail uni ishga tushirardi. Shuning uchun skeletonda qotib qolgan: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing watch_services... - No issues found! (ran in 5.6s) - ⎿  Updated lib/features/watch_services/presentation/pages/watch_services_ - page.dart (+10 -0) - 40 @override - 41 void initState() { - 42 super.initState(); - 43 + // Nothing else asks. The bloc is a singleton so that its a - +nswer survives a - 44 + // Home rebuild, and the rail that used to kick the first l - +oad off came off - 45 + // Home — which left this page waiting on a load nobody had - + started, i.e. a - 46 + // skeleton that never resolved. A screen is responsible fo - +r the data it - 47 + // shows; being able to reuse an answer somebody else fetch - +ed is not the - 48 + // same as relying on them to fetch it. - 49 + final bloc = getIt(); - 50 + if (bloc.state.status == WatchServicesStatus.initial) { - 51 + bloc.add(const WatchServicesLoad()); - 52 + } - 53 _scroll.addListener(() { - 54 if (_scroll.hasClients) { - 55 _blur.value = (_scroll.offset / 80).clamp(0.0, 1.0); - -⏺ Now the suggestion card — smaller, real service logos, no red block: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+163 -32) - 1 +import 'package:cached_network_image/cached_network_image.dart'; - 2 import 'package:easy_localization/easy_localization.dart'; - 3 import 'package:flutter/material.dart'; - 4 +import 'package:flutter_bloc/flutter_bloc.dart'; - 5 import 'package:go_router/go_router.dart'; - 6 - 7 import 'package:soplay/core/analytics/analytics.dart'; - ... - 11 import 'package:soplay/core/theme/app_colors.dart'; - 12 import 'package:soplay/core/tv/tv.dart'; - 13 import 'package:soplay/features/home/domain/home_rail.dart'; - 14 +import 'package:soplay/features/watch_services/domain/entities/ - +watch_service_entity.dart'; - 15 +import 'package:soplay/features/watch_services/presentation/blo - +c/watch_services/watch_services_bloc.dart'; - 16 - 17 /// The way in to the streaming line-up, once somebody has aske - d for it. - 18 /// - ... - 150 /// at all: a suggestion that comes back after "no thanks" is - not a suggestion. - 151 /// The yes is reversible from the band itself and from Appear - ance; the no is - 152 /// reversible from Appearance. Neither is reversed by the app - . - 153 +/// - 154 +/// ## Why it is this small - 155 +/// - 156 +/// It started as a full-width block with a filled button the - +size of a Play - 157 +/// control, above the hero, and it read as an advertisement r - +ather than as a - 158 +/// question — the loudest thing on a screen it was interrupti - +ng. A suggestion - 159 +/// is allowed one line of explanation and two words of assent - +. The buttons are - 160 +/// text, not fills: the accent in this app means "this is the - + action", and - 161 +/// nothing here is the action. - 162 +/// - 163 +/// The marks are the argument. "Streaming services" is an abs - +traction; four - 164 +/// logos somebody recognises is the feature, and showing them - + is also what - 165 +/// makes the page instant when it is opened, because the answ - +er is already in - 166 +/// the singleton by then. - 167 class HomeSuggestionCard extends StatefulWidget { - 168 const HomeSuggestionCard({ - 169 super.key, - ... - 195 AnalyticsEvent.homeSuggestionShown, - 196 props: {'rail': widget.rail.id}, - 197 ); - 198 + // The marks, and a warm cache for the page behind them. O - +nly while the - 199 + // question is unanswered, so an install that said no neve - +r asks again and - … 223 more lines (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the Home suggestion" - -⏺ Now the clearance so it stops rendering under the top bar: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 10.9s) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+8 - -2) - 293 - 294 // The hero sits under the status bar when it is fir - st. Moved down, it - 295 // is an ordinary rail and something else needs that - clearance — - 296 - // otherwise the top band renders behind the clock. - 296 + // otherwise the top band renders behind the clock. - +The suggestion - 297 + // card is the same case and arrived without it: a q - +uestion drawn - 298 + // through the logo, the source chip and the notific - +ation bell. - 299 // - 300 // The failure strip needs it too, and it is the rea - son this is not - 301 // just about the hero: the strip goes ABOVE the rai - ls, so with the - 302 // hero first there was no clearance at all and the - strip drew behind - 303 // the logo and the source chip — the one band whose - whole job is to - 304 // be read. - 305 + final suggesting = !hive.hasAnsweredHomeSuggestion( - 306 + HomeRail.watchServices.id, - 307 + ); - 308 final needsTopPad = - 309 catalogue is CatalogueFailed || - 310 + suggesting || - 311 rails.first != HomeRail.hero || - 312 !showHero; - 313 - ... - 325 // with. Not a dialog: Home is not a screen anyb - ody came to in - 326 // order to be interrupted, and a card that can - be ignored until - 327 // it is convenient is a question rather than a - demand. - 322 - if (!hive.hasAnsweredHomeSuggestion(HomeRail.wat - -chServices.id)) - 328 + if (suggesting) - 329 SliverToBoxAdapter( - 330 child: HomeSuggestionCard( - 331 rail: HomeRail.watchServices, - -⏺ Bash(cat > test/features/watch_services/watch_services_page_loads_test.dart - <<'DART' - // The page has to ask for its own data.…) - ⎿  00:00 +19: /Users/azamov/Sozo-Full/sozo/test/features/watch_services/w - atch_services_fallback_test.dart: a country with no line-up falls back - , and says so - … +13 lines (ctrl+o to expand) - ⎿  Created - test/features/watch_services/watch_services_page_loads_test.dart (+116 - -0) - 1 +// The page has to ask for its own data. - 2 +// - 3 +// The bloc is a singleton so that one answer serves Home and - +the page without - 4 +// being fetched twice, and the load used to be started by the - + Home rail. That - 5 +// rail came off Home — so the page was left waiting on a load - + nobody had - 6 +// started, and showed its skeleton for as long as it was open - +. Reusing an - 7 +// answer somebody else fetched is not the same as relying on - +them to fetch it. - 8 +import 'package:flutter_test/flutter_test.dart'; - 9 +import 'package:get_it/get_it.dart'; - 10 +import 'package:soplay/core/analytics/analytics.dart'; - 11 +import 'package:soplay/core/error/result.dart'; - 12 +import 'package:soplay/core/storage/hive_service.dart'; - 13 +import 'package:soplay/features/watch_services/domain/entities - +/watch_region_entity.dart'; - 14 +import 'package:soplay/features/watch_services/domain/entities - +/watch_service_entity.dart'; - 15 +import 'package:soplay/features/watch_services/domain/reposito - +ries/watch_services_repository.dart'; - 16 +import 'package:soplay/features/watch_services/domain/usecase/ - +watch_services_usecase.dart'; - 17 +import 'package:soplay/features/watch_services/presentation/bl - +oc/watch_services/watch_services_bloc.dart'; - 18 + - 19 +class _Repo implements WatchServicesRepository { - 20 + var calls = 0; - 21 + - 22 + @override - 23 + Future>> loadServices({ - 24 + required String region, - 25 + }) async { - 26 + calls++; - 27 + return const Success([ - 28 + WatchServiceEntity( - 29 + id: 8, - 30 + name: 'Netflix', - 31 + slug: 'netflix', - 32 + logo: 'https://example.test/n.jpg', - 33 + priority: 0, - 34 + ), - 35 + ]); - 36 + } - 37 + - 38 + @override - 39 + Future>> loadRegions() async - +=> - 40 + const Success([WatchRegionEntity(code: 'US', name: 'Unit - +ed States')]); - … 76 more lines (ctrl+o to expand) - - Pushed to work/2026-09-14, ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the Home suggestion" - -⏺ Monitor(Sozo rebuild with the smaller suggestion card) - ⎿  Monitor started · task bohol39t7 · timeout 900s - -⏺ Build ketyapti. Uchchala narsa tuzatildi — 56ce17e push qilindi. - - 1. "Bu qism ochilmayapti" — sahifa hech qachon so'ramagan - - WatchServicesLoad butun ilovada bitta joydan yuborilardi: hech kim ko'ra - olmaydigan error ekranidagi "retry" tugmasidan. Birinchi yuklashni Home'dagi - rail boshlardi — men uni olib tashlaganman. Natijada sahifa ochilib, - skeletonini chizib, o'sha yerda qolardi. Bloc singleton bo'lgani — javobni - qayta ishlatish uchun; lekin kimdir boshqa yuklab beradi deb kutish bu emas. - Endi sahifa o'zi so'raydi (agar bloc hali bo'sh bo'lsa). - - 2. Taklif kartasi — uchdan biriga kichrayди, logolar bilan - - - Rasmda ko'rganingizdek u top bar orqali chizilgan edi: aynan shu holat uchun - mavjud top padding faqat hero'ning joylashuviga qarab qo'shilardi va karta - haqida bilmasdi. Endi biladi. - - Tugmalar to'ldirilgan emas, matn. Bu ilovada accent rang "mana bu — harakat" - degani; bu yerda ikkala javob ham "harakat" emas. Qizil blok yo'q. - - Eng muhimi: chapda 4 ta haqiqiy service logosi, ustma-ust (Netflix, Apple TV - va h.k.), davlat eng ko'p ishlatadigan tartibda — TMDB shunday saralaydi, - shuning uchun eng tanishi oldinda turadi. "Striming servicelar" — mavhum - tushuncha; tanish 4 ta logo — bu funksiyaning o'zi. - - Logolarni olib kelish sahifani ham tez ochadi: bosilganda javob allaqachon - singletonda. - - Agar internet yo'q bo'lsa yoki javob hali kelmagan bo'lsa — karta logosiz, - lekin baribir chiqadi. - - 3. Test - - Yangi 3 ta test: "tegilmagan bloc initial holatda" (bu premise buzilsa - skeleton bug'i sezilmay qaytadi), "load uni to'ldiradi", "to'lgan blocda - ikkinchi load qayta so'ramaydi". - - flutter analyze toza, 1545 test o'tdi. - -✻ Churned for 10m 9s · done Monday 3:31 PM - -⏺ Monitor event: "Sozo rebuild with the smaller suggestion card" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -❯ faqat 1 martta chiqishni ochirib turda sababi ishlatib boldim - - Listed 1 directory (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the Home suggestion" - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing home... - No issues found! (ran in 3.6s) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+12 - -3) - 1 import 'package:easy_localization/easy_localization.dart'; - 2 +import 'package:flutter/foundation.dart'; - 3 import 'package:flutter/material.dart'; - 4 import 'package:flutter_bloc/flutter_bloc.dart'; - 5 import 'package:soplay/core/di/injection.dart'; - ... - 303 // hero first there was no clearance at all and the - strip drew behind - 304 // the logo and the source chip — the one band whose - whole job is to - 305 // be read. - 305 - final suggesting = !hive.hasAnsweredHomeSuggestion( - 306 - HomeRail.watchServices.id, - 307 - ); - 306 + // Debug builds ask every time. - 307 + // - 308 + // A once-ever card is impossible to iterate on: the - + first tap - 309 + // answers it and the only way to see it again is to - + wipe the - 310 + // install's settings, which on a device with an app - +-lock PIN means - 311 + // losing the PIN with it. Release honours the answe - +r — that is the - 312 + // whole point of the answer — and [HiveService.answ - +erHomeSuggestion] - 313 + // is unchanged either way, so what is under test st - +ays what ships. - 314 + final suggesting = - 315 + kDebugMode || - 316 + !hive.hasAnsweredHomeSuggestion(HomeRail.watchSe - +rvices.id); - 317 final needsTopPad = - 318 catalogue is CatalogueFailed || - 319 suggesting || - - Pushed to work/2026-09-14, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild, suggestion always shown in debug) - ⎿  Monitor started · task bekee0snl · timeout 900s - -⏺ Qildim — debug build'da taklif har safar chiqadi, shuning uchun uni bemalol - ko'rib, baholay olasiz. Build ketyapti (f3de491). - - Sababi: bir martalik kartani takomillashtirib bo'lmaydi — birinchi bosishda - javob berilib, uni qayta ko'rishning yagona yo'li app sozlamalarini tozalash - bo'lardi, bu esa app-lock PIN'ni ham olib ketadi. - - Release'da o'zgarmadi — javob hurmat qilinadi, javobning butun ma'nosi shu. - Javobni yozadigan answerHomeSuggestion ham tegilmagan, ya'ni testlar - tekshirayotgan narsa hamon yetkaziladigan narsa. Kerak bo'lmay qolsa, - home_content.dartdagi bitta kDebugMode || ni olib tashlash kifoya. - -✻ Worked for 5m 20s · done Monday 3:39 PM - -⏺ Monitor event: "Sozo rebuild, suggestion always shown in debug" - - Ran 1 shell command (ctrl+o to expand) - -❯ [Image #12] nega banner yuqorisiga qoshding lol bannerdan keyin mosroq yerga - qoy - ⎿  [Image #12] - -⏺ Logolar chiqdi — endi bannerdan keyin ko'chiraman: - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -❯ keyin yanada yaxshiroq qil zor xozirgisi biroz visual narsalar qoshilgan shu - kabi qilishni hohlayman boshqa yerlarni ham - -⏺ Bash(python3 - <<'PY' - import io…) ⎿  moved - error • Undefined name 'suggestionFollows'. Try correcting the name - to one that is defined, or defining the name • lib/features/home/prese - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+19 - -13) - 334 // with. Not a dialog: Home is not a screen anyb - ody came to in - 335 // order to be interrupted, and a card that can - be ignored until - 336 // it is convenient is a question rather than a - demand. - 337 - if (suggesting) - 338 - SliverToBoxAdapter( - 339 - child: HomeSuggestionCard( - 340 - rail: HomeRail.watchServices, - 341 - title: 'home.suggest_services_title'.tr(), - 342 - body: 'home.suggest_services_body'.tr(), - 343 - icon: Icons.subscriptions_rounded, - 344 - // The bands are read at build time, so th - -e answer has to - 345 - // reach this widget the same way the cust - -omizer's does. - 346 - onAnswered: () => hive.homeRailsChanged.va - -lue = - 347 - !hive.homeRailsChanged.value, - 337 + for (final rail in rails) ...[ - 338 + ...sliversFor(rail), - 339 + // Asked once, and not as a dialog: Home is no - +t a screen - 340 + // anybody came to in order to be interrupted, - + and a card that - 341 + // can be ignored until it is convenient is a - +question rather - 342 + // than a demand. - 343 + if (suggesting && rail == suggestionFollows) - 344 + SliverToBoxAdapter( - 345 + child: HomeSuggestionCard( - 346 + rail: HomeRail.watchServices, - 347 + title: 'home.suggest_services_title'.tr( - +), - 348 + body: 'home.suggest_services_body'.tr(), - 349 + icon: Icons.subscriptions_rounded, - 350 + // The bands are read at build time, so - +the answer has to - 351 + // reach this widget the same way the cu - +stomizer's does. - 352 + onAnswered: () => hive.homeRailsChanged. - +value = - 353 + !hive.homeRailsChanged.value, - 354 + ), - 355 ), - 349 - ), - 350 - for (final rail in rails) ...sliversFor(rail), - 356 + ], - 357 SliverToBoxAdapter( - 358 child: SizedBox( - 359 // Clear the floating nav capsule: desktop p - ill (~66+18) and - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing home... - No issues found! (ran in 5.1s) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+11 - -1) - 316 !hive.hasAnsweredHomeSuggestion(HomeRail.watchSe - rvices.id); - 317 final needsTopPad = - 318 catalogue is CatalogueFailed || - 319 - suggesting || - 319 rails.first != HomeRail.hero || - 320 !showHero; - 321 - 322 + // Under the hero, not over it. - 323 + // - 324 + // Above, the card was the first thing on Home and p - +ushed the banner - 325 + // — the screen's whole opening image — down by its - +own height, to - 326 + // ask a question nobody opened the app to answer. A - +fter the hero it - 327 + // is among the rails, which is what it is offering - +to become one of. - 328 + // It never leads: with no hero it follows whatever - +does. - 329 + final suggestionFollows = showHero && rails.contains - +(HomeRail.hero) - 330 + ? HomeRail.hero - 331 + : rails.first; - 332 + - 333 return CustomScrollView( - 334 controller: scrollController, - 335 physics: const AlwaysScrollableScrollPhysics(), - -⏺ Now the same treatment on the band you get after accepting — real logos, not a - generic icon: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 145 packages have newer versions incompatible with dependency constrai - nts. - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+27 -13) - 23 /// - 24 /// It carries its own way off Home, because a band that can on - ly be removed - 25 /// from a settings screen somewhere else is a band people put - up with. - 26 -class HomeWatchServicesSection extends StatelessWidget { - 26 +class HomeWatchServicesSection extends StatefulWidget { - 27 const HomeWatchServicesSection({super.key}); - 28 - 29 + @override - 30 + State createState() => - 31 + _HomeWatchServicesSectionState(); - 32 +} - 33 + - 34 +class _HomeWatchServicesSectionState extends State { - 35 + @override - 36 + void initState() { - 37 + super.initState(); - 38 + // On an install that accepted the offer, this is the first - + thing that - 39 + // wants the marks — the card that fetched them is gone. - 40 + final bloc = getIt(); - 41 + if (bloc.state.status == WatchServicesStatus.initial) { - 42 + bloc.add(const WatchServicesLoad()); - 43 + } - 44 + } - 45 + - 46 @override - 47 Widget build(BuildContext context) { - 48 void open() { - ... - 75 ), - 76 child: Row( - 77 children: [ - 61 - Container( - 62 - width: 38, - 63 - height: 38, - 64 - alignment: Alignment.center, - 65 - decoration: BoxDecoration( - 66 - color: AppColors.primary.withValues(alpha: 0.12), - 67 - borderRadius: BorderRadius.circular(11), - 68 - ), - 69 - child: Icon( - 70 - Icons.subscriptions_rounded, - … 14 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - - error • The method 'ItemAppear' isn't defined for the type '_HomeSug - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+25 -12) - 234 - 235 @override - 236 Widget build(BuildContext context) { - 237 - return Padding( - 238 - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - 239 - child: Container( - 240 - padding: const EdgeInsetsDirectional.fromSTEB(12, 11, - -8, 6), - 241 - decoration: BoxDecoration( - 242 - color: AppColors.surface, - 243 - borderRadius: BorderRadius.circular(14), - 244 - border: Border.all(color: Colors.white.withValues(al - -pha: 0.06)), - 245 - ), - 237 + return ItemAppear( - 238 + index: 0, - 239 + child: Padding( - 240 + padding: const EdgeInsets.fromLTRB(16, 14, 16, 2), - 241 + child: Container( - 242 + padding: const EdgeInsetsDirectional.fromSTEB(12, 11 - +, 8, 6), - 243 + decoration: BoxDecoration( - 244 + color: AppColors.surface, - 245 + borderRadius: BorderRadius.circular(16), - 246 + border: Border.all(color: Colors.white.withValues( - +alpha: 0.06)), - 247 + // Lifted off Home rather than flush with it: this - + is a card among - 248 + // rails of artwork, and without a shadow it reads - + as a gap in the - 249 + // background where a rail should be. - 250 + boxShadow: [ - 251 + BoxShadow( - 252 + color: Colors.black.withValues(alpha: 0.30), - 253 + blurRadius: 18, - 254 + offset: const Offset(0, 6), - 255 + ), - 256 + ], - 257 + ), - 258 child: Column( - 259 crossAxisAlignment: CrossAxisAlignment.start, - 260 mainAxisSize: MainAxisSize.min, - ... - 316 bold: true, - 317 onTap: () => _answer(accepted: true), - 318 ), - 307 - ], - … 9 more lines (ctrl+o to expand) - -⏺ Bash(sed -i '' "s|import 'package:soplay/core/system/responsive.dart';|import - 'package:soplay/core/system/responsive.dart';\nimport - 'package:soplay/core/widgets/item…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 12.1s) - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+67 -57) - 8 import 'package:soplay/core/di/injection.dart'; - 9 import 'package:soplay/core/storage/hive_service.dart'; - 10 import 'package:soplay/core/system/responsive.dart'; - 11 +import 'package:soplay/core/widgets/item_appear.dart'; - 12 import 'package:soplay/core/theme/app_colors.dart'; - 13 import 'package:soplay/core/tv/tv.dart'; - 14 import 'package:soplay/features/home/domain/home_rail.dart'; - ... - 256 ), - 257 ], - 258 ), - 258 - child: Column( - 259 - crossAxisAlignment: CrossAxisAlignment.start, - 260 - mainAxisSize: MainAxisSize.min, - 261 - children: [ - 262 - Row( - 263 - crossAxisAlignment: CrossAxisAlignment.start, - 264 - children: [ - 265 - BlocBuilder( - 266 - bloc: getIt(), - 267 - builder: (context, state) => _ServiceMarks( - 268 - services: state.services, - 269 - fallback: widget.icon, - 259 + child: Column( - 260 + crossAxisAlignment: CrossAxisAlignment.start, - 261 + mainAxisSize: MainAxisSize.min, - 262 + children: [ - 263 + Row( - 264 + crossAxisAlignment: CrossAxisAlignment.start, - 265 + children: [ - 266 + BlocBuilder( - 267 + bloc: getIt(), - 268 + builder: (context, state) => _ServiceMarks - +( - 269 + services: state.services, - 270 + fallback: widget.icon, - 271 + ), - 272 ), - 271 - ), - 272 - const SizedBox(width: 11), - 273 - Expanded( - 274 - child: Column( - … 106 more lines (ctrl+o to expand) - - Pushed to work/2026-09-14 (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild, suggestion always shown in debug" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild with the smaller suggestion card" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild, card under the banner) - ⎿  Monitor started · task b1gn09jww · timeout 900s - -⏺ Build ketyapti — 4fdead1 push qilindi. - - Joyi. Karta bannerdan yuqorida turganda u Home'dagi birinchi narsa edi va - bannerni — ekranning butun ochilish tasvirini — o'z balandligiga pastga - surardi, hech kim ilovani javob berish uchun ochmagan savol uchun. Endi - hero'dan keyin, railar orasida — u aynan railardan biriga aylanishni taklif - qilyapti. Va u hech qachon birinchi bo'lmaydi: hero yo'q bo'lsa, birinchi nima - bo'lsa o'shandan keyin turadi, ya'ni status bar ostiga hech qachon tushmaydi. - - "Ha" degandan keyingi band ham xuddi shu ko'rinishda. Ilgari u umumiy ikonka - bilan edi — taklifda 4 ta tanish logo turib, qabul qilgandan keyin menyu - punktiga aylangandek o'qilardi. Endi o'sha logolar. Va qabul qilgan - o'rnatishda band o'zi logolarni so'raydi: ularni olib kelgan karta o'sha - paytda yo'q. - - Vizual. Ikkalasi ham Home'dagi boshqa bandlar kabi ItemAppear bilan kiradi; - kartaning ostida soya bor — artwork railari orasida u kartadek o'qilishi - uchun, aks holda fondagi teshikdek ko'rinadi; har bir logoda esa service - gridining o'z ko'tarilishi — soyasiz app ikonka stikerga o'xshaydi. - - "Boshqa yerlarni ham shu kabi" — aytganingizcha davom ettiraman; qaysi - ekranlardan boshlashimni ayting yoki men taklif qilay: source sheet'dagi - katalog kartalari (u yerda ham kichik cover'lar bor), Live TV bandi (kanal - logolari bor), va detal sahifasidagi "via" qatori. - -✻ Brewed for 10m 2s · done Monday 3:49 PM - -⏺ Monitor event: "Sozo rebuild, card under the banner" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Ilova qayta ishga tushdi va PIN so'rayapti — siz kiritayotgan ekansiz (bitta - raqam terilgan). PIN'ni kiritib bo'lgach Home'ni ko'rasiz: karta endi - bannerdan keyin, Continue Watching'dan oldin turadi. - - Ko'rib chiqing, keyin aytasiz. Shu orada "boshqa yerlarni ham" bo'yicha navbat - kutayotganlar — qaysi birini birinchi qilay: - - 1. Source sheet'dagi katalog kartalari — AniList/TMDB kartalarida allaqachon - kichik cover'lar bor, lekin ular kesilgan holda chetga yopishib turibdi; - xuddi shu logo-fan uslubiga keltirsa bo'ladi. - 2. Live TV bandi — kanal logolari bor, lekin ular oddiy kvadratlar; service - marklari kabi radius + ko'tarilish yetishmaydi. - 3. Detal sahifasidagi "via" qatori — "TMDB › VidAPI" matn sifatida; manbalar - belgilari bilan ko'rsatilsa bir qarashda o'qiladi. - - Yoki o'zingiz aytgan boshqa joyni ayting — men shundan boshlayman. - -✻ Cooked for 1m 4s · done Monday 3:50 PM - -❯ shu genresdan keyin qosh shu suggestionni iloji bormi - -⏺ Ha, albatta. Genres — "ko'rish yo'llari" bandi, taklif ham aynan yana bitta - ko'rish yo'lini taklif qilyapti, ya'ni joyi to'g'ri: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing home... - No issues found! (ran in 3.1s) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+23 - -9) - 319 rails.first != HomeRail.hero || - 320 !showHero; - 321 - 322 - // Under the hero, not over it. - 322 + // After Genres, where the other ways to browse are. - 323 // - 324 - // Above, the card was the first thing on Home and p - -ushed the banner - 325 - // — the screen's whole opening image — down by its - -own height, to - 326 - // ask a question nobody opened the app to answer. A - -fter the hero it - 327 - // is among the rails, which is what it is offering - -to become one of. - 328 - // It never leads: with no hero it follows whatever - -does. - 329 - final suggestionFollows = showHero && rails.contains - -(HomeRail.hero) - 330 - ? HomeRail.hero - 331 - : rails.first; - 324 + // The card is offering one more way to find somethi - +ng, and Genres is - 325 + // the band that already is one — so it lands next t - +o its own kind, - 326 + // rather than between the banner and Continue Watch - +ing, which is - 327 + // where somebody is looking for what they were alre - +ady watching. - 328 + // - 329 + // A cascade, because none of these bands is guarant - +eed: Genres only - 330 + // draws when the source HAS genres, and a band that - + drew nothing - 331 + // would leave the card floating after an invisible - +section. Failing - 332 + // that, the hero; failing that, whatever leads. It - +never leads - 333 + // itself — above the banner it pushed the screen's - +whole opening - 334 + // image down by its own height to ask a question no - +body opened the - 335 + // app to answer, and first is also the one position - + that renders - 336 + // under the status bar. - 337 + final genresDraw = - 338 + rails.contains(HomeRail.genres) && - 339 + loaded != null && - 340 + loaded.genres.isNotEmpty; - 341 + final suggestionFollows = genresDraw - 342 + ? HomeRail.genres - 343 + : (showHero && rails.contains(HomeRail.hero) - 344 + ? HomeRail.hero - 345 + : rails.first); - 346 - 347 return CustomScrollView( - 348 controller: scrollController, - - Ran 1 shell command (ctrl+o to expand) - -❯ endi ozingga kelyapsan boshqa narsalnri ham qilayotganda shu kabi visual - narsalarni ham qoshib ketishimiz kerak boladi esingdan chiqmasin - -⏺ Esimda — bu endi doimiy qoida. Avval genres ko'chirishni yakunlay, keyin buni - yozib qo'yaman: - - Pushed to work/2026-09-14 (ctrl+o to expand) - -⏺ Monitor event: "Sozo rebuild, suggestion always shown in debug" - - Ran 2 shell commands, wrote 3 memories (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild, card after Genres) - ⎿  Monitor started · task b0jdnw0bw · timeout 900s - -⏺ API Error: Can't reach the API server — check your internet or DNS - (ENOTFOUND) - -✻ Cogitated for 51m 9s · done Monday 4:42 PM - -⏺ Monitor event: "Sozo rebuild, card after Genres" - -⏺ Monitor event: "Sozo rebuild, card after Genres" - -⏺ Monitor event: "Sozo rebuild, card under the banner" -⏺ Monitor event: "Sozo rebuild, card after Genres" - -⏺ API Error: Can't reach the API server — check your internet or DNS - (ENOTFOUND) - -✻ Crunched for 1h 14m 50s · done Monday 5:56 PM - -❯ continue - - Ran 1 shell command (ctrl+o to expand) - -⏺ Build o'zi o'tdi — avvalgi xato oldingi Gradle jarayonini o'rtasida - to'xtatganimdan edi. Ishga tushiryapman: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo run, card after Genres) - ⎿  Monitor started · task b5cxrj4xe · timeout 900s - -⏺ Build va o'rnatish ketyapti. Shu orada yozib qo'ydim — endi bu doimiy qoida, - har safar so'rashingiz shart emas: - - Yangi UI to'g'ri bo'lishi — tugallangani emas. Sozo — media ilova, uning butun - tili — rasm. Material default'laridan yig'ilgan ekran bu yerda adashib kirib - qolgan sozlamalar sahifasidek o'qiladi. Shuning uchun har bir yangi narsada, - sizga ko'rsatishdan oldin: - - - Umumiy ikonka emas, haqiqiy rasm — service logolari, coverlar, kanal - belgilari. Kerak bo'lsa yuklab kelinadi. To'rtta tanish logo "striming - servicelar" degan so'zdan kuchliroq. Rangli kvadratdagi Icons.* — rasmlar - hali kelmaganidagi zaxira, dizayn emas. - - Kirish animatsiyasi — ItemAppear, Home'dagi boshqa bandlar bilan bir xil. - - Chuqurlik — karta ostida soya (artwork railari orasida u karta bo'lib - ko'rinishi uchun), har bir belgi ostida gridning o'z ko'tarilishi (soyasiz - app ikonka stiker). - - Tinch harakatlar — matnli tugmalar, to'ldirilgan accent blok emas. - To'ldirilgan accent "mana bu — o'sha harakat" degani; ko'p narsa unday emas. - - Loading holati o'rniga keladigan narsaga mos bo'lsin — shakli, ustun soni, - o'lchami. Bu allaqachon uch marta haqiqiy defekt bo'ldi (search grid, genre - grid, service grid). - - Shuningdek qizil accent haqidagi qoida ham yozildi: ma'noni rang emas, - og'irlik tashiydi; accent faqat o'sha ekrandagi haqiqiy asosiy harakat uchun. - -✻ Sautéed for 12m 37s · done Monday 8:32 PM - -⏺ Monitor event: "Sozo run, card after Genres" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Emulator o'chib qolibdi (adb: no devices/emulators found). Qayta ishga - tushiraman: - - Ran 3 shell commands (ctrl+o to expand) - -⏺ Emulator yuklanyapti — ko'tarilishi bilan ilovani o'rnatib, Genres'dan keyingi - kartani screenshot qilib ko'rsataman. - -✻ Cogitated for 1m 2s · done Monday 8:42 PM - -⏺ Background command "Wait for the emulator to finish booting" completed (exit -code 0) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo run on the restarted emulator) - ⎿  Monitor started · task bfd0rlliu · timeout 900s - -⏺ Emulator ko'tarildi, ilova o'rnatilyapti. Tayyor bo'lishi bilan screenshot - olaman. - - Eslatma: emulator yangidan ishga tushgani uchun app-lock PIN yana so'raladi — - u menda yo'q, siz kiritasiz, keyin Home'ni ko'rsataman. - -✻ Baked for 33s · done Monday 8:43 PM - -⏺ Monitor event: "Sozo run on the restarted emulator" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Ilova yangi emulatorda ishga tushdi va PIN so'rayapti — PIN menda yo'q, app - data'ni ham tozalamayman. Kiriting, keyin Home'ni ko'rsataman. - - Ko'radigan narsangiz: taklif kartasi endi Genres'dan keyin — banner, Continue - Watching, Genres, keyin karta. Sababi: Genres — hali tanlanmagan narsani - topish uchun band, karta esa aynan yana bitta shunday yo'lni taklif qilyapti, - ya'ni o'z turdoshlari yonida turibdi. Banner bilan Continue Watching orasida - emas — u yerda odam allaqachon ko'rayotgan narsasini qidiradi. - - Joylashuv qat'iy emas, kaskad: Genres chizilmasa (manbada janrlar bo'lmasa) — - hero'dan keyin; u ham bo'lmasa — nima birinchi bo'lsa, o'shandan keyin. Hech - qachon birinchi bo'lmaydi, chunki birinchi o'rin — status bar ostiga - tushadigan yagona joy. - - Hozirgi holat: bc43f1e work/2026-09-14 ga push qilingan, flutter analyze toza, - 1545 test o'tgan. - -✻ Sautéed for 47s · done Monday 8:44 PM - -❯ [Image #13] shuni oziga qoshilsin yani shu chiqgan yerda keyin 1 martta - chiqishni update qilib ushani yoqib qoysang boladi - ⎿  [Image #13] - -⏺ Ikkalasini ham qilaman: band aynan karta turgan joyda paydo bo'lsin, va bir - martalik rejim yoqilsin: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing 2 items... - No issues found! (ran in 1.4s) - ⎿  Updated lib/core/storage/hive_service.dart (+21 -4) - 357 - 358 /// Records the answer and applies it, in one write. - 359 /// - 360 - /// Both halves together, because they are one decision: a y - -es that recorded - 361 - /// the answer without switching the band on would ask once - -and do nothing. - 362 - Future answerHomeSuggestion(String id, {required bool - -accepted}) async { - 360 + /// All of it together, because it is one decision: a yes th - +at recorded the - 361 + /// answer without switching the band on would ask once and - +do nothing. - 362 + /// - 363 + /// [after] is the band the offer was shown under, and a yes - + moves the new - 364 + /// band to sit directly beneath it — somebody who accepts a - + card between - 365 + /// Genres and Live TV should get the band there, not wherev - +er the default - 366 + /// order happens to put it. - 367 + Future answerHomeSuggestion( - 368 + String id, { - 369 + required bool accepted, - 370 + String? after, - 371 + }) async { - 372 final answered = {...getAnsweredHomeSuggestions(), id}; - 373 await _settingsBox.put( - 374 AppConstants.homeSuggestionsAnsweredKey, - 375 answered.toList(), - 376 ); - 377 final hidden = getHomeRailHidden(); - 369 - await saveHomeRails(getHomeRailOrder(), { - 378 + // Sanitized first: nothing may be stored yet, and an orde - +r that does not - 379 + // contain the band cannot have it moved within it. - 380 + var order = [ - 381 + for (final r in sanitizeRailOrder(getHomeRailOrder())) r - +.id, - 382 + ]; - 383 + if (accepted && after != null) { - 384 + order = placeRailAfter(order, id, after); - 385 + } - 386 + await saveHomeRails(order, { - 387 for (final h in hidden) - 388 if (!(accepted && h == id)) h, - 389 if (!accepted) id, - ⎿  Updated lib/features/home/domain/home_rail.dart (+20 -0) - 118 ]; - 119 return shown.isEmpty ? [HomeRail.catalogue] : shown; - 120 } - 121 + - 122 +/// [order] with [id] moved to sit directly after [after]. - 123 +/// - 124 +/// So that a band somebody accepts appears where they were as - +ked about it. - 125 +/// The offer is a card among the rails — after Genres — and t - +aking it up used - 126 +/// to drop the band wherever [HomeRail.defaults] happened to - +put it, which on - 127 +/// a default install is three rails further down. Tapping "ad - +d" and watching - 128 +/// something appear somewhere else reads as having pressed th - +e wrong thing. - 129 +/// - 130 +/// Ids rather than rails, because that is what is stored, and - + unknown ids are - 131 +/// left where they are: a stored order can name a band this v - +ersion has - 132 +/// dropped, and re-ordering around it must not quietly delete - + it. - 133 +List placeRailAfter(List order, String id, Str - +ing after) { - 134 + if (id == after) return order; - 135 + final out = [...order]; - 136 + if (!out.contains(id) || !out.contains(after)) return out; - 137 + out.remove(id); - 138 + out.insert(out.indexOf(after) + 1, id); - 139 + return out; - 140 +} - -⏺ Monitor event: "Sozo run on the restarted emulator" - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - - info • The import of 'package:flutter/foundation.dart' is unnecessa - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+4 - -11) - 303 // hero first there was no clearance at all and the - strip drew behind - 304 // the logo and the source chip — the one band whose - whole job is to - 305 // be read. - 306 - // Debug builds ask every time. - 307 - // - 308 - // A once-ever card is impossible to iterate on: the - - first tap - 309 - // answers it and the only way to see it again is to - - wipe the - 310 - // install's settings, which on a device with an app - --lock PIN means - 311 - // losing the PIN with it. Release honours the answe - -r — that is the - 312 - // whole point of the answer — and [HiveService.answ - -erHomeSuggestion] - 313 - // is unchanged either way, so what is under test st - -ays what ships. - 314 - final suggesting = - 315 - kDebugMode || - 316 - !hive.hasAnsweredHomeSuggestion(HomeRail.watchSe - -rvices.id); - 306 + final suggesting = !hive.hasAnsweredHomeSuggestion( - 307 + HomeRail.watchServices.id, - 308 + ); - 309 final needsTopPad = - 310 catalogue is CatalogueFailed || - 311 rails.first != HomeRail.hero || - ... - 363 title: 'home.suggest_services_title'.tr( - ), - 364 body: 'home.suggest_services_body'.tr(), - 365 icon: Icons.subscriptions_rounded, - 366 + follows: suggestionFollows, - 367 // The bands are read at build time, so - the answer has to - 368 // reach this widget the same way the cu - stomizer's does. - 369 onAnswered: () => hive.homeRailsChanged. - value = - ⎿  Updated lib/features/home/presentation/widgets/home_watch_services_sec - tion.dart (+8 -0) - 186 required this.title, - 187 required this.body, - 188 required this.icon, - 189 + required this.follows, - 190 required this.onAnswered, - 191 }); - 192 - 193 final HomeRail rail; - 194 + - 195 + /// The band this card was shown under. A yes puts the new b - +and here, so it - 196 + /// appears where it was offered rather than wherever the de - +fault order has - 197 + /// it — accepting something and watching it materialise thr - +ee rails away - 198 + /// reads as having pressed the wrong thing. - 199 + final HomeRail follows; - 200 final String title; - 201 final String body; - 202 final IconData icon; - ... - 230 await getIt().answerHomeSuggestion( - 231 widget.rail.id, - 232 accepted: accepted, - 233 + after: widget.follows.id, - 234 ); - 235 getIt().track( - 236 accepted - -⏺ Bash(sed -i '' "/^import 'package:flutter\/foundation.dart';$/d" - lib/features/home/presentation/widgets/home_content.dart && python3 - - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 12.2s) - … +7 lines (ctrl+o to expand) - ⎿  Updated lib/core/storage/hive_service.dart (+1 -3) - 377 final hidden = getHomeRailHidden(); - 378 // Sanitized first: nothing may be stored yet, and an orde - r that does not - 379 // contain the band cannot have it moved within it. - 380 - var order = [ - 381 - for (final r in sanitizeRailOrder(getHomeRailOrder())) r - -.id, - 382 - ]; - 380 + var order = [for (final r in sanitizeRailOrder(getHomeRail - +Order())) r.id]; - 381 if (accepted && after != null) { - 382 order = placeRailAfter(order, id, after); - 383 } - ⎿  Updated lib/features/home/presentation/widgets/home_content.dart (+0 - -1) - 1 import 'package:easy_localization/easy_localization.dart'; - 2 -import 'package:flutter/foundation.dart'; - 2 import 'package:flutter/material.dart'; - 3 import 'package:flutter_bloc/flutter_bloc.dart'; - 4 import 'package:soplay/core/di/injection.dart'; - ⎿  Updated test/features/home/home_rail_test.dart (+44 -0) - 52 }); - 53 }); - 54 - 55 + group('placing a band where it was offered', () { - 56 + test('an accepted band lands under the one it was offered - +beneath', () { - 57 + // The card sits after Genres. Taking it up used to drop - + the band - 58 + // wherever the defaults put it — three rails further do - +wn — so the thing - 59 + // you just agreed to appeared somewhere you were not lo - +oking. - 60 + final out = placeRailAfter( - 61 + ['hero', 'resume', 'genres', 'live_tv', 'watch_service - +s', 'catalogue'], - 62 + 'watch_services', - 63 + 'genres', - 64 + ); - 65 + expect(out, [ - 66 + 'hero', - 67 + 'resume', - 68 + 'genres', - 69 + 'watch_services', - 70 + 'live_tv', - 71 + 'catalogue', - 72 + ]); - 73 + }); - 74 + - 75 + test('and moving it backwards works the same way', () { - 76 + expect(placeRailAfter(['a', 'b', 'c'], 'a', 'c'), ['b', - +'c', 'a']); - 77 + }); - 78 + - 79 + test('an id that is already there is left alone', () { - 80 + expect( - 81 + placeRailAfter( - 82 + ['hero', 'genres', 'watch_services'], - 83 + 'watch_services', - 84 + 'genres', - 85 + ), - 86 + ['hero', 'genres', 'watch_services'], - 87 + ); - 88 + }); - 89 + - 90 + test('and an unknown id changes nothing', () { - 91 + // A stored order can name a band this version has dropp - +ed. Re-ordering - … 10 more lines (ctrl+o to expand) - - Pushed to work/2026-09-14, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild, band lands where the card was) - ⎿  Monitor started · task bfxb25lgm · timeout 900s -⏺ Ikkalasi ham qilindi — fe7fdea push qilindi, build ketyapti. - - 1. Band endi aynan karta turgan joyda paydo bo'ladi. Ilgari "Add to Home" - bosilganda band HomeRail.defaults qayerga qo'ysa o'sha yerga tushardi — - standart o'rnatishda savol berilgan joydan uch qator pastga. "Qo'shish"ni - bosib, narsaning butunlay boshqa joyda paydo bo'lishini ko'rish — noto'g'ri - tugmani bosgandek o'qiladi. Endi "ha" deyilganda band karta nimaning ostida - ko'rsatilgan bo'lsa (ya'ni Genres), o'sha yerga ko'chiriladi. - - placeRailAfter rail'lar bilan emas, id'lar bilan ishlaydi va notanish id'ga - tegmaydi: saqlangan tartibda bu versiya olib tashlagan band nomi bo'lishi - mumkin, va atrofida qayta tartiblash uni jimgina o'chirib yubormasligi kerak. - - 2. Bir martalik rejim qaytdi. kDebugMode chetlab o'tishi olib tashlandi — u - faqat kartani ko'rib-tuzatish uchun kerak edi, karta tayyor. - - 4 ta yangi test (joylashtirish, orqaga ko'chirish, allaqachon joyida, notanish - id) — jami 1549 test o'tdi, analyze toza. - -✻ Worked for 3m 52s · done Monday 9:01 PM - -⏺ Monitor event: "Sozo rebuild, band lands where the card was" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Ilova ishga tushdi, PIN ekranida. PIN'ni kiriting. - - Sinab ko'rish uchun: Genres'dan pastga tushing → karta turibdi → Add to Home - bosing. Band aynan o'sha joyda — Genres bilan Live TV orasida — paydo bo'lishi - kerak, va karta yo'qoladi. Ilovani qayta ishga tushirsangiz karta boshqa - chiqmaydi (bir martalik rejim qaytarilgan). - - Qaytarib olish yo'llari: bandning o'zidagi ⋮ → Home'dan olib tashlash, yoki - Appearance → Home layout. Ikkalasida ham. - -✻ Worked for 1m 7s · done Monday 9:03 PM - -⏺ Monitor event: "Sozo rebuild, band lands where the card was" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Hali PIN ekranida — siz kiritmaguningizcha o'tolmayman. Ilova emulatorda - tayyor turibdi. - - Shu sessiyadagi yakuniy holat — hammasi work/2026-09-14da, oxirgisi fe7fdea: - - ┌───────────────┬─────────────────────────────────────────────────────────┐ - │ Commit │ Nima │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ 3aeb168 │ Qidiruv nima ko'rsatayotganini aytadi; landing ikki │ - │ │ marta "tushishi" to'xtatildi │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ 072ee6b │ Flip clock About'ga, sekundlar, split-flap; source │ - │ │ ro'yxati "deraza" bo'ldi │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ 16422e5 │ Flip clockdagi 2 ta render bug'i; sheet sliverlarga; │ - │ │ servicelarga kirish yo'li │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ 559296b │ Home taklifi, opt-in band, Amplitude, region fallback │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ 56ce17e │ Sahifa o'z yuklashini boshlaydi; karta kichrayди, │ - │ │ logolar bilan │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ f3de491 → │ Debug'da qayta so'rash; karta Genres'dan keyin │ - │ bc43f1e │ │ - ├───────────────┼─────────────────────────────────────────────────────────┤ - │ fe7fdea │ Band karta turgan joyda paydo bo'ladi; bir martalik │ - │ │ rejim qaytdi │ - └───────────────┴─────────────────────────────────────────────────────────┘ - - flutter analyze toza, 1549 test o'tadi. - - Ochiq qolgan, siz hal qiladigan narsalar: - 1. sozo-backend'dagi clean-master PR talabini mening push'larim repo-roli - orqali chetlab o'tyapti — shu holicha qolsinmi yoki PR orqali ishlaymizmi. - 2. CloudStream host'idan qaytgan javob JSON emas, oddiy UUID — safeApiCall uni - parse qilishga urinib xato beryapti. Qaysi manba ekanini aniqlab, - tuzatishim mumkin. - 3. "Boshqa yerlarni ham shu kabi" — men uchtasini taklif qilgandim (source - sheet katalog kartalari, Live TV bandi, detaldagi "via" qatori). Qaysi - biridan boshlay. - -✻ Churned for 38s · done Monday 9:17 PM - -❯ endi nimalar qoldi - -⏺ Aniq aytish uchun kodni tekshirib ko'ray, xotiradan sanab bermay: - - Searched for 1 pattern, ran 3 shell commands (ctrl+o to expand) - -⏺ Kodni tekshirib chiqdim — quyidagilar aniq holat, taxmin emas. - - Siz so'ragan, hali qilinmagan - - 1. PiP — desktop va iOS. Hozircha faqat Android: floating paketi Android-only. - Desktopda window_manager bor, lekin u faqat fullscreen uchun ishlatilgan — - alohida kichik, ustida turadigan oyna rejimi yozilmagan. iOS uchun - AVPictureInPictureController ustiga platform kanal kerak. Siz "oxirroqda - bo'lsa ham boladi" degandingiz — hali o'sha yerda. - - 2. "Boshqa yerlarni ham shu kabi" — vizual ishlov. Men uchtasini nomzod qilib - qo'ygandim: source sheet'dagi katalog kartalari, Live TV bandi, detaldagi - "via" qatori. Hech biriga hali tegilmagan. - - Sizning qaroringizni kutayotgan - - 3. sozo-backend / clean-master. Repoda "Protect Default" ruleset PR talab - qiladi, lekin mening push'larim repo-rolim orqali uni chetlab o'tadi — ya'ni - men to'g'ridan-to'g'ri deploy qilyapman. Shu holicha qolsinmi, yoki PR orqali - ishlaylikmi? - - Men ko'rgan, tuzatilmagan nosozliklar - - 4. CloudStream host'i JSON emas, oddiy UUID qaytaryapti (50584590-3c7c-…), - safeApiCall uni parse qilmoqchi bo'lib yiqilyapti. Qaysi manba ekanini - aniqlash kerak. - 5. [support-sse] 401 — backend support kanaliga ulanish rad etilyapti. - - 17 talik ro'yxatdan — hech biri boshlanmagan - - Tekshirdim, kodda izi yo'q: kutubxonani avtomatik yangilash jadvali, manba - migratsiyasi, qo'shimcha trackerlar, TTS, bob ichidan matn qidirish, scanlator - filtri (Kotlin tomoni ham kerak), lokal subtitr fayli, "pauzada boshlash", - boblarni guruhlash, yuklab olish cooldown'i, WebView manzil qatori, EPUB - progressi, tracker yangilash, Material You (dynamic_color ham qo'shilmagan), - sana formati. - - --- - - Mening taklifim navbat bo'yicha: PiP (siz aytgan, eng katta va eng kutilgani) - → 4-band (haqiqiy nosozlik, foydalanuvchiga tegadi) → vizual ishlov → qolgan - ro'yxat. Qaysi biridan boshlay? - -✻ Brewed for 8m 6s · done Monday 10:33 PM - -❯ 17talik ruyhatni tugat + PIP ni ham to`liq qil - -⏺ Katta ish — ketma-ket, partiyalarga bo'lib qilaman va har partiyani alohida - commit qilaman. Avval joylashuvni aniqlayman: - - Searched for 1 pattern, listed 3 directories, ran 2 shell commands (ctrl+o to expand) - -⏺ Now Batch 1 — start-paused, local subtitle file, download cooldown, date - format. Storage first: - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  constants ok - hive ok - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/core/constants/app_constants.dart (+9 -0) - 80 /// somebody chose to spend. - 81 static const String downloadWifiOnlyKey = 'download_wifi_only - '; - 82 - 83 + /// Seconds to wait between one download finishing and the ne - +xt starting. - 84 + static const String downloadCooldownKey = 'download_cooldown_ - +seconds'; - 85 + - 86 + /// How a date is written: day first, month first, or ISO. - 87 + static const String dateFormatKey = 'date_format'; - 88 + - 89 /// Which volume downloads are kept on. - 90 /// - 91 /// Holds the VOLUME's base path, not the downloads folder it - self, so the - ... - 244 static const String defaultPlaybackSpeedKey = 'default_playb - ack_speed'; - 245 static const String defaultPlayerFitKey = 'default_player_fi - t'; - 246 static const String autoPlayNextEpisodeKey = 'auto_play_next - _episode'; - 247 + - 248 + /// Open the player without starting it. - 249 + static const String startPausedKey = 'start_paused'; - 250 static const String doubleTapSeekSecondsKey = 'double_tap_se - ek_seconds'; - 251 static const String longPressBoostKey = 'long_press_boost'; - 252 static const String brightnessGestureKey = 'brightness_gestu - re'; - ⎿  Updated lib/core/storage/hive_service.dart (+48 -0) - 408 _settingsBox.get(AppConstants.downloadWifiOnlyKey, defau - ltValue: false) == - 409 true; - 410 - 411 + /// How long the queue waits after one download before start - +ing the next. - 412 + /// - 413 + /// Zero is the old behaviour and the default. It exists bec - +ause some hosts - 414 + /// count requests rather than bytes and hand out a temporar - +y block to a - 415 + /// client that starts six files back to back — a gap turns - +a ban into a - 416 + /// slower queue. - 417 + int get downloadCooldownSeconds { - 418 + final raw = _settingsBox.get( - 419 + AppConstants.downloadCooldownKey, - 420 + defaultValue: 0, - 421 + ); - 422 + final value = raw is int ? raw : int.tryParse('$raw') ?? 0 - +; - 423 + return value.clamp(0, 600); - 424 + } - 425 + - 426 + Future setDownloadCooldownSeconds(int value) async { - 427 + await _settingsBox.put( - 428 + AppConstants.downloadCooldownKey, - 429 + value.clamp(0, 600), - 430 + ); - 431 + } - 432 + - 433 + /// How dates are written across the app. - 434 + /// - 435 + /// Stored as the pattern itself rather than as an enum inde - +x, so a locale - 436 + /// that wants something this app has not thought of is one - +string away and - 437 + /// an old stored value keeps meaning what it meant. - 438 + String get dateFormatPattern => - 439 + _settingsBox.get(AppConstants.dateFormatKey, defaultValu - +e: '') as String; - 440 + - 441 + Future setDateFormatPattern(String pattern) async { - 442 + await _settingsBox.put(AppConstants.dateFormatKey, pattern - +); - 443 + } - 444 + - 445 Future setDownloadWifiOnly(bool value) async { - 446 await _settingsBox.put(AppConstants.downloadWifiOnlyKey, v - alue); - 447 downloadWifiOnlyChanged.value = value; - … 20 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  lib/features/detail/presentation/pages/player_page.history.dart:149: - void _saveHistoryForNextEpisode() { - lib/features/detail/presentation/pages/player_page.media.dart:1240: - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/pages/player_page.media.dart - (+8 -1) - 1232 await controller.seekTo(resumeAt); - 1233 } - 1234 if (!mounted || generation != _mediaGeneration) retur - n; - 1235 - await controller.play(); - 1235 + // Paused, if that is what was asked for — but only f - +or the episode - 1236 + // somebody opened. An auto-advance is already playin - +g by definition: - 1237 + // the preference is about the app starting a stream - +on its own when a - 1238 + // page is opened, and refusing to continue a run som - +ebody is already - 1239 + // watching would be a different setting entirely. - 1240 + if (!_hive.startPaused || _autoAdvanced) { - 1241 + await controller.play(); - 1242 + } - 1243 } - 1244 _plog('play started — total ${stopwatch.elapsedMillisec - onds}ms'); - 1245 // Against the source that SERVED this, which is the wh - ole point. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  427: bool _endHandled = false; - ⎿  Updated lib/features/detail/presentation/pages/player_page.media.dart - (+3 -0) - 1575 widget.args.isSerial && - 1576 _hasNextEpisode) { - 1577 _saveHistoryForNextEpisode(); - 1578 + // Marks the next load as a continuation rather tha - +n an opening, so - 1579 + // "start paused" does not stop a run that is alrea - +dy going. - 1580 + _autoAdvanced = true; - 1581 _loadEpisode(_episodeIndex + 1); - 1582 return; - 1583 } - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing detail... - No issues found! (ran in 3.7s) - ⎿  Updated lib/features/detail/presentation/pages/player_page.dart (+9 - -0) - 426 /// Reset alongside [_countedComplete]. - 427 bool _endHandled = false; - 428 - 429 + /// This episode was reached by auto-advance rather than by - +being opened. - 430 + /// - 431 + /// Read only by the "start paused" preference, which is abo - +ut the app - 432 + /// starting a stream on its own when a page is opened — not - + about refusing - 433 + /// to continue a run somebody is already watching. Never re - +set: once a - 434 + /// session has advanced, everything after it in that sessio - +n is a - 435 + /// continuation. - 436 + bool _autoAdvanced = false; - 437 + - 438 double _playbackSpeed = 1.0; - 439 - 440 /// The picture profile in force. Read from settings at open - and changeable - -⏺ Now the local subtitle file — file_picker is already a dependency: - - Searched for 5 patterns, ran 5 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 2:part of 'player_page.dart'; - 1:import 'package:soplay/features/search/data/source_health_store.dart - … +4 lines (ctrl+o to expand) - ⎿  Updated - lib/features/detail/presentation/pages/player_page.subtitles.dart (+89 - -0) - 48 return (cues == null || cues.isEmpty) ? null : cues; - 49 } - 50 - 51 + // A file off this device. Read rather than fetched, and th - +en handed to - 52 + // exactly the same parser — the charset guessing, the arch - +ive handling and - 53 + // every failure message are the part that matters, and non - +e of it cares - 54 + // where the bytes came from. - 55 + if (!sub.file.startsWith('http')) { - 56 + try { - 57 + final bytes = await File(sub.file).readAsBytes(); - 58 + if (!mounted) return null; - 59 + final local = parseSubtitleBytes( - 60 + bytes, - 61 + url: sub.file, - 62 + declaredFormat: declaredFormat, - 63 + ); - 64 + if (!local.isSuccess) { - 65 + _plog('subtitle parse failed: ${local.failure}', leve - +l: LogLevel.warn); - 66 + _toast(_subtitleFailureMessage(local.failure)); - 67 + return null; - 68 + } - 69 + return local.captions; - 70 + } catch (e) { - 71 + _plog('subtitle read error: $e', level: LogLevel.warn); - 72 + if (!mounted) return null; - 73 + _toast('player.subtitle_failed_download'.tr()); - 74 + return null; - 75 + } - 76 + } - 77 + - 78 SubtitleParseResult result; - 79 try { - 80 // ResponseType.bytes: Dio's string transformer always ru - ns - ... - 238 _searchOnlineSubtitles(); - 239 }, - 240 ), - 241 + // Below the online search, because it is the an - +swer when that - 242 + // one fails — but not hidden behind it, because - + for anybody who - 243 + // downloads their own subtitles it is the only - +row here that - 244 + // works every time. - … 67 more lines (ctrl+o to expand) - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(sed -i '' "17a\\ - import 'package:file_picker/file_picker.dart';…) - ⎿   error • The getter 'platform' isn't defined for the type 'FilePicker - '. Try importing the library that defines 'platform', correcting the n - ame to the name of an existing getter, or defining a getter or field n - … +9 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/pages/player_page.dart (+1 - -0) - 15 import 'package:soplay/core/network/user_agent.dart'; - 16 import 'dart:async'; - 17 import 'dart:io'; - 18 +import 'package:file_picker/file_picker.dart'; - 19 - 20 import 'package:dio/dio.dart'; - 21 import 'package:easy_localization/easy_localization.dart'; - - Searched for 1 pattern, ran 5 shell commands (ctrl+o to expand) - -⏺ Bash(sed -i '' 's/await FilePicker\.platform\.pickFiles(/await - FilePicker.pickFiles(/g' - lib/features/detail/presentation/pages/player_page.subtitles.dart && - export P…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated - lib/features/detail/presentation/pages/player_page.subtitles.dart (+2 - -2) - 441 Future _pickLocalSubtitle() async { - 442 FilePickerResult? picked; - 443 try { - 444 - picked = await FilePicker.platform.pickFiles( - 444 + picked = await FilePicker.pickFiles( - 445 // `custom` with extensions, not `any`: a file manager - that lets you - 446 // choose a JPEG for a subtitle track is offering a mi - stake. - 447 type: FileType.custom, - ... - 452 // Some Android file providers reject a filtered pick ou - tright. Asking - 453 // for anything is better than the button doing nothing. - 454 _plog('subtitle picker error: $e', level: LogLevel.warn) - ; - 455 - picked = await FilePicker.platform.pickFiles(withData: f - -alse); - 455 + picked = await FilePicker.pickFiles(withData: false); - 456 } - 457 final path = picked?.files.singleOrNull?.path; - 458 if (path == null || path.isEmpty || !mounted) return; - -⏺ Now the download cooldown and the settings tiles. Let me find the download - queue: - Searched for 4 patterns, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing download... - No issues found! (ran in 2.9s) - ⎿  Updated - lib/features/download/data/repositories/download_repository_impl.dart - (+30 -0) - 140 _connectivity?.cancel(); - 141 _hive.downloadWifiOnlyChanged.removeListener(_pump); - 142 _nativePoll?.cancel(); - 143 + _cooldownTimer?.cancel(); - 144 _transfer.dispose(); - 145 } - 146 - ... - 408 } - 409 - 410 void _pumpNow() { - 411 + // A gap between one file and the next, when one has been - +asked for. - 412 + // - 413 + // Some hosts count requests rather than bytes and hand a - +temporary block - 414 + // to a client that opens six connections back to back — w - +hich looks, from - 415 + // inside the app, like the source suddenly breaking. Zero - + is the default - 416 + // and skips this entirely: the queue starts the next item - + the instant a - 417 + // slot frees, which is what everybody who is not being ra - +te-limited wants. - 418 + final cooldown = _hive.downloadCooldownSeconds; - 419 + if (cooldown > 0 && _cooldownUntil != null) { - 420 + final left = _cooldownUntil!.difference(DateTime.now()); - 421 + if (!left.isNegative) { - 422 + // Rescheduled rather than dropped: this is the only t - +hing that will - 423 + // restart the queue, since nothing else is going to f - +ire. - 424 + _cooldownTimer?.cancel(); - 425 + _cooldownTimer = Timer(left, _pump); - 426 + return; - 427 + } - 428 + _cooldownUntil = null; - 429 + } - 430 while (_running.length < maxConcurrent && _queue.isNotEmpt - y) { - 431 final id = _queue.removeAt(0); - 432 final item = _local.get(id); - ... - 435 unawaited( - 436 _start(item).whenComplete(() { - 437 _running.remove(id); - 438 + // Measured from the finish, not the start: the poin - +t is the gap - 439 + // between requests, and a long file has already pro - +vided one. - 440 + final wait = _hive.downloadCooldownSeconds; - 441 + if (wait > 0 && _queue.isNotEmpty) { - 442 + _cooldownUntil = DateTime.now().add(Duration(secon - +ds: wait)); - … 14 more lines (ctrl+o to expand) - -❯ S ga bolgan animationlarni ultracode bilan improve qil visual Sozo splashga - aytyapman unchalik darajada zor emas tushundingmi yanada yaxshiroq qil - logoni customize vector qilib animatisya ishlat tushundingmi nimadir - harakatlar bilan dragon nimadir qimirlashlarni qil logoni detalni harakati - kerak ultracode access beraman - -⏺ Splash'ni ultracode bilan qilaman — avval shu partiyani yakunlab, commit qilib - qo'yay, yarim ish qolmasin. - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/k1.json <<'JSON' - {…) - ⎿  added 7 keys x 12 locales - ⎿  Updated assets/translations/ar.json (+10 -3) - 408 "src_search_empty": "لا يوجد في {} شيء عن «{}». جرّب تهجئة - أخرى — الروماجي، أو اسم الدبلجة.", - 409 "src_search_slow": "لم يستجب {} في الوقت المناسب. قد يكون - بطيئًا لا أن العنوان مفقود — حاول مرة أخرى.", - 410 "src_search_failed": "تعذّر الوصول إلى {}. تحقّق من المصدر ف - ي المصادر ثم حاول مرة أخرى.", - 411 - "src_search_unavailable": "لم يعد {} مثبتًا على هذا الجهاز. - -" - 411 + "src_search_unavailable": "لم يعد {} مثبتًا على هذا الجهاز. - +", - 412 + "open_subtitle_file": "فتح ملف ترجمة…" - 413 }, - 414 "profile": { - 415 "title": "حسابي", - ... - 508 "default_fit_desc": "كيف يملأ الفيديو الشاشة", - 509 "auto_next_episode": "تشغيل الحلقة التالية تلقائياً", - 510 "auto_next_episode_desc": "ابدأ الحلقة التالية عند انتهاء - الحالية", - 511 + "start_paused": "البدء موقوفًا مؤقتًا", - 512 "keep_screen_on": "إبقاء الشاشة مضاءة", - 513 "keep_screen_on_desc": "امنع انطفاء الشاشة أثناء المشاهدة" - , - 514 "gestures_section": "الإيماءات", - ... - 577 "trackers": "المتتبعات", - 578 "stat_days": "أيام مشاهدة", - 579 "mangayomi_sources": "مصادر Mangayomi", - 578 - "view_only": "للمشاهدة فقط" - 580 + "view_only": "للمشاهدة فقط", - 581 + "start_paused_desc": "يفتح المشغّل دون تشغيل. الانتقال التل - +قائي يشتغل كالمعتاد." - 582 }, - 583 "appearance": { - 584 "title": "المظهر", - ... - 1303 "location_sdcard": "بطاقة SD", - 1304 "location_moved": "تم نقل التنزيلات", - 1305 "location_unchanged": "موجودة هنا بالفعل", - 1303 - "location_failed": "تعذّر النقل — التنزيلات ما زالت في مكا - -نها" - 1306 + "location_failed": "تعذّر النقل — التنزيلات ما زالت في مكا - +نها", - 1307 + "cooldown": "فاصل بين التنزيلات", - 1308 + "cooldown_desc": "بعض المواقع تحظر العميل الذي يبدأ ملفًا - +تلو الآخر. الفاصل يحوّل ذلك إلى طابور أبطأ فقط.", - 1309 + "cooldown_off": "بلا فاصل", - 1310 + "cooldown_n": "{} ثانية" - 1311 }, - 1312 "history": { - 1313 "clear_title": "مسح السجل", - ⎿  Updated assets/translations/de.json (+10 -3) - 408 "src_search_empty": "{} hat nichts zu „{}“. Versuch eine a - ndere Schreibweise — das Romaji oder den Namen der Synchronfas - sung.", - 409 "src_search_slow": "{} hat nicht rechtzeitig geantwortet. - Sie ist vielleicht nur langsam — versuch es erneut.", - 410 "src_search_failed": "{} war nicht erreichbar. Prüfe die Q - uelle unter Quellen und versuch es erneut.", - 411 - "src_search_unavailable": "{} ist auf diesem Gerät nicht m - -ehr installiert." - 411 + "src_search_unavailable": "{} ist auf diesem Gerät nicht m - +ehr installiert.", - 412 + "open_subtitle_file": "Untertiteldatei öffnen …" - 413 }, - 414 "profile": { - 415 "title": "Profil", - ... - 508 "default_fit_desc": "Wie das Video den Bildschirm füllt", - 509 "auto_next_episode": "Nächste Folge automatisch", - 510 "auto_next_episode_desc": "Die nächste Folge starten, wenn - eine endet", - 511 + "start_paused": "Pausiert starten", - 512 "keep_screen_on": "Bildschirm anlassen", - 513 "keep_screen_on_desc": "Verhindert, dass sich der Bildschi - rm beim Schauen ausschaltet", - 514 "gestures_section": "GESTEN", - ... - 577 "trackers": "Tracker", - 578 "stat_days": "Tage gesehen", - 579 "mangayomi_sources": "Mangayomi-Quellen", - 578 - "view_only": "NUR ANSEHEN" - 580 + "view_only": "NUR ANSEHEN", - 581 + "start_paused_desc": "Der Player öffnet, spielt aber nicht - +. Das automatische Weiterspielen läuft trotzdem." - 582 }, - 583 "appearance": { - 584 "title": "Darstellung", - ... - 1303 "location_sdcard": "SD-Karte", - 1304 "location_moved": "Downloads verschoben", - 1305 "location_unchanged": "Schon dort", - 1303 - "location_failed": "Die Downloads ließen sich nicht versc - -hieben — sie liegen noch, wo sie waren" - 1306 + "location_failed": "Die Downloads ließen sich nicht versc - +hieben — sie liegen noch, wo sie waren", - 1307 + "cooldown": "Pause zwischen Downloads", - 1308 + "cooldown_desc": "Manche Seiten sperren einen Client, der - + Datei um Datei startet. Eine Pause macht daraus nur eine lan - +gsamere Warteschlange.", - 1309 + "cooldown_off": "Keine Pause", - 1310 + "cooldown_n": "{} Sekunden" - 1311 }, - 1312 "history": { - 1313 "clear_title": "Verlauf löschen", - ⎿  Updated assets/translations/en.json (+10 -3) - 408 "src_search_unavailable": "{} is not installed on this dev - ice any more.", - 409 "resolve_timeout": "The source did not answer in time. Try - another server or another source.", - 410 "layout_center": "Around play", - 411 - "layout_center_full": "No room left around play" - 411 + "layout_center_full": "No room left around play", - 412 + "open_subtitle_file": "Open a subtitle file…" - 413 }, - 414 "profile": { - 415 "title": "Profile", - ... - 508 "default_fit_desc": "How video fits the screen", - 509 "auto_next_episode": "Auto-play next episode", - 510 "auto_next_episode_desc": "Start the next episode when one - ends", - 511 + "start_paused": "Start paused", - 512 "keep_screen_on": "Keep screen on", - 513 "keep_screen_on_desc": "Stop the screen turning off while - watching", - 514 "gestures_section": "GESTURES", - ... - 577 "trackers": "Trackers", - 578 "stat_days": "days watched", - 579 "mangayomi_sources": "Mangayomi Sources", - 578 - "view_only": "VIEW ONLY" - 580 + "view_only": "VIEW ONLY", - 581 + "start_paused_desc": "Open the player without playing. Aut - +o-advance still plays." - 582 }, - 583 "appearance": { - 584 "title": "Appearance", - ... - 1303 "location_sdcard": "SD card", - 1304 "location_moved": "Downloads moved", - 1305 "location_unchanged": "Already there", - 1303 - "location_failed": "Could not move the downloads — they a - -re still where they were" - 1306 + "location_failed": "Could not move the downloads — they a - +re still where they were", - 1307 + "cooldown": "Pause between downloads", - 1308 + "cooldown_desc": "Some sites block a client that starts f - +ile after file. A gap turns that into a slower queue.", - 1309 + "cooldown_off": "No pause", - 1310 + "cooldown_n": "{} seconds" - 1311 }, - 1312 "history": { - 1313 "clear_title": "Clear History", - ⎿  Updated assets/translations/es.json (+10 -3) - 408 "src_search_empty": "{} no tiene nada para «{}». Prueba ot - ra grafía: el romaji o el nombre del doblaje.", - 409 "src_search_slow": "{} no respondió a tiempo. Puede ser le - nta y no que le falte el título: inténtalo de nuevo.", - 410 "src_search_failed": "No se pudo contactar con {}. Revísal - a en Fuentes y vuelve a intentarlo.", - 411 - "src_search_unavailable": "{} ya no está instalada en este - - dispositivo." - 411 + "src_search_unavailable": "{} ya no está instalada en este - + dispositivo.", - 412 + "open_subtitle_file": "Abrir un archivo de subtítulos…" - 413 }, - 414 "profile": { - 415 "title": "Perfil", - ... - 508 "default_fit_desc": "Cómo encaja el vídeo en la pantalla", - 509 "auto_next_episode": "Reproducir el siguiente episodio", - 510 "auto_next_episode_desc": "Empieza el siguiente episodio c - uando termina uno", - 511 + "start_paused": "Empezar en pausa", - 512 "keep_screen_on": "Mantener la pantalla encendida", - 513 "keep_screen_on_desc": "Evita que la pantalla se apague mi - entras ves algo", - 514 "gestures_section": "GESTOS", - ... - 577 "trackers": "Trackers", - 578 "stat_days": "días vistos", - 579 "mangayomi_sources": "Fuentes de Mangayomi", - 578 - "view_only": "SOLO VER" - 580 + "view_only": "SOLO VER", - 581 + "start_paused_desc": "El reproductor abre sin reproducir. - +El avance automático sí reproduce." - 582 }, - 583 "appearance": { - 584 "title": "Apariencia", - ... - 1303 "location_sdcard": "Tarjeta SD", - 1304 "location_moved": "Descargas movidas", - 1305 "location_unchanged": "Ya estaban ahí", - 1303 - "location_failed": "No se pudieron mover las descargas: s - -iguen donde estaban" - 1306 + "location_failed": "No se pudieron mover las descargas: s - +iguen donde estaban", - 1307 + "cooldown": "Pausa entre descargas", - 1308 + "cooldown_desc": "Algunos sitios bloquean a quien descarg - +a archivo tras archivo. Una pausa lo convierte en una cola má - +s lenta.", - 1309 + "cooldown_off": "Sin pausa", - 1310 + "cooldown_n": "{} segundos" - 1311 }, - 1312 "history": { - 1313 "clear_title": "Borrar historial", - ⎿  Updated assets/translations/fr.json (+10 -3) - 408 "src_search_empty": "{} n'a rien pour « {} ». Essayez une - autre orthographe — le romaji, ou le nom du doublage.", - 409 "src_search_slow": "{} n'a pas répondu à temps. Elle est p - eut-être lente plutôt que sans le titre — réessayez.", - 410 "src_search_failed": "Impossible de joindre {}. Vérifiez l - a source dans Sources, puis réessayez.", - 411 - "src_search_unavailable": "{} n'est plus installée sur cet - - appareil." - 411 + "src_search_unavailable": "{} n'est plus installée sur cet - + appareil.", - 412 + "open_subtitle_file": "Ouvrir un fichier de sous-titres…" - 413 }, - 414 "profile": { - 415 "title": "Profil", - ... - 508 "default_fit_desc": "Comment la vidéo s’adapte à l’écran", - 509 "auto_next_episode": "Lire l’épisode suivant", - 510 "auto_next_episode_desc": "Démarre l’épisode suivant quand - un épisode se termine", - 511 + "start_paused": "Démarrer en pause", - 512 "keep_screen_on": "Garder l’écran allumé", - 513 "keep_screen_on_desc": "Empêche l’écran de s’éteindre pend - ant la lecture", - 514 "gestures_section": "GESTES", - ... - 577 "trackers": "Trackers", - 578 "stat_days": "jours regardés", - 579 "mangayomi_sources": "Sources Mangayomi", - 578 - "view_only": "LECTURE SEULE" - 580 + "view_only": "LECTURE SEULE", - 581 + "start_paused_desc": "Le lecteur s'ouvre sans lancer la le - +cture. L'épisode suivant démarre quand même." - 582 }, - 583 "appearance": { - 584 "title": "Apparence", - ... - 1303 "location_sdcard": "Carte SD", - 1304 "location_moved": "Téléchargements déplacés", - 1305 "location_unchanged": "Déjà là", - 1303 - "location_failed": "Impossible de déplacer les télécharge - -ments — ils sont restés où ils étaient" - 1306 + "location_failed": "Impossible de déplacer les télécharge - +ments — ils sont restés où ils étaient", - 1307 + "cooldown": "Pause entre les téléchargements", - 1308 + "cooldown_desc": "Certains sites bloquent un client qui e - +nchaîne les fichiers. Une pause en fait simplement une file p - +lus lente.", - 1309 + "cooldown_off": "Aucune pause", - 1310 + "cooldown_n": "{} secondes" - 1311 }, - 1312 "history": { - 1313 "clear_title": "Effacer l’historique", - ⎿  … 7 more files changed - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  STATE MISSING - 51: late bool _autoNext; - 90: _autoNext = _hive.autoPlayNextEpisode; - 230: value: _autoNext, - ⎿  Updated - lib/features/profile/presentation/pages/player_settings_page.dart (+11 - -0) - 234 }, - 235 ), - 236 const SettingsDivider(), - 237 + SettingsSwitchTile( - 238 + icon: Icons.pause_circle_outline_rounded, - 239 + title: 'profile.start_paused'.tr(), - 240 + subtitle: 'profile.start_paused_desc'.tr(), - 241 + value: _startPaused, - 242 + onChanged: (v) { - 243 + setState(() => _startPaused = v); - 244 + _hive.setStartPaused(v); - 245 + }, - 246 + ), - 247 + const SettingsDivider(), - 248 SettingsSwitchTile( - 249 icon: Icons.lightbulb_outline_rounded, - 250 title: 'profile.keep_screen_on'.tr(), - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing profile... - No issues found! (ran in 3.7s) - ⎿  Updated - lib/features/profile/presentation/pages/player_settings_page.dart (+2 - -0) - 49 late double _speed; - 50 late String _fit; - 51 late bool _autoNext; - 52 + late bool _startPaused; - 53 late int _seekSeconds; - 54 late double _boost; - 55 late bool _brightnessGesture; - ... - 89 _speed = _hive.getDefaultPlaybackSpeed(); - 90 _fit = _hive.getDefaultPlayerFit(); - 91 _autoNext = _hive.autoPlayNextEpisode; - 92 + _startPaused = _hive.startPaused; - 93 _incognito = _hive.isIncognito; - 94 _autoSkipIntro = _hive.autoSkipIntro; - 95 _seekSeconds = _hive.getDoubleTapSeekSeconds(); - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  37:class DownloadsPage extends StatelessWidget { - 59:class _DownloadsView extends StatelessWidget { - 413:class _OverflowMenu extends StatelessWidget { - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/download/presentation/pages/downloads_page.dart - (+23 -0) - 163 ), - 164 ), - 165 - 166 + // Next to Wi-Fi only, because they are the same - + kind of setting: - 167 + // both make the queue slower on purpose, for a - +reason outside - 168 + // the app. - 169 + SliverToBoxAdapter( - 170 + child: Padding( - 171 + padding: const EdgeInsets.fromLTRB(16, 0, 16 - +, 4), - 172 + child: SettingsDropdownTile( - 173 + icon: Icons.hourglass_empty_rounded, - 174 + title: 'downloads.cooldown'.tr(), - 175 + subtitle: 'downloads.cooldown_desc'.tr(), - 176 + value: _cooldown, - 177 + options: const [0, 5, 15, 30, 60, 120], - 178 + labelOf: (v) => v == 0 - 179 + ? 'downloads.cooldown_off'.tr() - 180 + : 'downloads.cooldown_n'.tr(args: ['$v - +']), - 181 + onChanged: (v) { - 182 + setState(() => _cooldown = v); - 183 + getIt().setDownloadCooldown - +Seconds(v); - 184 + }, - 185 + ), - 186 + ), - 187 + ), - 188 + - 189 if (state.total > 0) - 190 SliverToBoxAdapter( - 191 child: DownloadsToolbar( - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 145 packages have newer versions incompatible with dependency constrai - nts. - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/download/presentation/bloc/downloads_bloc.dart - (+15 -1) - 55 on(_onClear); - 56 on(_onSweep); - 57 on(_onWifiOnly); - 58 + on(_onCooldown); - 59 on(_onLocation); - 60 } - 61 - ... - 81 _get.revision.addListener(listener); - 82 _unsubscribe = () => _get.revision.removeListener(listener) - ; - 83 - 83 - emit(state.copyWith(wifiOnly: _hive.downloadWifiOnly)); - 84 + emit( - 85 + state.copyWith( - 86 + wifiOnly: _hive.downloadWifiOnly, - 87 + cooldownSeconds: _hive.downloadCooldownSeconds, - 88 + ), - 89 + ); - 90 // Draw what is known first, then correct it. The sweep wal - ks the whole - 91 // folder, and an empty screen while it runs is worse than - a screen that - 92 // adjusts a moment later. - ... - 179 await _hive.setDownloadWifiOnly(event.value); - 180 } - 181 - 182 + Future _onCooldown( - 183 + DownloadsCooldownChanged event, - 184 + Emitter emit, - 185 + ) async { - 186 + emit(state.copyWith(cooldownSeconds: event.seconds)); - 187 + await _hive.setDownloadCooldownSeconds(event.seconds); - 188 + } - 189 + - 190 /// Moves the library, reporting what happened. - 191 /// - 192 /// Marked busy for the whole copy: it is minutes on a large - library, and a - ⎿  Updated lib/features/download/presentation/bloc/downloads_event.dart - (+10 -0) - 85 List get props => [location.path]; - 86 } - 87 - 88 +/// How long the queue waits between one file and the next. - 89 +class DownloadsCooldownChanged extends DownloadsEvent { - 90 + const DownloadsCooldownChanged(this.seconds); - 91 + - 92 + final int seconds; - 93 + - 94 + @override - 95 + List get props => [seconds]; - 96 +} - 97 + - 98 class DownloadsWifiOnlyToggled extends DownloadsEvent { - 99 const DownloadsWifiOnlyToggled(this.value); - 100 final bool value; - ⎿  Updated lib/features/download/presentation/bloc/downloads_state.dart - (+6 -0) - 104 this.usage = StorageUsage.empty, - 105 this.waitingForWifi = false, - 106 this.wifiOnly = false, - 107 + this.cooldownSeconds = 0, - 108 this.loading = true, - 109 this.busy = false, - 110 this.locations = const [], - ... - 123 final StorageUsage usage; - 124 final bool waitingForWifi; - 125 final bool wifiOnly; - 126 + - 127 + /// Seconds between one download finishing and the next star - +ting. - 128 + final int cooldownSeconds; - 129 final bool loading; - 130 - 131 /// A long-running action — an export, an orphan sweep, a mo - ve — is in - ... - 158 StorageUsage? usage, - 159 bool? waitingForWifi, - 160 bool? wifiOnly, - 161 + int? cooldownSeconds, - 162 bool? loading, - 163 bool? busy, - 164 List? locations, - ... - 172 usage: usage ?? this.usage, - 173 waitingForWifi: waitingForWifi ?? this.waitingForWifi, - 174 wifiOnly: wifiOnly ?? this.wifiOnly, - 175 + cooldownSeconds: cooldownSeconds ?? this.cooldownSecon - +ds, - 176 loading: loading ?? this.loading, - 177 busy: busy ?? this.busy, - 178 locations: locations ?? this.locations, - ⎿  Updated lib/features/download/presentation/pages/downloads_page.dart - (+2 -5) - 173 icon: Icons.hourglass_empty_rounded, - 174 title: 'downloads.cooldown'.tr(), - 175 subtitle: 'downloads.cooldown_desc'.tr(), - 176 - value: _cooldown, - 176 + value: state.cooldownSeconds, - 177 options: const [0, 5, 15, 30, 60, 120], - 178 labelOf: (v) => v == 0 - 179 ? 'downloads.cooldown_off'.tr() - 180 : 'downloads.cooldown_n'.tr(args: ['$v - ']), - 181 - onChanged: (v) { - 182 - setState(() => _cooldown = v); - 183 - getIt().setDownloadCooldown - -Seconds(v); - 184 - }, - 181 + onChanged: (v) => bloc.add(DownloadsCooldo - +wnChanged(v)), - 182 ), - 183 ), - 184 ), - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/features/download - lib/features/profile/presentation/pages/player_settings…) - ⎿  Formatted 41 files (17 changed) in 0.15 seconds. - Try `flutter pub outdated` for more information. - Analyzing sozo... - … +7 lines (ctrl+o to expand) - ⎿  Updated lib/features/detail/presentation/pages/player_page.media.dart - (+5 -8) - 1063 setState(() { - 1064 _initializing = false; - 1065 _errorMessage = null; - 1066 - _errorRaw = null; - 1066 + _errorRaw = null; - 1067 _isCodecError = false; - 1068 }); - 1069 await _handOffToExternalPlayer(); - ... - 1278 setState(() { - 1279 _initializing = false; - 1280 _errorMessage = null; - 1281 - _errorRaw = null; - 1281 + _errorRaw = null; - 1282 _isCodecError = false; - 1283 }); - 1284 _scheduleHide(); - ... - 1367 hasUntriedSource: _hasUntriedSource, - 1368 ); - 1369 if (action == RetryAction.nextSource && _hasUntriedSo - urce) { - 1370 - _plog( - 1371 - 'refused here, trying another source', - 1372 - level: LogLevel.warn, - 1373 - ); - 1370 + _plog('refused here, trying another source', level: - + LogLevel.warn); - 1371 _retryAttempts++; - 1372 _lifetimeRetries++; - 1373 _autoRetrying = true; - ... - 1681 _initializing = true; - 1682 _stage = _LoadingStage.loading; - 1683 _errorMessage = null; - 1687 - _errorRaw = null; - 1684 + _errorRaw = null; - 1685 _isCodecError = false; - 1686 _currentSourceIndex = nextIdx; - 1687 _currentQuality = next.quality; - ... - 1825 _initializing = true; - 1826 _stage = _LoadingStage.loading; - 1827 _errorMessage = null; - 1831 - _errorRaw = null; - 1828 + _errorRaw = null; - … 3 more lines (ctrl+o to expand) - ⎿  Updated - lib/features/detail/presentation/pages/player_page.subtitles.dart (+4 - -1) - 62 declaredFormat: declaredFormat, - 63 ); - 64 if (!local.isSuccess) { - 65 - _plog('subtitle parse failed: ${local.failure}', leve - -l: LogLevel.warn); - 65 + _plog( - 66 + 'subtitle parse failed: ${local.failure}', - 67 + level: LogLevel.warn, - 68 + ); - 69 _toast(_subtitleFailureMessage(local.failure)); - 70 return null; - 71 } - ⎿  Updated lib/features/download/data/datasources/download_transfer_data_ - source.dart (+18 -17) - 134 // One kind, two shapes. A comic chapter is a folder o - f images; a - 135 // novel chapter is one document, whose pictures are f - etched into the - 136 // same folder so the words and the illustrations are - deleted together. - 137 - DownloadKind.manga => (chapterHtml ?? '').trim().isNot - -Empty - 138 - ? await _prose( - 139 - dirPath: dirPath, - 140 - html: chapterHtml!, - 141 - sourceUrl: sourceUrl, - 142 - headers: headers, - 143 - cancel: cancel, - 144 - onProgress: onProgress, - 145 - ) - 146 - : await _pages( - 147 - dirPath: dirPath, - 148 - pageUrls: pageUrls, - 149 - imageHeaders: imageHeaders, - 150 - headers: headers, - 151 - cancel: cancel, - 152 - onProgress: onProgress, - 153 - ), - 137 + DownloadKind.manga => - 138 + (chapterHtml ?? '').trim().isNotEmpty - 139 + ? await _prose( - 140 + dirPath: dirPath, - 141 + html: chapterHtml!, - 142 + sourceUrl: sourceUrl, - 143 + headers: headers, - 144 + cancel: cancel, - 145 + onProgress: onProgress, - 146 + ) - 147 + : await _pages( - 148 + dirPath: dirPath, - 149 + pageUrls: pageUrls, - 150 + imageHeaders: imageHeaders, - 151 + headers: headers, - 152 + cancel: cancel, - 153 + onProgress: onProgress, - 154 + ), - 155 }; - 156 } on _NotMediaException catch (e) { - … 1 more lines (ctrl+o to expand) - ⎿  Updated lib/features/download/data/epub_builder.dart (+14 -5) - 43 abstract final class EpubBuilder { - 44 /// Void elements: legal unclosed in HTML, fatal unclosed in - XML. - 45 static const List _void = [ - 46 - 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', - 47 - 'input', 'link', 'meta', 'source', 'track', 'wbr', - 46 + 'area', - 47 + 'base', - 48 + 'br', - 49 + 'col', - 50 + 'embed', - 51 + 'hr', - 52 + 'img', - 53 + 'input', - 54 + 'link', - 55 + 'meta', - 56 + 'source', - 57 + 'track', - 58 + 'wbr', - 59 ]; - 60 - 61 /// An `&` that does not start an entity. XML rejects it outr - ight, and it is - ... - 135 ..compression = CompressionType.none, - 136 ); - 137 - 127 - archive.add( - 128 - ArchiveFile.string('META-INF/container.xml', _container) - -, - 129 - ); - 138 + archive.add(ArchiveFile.string('META-INF/container.xml', _ - +container)); - 139 - 140 final names = []; - 141 for (var i = 0; i < chapters.length; i++) { - ⎿  Updated - lib/features/download/data/repositories/download_repository_impl.dart - (+8 -5) - 1038 d.provider == item.provider, - 1039 ) - 1040 .toList() - 1041 - ..sort((a, b) => (a.episodeNumber ?? 0).compareTo(b - -.episodeNumber ?? 0)); - 1041 + ..sort( - 1042 + (a, b) => (a.episodeNumber ?? 0).compareTo(b.epis - +odeNumber ?? 0), - 1043 + ); - 1044 if (siblings.isEmpty) return null; - 1045 - 1046 final chapters = []; - ... - 1079 // Written into the app's own space first. The exporter c - opies a path out; - 1080 // it has no way to be handed bytes, and inventing one fo - r this would mean a - 1081 // second platform channel doing what this one already do - es. - 1080 - final staged = File('${(await _storage.ensureDir(item.id) - -).path}/export.epub'); - 1082 + final staged = File( - 1083 + '${(await _storage.ensureDir(item.id)).path}/export.epu - +b', - 1084 + ); - 1085 await staged.writeAsBytes(bytes, flush: true); - 1086 try { - 1087 return await _native.exportToDownloads( - ... - 1125 // document; the reader is handed a string, not a direc - tory, so they are - 1126 // made absolute here — the one place that knows where - the folder is. - 1127 return DownloadTransferDataSource.rewriteImageSources(h - tml, { - 1124 - for (final entity in Directory(_storage.dirOf(id)).li - -stSync()) - 1125 - if (entity is File) - 1126 - entity.uri.pathSegments.last: entity.path, - 1128 + for (final entity in Directory(_storage.dirOf(id)).li - +stSync()) - 1129 + if (entity is File) entity.uri.pathSegments.last: e - +ntity.path, - 1130 }); - 1131 } catch (e) { - 1132 debugPrint('[downloads] could not read chapter html for - $id: $e'); - ⎿  … 12 more files changed - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(git checkout -- - lib/features/download/data/datasources/download_transfer_data_source.dart - lib/features/download/data/epub_builder.dart lib/features/download/dat…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 9.9s) - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/download/data/datasources/download_transfer_data_ - source.dart (+17 -18) - 134 // One kind, two shapes. A comic chapter is a folder o - f images; a - 135 // novel chapter is one document, whose pictures are f - etched into the - 136 // same folder so the words and the illustrations are - deleted together. - 137 - DownloadKind.manga => - 138 - (chapterHtml ?? '').trim().isNotEmpty - 139 - ? await _prose( - 140 - dirPath: dirPath, - 141 - html: chapterHtml!, - 142 - sourceUrl: sourceUrl, - 143 - headers: headers, - 144 - cancel: cancel, - 145 - onProgress: onProgress, - 146 - ) - 147 - : await _pages( - 148 - dirPath: dirPath, - 149 - pageUrls: pageUrls, - 150 - imageHeaders: imageHeaders, - 151 - headers: headers, - 152 - cancel: cancel, - 153 - onProgress: onProgress, - 154 - ), - 137 + DownloadKind.manga => (chapterHtml ?? '').trim().isNot - +Empty - 138 + ? await _prose( - 139 + dirPath: dirPath, - 140 + html: chapterHtml!, - 141 + sourceUrl: sourceUrl, - 142 + headers: headers, - 143 + cancel: cancel, - 144 + onProgress: onProgress, - 145 + ) - 146 + : await _pages( - 147 + dirPath: dirPath, - 148 + pageUrls: pageUrls, - 149 + imageHeaders: imageHeaders, - 150 + headers: headers, - 151 + cancel: cancel, - 152 + onProgress: onProgress, - 153 + ), - 154 }; - 155 } on _NotMediaException catch (e) { - … 1 more lines (ctrl+o to expand) - ⎿  Updated lib/features/download/data/epub_builder.dart (+5 -14) - 43 abstract final class EpubBuilder { - 44 /// Void elements: legal unclosed in HTML, fatal unclosed in - XML. - 45 static const List _void = [ - 46 - 'area', - 47 - 'base', - 48 - 'br', - 49 - 'col', - 50 - 'embed', - 51 - 'hr', - 52 - 'img', - 53 - 'input', - 54 - 'link', - 55 - 'meta', - 56 - 'source', - 57 - 'track', - 58 - 'wbr', - 46 + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', - 47 + 'input', 'link', 'meta', 'source', 'track', 'wbr', - 48 ]; - 49 - 50 /// An `&` that does not start an entity. XML rejects it outr - ight, and it is - ... - 124 ..compression = CompressionType.none, - 125 ); - 126 - 138 - archive.add(ArchiveFile.string('META-INF/container.xml', _ - -container)); - 127 + archive.add( - 128 + ArchiveFile.string('META-INF/container.xml', _container) - +, - 129 + ); - 130 - 131 final names = []; - 132 for (var i = 0; i < chapters.length; i++) { - ⎿  Updated lib/features/download/data/storage/download_storage.dart (+7 - -12) - 93 try { - 94 if (!await target.exists()) await target.create(recursiv - e: true); - 95 if (await from.exists()) { - 96 - await for (final entity in from.list( - 97 - recursive: true, - 98 - followLinks: false, - 99 - )) { - 96 + await for (final entity in from.list(recursive: true, - +followLinks: false)) { - 97 final relative = entity.path.substring(from.path.len - gth + 1); - 98 final destination = '${target.path}/$relative'; - 99 if (entity is Directory) { - 100 await Directory(destination).create(recursive: tru - e); - 101 } else if (entity is File) { - 105 - await Directory( - 106 - destination.substring(0, destination.lastIndexOf - -('/')), - 107 - ).create(recursive: true); - 102 + await Directory(destination.substring(0, destinati - +on.lastIndexOf('/'))) - 103 + .create(recursive: true); - 104 await entity.copy(destination); - 105 } - 106 } - ... - 207 if (!await dir.exists()) return 0; - 208 var total = 0; - 209 try { - 214 - await for (final entity in dir.list( - 215 - recursive: true, - 216 - followLinks: false, - 217 - )) { - 210 + await for (final entity in dir.list(recursive: true, fol - +lowLinks: false)) { - 211 if (entity is File) { - 212 try { - 213 total += await entity.length(); - ... - 231 try { - 232 await for (final entity in dir.list(followLinks: false)) - { - 233 if (entity is! Directory) continue; - 241 - final name = entity.uri.pathSegments.where((s) => s.is - -NotEmpty).last; - 234 + final name = entity.uri.pathSegments - 235 + .where((s) => s.isNotEmpty) - 236 + .last; - 237 if (!knownIds.contains(name)) out.add(name); - … 2 more lines (ctrl+o to expand) - ⎿  Updated lib/features/download/domain/download_layout.dart (+9 -6) - 41 String id, { - 42 required DownloadKind kind, - 43 String? extension, - 44 - }) => switch (kind) { - 45 - DownloadKind.manga => dirFor(id), - 46 - DownloadKind.hls => '${dirFor(id)}/$hlsIndexName', - 47 - DownloadKind.video => '${dirFor(id)}/$videoStemName${extens - -ion ?? '.mp4'}', - 48 - }; - 44 + }) => - 45 + switch (kind) { - 46 + DownloadKind.manga => dirFor(id), - 47 + DownloadKind.hls => '${dirFor(id)}/$hlsIndexName', - 48 + DownloadKind.video => - 49 + '${dirFor(id)}/$videoStemName${extension ?? '.mp4'}', - 50 + }; - 51 - 52 /// The playlist rewritten to point at local segments. - 53 static const String hlsIndexName = 'index.m3u8'; - ... - 105 /// takes the pictures with the words. - 106 static const String chapterHtmlName = 'chapter.html'; - 107 - 106 - static String chapterHtmlFor(String id) => '${dirFor(id)}/$c - -hapterHtmlName'; - 108 + static String chapterHtmlFor(String id) => - 109 + '${dirFor(id)}/$chapterHtmlName'; - 110 - 111 /// Recovers a relative path from whatever an older build st - ored. - 112 /// - ⎿  Updated lib/features/download/domain/entities/download_kind.dart (+4 - -4) - 73 } - 74 - 75 static DownloadUnit forKind(DownloadKind kind) => switch (kin - d) { - 76 - DownloadKind.video => DownloadUnit.bytes, - 77 - DownloadKind.hls => DownloadUnit.segments, - 78 - DownloadKind.manga => DownloadUnit.pages, - 79 - }; - 76 + DownloadKind.video => DownloadUnit.bytes, - 77 + DownloadKind.hls => DownloadUnit.segments, - 78 + DownloadKind.manga => DownloadUnit.pages, - 79 + }; - 80 } - ⎿  … 5 more files changed - - Pushed to work/2026-09-14 (ctrl+o to expand) - -⏺ Batch 1 tayyor (c6c4b60). Endi splash — ultracode bilan. Avval hozirgisini - ko'ray: - - Searched for 1 pattern, read 2 files, listed 2 directories, ran 1 shell command (ctrl+o to expand) - -⏺ Splash uchun ultracode ishlataman — ruxsat berdingiz. Avval mavjud vektor - mashinasini ko'ray, chunki u allaqachon bor: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Skill(workflow-authoring) - ⎿  Successfully loaded skill - -⏺ Ultracode uchun ishga tushiraman — 3 xil konsepsiya parallel ishlab chiqiladi - va adversarial baholanadi: - -⏺ Workflow(Design and adversarially judge three animated vector splash concepts - for Sozo) - ⎿  /workflows to view dynamic workflow runs - -⏺ Workflow ketyapti. Shu orada ro'yxatning keyingi partiyasi — reader tomoni: - - Searched for 2 patterns, ran 3 shell commands (ctrl+o to expand) - -⏺ Chapter grouping + filtri — 500 bobli ro'yxat uchun. Avval toza mantiq, - testlanadigan: - -⏺ Bash(cat > lib/features/manga/domain/reading/chapter_groups.dart <<'DART' - import - 'package:soplay/features/detail/domain/entities/episode_entity.dart';…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing domain... - No issues found! (ran in 0.3s) - ⎿  Created lib/features/manga/domain/reading/chapter_groups.dart (+126 - -0) - 1 +import 'package:soplay/features/detail/domain/entities/episode - +_entity.dart'; - 2 + - 3 +/// One heading in the chapter list, and the chapters under it - +. - 4 +class ChapterGroup { - 5 + const ChapterGroup({ - 6 + required this.label, - 7 + required this.volume, - 8 + required this.indices, - 9 + }); - 10 + - 11 + /// What the heading says. Empty for the single group a shor - +t list gets, - 12 + /// which is drawn without a heading at all. - 13 + final String label; - 14 + - 15 + /// The volume number this group is, or null when the groupi - +ng is by range. - 16 + final int? volume; - 17 + - 18 + /// Positions in the ORIGINAL chapter list, so tapping a row - + still loads the - 19 + /// right chapter. Grouping must never renumber anything: th - +e index is the - 20 + /// identity everything else in the reader is keyed on. - 21 + final List indices; - 22 +} - 23 + - 24 +/// Splits a chapter list into headings somebody can navigate. - 25 +/// - 26 +/// A source with six hundred chapters gives a flat list six h - +undred rows long, - 27 +/// and the only way to reach chapter 300 is to drag a scrollb - +ar until the - 28 +/// numbers look right. Grouping gives it a spine. - 29 +/// - 30 +/// Two ways, in order of preference: - 31 +/// - 32 +/// * **By volume**, when the labels say so. Most scanlation - +sources write - 33 +/// "Vol. 4 Ch. 31" or "Volume 4, Chapter 31", and a volume - + is a real - 34 +/// division somebody remembers reading — worth far more th - +an an arbitrary - 35 +/// block. Used only when MOST chapters carry one, because - +a handful of - 36 +/// volume-labelled specials in an otherwise unlabelled lis - +t would put - 37 +/// nearly everything in "Other". - 38 +/// * **By range**, otherwise: blocks of [rangeSize], labelle - +d by the chapter - 39 +/// numbers at each end rather than by position, so the hea - +ding reads like - 40 +/// the numbers on the rows under it. - … 86 more lines (ctrl+o to expand) - -⏺ Bash(mkdir -p test/features/manga && cat > - test/features/manga/chapter_groups_test.dart <<'DART' - // A six-hundred-chapter list is a scrollbar and a guess.…) - ⎿  00:00 +2: grouping a short list is left alone, with no heading - 00:00 +3: grouping a long list with volumes groups by volume - 00:00 +4: grouping and a handful of volume labels is not a volume list - … +8 lines (ctrl+o to expand) - ⎿  Created test/features/manga/chapter_groups_test.dart (+106 -0) - 1 +// A six-hundred-chapter list is a scrollbar and a guess. - 2 +// - 3 +// The reader's chapter sheet was flat: six hundred identical - +rows, and the only - 4 +// way to reach chapter 300 was to drag until the numbers look - +ed right. Grouping - 5 +// gives it a spine — but only where the source's own labels s - +upport one, and - 6 +// never by renumbering anything. - 7 +import 'package:flutter_test/flutter_test.dart'; - 8 +import 'package:soplay/features/detail/domain/entities/episode - +_entity.dart'; - 9 +import 'package:soplay/features/manga/domain/reading/chapter_g - +roups.dart'; - 10 + - 11 +EpisodeEntity ch(String label, [int number = 0]) => - 12 + EpisodeEntity(episode: number, label: label, mediaRef: lab - +el); - 13 + - 14 +void main() { - 15 + group('reading a volume out of a label', () { - 16 + test('the spellings sources actually use', () { - 17 + expect(volumeOf('Vol. 4 Ch. 31'), 4); - 18 + expect(volumeOf('Volume 12, Chapter 3'), 12); - 19 + expect(volumeOf('vol 7 - the descent'), 7); - 20 + expect(volumeOf('Tome 2 Chapitre 9'), 2); - 21 + }); - 22 + - 23 + test('and nothing else', () { - 24 + // A looser pattern reads the "2" out of these and inven - +ts volumes - 25 + // nobody wrote, which is worse than no grouping at all. - 26 + expect(volumeOf('Chapter 31'), isNull); - 27 + expect(volumeOf('Season 2 Episode 4'), isNull); - 28 + expect(volumeOf('2 Broke Girls'), isNull); - 29 + expect(volumeOf('Revolution 9'), isNull); - 30 + }); - 31 + }); - 32 + - 33 + group('grouping', () { - 34 + test('a short list is left alone, with no heading', () { - 35 + final groups = groupChapters([for (var i = 1; i <= 12; i - +++) ch('Ch $i', i)]); - 36 + expect(groups, hasLength(1)); - 37 + expect(groups.single.label, ''); - 38 + expect(groups.single.indices, hasLength(12)); - 39 + }); - 40 + - … 66 more lines (ctrl+o to expand) - -⏺ All 9 pass. Now wiring it into the reader's chapter sheet, with a filter: -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/k2.json <<'JSON' - {…) - ⎿  added 4 keys x 12 locales - ⎿  Updated assets/translations/ar.json (+5 -1) - 1026 "mark_read": "تحديد كمقروء", - 1027 "mark_unread": "تحديد كغير مقروء", - 1028 "marked_read_n": "تم تحديد {} كمقروء", - 1029 - "marked_unread_n": "تم تحديد {} كغير مقروء" - 1029 + "marked_unread_n": "تم تحديد {} كغير مقروء", - 1030 + "volume_n": "المجلد {}", - 1031 + "other_chapters": "أخرى", - 1032 + "find_chapter": "ابحث عن فصل…", - 1033 + "no_chapter_match": "لا يوجد فصل مطابق." - 1034 }, - 1035 "cloudflare": { - 1036 "title": "تأكيد أنك لست روبوتاً", - ⎿  Updated assets/translations/de.json (+5 -1) - 1026 "mark_read": "Als gelesen", - 1027 "mark_unread": "Als ungelesen", - 1028 "marked_read_n": "{} als gelesen markiert", - 1029 - "marked_unread_n": "{} als ungelesen markiert" - 1029 + "marked_unread_n": "{} als ungelesen markiert", - 1030 + "volume_n": "Band {}", - 1031 + "other_chapters": "Sonstige", - 1032 + "find_chapter": "Kapitel suchen …", - 1033 + "no_chapter_match": "Kein Kapitel passt dazu." - 1034 }, - 1035 "cloudflare": { - 1036 "title": "Bestätige, dass du ein Mensch bist", - ⎿  Updated assets/translations/en.json (+5 -1) - 1026 "mark_read": "Mark read", - 1027 "mark_unread": "Mark unread", - 1028 "marked_read_n": "{} marked read", - 1029 - "marked_unread_n": "{} marked unread" - 1029 + "marked_unread_n": "{} marked unread", - 1030 + "volume_n": "Volume {}", - 1031 + "other_chapters": "Other", - 1032 + "find_chapter": "Find a chapter…", - 1033 + "no_chapter_match": "No chapter matches that." - 1034 }, - 1035 "cloudflare": { - 1036 "title": "Verify you're human", - ⎿  Updated assets/translations/es.json (+5 -1) - 1026 "mark_read": "Marcar leído", - 1027 "mark_unread": "Marcar no leído", - 1028 "marked_read_n": "{} marcados como leídos", - 1029 - "marked_unread_n": "{} marcados como no leídos" - 1029 + "marked_unread_n": "{} marcados como no leídos", - 1030 + "volume_n": "Volumen {}", - 1031 + "other_chapters": "Otros", - 1032 + "find_chapter": "Buscar un capítulo…", - 1033 + "no_chapter_match": "Ningún capítulo coincide." - 1034 }, - 1035 "cloudflare": { - 1036 "title": "Verifica que eres humano", - ⎿  Updated assets/translations/fr.json (+5 -1) - 1026 "mark_read": "Marquer lu", - 1027 "mark_unread": "Marquer non lu", - 1028 "marked_read_n": "{} marqués comme lus", - 1029 - "marked_unread_n": "{} marqués comme non lus" - 1029 + "marked_unread_n": "{} marqués comme non lus", - 1030 + "volume_n": "Tome {}", - 1031 + "other_chapters": "Autres", - 1032 + "find_chapter": "Trouver un chapitre…", - 1033 + "no_chapter_match": "Aucun chapitre ne correspond." - 1034 }, - 1035 "cloudflare": { - 1036 "title": "Confirme que tu es humain", - ⎿  … 7 more files changed - ⎿  (another command ran in this repository at the same time; a change - made by either may show under either result) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 32:import 'package:soplay/features/manga/domain/entities/reader_args.d - art'; - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/manga/presentation/pages/reader_page.dart (+22 - -84) - 30 import 'package:soplay/features/manga/presentation/widgets/nove - l_text.dart'; - 31 import 'package:soplay/features/manga/domain/entities/manga_pag - e_entity.dart'; - 32 import 'package:soplay/features/manga/domain/entities/reader_ar - gs.dart'; - 33 +import 'package:soplay/features/manga/domain/reading/chapter_gr - +oups.dart'; - 34 import 'package:flutter_cache_manager/flutter_cache_manager.dar - t'; - 35 import 'package:soplay/features/manga/data/chapter_read_store.d - art'; - 36 import 'package:soplay/features/manga/data/page_tiles.dart'; - ... - 1314 _snack(downloadOutcomeMessage(outcome)); - 1315 } - 1316 - 1317 + /// The chapter list, with a spine and a way to search it. - 1318 + /// - 1319 + /// It was six hundred identical rows and a scrollbar. Reac - +hing chapter 300 - 1320 + /// meant dragging until the numbers looked right, and ther - +e was nothing to - 1321 + /// type into — which for the one list in this app that rou - +tinely runs to - 1322 + /// four figures is the wrong shape entirely. - 1323 + /// - 1324 + /// Two things. Headings, from the source's own labels wher - +e they carry a - 1325 + /// volume and from chapter-number ranges where they do not - + — see - 1326 + /// [groupChapters], which never renumbers anything, becaus - +e the index is the - 1327 + /// identity the rest of the reader is keyed on. And a filt - +er, which is how - 1328 + /// somebody who knows the number gets there in one move. - 1329 void _openChapterList() { - 1317 - Widget tile(int i) { - 1318 - final ch = widget.args.chapters[i]; - 1319 - final selected = i == _chapterIndex; - 1320 - return ListTile( - 1321 - dense: true, - 1322 - selected: selected, - 1323 - selectedTileColor: _accent.withValues(alpha: 0.12), - 1324 - leading: Icon( - 1325 - selected ? Icons.play_arrow_rounded : Icons.menu_bo - -ok_outlined, - 1326 - color: selected ? _accent : Colors.white38, - 1327 - size: 20, - 1328 - ), - 1329 - title: Text( - 1330 - ch.label, - 1331 - maxLines: 1, - 1332 - overflow: TextOverflow.ellipsis, - 1333 - style: TextStyle( - … 84 more lines (ctrl+o to expand) - -⏺ Bash(cat >> lib/features/manga/presentation/pages/reader_page.dart <<'DART'…) - ⎿  Analyzing manga... - - error • The argument type 'List' can't be assigned to the p - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/manga/presentation/pages/reader_page.dart (+192 - -0) - 1936 ), - 1937 ); - 1938 } - 1939 + - 1940 +/// The chapter list: headings, a filter, and the current cha - +pter in view. - 1941 +/// - 1942 +/// Stateful because of the filter. A sheet that rebuilds the - + whole reader on - 1943 +/// every keystroke would be the wrong thing twice over — the - + reader is holding - 1944 +/// decoded pages. - 1945 +class _ChapterListSheet extends StatefulWidget { - 1946 + const _ChapterListSheet({ - 1947 + required this.chapters, - 1948 + required this.current, - 1949 + required this.accent, - 1950 + required this.onPick, - 1951 + }); - 1952 + - 1953 + final List chapters; - 1954 + final int current; - 1955 + final Color accent; - 1956 + final ValueChanged onPick; - 1957 + - 1958 + @override - 1959 + State<_ChapterListSheet> createState() => _ChapterListSheet - +State(); - 1960 +} - 1961 + - 1962 +class _ChapterListSheetState extends State<_ChapterListSheet> - + { - 1963 + final TextEditingController _filter = TextEditingController - +(); - 1964 + String _query = ''; - 1965 + - 1966 + /// Past this many, reading the list is slower than typing - +at it. - 1967 + static const int _filterThreshold = 25; - 1968 + - 1969 + bool get _hasFilter => widget.chapters.length >= _filterThr - +eshold; - 1970 + - 1971 + @override - 1972 + void dispose() { - 1973 + _filter.dispose(); - 1974 + super.dispose(); - 1975 + } - … 155 more lines (ctrl+o to expand) - - Searched for 2 patterns (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - nts. - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/manga/presentation/pages/reader_page.dart (+3 -1) - 146 - 147 int get _currentPage => _page.value; - 148 int get _pageCount => _pages.length; - 149 - List get _chapters => widget.args.chapters; - 149 + /// Typed, because the args are: this was `List`, w - +hich meant every - 150 + /// read off a chapter in this file went unchecked. - 151 + List get _chapters => widget.args.chapters; - 152 - 153 @override - 154 void initState() { - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/features/manga/presentation/pages/reader_page.dart - lib/features/manga/dom…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 10.2s) - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/features/manga/presentation/pages/reader_page.dart (+9 -7) - 146 - 147 int get _currentPage => _page.value; - 148 int get _pageCount => _pages.length; - 149 + - 150 /// Typed, because the args are: this was `List`, w - hich meant every - 151 /// read off a chapter in this file went unchecked. - 152 List get _chapters => widget.args.chapters; - ... - 1207 if (_chapterOnSite case final uri?) - 1208 IconButton( - 1209 tooltip: 'manga.open_on_site'.tr(), - 1209 - icon: const Icon(Icons.open_in_new_rounded, c - -olor: Colors.white), - 1210 + icon: const Icon( - 1211 + Icons.open_in_new_rounded, - 1212 + color: Colors.white, - 1213 + ), - 1214 onPressed: () => _openOnSite(uri), - 1215 ), - 1216 IconButton( - ... - 1381 // button — which an in-app webview would only half do. - 1382 final ok = await launchUrl(uri, mode: LaunchMode.external - Application); - 1383 if (!ok && mounted) { - 1380 - ScaffoldMessenger.of(context).showSnackBar( - 1381 - SnackBar(content: Text('errors.no_browser'.tr())), - 1382 - ); - 1384 + ScaffoldMessenger.of( - 1385 + context, - 1386 + ).showSnackBar(SnackBar(content: Text('errors.no_browse - +r'.tr()))); - 1387 } - 1388 } - 1389 - ... - 2008 child: Text( - 2009 group.volume != null - 2010 ? 'manga.volume_n'.tr(args: ['${group.volume}']) - 2007 - : (group.label.isEmpty - 2008 - ? 'manga.other_chapters'.tr() - 2009 - : group.label), - 2011 + : (group.label.isEmpty ? 'manga.other_chapters'.tr( - +) : group.label), - 2012 style: const TextStyle( - 2013 color: Colors.white38, - 2014 fontSize: 11, - ⎿  Updated test/features/manga/chapter_groups_test.dart (+9 -7) - 32 - 33 group('grouping', () { - 34 test('a short list is left alone, with no heading', () { - 35 - final groups = groupChapters([for (var i = 1; i <= 12; i+ - -+) ch('Ch $i', i)]); - 35 + final groups = groupChapters([ - 36 + for (var i = 1; i <= 12; i++) ch('Ch $i', i), - 37 + ]); - 38 expect(groups, hasLength(1)); - 39 expect(groups.single.label, ''); - 40 expect(groups.single.indices, hasLength(12)); - ... - 78 }); - 79 - 80 test('a long list without volumes groups by range, named by - number', () { - 79 - final groups = groupChapters( - 80 - [for (var i = 1; i <= 120; i++) ch('Chapter $i', i)], - 81 - ); - 81 + final groups = groupChapters([ - 82 + for (var i = 1; i <= 120; i++) ch('Chapter $i', i), - 83 + ]); - 84 expect(groups, hasLength(3)); - 85 expect(groups[0].label, '1 – 50'); - 86 expect(groups[1].label, '51 – 100'); - ... - 99 }); - 100 - 101 test('a source that numbers nothing still gets readable he - adings', () { - 100 - final groups = groupChapters( - 101 - [for (var i = 1; i <= 60; i++) ch('Chapter $i')], - 102 - ); - 102 + final groups = groupChapters([ - 103 + for (var i = 1; i <= 60; i++) ch('Chapter $i'), - 104 + ]); - 105 expect(groups.first.label, '1 – 50'); - 106 }); - 107 }); - ⎿  (another command ran in this repository at the same time; a change - made by either may show under either result) - - Pushed to work/2026-09-14 (ctrl+o to expand) - -⏺ Ikkinchi partiya ham tayyor (41df3b3). Hozirgi holat: - - Ro'yxatdan bajarildi (5/15): - - ✅ Pauzada boshlash - - ✅ Lokal subtitr fayli - - ✅ Yuklab olishlar orasida tanaffus - - ✅ Boblarni guruhlash (jild/diapazon bo'yicha, 9 ta test) - - ✅ Bob ro'yxatidan qidirish - - Keyingi navbat: bob ichidan matn qidirish, TTS, EPUB progressi, kutubxonani - yangilash jadvali, manba migratsiyasi, scanlator filtri, WebView manzil - qatori, trackerlar, Material You, sana formati — keyin PiP. - - Splash uchun ultracode workflow fonda ishlayapti: 2 ta agent kodni o'rganyapti - (mark SVG'ning haqiqiy geometriyasi va ilovaning harakat lug'ati), keyin 3 ta - konsepsiya parallel ishlab chiqiladi — tirik maxluq (yo'l bo'ylab burilish, - nafas olish, orqa miya bo'ylab yorug'lik), yozilgan (qalam bir harakatda - chizadi, siyoh burilishlarda to'planadi), quyilgan (erigan yorug'lik konturdan - oqadi, uchqunlar). Har biri haqiqiy Flutter kodi bilan, so'ng har biri 4 ta - linzadan adversarial baholanadi: bajarilishi, brifga sodiqligi (haqiqatan - "ajdaho harakati"mi yoki nomi o'zgargan scale-and-fade'mi), ishlash tezligi, - did. - - Natija kelganda g'olibini o'zim yozaman. - -✻ Crunched for 23m 52s · done Monday 11:01 PM - -⏺ Dynamic workflow "Design and adversarially judge three animated vector splash -concepts for Sozo" completed · 40m 20s - -⏺ Workflow tugadi — 8 ta agent, ~900k token. Hakamlarning hukmini o'qiy: - - Ran 3 shell commands (ctrl+o to expand) - -⏺ Uchala hakam ham bir xil xulosaga keldi, va u aynan sizning so'zingiz bilan - bir xil: asset'ning o'zi muammo. Hozirgi sozo_mark.svg — bitta yopiq, rasmdan - ko'chirilgan kontur. Ichida harakatlanadigan detal yo'q, shuning uchun har - qanday animatsiya konturni aylanib chiqish bilan tugaydi. - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Mark — qalin lentali S. Hakamlar haq: ichida detal yo'q, shuning uchun har uch - konsepsiya ham konturni aylanib chiqadi. Yechim — asset'ni qayta kesish. - Avval lentaning umurtqasini geometrik chiqaraman: - - Ran 5 shell commands (ctrl+o to expand) - -⏺ Now the custom vector: the body unchanged, plus a named spine the pen can - actually write: - -⏺ Bash(python3 - <<'PY' - import io, re…) - ⎿  written 4148 bytes - 200: - assets/translations/ - 201: - assets/icons/ - … +4 lines (ctrl+o to expand) - ⎿  Created assets/brand/sozo_mark_animated.svg (+34 -0) - 1 + - 2 + - 32 + - 33 + - 34 + - - Read 1 file, ran 2 shell commands (ctrl+o to expand) - -⏺ Umurtqa lentaning o'rtasidan aniq o'tyapti. Faqat ikki uchi kalta — qalam - harfni oxirigacha yozishi uchun uzaytiraman: - -⏺ Bash(python3 - <<'PY' - import re, io, math…) - ⎿  head (287.4, 171.5) -> (280.4, 171.0) - tail (300.5, 365.0) -> (264.0, 394.6) - spine now 586 chars - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - -⏺ Bash(python3 - <<'PY' - import re, io, math…) - ⎿  head (287.4, 171.5) -> (279.4, 171.0) - tail (300.5, 365.0) -> (265.9, 393.4) - ok - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import re, io, math…) - ⎿  head run outside at [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 2 - 6, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40] - tail run outside at nowhere - written - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - - Ran 1 shell command (ctrl+o to expand) - -⏺ Umurtqaning yuqori qo'li tushib qolgan ekan — filtr noto'g'ri edi. Medial - nuqtani klirens bo'yicha tekshirib qayta quraman: - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import re, io…) - ⎿  ok - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - - Read 1 file (ctrl+o to expand) -⏺ Umurtqa endi uchdan-uchga o'tyapti. Bitta tugun chetga juda yaqin (klirens - 8.3, kerak ~30) — relaksatsiya bilan o'rtaga tortaman: - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/relax.py <<'PY' - import re, io, math…) - ⎿  knots 28 min clearance before 8.3 - min clearance after 4.3 - installed - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - -⏺ Bash(python3 - <<'PY' - import re, io, math…) - ⎿   0 ( 232.0, 120.0) clear 8.3 - 1 ( 225.5, 126.0) clear 14.0 - 2 ( 218.5, 132.0) clear 20.0 - … +25 lines (ctrl+o to expand) - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - -⏺ Uchlar past bo'lishi tabiiy, lekin o'rta diagonalda 3 ta tugun chetga yaqin. - O'sha qismni vertikal skanlash bilan tuzataman: - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && sed -i '' - 's|S2.append(((K\[i-1\]\[0\]+K\[i\]\[0\]\*2+K\[i+…) - ⎿  interior clearance 10.3 -> 20.3 - after smoothing 18.4 - installed - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Relaksatsiya metrikani yaxshilab, ko'rinishni buzdi — chetga yopishdi. Ko'z - bilan tekshirilgani ustun, avvalgisini qaytaraman: - -⏺ Bash(python3 - <<'PY' - import re, io…) - ⎿  reverted to the visually-checked spine - #spine an OPEN path down the middle of the ribbon, written top- - right to - … +29 lines (ctrl+o to expand) - ⎿  Updated assets/brand/sozo_mark_animated.svg (+1 -1) - 30 stroke, for the centre a flood spreads out from. - 31 --> - 32 - 33 - - 33 + - 34 - -⏺ Asset tayyor. Endi shu ustiga geometriya qatlami — bir marta parse, bir marta - o'lchov: - -⏺ Bash(mkdir -p lib/core/brand && cat > lib/core/brand/sozo_mark_geometry.dart - <<'DART' - import 'dart:typed_data';…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Created lib/core/brand/sozo_mark_geometry.dart (+225 -0) - 1 +import 'dart:typed_data'; - 2 +import 'dart:ui' as ui; - 3 + - 4 +import 'package:flutter/services.dart'; - 5 +import 'package:path_parsing/path_parsing.dart'; - 6 + - 7 +/// The mark, parsed once, measured once, and sampled once. - 8 +/// - 9 +/// Three things live here that a painter must not do per fram - +e. - 10 +/// - 11 +/// The **parse** — an SVG path walk over a bundle string. It - +was inside - 12 +/// `SozoSignature`, which is the only thing that needed it; t - +he splash needs - 13 +/// the same path, and two copies of a parse is two copies of - +a bug. - 14 +/// - 15 +/// The **measure** — [ui.Path.computeMetrics] is the expensiv - +e half of - 16 +/// trimming a path to a pen position. The path is parsed once - + and never - 17 +/// mutated, so the answer is the same on every frame for the - +life of the - 18 +/// process. - 19 +/// - 20 +/// The **samples** — [ui.PathMetric.getTangentForOffset] walk - +s the contour on - 21 +/// every call. A mark that has to bend needs the answer for a - + few hundred - 22 +/// places at once, sixty times a second, which is exactly the - + work that turns - 23 +/// a 16ms budget into a dropped frame on a cold start. Asked - +once, the answers - 24 +/// sit in flat [Float32List]s and a frame becomes arithmetic - +over memory. - 25 +class SozoMarkGeometry { - 26 + const SozoMarkGeometry._({ - 27 + required this.body, - 28 + required this.spine, - 29 + required this.spineMetric, - 30 + required this.spineLength, - 31 + required this.outlineX, - 32 + required this.outlineY, - 33 + required this.normalX, - 34 + required this.normalY, - 35 + required this.spineX, - 36 + required this.spineY, - 37 + required this.bounds, - 38 + }); - 39 + - 40 + /// The asset this is read from — the shipped mark re-cut in - +to named parts. - … 185 more lines (ctrl+o to expand) - -⏺ Endi splash. Yozish qalam umurtqa bo'ylab yuradi, to'ldirish esa umurtqadan - tashqariga — lenta aynan umurtqaning yarim kenglikka kengaytirilgani, shuning - uchun teshik paydo bo'lishi mumkin emas: - -⏺ Bash(cat > lib/features/splash/presentation/widgets/sozo_splash.dart <<'DART' - import 'dart:math' as math;…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing splash... - … +4 lines (ctrl+o to expand) - ⎿  Created lib/features/splash/presentation/widgets/sozo_splash.dart - (+351 -0) - 1 +import 'dart:math' as math; - 2 +import 'dart:ui' as ui; - 3 + - 4 +import 'package:flutter/material.dart'; - 5 +import 'package:flutter/services.dart'; - 6 + - 7 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 8 +import 'package:soplay/core/theme/app_colors.dart'; - 9 +import 'package:soplay/core/widgets/sozo_mark.dart'; - 10 + - 11 +/// The splash: the mark written, filled, and then left stirri - +ng. - 12 +/// - 13 +/// ## What was here - 14 +/// - 15 +/// The letters "S" and "OZO" as `Text`, the S scaled up and t - +he rest revealed - 16 +/// by an `Align(widthFactor:)` wipe, over 4.3 seconds. Three - +things wrong with - 17 +/// that, in order of how much they cost: the wordmark rendere - +d in whatever - 18 +/// font the handset shipped, so the app's first impression wa - +s Roboto on one - 19 +/// device and Samsung One on the next; a `widthFactor` animat - +ion re-lays-out - 20 +/// every frame; and it was a logo *appearing*, which is the o - +ne thing a brand - 21 +/// animation must not be. - 22 +/// - 23 +/// ## Why it needed a new asset - 24 +/// - 25 +/// The shipped mark is a single closed contour traced from th - +e app icon, and - 26 +/// that is why every attempt to animate it came out as a neon - + tube lighting - 27 +/// up. There is nothing inside a silhouette. A pen given only - + an outline can - 28 +/// ring the letter; it cannot write it, because writing means - + travelling the - 29 +/// stroke rather than its edge. - 30 +/// - 31 +/// `sozo_mark_animated.svg` carries the same silhouette plus - +its own - 32 +/// centreline, derived from the outline rather than drawn by - +eye. Everything - 33 +/// below follows from having it. - 34 +/// - 35 +/// ## The one geometric fact the fill is built on - 36 +/// - 37 +/// **The ribbon is the spine dilated by half its width.** So - +the fill is not a - 38 +/// gradient sweeping over the shape and it is not a radius gu - +essed as a - 39 +/// fraction of the viewBox — it is the same stroke the pen wr - +ote, drawn wider, - 40 +/// clipped to the silhouette. It cannot leave a hole in the m - +iddle and it - … 311 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+4 - -1) - 89 void initState() { - 90 super.initState(); - 91 SystemChrome.setSystemUIOverlayStyle( - 92 - const SystemUiOverlayStyle( - 92 + SystemUiOverlayStyle( - 93 statusBarColor: Colors.transparent, - 94 statusBarIconBrightness: Brightness.light, - 95 + // Not a constant: the background is a runtime choice - +now, like the - 96 + // accent, so a const style here would paint the AMOLE - +D theme's nav bar - 97 + // the default theme's grey. - 98 systemNavigationBarColor: AppColors.background, - 99 ), - 100 ); - -⏺ Now wiring it in, with the parse done before runApp so no launch falls back to - a flat logo: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/pages/splash_page.dart (+20 - -13) - 5 import 'package:soplay/core/di/injection.dart'; - 6 import 'package:soplay/features/app_lock/domain/repositories/ap - p_lock_repository.dart'; - 7 import 'package:soplay/core/storage/hive_service.dart'; - 8 -import 'package:soplay/features/splash/presentation/widgets/net - -flix_splash.dart'; - 8 +import 'package:soplay/features/splash/presentation/widgets/soz - +o_splash.dart'; - 9 - 10 class SplashPage extends StatefulWidget { - 11 const SplashPage({super.key}); - ... - 15 } - 16 - 17 class _SplashPageState extends State { - 18 - void _onComplete() { - 19 - unawaited(_resolveRoute()); - 18 + /// Where to go, decided while the animation is still finishi - +ng. - 19 + /// - 20 + /// The lock check reads a file and the onboarding check read - +s Hive. Starting - 21 + /// both when the letter lands rather than when the animation - + ends overlaps - 22 + /// them with the last 800ms, so the handover is a fade into - +a screen that is - 23 + /// already resolved instead of a fade into a wait. - 24 + Future? _target; - 25 + - 26 + void _onSettled() => _target ??= _resolveRoute(); - 27 + - 28 + Future _onDone() async { - 29 + final target = await (_target ??= _resolveRoute()); - 30 + if (!mounted) return; - 31 + context.go(target); - 32 } - 33 - 22 - Future _resolveRoute() async { - 34 + Future _resolveRoute() async { - 35 final lock = getIt(); - 36 await lock.ensureConsistent(); - 25 - if (!mounted) return; - 37 // The PIN itself is asked for by the lock overlay, which h - as covered the - 38 // app since the first frame (see AppLockGate) — so a deep - link that lands - 39 // before this runs is behind it too. - 29 - if (lock.isEnabled) { - 30 - context.go('/main'); - 31 - return; - … 21 more lines (ctrl+o to expand) - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/main.dart (+11 -0) - 35 import 'package:soplay/core/system/app_orientation.dart'; - 36 import 'package:soplay/core/js/provider_registry.dart'; - 37 import 'package:soplay/features/download/domain/repositories/do - wnload_repository.dart'; - 38 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 39 import 'package:soplay/features/notifications/data/services/not - ification_service.dart'; - 40 - 41 import 'package:soplay/core/network/user_agent.dart'; - ... - 216 // the one call site that matters. - 217 root = LiquidGlassWidgets.wrap(child: root, adaptiveQualit - y: true); - 218 } - 219 + // Before the first frame, and worth the wait it is not. - 220 + // - 221 + // The splash is the first thing this process draws, and it - +draws the mark as - 222 + // geometry — so the parse has to be done before it starts, - +not scheduled - 223 + // alongside it. It is one bundle string and one path walk, - +measured at a - 224 + // couple of milliseconds; awaited here, every launch is the - + animated one. - 225 + // Started after `runApp`, exactly one launch per install — - +the cold one - 226 + // everybody's first impression is made of — would fall back - + to a flat logo. - 227 + await SozoMarkGeometry.precache(); - 228 + - 229 runApp(root); - 230 - 231 // Deferred to after the first frame on purpose. - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(git rm -q lib/features/splash/presentation/widgets/netflix_splash.dart - lib/features/splash/presentation/widgets/apple_splash.dart && cat > - test/core/brand/sozo_…) - ⎿  (eval):1: no such file or directory: test/core/brand/sozo_mark_geometr - y_test.dart - ⎿  Deleted lib/features/splash/presentation/widgets/apple_splash.dart (+0 - -145) - 1 -import 'package:flutter/material.dart'; - 2 -import 'package:flutter/services.dart'; - 3 - - 4 -class AppleSplash extends StatefulWidget { - 5 - const AppleSplash({super.key, required this.onComplete}); - 6 - final VoidCallback onComplete; - 7 - - 8 - @override - 9 - State createState() => _AppleSplashState(); - 10 -} - 11 - - 12 -class _AppleSplashState extends State - 13 - with SingleTickerProviderStateMixin { - 14 - late AnimationController _controller; - 15 - late Animation _scale; - 16 - late Animation _opacity; - 17 - late Animation _shimmer; - 18 - late Animation _fadeOut; - 19 - - 20 - @override - 21 - void initState() { - 22 - super.initState(); - 23 - SystemChrome.setSystemUIOverlayStyle( - 24 - const SystemUiOverlayStyle( - 25 - statusBarColor: Colors.transparent, - 26 - statusBarIconBrightness: Brightness.light, - 27 - systemNavigationBarColor: Colors.black, - 28 - ), - 29 - ); - 30 - _setup(); - 31 - _run(); - 32 - } - 33 - - 34 - void _setup() { - 35 - _controller = AnimationController( - 36 - vsync: this, - 37 - duration: const Duration(milliseconds: 1500), - 38 - ); - 39 - - 40 - _scale = Tween(begin: 0.94, end: 1.0).animate( - … 105 more lines (ctrl+o to expand) - ⎿  Deleted lib/features/splash/presentation/widgets/netflix_splash.dart - (+0 -140) - 1 -import 'package:flutter/material.dart'; - 2 -import 'package:flutter/services.dart'; - 3 -import 'package:soplay/core/theme/app_colors.dart'; - 4 - - 5 -class NetflixSplash extends StatefulWidget { - 6 - const NetflixSplash({super.key, required this.onComplete}); - 7 - final VoidCallback onComplete; - 8 - - 9 - @override - 10 - State createState() => _NetflixSplashState(); - 11 -} - 12 - - 13 -class _NetflixSplashState extends State - 14 - with SingleTickerProviderStateMixin { - 15 - late AnimationController _controller; - 16 - late Animation _sScale; - 17 - late Animation _sOpacity; - 18 - late Animation _oplayWidth; - 19 - late Animation _oplayOpacity; - 20 - late Animation _fadeOut; - 21 - - 22 - @override - 23 - void initState() { - 24 - super.initState(); - 25 - SystemChrome.setSystemUIOverlayStyle( - 26 - SystemUiOverlayStyle( - 27 - statusBarColor: Colors.transparent, - 28 - statusBarIconBrightness: Brightness.light, - 29 - systemNavigationBarColor: AppColors.background, - 30 - ), - 31 - ); - 32 - _setup(); - 33 - _run(); - 34 - } - 35 - - 36 - void _setup() { - 37 - _controller = AnimationController( - 38 - vsync: this, - 39 - duration: const Duration(milliseconds: 3800), - 40 - ); - … 100 more lines (ctrl+o to expand) - - Listed 1 directory (ctrl+o to expand) - -⏺ Bash(cat > test/core/brand/sozo_mark_geometry_test.dart <<'DART' - // The mark, as geometry rather than as a picture.…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +14 lines (ctrl+o to expand) - ⎿  Created test/core/brand/sozo_mark_geometry_test.dart (+116 -0) - 1 +// The mark, as geometry rather than as a picture. - 2 +// - 3 +// The splash draws the letter by travelling its stroke, which - + is only possible - 4 +// because the asset carries a centreline as well as a silhoue - +tte. These are the - 5 +// facts that make that work — if any of them stops being true - + the splash - 6 +// degrades silently to a flat logo, which is exactly the fail - +ure the whole - 7 +// thing exists to replace. - 8 +import 'dart:ui' as ui; - 9 + - 10 +import 'package:flutter_test/flutter_test.dart'; - 11 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 12 + - 13 +void main() { - 14 + TestWidgetsFlutterBinding.ensureInitialized(); - 15 + - 16 + late SozoMarkGeometry geometry; - 17 + - 18 + setUpAll(() async { - 19 + await SozoMarkGeometry.precache(); - 20 + final value = SozoMarkGeometry.value; - 21 + expect(value, isNotNull, reason: 'the asset did not parse' - +); - 22 + geometry = value!; - 23 + }); - 24 + - 25 + test('the asset carries both parts', () { - 26 + expect(geometry.body.computeMetrics().isNotEmpty, isTrue); - 27 + expect(geometry.spineLength, greaterThan(400)); - 28 + // A spine as long as the outline would mean it had ringed - + the shape rather - 29 + // than run through it. - 30 + expect(geometry.spineLength, lessThan(900)); - 31 + }); - 32 + - 33 + test('and the spine runs inside the body, end to end', () { - 34 + // The property the fill depends on: the ribbon is the spi - +ne dilated by - 35 + // half its width, so a spine that wandered outside would - +leave the fill - 36 + // spilling in one place and short in another. - 37 + var outside = 0; - 38 + for (var i = 0; i < SozoMarkGeometry.spineSamples; i++) { - 39 + final p = ui.Offset(geometry.spineX[i], geometry.spineY[ - +i]); - 40 + if (!geometry.body.contains(p)) outside++; - … 76 more lines (ctrl+o to expand) - -⏺ All 8 pass — the spine genuinely runs inside the body end-to-end, and the - normals point outward. Running it: - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/core/brand lib/features/splash lib/main.dart - test/core/brand >/dev/null &…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 9.0s) - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+16 - -3) - 128 super.dispose(); - 129 } - 130 - 131 - static double _span(double ms, int from, int to, {Curve curv - -e = Curves.linear}) { - 131 + static double _span( - 132 + double ms, - 133 + int from, - 134 + int to, { - 135 + Curve curve = Curves.linear, - 136 + }) { - 137 final t = ((ms - from) / (to - from)).clamp(0.0, 1.0); - 138 return curve.transform(t); - 139 } - ... - 141 @override - 142 Widget build(BuildContext context) { - 143 final geometry = _geometry; - 139 - final size = math.min(MediaQuery.sizeOf(context).shortestS - -ide * 0.42, 210.0); - 144 + final size = math.min( - 145 + MediaQuery.sizeOf(context).shortestSide * 0.42, - 146 + 210.0, - 147 + ); - 148 - 149 // Reduce motion gets the mark, not the performance. Still - a fade rather - 150 // than a cut, because an image that blinks into existence - reads as a - ... - 175 final ms = _c.value * _total; - 176 return _Beat( - 177 write: _span(ms, _writeFrom, _writeTo, curve: - _writeCurve), - 170 - fill: _span(ms, _fillFrom, _fillTo, curve: Cur - -ves.easeInOutCubic), - 178 + fill: _span( - 179 + ms, - 180 + _fillFrom, - 181 + _fillTo, - 182 + curve: Curves.easeInOutCubic, - 183 + ), - 184 land: _span(ms, _landFrom, _landTo, curve: Cur - ves.easeOutBack), - 185 // Time, not progress: the stir is a clock, so - that it is still - 186 // running when everything scheduled has finis - hed. - ⎿  Updated lib/main.dart (+37 -14) - 31 import 'package:soplay/features/detail/domain/entities/player_a - rgs.dart'; - 32 import 'package:soplay/features/extensions/presentation/repo_fi - le_import.dart'; - 33 import 'package:soplay/core/js/js_runtime_service.dart'; - 34 -import 'package:soplay/core/player/media_controller.dart' show - -warmUpPlayerEngine; - 34 +import 'package:soplay/core/player/media_controller.dart' - 35 + show warmUpPlayerEngine; - 36 import 'package:soplay/core/system/app_orientation.dart'; - 37 import 'package:soplay/core/js/provider_registry.dart'; - 38 import 'package:soplay/features/download/domain/repositories/do - wnload_repository.dart'; - ... - 44 import 'package:soplay/features/app_lock/presentation/app_lock_ - gate.dart'; - 45 import 'app.dart'; - 46 - 46 - - 47 /// [args] is what the OS handed the process, and on Windows an - d Linux that is - 48 /// how "Open with Sozo" arrives: both runners hand `argv` to t - he engine as the - 49 /// Dart entrypoint arguments, so the file the user double-clic - ked was reaching - ... - 57 /// is simply empty there. - 58 void main(List args) async { - 59 WidgetsFlutterBinding.ensureInitialized(); - 60 - await initTvPlatform(); - 60 + await initTvPlatform(); - 61 if (isDesktopPlatform) { - 62 MediaKit.ensureInitialized(); - 63 await windowManager.ensureInitialized(); - ... - 65 // Not `isMobilePlatform`: on iOS 26 the bar is a real UITabB - ar, so compiling - 66 // a GLSL glass pipeline is cost with nothing to show for it. - 67 if (usesFlutterGlass) { - 68 - try { - 68 + try { - 69 await LiquidGlassWidgets.initialize(); - 70 } catch (_) {} - 71 } - ... - 85 // and a first run must be stamped before anything can be cal - led new. - 86 await WhatsNew.init(); - 87 if (isDesktopPlatform) { - 88 - final native = Hive.box(AppConstants.settingsBox) - 89 - .get('use_native_title_bar', defaultValue: false) == tr - -ue; - 88 + final native = - 89 + Hive.box( - 90 + AppConstants.settingsBox, - … 62 more lines (ctrl+o to expand) - - Pushed to work/2026-09-14, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo rebuild with the written-mark splash) - ⎿  Monitor started · task bbpq7utvz · timeout 900s - -⏺ Splash tayyor, build ketyapti (7b1094b). - - Ultracode nima topdi: uchala konsepsiya ham qattiq baholandi va uchala hakam - bir xil xulosaga keldi — muammo animatsiyada emas, asset'da. sozo_mark.svg — - app ikonkasidan ko'chirilgan bitta yopiq kontur. Siluet ichida hech narsa - yo'q. Faqat konturi berilgan qalam harfni aylanib chiqishi mumkin, lekin yoza - olmaydi — yozish qirrani emas, shtrixni bosib o'tish demakdir. Shu sababli - uchala urinish ham "neon chiroq yonishi" bo'lib chiqqan. - - Nima qildim: - - 1. Mark o'z umurtqasini oldi. sozo_mark_animated.svg — shipped siluet - bayt-baytigacha o'zgarmagan (brendga hech narsa qo'shilmadi, olinmadi) + - #spine yo'li. U ko'z bilan chizilmagan, hisoblab chiqarilgan: konturdagi har - bir nuqta lentaning qarama-qarshi tomonidagi nuqta bilan juftlanadi, - juftlarning o'rta nuqtalari — shtrixning o'zi. O'lchandi: lenta kengligi 61 - birlik, umurtqa 584 birlik, kontur 1472 — bir shtrixli harf uchun aynan shu - nisbat. - - 2. To'ldirish endi taxmin emas. Lenta — umurtqaning yarim kenglikka - kengaytirilgani. Demak harfni to'ldirish — qalam yozgan o'sha shtrixni kengroq - chizib, siluetga qirqish. O'rtada teshik qolishi mumkin emas, uchlarida erta - tugashi ham. Avvalgi urinishlarning barchasi belda qora teshik chiqargan edi, - sababi aynan shu: radiusni viewBox ulushi sifatida taxmin qilishgan. - - 3. Qo'ngandan keyin ham harakatlanadi. Kontur nuqtalari o'z normallari - bo'ylab, kontur bo'ylab yuguradigan to'lqin bilan suriladi, fazasi hech - to'xtamaydi. Bu — rasm tebranishi emas, tananing egilishi. Shtrix bo'ylab - yorug'lik ham yuguradi. Uzluksiz faza bo'lgani uchun kadr tushib qolsa ham - buzilmaydi. - - Sifat: parse/o'lchov/namuna olish butun jarayon uchun bir marta - (getTangentForOffset har chaqiruvda konturni bosib o'tadi — kadriga 320 marta - so'rash 16ms byudjetni yeydi). Deformatsiyalanuvchi siluet qayta - ishlatiladigan bitta ui.Path ga quriladi. Opacity yo'q, gradient yo'q — - birinchi kadrda shader kompilyatsiyasi yo'q. Reduce-motion markni 260ms fade - bilan oladi. Marshrut endi harf qo'nganda hal qilinadi, tugaganda emas — - oxirgi 800ms ish bilan ustma-ust tushadi. 4300ms → 2150ms. - - 8 ta yangi test, jami 1566 test o'tdi. -✻ Worked for 1h 19m 19s · done Monday 11:57 PM - -⏺ Monitor event: "Sozo rebuild with the written-mark splash" - - Ran 3 shell commands (ctrl+o to expand) - -⏺ Emulator yuklanyapti. Shu orada ro'yxatdan davom: sana formati — saqlash qismi - tayyor, endi ko'rsatiladigan joylar: - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Background command "Wait for the emulator to boot" completed (exit code 0) - - Searched for 2 patterns, ran 2 shell commands (ctrl+o to expand) - -⏺ 14 ta joyda sana chiqadi, hammasi til sozlamasiga bog'langan. Markaziy - formatter va tanlov qo'shaman: - -⏺ Bash(cat > lib/core/system/app_dates.dart <<'DART' - import 'package:easy_localization/easy_localization.dart';…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing app_dates.dart... - No issues found! (ran in 3.5s) - ⎿  Created lib/core/system/app_dates.dart (+91 -0) - 1 +import 'package:easy_localization/easy_localization.dart'; - 2 +import 'package:flutter/widgets.dart'; - 3 + - 4 +import 'package:soplay/core/di/injection.dart'; - 5 +import 'package:soplay/core/storage/hive_service.dart'; - 6 + - 7 +/// How this app writes a date. - 8 +/// - 9 +/// Every absolute date used to go through `DateFormat.yMMMd(lo - +cale)`, which - 10 +/// takes its ordering from the app's language. That is the rig - +ht default and - 11 +/// the wrong rule: the language somebody reads an app in and t - +he date order - 12 +/// they can parse at a glance are not the same choice. Someone - + who runs the - 13 +/// app in English in Tashkent still reads 22/09/2026, and `09/ - +22/2026` in the - 14 +/// middle of a download list is a number they have to stop and - + decode. - 15 +/// - 16 +/// So the language remains the default, and anyone it does not - + suit can say - 17 +/// so once. Stored as the pattern itself rather than as an enu - +m index, so an - 18 +/// old stored value keeps meaning what it meant and a format t - +his app has not - 19 +/// thought of is one string away. - 20 +class AppDates { - 21 + const AppDates._(); - 22 + - 23 + /// The empty pattern: whatever the app's language does. Not - +a format in the - 24 + /// list of formats — it is the absence of an override. - 25 + static const String followLanguage = ''; - 26 + - 27 + /// What the settings screen offers, in the order it offers t - +hem. - 28 + /// - 29 + /// Deliberately short. This is a preference for people whose - + language gets - 30 + /// it wrong, not a date-pattern editor, and every extra row - +is another thing - 31 + /// to read past for everybody it was already right for. - 32 + static const List choices = [ - 33 + followLanguage, - 34 + 'dd/MM/yyyy', - 35 + 'MM/dd/yyyy', - 36 + 'yyyy-MM-dd', - 37 + 'd MMM yyyy', - 38 + ]; - 39 + - 40 + /// A date, written the way this install writes dates. - … 51 more lines (ctrl+o to expand) - -⏺ Bash(mkdir -p test/core/system && cat > test/core/system/app_dates_test.dart - <<'DART' - // The language somebody reads an app in and the date order they can parse - at a…) - ⎿  00:00 +0 -1: loading /Users/azamov/Sozo-Full/sozo/test/core/system/app - _dates_test.dart [E] - Failed to load "/Users/azamov/Sozo-Full/sozo/test/core/system/app_da - … +13 lines (ctrl+o to expand) - ⎿  Created test/core/system/app_dates_test.dart (+65 -0) - 1 +// The language somebody reads an app in and the date order the - +y can parse at a - 2 +// glance are not the same choice. Someone running the app in E - +nglish in - 3 +// Tashkent still reads 22/09/2026, and 09/22/2026 in the middl - +e of a download - 4 +// list is a number they have to stop and decode. - 5 +import 'package:easy_localization/easy_localization.dart'; - 6 +import 'package:flutter_test/flutter_test.dart'; - 7 +import 'package:soplay/core/system/app_dates.dart'; - 8 + - 9 +void main() { - 10 + TestWidgetsFlutterBinding.ensureInitialized(); - 11 + - 12 + setUpAll(() async { - 13 + await initializeDateFormatting(); - 14 + }); - 15 + - 16 + final date = DateTime(2026, 9, 22); - 17 + - 18 + test('no override follows the language', () { - 19 + expect(AppDates.format(date, locale: 'en'), contains('2026' - +)); - 20 + expect(AppDates.format(date, locale: 'en'), contains('22')) - +; - 21 + // The point of the default: two languages, two orders, nei - +ther chosen. - 22 + expect( - 23 + AppDates.format(date, locale: 'en'), - 24 + isNot(AppDates.format(date, locale: 'ru')), - 25 + ); - 26 + }); - 27 + - 28 + test('and an override is honoured whatever the language', () - +{ - 29 + for (final locale in ['en', 'ru', 'uz', 'ar']) { - 30 + expect( - 31 + AppDates.format(date, locale: locale, pattern: 'dd/MM/y - +yyy'), - 32 + '22/09/2026', - 33 + reason: locale, - 34 + ); - 35 + } - 36 + }); - 37 + - 38 + test('every offered pattern produces something different and - +readable', () { - 39 + final seen = {}; - 40 + for (final p in AppDates.choices) { - … 25 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   package:matcher expect - package:flutter_test/src/widget_tester.dart 473:18 expect - test/core/system/app_dates_test.dart 53:5 main. - … +8 lines (ctrl+o to expand) - ⎿  Updated test/core/system/app_dates_test.dart (+4 -4) - 2 // glance are not the same choice. Someone running the app in E - nglish in - 3 // Tashkent still reads 22/09/2026, and 09/22/2026 in the middl - e of a download - 4 // list is a number they have to stop and decode. - 5 -import 'package:easy_localization/easy_localization.dart'; - 5 import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:intl/date_symbol_data_local.dart'; - 7 import 'package:soplay/core/system/app_dates.dart'; - 8 - 9 void main() { - 10 TestWidgetsFlutterBinding.ensureInitialized(); - 11 - 12 - setUpAll(() async { - 13 - await initializeDateFormatting(); - 14 - }); - 12 + // Every locale's month names and default orders, which `Date - +Format` only - 13 + // has for `en` until they are loaded. - 14 + setUpAll(initializeDateFormatting); - 15 - 16 final date = DateTime(2026, 9, 22); - 17 - -⏺ DateFormat yomon patternda xato bermay, axlat qaytarar ekan — shuning uchun - tekshiruv kerak, try/catch emas: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +0: (setUpAll) - 00:00 +0: no override follows the language - 00:00 +1: and an override is honoured whatever the language - … +6 lines (ctrl+o to expand) - ⎿  Updated lib/core/system/app_dates.dart (+8 -8) - 67 required String locale, - 68 String pattern = followLanguage, - 69 }) { - 70 + // Checked against the menu, not merely tried. `DateFormat` - + does not - 71 + // reject a pattern it cannot read — it treats the letters - +it does not know - 72 + // as literal text, so "not a pattern" formats to "not a pa - +ttern" with a - 73 + // couple of digits in it rather than throwing. A stored va - +lue from a - 74 + // future version or from somebody's edited backup would pr - +int that, on - 75 + // every date in the app, with nothing to catch. - 76 + if (!choices.contains(pattern)) return DateFormat.yMMMd(loc - +ale).format(at); - 77 if (pattern.isEmpty) return DateFormat.yMMMd(locale).format - (at); - 71 - try { - 72 - return DateFormat(pattern, locale).format(at); - 73 - } catch (_) { - 74 - // A stored pattern from a future version, or one somebod - -y typed into a - 75 - // backup file. A date in the wrong order is a nuisance; - -a screen that - 76 - // throws while drawing one is not. - 77 - return DateFormat.yMMMd(locale).format(at); - 78 - } - 78 + return DateFormat(pattern, locale).format(at); - 79 } - 80 - 81 /// What to show in the settings row for [pattern]. - ⎿  Updated test/core/system/app_dates_test.dart (+20 -6) - 4 // list is a number they have to stop and decode. - 5 import 'package:flutter_test/flutter_test.dart'; - 6 import 'package:intl/date_symbol_data_local.dart'; - 7 +import 'package:intl/intl.dart'; - 8 import 'package:soplay/core/system/app_dates.dart'; - 9 - 10 void main() { - ... - 46 } - 47 }); - 48 - 48 - test('a pattern that will not parse falls back rather than th - -rowing', () { - 49 - // A stored value from a future version, or one somebody ty - -ped into a - 50 - // backup file. A date in the wrong order is a nuisance; a - -screen that - 51 - // throws while drawing one is not. - 52 - final out = AppDates.format(date, locale: 'en', pattern: 'n - -ot a pattern'); - 53 - expect(out, AppDates.format(date, locale: 'en')); - 49 + test('a pattern that is not on the menu falls back to the lan - +guage', () { - 50 + // `DateFormat` does NOT reject a pattern it cannot read — - +it treats the - 51 + // letters it does not know as literal text, so this would - +otherwise print - 52 + // "not a pattern" with a couple of digits in it, on every - +date in the app. - 53 + expect( - 54 + DateFormat('not a pattern', 'en').format(date), - 55 + contains('not'), - 56 + reason: 'intl started rejecting bad patterns; the guard c - +an be a catch', - 57 + ); - 58 + expect( - 59 + AppDates.format(date, locale: 'en', pattern: 'not a patte - +rn'), - 60 + AppDates.format(date, locale: 'en'), - 61 + ); - 62 + // Including a pattern that WOULD work but was never offere - +d: the menu is - 63 + // the contract, so that nothing can arrive by way of a res - +tored backup. - 64 + expect( - 65 + AppDates.format(date, locale: 'en', pattern: 'yyyy/dd/MM' - +), - 66 + AppDates.format(date, locale: 'en'), - 67 + ); - 68 }); - 69 - 70 test('the sample is a date whose parts cannot be confused', ( - ) { - -⏺ Now routing the six absolute-date sites through it, plus the settings row: - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 1 package is discontinued. - 145 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/history/presentation/pages/history_page.dart (+2 - -1) - 1 import 'package:easy_localization/easy_localization.dart'; - 2 import 'package:flutter/material.dart'; - 3 +import 'package:soplay/core/system/app_dates.dart'; - 4 import 'package:go_router/go_router.dart'; - 5 import 'package:soplay/core/di/injection.dart'; - 6 import 'package:soplay/core/system/platform_utils.dart'; - ... - 498 if (diff.inHours < 1) return 'time.minutes'.tr(args: ['${d - iff.inMinutes}']); - 499 if (diff.inDays < 1) return 'time.hours'.tr(args: ['${diff - .inHours}']); - 500 if (diff.inDays < 7) return 'time.days'.tr(args: ['${diff. - inDays}']); - 500 - return DateFormat.yMMMd(context.locale.toString()).format( - -at); - 501 + return AppDates.short(context, at); - 502 } - 503 } - 504 - ⎿  Updated - lib/features/notifications/presentation/pages/notifications_page.dart - (+2 -1) - 1 import 'package:cached_network_image/cached_network_image.dart'; - 2 import 'package:easy_localization/easy_localization.dart'; - 3 import 'package:flutter/material.dart'; - 4 +import 'package:soplay/core/system/app_dates.dart'; - 5 import 'package:flutter_bloc/flutter_bloc.dart'; - 6 import 'package:go_router/go_router.dart'; - 7 import 'package:soplay/core/di/injection.dart'; - ... - 474 if (diff.inHours < 1) return 'time.minutes'.tr(args: ['${d - iff.inMinutes}']); - 475 if (diff.inDays < 1) return 'time.hours'.tr(args: ['${diff - .inHours}']); - 476 if (diff.inDays < 7) return 'time.days'.tr(args: ['${diff. - inDays}']); - 476 - return DateFormat.yMMMd(context.locale.toString()).format( - -dt); - 477 + return AppDates.short(context, dt); - 478 } - 479 } - 480 - ⎿  Updated lib/features/profile/presentation/pages/backup_page.dart (+4 - -3) - 3 import 'package:easy_localization/easy_localization.dart'; - 4 import 'package:file_picker/file_picker.dart'; - 5 import 'package:flutter/material.dart'; - 6 +import 'package:soplay/core/system/app_dates.dart'; - 7 import 'package:share_plus/share_plus.dart'; - 8 - 9 import 'package:soplay/core/di/injection.dart'; - ... - 141 String _lastLine() { - 142 final at = _service.lastExportAt; - 143 if (at == null) return 'backup.never_exported'.tr(); - 143 - final stamp = DateFormat.yMMMd( - 144 - context.locale.toString(), - 145 - ).add_Hm().format(at); - 144 + final stamp = - 145 + '${AppDates.short(context, at)} ' - 146 + '${DateFormat.Hm(context.locale.toString()).format(at) - +}'; - 147 return 'backup.last_export'.tr(args: [stamp]); - 148 } - 149 - ⎿  Updated lib/features/stats/presentation/watch_stats_page.dart (+2 -1) - 1 import 'package:easy_localization/easy_localization.dart'; - 2 import 'package:flutter/material.dart'; - 3 +import 'package:soplay/core/system/app_dates.dart'; - 4 - 5 import 'package:soplay/core/theme/app_colors.dart'; - 6 import 'package:soplay/features/stats/data/watch_stats_store.dar - t'; - ... - 228 // last Tuesday must not read as a lifetime. - 229 Text( - 230 'stats.since'.tr( - 230 - args: [DateFormat.yMMMd(context.locale.toStrin - -g()).format(since!)], - 231 + args: [AppDates.short(context, since!)], - 232 ), - 233 style: const TextStyle(color: AppColors.textHint - , fontSize: 11.5), - 234 ), - -⏺ Bash(S=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad && cat > $S/k3.json <<'JSON' - {…) - ⎿  added 3 keys x 12 locales - ⎿  Updated assets/translations/ar.json (+4 -1) - 578 "stat_days": "أيام مشاهدة", - 579 "mangayomi_sources": "مصادر Mangayomi", - 580 "view_only": "للمشاهدة فقط", - 581 - "start_paused_desc": "يفتح المشغّل دون تشغيل. الانتقال التل - -قائي يشتغل كالمعتاد." - 581 + "start_paused_desc": "يفتح المشغّل دون تشغيل. الانتقال التل - +قائي يشتغل كالمعتاد.", - 582 + "date_format": "تنسيق التاريخ", - 583 + "date_format_desc": "وإلّا فاللغة هي التي تحدّد ذلك.", - 584 + "date_format_auto": "حسب اللغة" - 585 }, - 586 "appearance": { - 587 "title": "المظهر", - ⎿  Updated assets/translations/de.json (+4 -1) - 578 "stat_days": "Tage gesehen", - 579 "mangayomi_sources": "Mangayomi-Quellen", - 580 "view_only": "NUR ANSEHEN", - 581 - "start_paused_desc": "Der Player öffnet, spielt aber nicht - -. Das automatische Weiterspielen läuft trotzdem." - 581 + "start_paused_desc": "Der Player öffnet, spielt aber nicht - +. Das automatische Weiterspielen läuft trotzdem.", - 582 + "date_format": "Datumsformat", - 583 + "date_format_desc": "Sonst entscheidet das die Sprache.", - 584 + "date_format_auto": "Der Sprache folgen" - 585 }, - 586 "appearance": { - 587 "title": "Darstellung", - ⎿  Updated assets/translations/en.json (+4 -1) - 578 "stat_days": "days watched", - 579 "mangayomi_sources": "Mangayomi Sources", - 580 "view_only": "VIEW ONLY", - 581 - "start_paused_desc": "Open the player without playing. Aut - -o-advance still plays." - 581 + "start_paused_desc": "Open the player without playing. Aut - +o-advance still plays.", - 582 + "date_format": "Date format", - 583 + "date_format_desc": "The language decides this unless you - +say otherwise.", - 584 + "date_format_auto": "Follow the language" - 585 }, - 586 "appearance": { - 587 "title": "Appearance", - ⎿  Updated assets/translations/es.json (+4 -1) - 578 "stat_days": "días vistos", - 579 "mangayomi_sources": "Fuentes de Mangayomi", - 580 "view_only": "SOLO VER", - 581 - "start_paused_desc": "El reproductor abre sin reproducir. - -El avance automático sí reproduce." - 581 + "start_paused_desc": "El reproductor abre sin reproducir. - +El avance automático sí reproduce.", - 582 + "date_format": "Formato de fecha", - 583 + "date_format_desc": "Si no eliges, lo decide el idioma.", - 584 + "date_format_auto": "Seguir el idioma" - 585 }, - 586 "appearance": { - 587 "title": "Apariencia", - ⎿  Updated assets/translations/fr.json (+4 -1) - 578 "stat_days": "jours regardés", - 579 "mangayomi_sources": "Sources Mangayomi", - 580 "view_only": "LECTURE SEULE", - 581 - "start_paused_desc": "Le lecteur s'ouvre sans lancer la le - -cture. L'épisode suivant démarre quand même." - 581 + "start_paused_desc": "Le lecteur s'ouvre sans lancer la le - +cture. L'épisode suivant démarre quand même.", - 582 + "date_format": "Format de date", - 583 + "date_format_desc": "Sinon, c'est la langue qui décide.", - 584 + "date_format_auto": "Suivre la langue" - 585 }, - 586 "appearance": { - 587 "title": "Apparence", - ⎿  … 7 more files changed - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  1:import 'package:easy_localization/easy_localization.dart'; - 2:import 'package:flutter/material.dart'; - 3:import 'package:flutter/services.dart'; - … +15 lines (ctrl+o to expand) - ⎿  Updated lib/features/profile/presentation/pages/appearance_page.dart - (+18 -0) - 174 onChanged: _setNavTinted, - 175 ), - 176 const SettingsDivider(), - 177 + // The sample, not the pattern: "dd/MM/yyyy" mea - +ns nothing to - 178 + // most people, and "22/09/2026" is the question - + they are - 179 + // actually being asked. - 180 + SettingsDropdownTile( - 181 + icon: Icons.event_rounded, - 182 + title: 'profile.date_format'.tr(), - 183 + subtitle: 'profile.date_format_desc'.tr(), - 184 + value: _dateFormat, - 185 + options: AppDates.choices, - 186 + labelOf: (p) => p.isEmpty - 187 + ? 'profile.date_format_auto'.tr() - 188 + : AppDates.sample(p, context.locale.toStri - +ng()), - 189 + onChanged: (p) { - 190 + setState(() => _dateFormat = p); - 191 + getIt().setDateFormatPattern(p) - +; - 192 + }, - 193 + ), - 194 + const SettingsDivider(), - 195 _ActionRow( - 196 icon: Icons.shuffle_rounded, - 197 title: 'appearance.shuffle'.tr(), - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   info • The imported package 'intl' isn't a dependency of the import - ing package. Try adding a dependency for 'intl' in the 'pubspec.yaml' - file • test/core/system/app_dates_test.dart:6:8 • depend_on_referenced - … +7 lines (ctrl+o to expand) - ⎿  Updated lib/features/profile/presentation/pages/appearance_page.dart - (+4 -0) - 3 import 'package:flutter/services.dart'; - 4 - 5 import 'package:soplay/core/di/injection.dart'; - 6 +import 'package:soplay/core/storage/hive_service.dart'; - 7 +import 'package:soplay/core/system/app_dates.dart'; - 8 import 'package:soplay/core/system/platform_utils.dart'; - 9 import 'package:soplay/core/theme/app_accent.dart'; - 10 import 'package:soplay/core/theme/app_colors.dart'; - ... - 61 } - 62 - 63 class _AppearanceSettingsState extends State { - 64 + late String _dateFormat = getIt().dateFormatPatt - +ern; - 65 + - 66 final ThemeController _theme = getIt(); - 67 - 68 /// Held in the State, not started in build(): this page rebu - ilds on every - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿   info • The imported package 'intl' isn't a dependency of the import - ing package. Try adding a dependency for 'intl' in the 'pubspec.yaml' - file • test/core/system/app_dates_test.dart:10:8 • depend_on_reference - … +3 lines (ctrl+o to expand) - ⎿  Updated test/core/system/app_dates_test.dart (+4 -1) - 3 // Tashkent still reads 22/09/2026, and 09/22/2026 in the middl - e of a download - 4 // list is a number they have to stop and decode. - 5 import 'package:flutter_test/flutter_test.dart'; - 6 +// easy_localization re-exports intl, which is how the app itse - +lf reaches - 7 +// DateFormat. Same door here, rather than depending on a packa - +ge the app does - 8 +// not declare. - 9 +import 'package:easy_localization/easy_localization.dart'; - 10 import 'package:intl/date_symbol_data_local.dart'; - 7 -import 'package:intl/intl.dart'; - 11 import 'package:soplay/core/system/app_dates.dart'; - 12 - 13 void main() { - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  145 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +6 lines (ctrl+o to expand) - ⎿  Updated pubspec.lock (+1 -1) - 1053 source: sdk - 1054 version: "0.0.0" - 1055 intl: - 1056 - dependency: transitive - 1056 + dependency: "direct dev" - 1057 description: - 1058 name: intl - 1059 sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd34 - 1e550ce0bfa50e9aa5" - ⎿  Updated pubspec.yaml (+5 -0) - 173 - 174 dev_dependencies: - 175 shared_preferences: 2.5.5 - 176 + # Declared for the tests only. The app reaches DateFormat th - +rough - 177 + # easy_localization's re-export, but `date_symbol_data_local - +` is not part of - 178 + # that — and without it a test that checks a non-English dat - +e order is - 179 + # checking English twice. - 180 + intl: any - 181 flutter_test: - 182 sdk: flutter - 183 integration_test: - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/core/system/app_dates.dart - lib/features/profile/presentation/pages/appear…) - ⎿  00:28 +1570: /Users/azamov/Sozo-Full/sozo/test/features/sources/source - _hub_catalog_test.dart: CAT-03 Failed next page must stop spinner and - offer retry - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/profile/presentation/pages/appearance_page.dart - (+73 -50) - 304 /// Hues around the wheel chip. Twelve is enough for the swe - ep to read as - 305 /// continuous at this size. - 306 static const List _wheel = [ - 307 - Color(0xFFE53935), Color(0xFFF4511E), Color(0xFFFFB300), - 308 - Color(0xFFC0CA33), Color(0xFF43A047), Color(0xFF00897B), - 309 - Color(0xFF039BE5), Color(0xFF3949AB), Color(0xFF8E24AA), - 310 - Color(0xFFD81B60), Color(0xFFE53935), - 307 + Color(0xFFE53935), - 308 + Color(0xFFF4511E), - 309 + Color(0xFFFFB300), - 310 + Color(0xFFC0CA33), - 311 + Color(0xFF43A047), - 312 + Color(0xFF00897B), - 313 + Color(0xFF039BE5), - 314 + Color(0xFF3949AB), - 315 + Color(0xFF8E24AA), - 316 + Color(0xFFD81B60), - 317 + Color(0xFFE53935), - 318 ]; - 319 - 320 @override - ... - 449 .substring(2) - 450 .toUpperCase(); - 451 return Padding( - 445 - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - 446 - child: Row( - 447 - children: [ - 448 - Container( - 449 - width: 26, - 450 - height: 26, - 451 - decoration: BoxDecoration( - 452 - color: accent.base, - 453 - borderRadius: BorderRadius.circular(8), - 454 - border: Border.all( - 455 - color: AppColors.textPrimary.withValues(al - -pha: 0.12), - 456 - ), - 457 - ), - 458 - ), - 459 - const SizedBox(width: 12), - 460 - Expanded( - … 144 more lines (ctrl+o to expand) - - Pushed to work/2026-09-14, ran 1 shell command (ctrl+o to expand) - -⏺ App ishlayapti. Splash'ni ko'rish uchun ilovani qayta ishga tushirish PIN'ni - qayta so'raydi — buning o'rniga animatsiyani testda kadrma-kadr chizib olaman: - -⏺ Bash(cat > test/splash_frames_probe_test.dart <<'DART' - // Not a test — a viewer. Dumps the splash at a series of times so the…) - ⎿  Try `flutter pub outdated` for more information. - 00:00 +0: loading /Users/azamov/Sozo-Full/sozo/test/splash_frames_prob - e_test.dart - … +3 lines (ctrl+o to expand) - ⎿  Created test/splash_frames_probe_test.dart (+49 -0) - 1 +// Not a test — a viewer. Dumps the splash at a series of times - + so the - 2 +// animation can be looked at rather than reasoned about. - 3 +import 'dart:io'; - 4 +import 'dart:ui' as ui; - 5 + - 6 +import 'package:flutter/material.dart'; - 7 +import 'package:flutter/rendering.dart'; - 8 +import 'package:flutter_test/flutter_test.dart'; - 9 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 10 +import 'package:soplay/features/splash/presentation/widgets/soz - +o_splash.dart'; - 11 + - 12 +void main() { - 13 + TestWidgetsFlutterBinding.ensureInitialized(); - 14 + - 15 + testWidgets('dump frames', (tester) async { - 16 + await SozoMarkGeometry.precache(); - 17 + final key = GlobalKey(); - 18 + tester.view.physicalSize = const Size(420, 420); - 19 + tester.view.devicePixelRatio = 1.0; - 20 + addTearDown(tester.view.reset); - 21 + - 22 + await tester.pumpWidget( - 23 + MaterialApp( - 24 + debugShowCheckedModeBanner: false, - 25 + home: RepaintBoundary( - 26 + key: key, - 27 + child: SozoSplash(onSettled: () {}, onDone: () {}), - 28 + ), - 29 + ), - 30 + ); - 31 + - 32 + const stops = [60, 260, 460, 700, 900, 1060, 1180, 1340, 16 - +00, 1900, 2080]; - 33 + var last = 0; - 34 + for (final ms in stops) { - 35 + await tester.pump(Duration(milliseconds: ms - last)); - 36 + last = ms; - 37 + final boundary = - 38 + key.currentContext!.findRenderObject()! as RenderRepa - +intBoundary; - 39 + await tester.runAsync(() async { - 40 + final image = await boundary.toImage(); - … 9 more lines (ctrl+o to expand) - - Read 3 files (ctrl+o to expand) -⏺ Ishlayapti — harf yozilib, keyin to'lib, uchlari o'tkir. Uchta sozlash: - kattaroq, jonliroq porlash, sezilarli nafas: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 00:00 +0: dump frames - 00:00 +1: All tests passed! - ok - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+9 - -4) - 189 // idle breath it keeps forever. - 190 stir: ui.lerpDouble( - 191 7.0, - 192 - 1.8, - 192 + 3.0, - 193 _span(ms, _writeTo - 200, _landTo), - 194 )!, - 195 glint: _span(ms, _landFrom + 60, _fadeFrom - 2 - 20), - ... - 327 // detail that is still moving when everything else has st - opped. - 328 if (b.glint > 0 && b.glint < 1) { - 329 final head = geometry.spineLength * b.glint; - 330 - final tail = math.max(0.0, head - geometry.spineLength * - - 0.22); - 330 + final tail = math.max(0.0, head - geometry.spineLength * - + 0.26); - 331 + // Bright enough to read against a filled letter in the - +accent colour. - 332 + // At a third of that it was a highlight nobody saw, whi - +ch is the same as - 333 + // not having one. - 334 _stroke - 332 - ..color = highlight.withValues(alpha: 0.30 * b.dim * ( - -1 - b.glint)) - 333 - ..strokeWidth = _halfRibbon * 0.8; - 335 + ..color = highlight.withValues( - 336 + alpha: 0.62 * b.dim * math.sin(b.glint * math.pi), - 337 + ) - 338 + ..strokeWidth = _halfRibbon * 1.1; - 339 canvas.drawPath(geometry.spineMetric.extractPath(tail, h - ead), _stroke); - 340 } - 341 canvas.restore(); - - Read 2 files (ctrl+o to expand) - -⏺ Porlash endi ko'rinadi — harf bo'ylab yuguradigan yorug'roq tasma. Zondni - o'chirib, commit qilaman: - -⏺ Bash(rm -f test/splash_frames_probe_test.dart && export - PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && dart - format lib/features/splash/presentation/…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 9.4s) - … +2 lines (ctrl+o to expand) - ⎿  Deleted test/splash_frames_probe_test.dart (+0 -49) - 1 -// Not a test — a viewer. Dumps the splash at a series of times - - so the - 2 -// animation can be looked at rather than reasoned about. - 3 -import 'dart:io'; - 4 -import 'dart:ui' as ui; - 5 - - 6 -import 'package:flutter/material.dart'; - 7 -import 'package:flutter/rendering.dart'; - 8 -import 'package:flutter_test/flutter_test.dart'; - 9 -import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 10 -import 'package:soplay/features/splash/presentation/widgets/soz - -o_splash.dart'; - 11 - - 12 -void main() { - 13 - TestWidgetsFlutterBinding.ensureInitialized(); - 14 - - 15 - testWidgets('dump frames', (tester) async { - 16 - await SozoMarkGeometry.precache(); - 17 - final key = GlobalKey(); - 18 - tester.view.physicalSize = const Size(420, 420); - 19 - tester.view.devicePixelRatio = 1.0; - 20 - addTearDown(tester.view.reset); - 21 - - 22 - await tester.pumpWidget( - 23 - MaterialApp( - 24 - debugShowCheckedModeBanner: false, - 25 - home: RepaintBoundary( - 26 - key: key, - 27 - child: SozoSplash(onSettled: () {}, onDone: () {}), - 28 - ), - 29 - ), - 30 - ); - 31 - - 32 - const stops = [60, 260, 460, 700, 900, 1060, 1180, 1340, 16 - -00, 1900, 2080]; - 33 - var last = 0; - 34 - for (final ms in stops) { - 35 - await tester.pump(Duration(milliseconds: ms - last)); - 36 - last = ms; - 37 - final boundary = - 38 - key.currentContext!.findRenderObject()! as RenderRepa - -intBoundary; - 39 - await tester.runAsync(() async { - 40 - final image = await boundary.toImage(); - … 9 more lines (ctrl+o to expand) - -⏺ Splash tayyor va tekshirildi — kadrma-kadr chizib ko'rdim, o'qib emas. - - Hozirgi holat: - - ┌──────────────────────────────────────────────────────────────────────────┐ - │ Ro'yxatdan bajarildi (7/15) │ - ├──────────────────────────────────────────────────────────────────────────┤ - │ ✅ Pauzada boshlash · ✅ Lokal subtitr fayli · ✅ Yuklab olish tanaffusi │ - │ · ✅ Boblarni guruhlash · ✅ Bob qidiruvi · ✅ Sana formati · (+ │ - │ splash, ro'yxatdan tashqari) │ - └──────────────────────────────────────────────────────────────────────────┘ - - Qolganlari (8): bob ichidan matn qidirish, TTS, EPUB progressi, kutubxona - yangilash jadvali, manba migratsiyasi, scanlator filtri, WebView manzil - qatori, trackerlar + tracker yangilash, Material You — keyin PiP (desktop + - iOS). - - Splash haqida qisqacha: ultracode 3 ta konsepsiyani ishlab chiqdi va 4 - linzadan baholadi; uchala hakam ham bir xil xulosaga keldi — muammo - animatsiyada emas, asset'da edi. Siluet ichida harakatlanadigan narsa yo'q, - shuning uchun har qanday urinish "neon chiroq" bo'lib chiqardi. Markka o'z - umurtqasi berildi (ko'z bilan chizilmagan — konturdan hisoblab chiqarilgan), - va shundan keyin hamma narsa o'z-o'zidan kelib chiqdi: qalam harfni yozadi, - to'ldirish esa o'sha shtrixning kengaytirilgani — belda teshik chiqishi - geometrik jihatdan mumkin emas. - - Emulator ishlab turibdi; keyingi ishga tushirishda yangi splash'ni ko'rasiz. - Davom etamanmi — keyingi navbatda bob ichidan qidirish va TTS? - -✻ Sautéed for 28m 22s · done 12:39 AM - -❯ bitta qaytadan ishga tushurchi - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor(Sozo fresh run with the new splash) - ⎿  Monitor started · task ba3jcrjvf · timeout 900s - -⏺ Qayta ishga tushirdim — build ketyapti. Ilova ochilganda yangi splash'ni - ko'rasiz: qalam markning umurtqasi bo'ylab harfni yozadi, keyin o'sha shtrix - kengayib harfni to'ldiradi, qo'ngandan keyin esa shtrix bo'ylab yorug'lik - yuguradi va kontur nafas olib turadi. - - Tayyor bo'lganda aytaman. - -✻ Crunched for 1m 17s · done 10:26 AM - -⏺ Monitor event: "Sozo fresh run with the new splash" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo fresh run with the new splash" - ⎿  Interrupted · What should Claude do instead? - -❯ qayta - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor(Sozo relaunch) - ⎿  Monitor started · task bxl9ew5c5 · timeout 900s - -⏺ Qayta ishga tushirdim — ilova o'chib qolgan ekan (oldingi run uzilgan). Build - va o'rnatish ketyapti, tayyor bo'lganda aytaman. - -✻ Churned for 1m 14s · done 10:28 AM - -⏺ Monitor event: "Sozo relaunch" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Ilova ishga tushdi, PIN ekranida turibdi. - - Splash PIN oynasi ostida o'ynaydi — AppLockGate birinchi kadrdan boshlab butun - ilovani yopadi, shuning uchun qulflangan qurilmada uni ko'rib bo'lmaydi. Uni - ko'rish uchun ikki yo'l bor: - - 1. PIN'ni kiriting → Profile → App lock'ni vaqtincha o'chiring → ilovani qayta - ishga tushiring. Shunda splash to'liq ko'rinadi. - 2. Yoki men animatsiyani kadrma-kadr chizib bergan kontakt varag'iga qaraysiz - — u aynan qurilmada chiziladigan narsa, faqat to'xtatilgan holda. - - Aytganingizcha qilaman — qaysi biri qulay? - -✻ Brewed for 1m 19s · done 10:29 AM - -❯ bitta hotrestart berchi AppLockGate Splashni yopmasin ok ? yani splash - pindan oldin chiqsin - - Searched for 3 patterns, read 1 file (ctrl+o to expand) - -⏺ Monitor event: "Sozo relaunch" - -⏺ Monitor event: "Sozo fresh run with the new splash" - -⏺ Monitor event: "Sozo relaunch" - -⏺ Tushundim — qulf butun ilovani birinchi kadrdan yopadi, splash ham shu ostida - qoladi. Splashda maxfiy hech narsa yo'q, faqat markning o'zi: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - import 'dart:async'; - import 'package:flutter/widgets.dart'; - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/app_lock/presentation/app_lock_gate.dart (+51 -1) - 1 +import 'dart:async'; - 2 import 'package:flutter/widgets.dart'; - 3 import 'package:soplay/features/app_lock/domain/repositories/app - _lock_repository.dart'; - 4 - ... - 28 bool _observing = false; - 29 DateTime? _backgroundedAt; - 30 - 31 + /// Held down while the splash is on screen. - 32 + /// - 33 + /// The lock exists to cover CONTENT. The splash has none: it - + is the app's - 34 + /// own mark on a black field, which is the same thing the la - +uncher icon - 35 + /// already shows to anybody holding the phone. Covering it b - +ought no - 36 + /// security and cost the one animation the app gets to intro - +duce itself - 37 + /// with — on a locked device the whole thing played behind t - +he PIN pad and - 38 + /// nobody ever saw it. - 39 + /// - 40 + /// Nothing else may use this. It is not a way to postpone th - +e lock; it is a - 41 + /// statement that what is on screen is not worth locking. - 42 + bool _splashHold = false; - 43 + Timer? _splashDeadline; - 44 + - 45 /// True while the lock screen must cover the app. Turning th - e lock off in - 46 /// settings clears it at once; turning it on does not lock t - he session the - 47 /// user is in — they have just typed the PIN. - 33 - bool get isLocked => _locked && _repo.isEnabled; - 48 + bool get isLocked => _locked && _repo.isEnabled && !_splashHo - +ld; - 49 + - 50 + /// Lets the splash play before the PIN pad covers it. - 51 + /// - 52 + /// Bounded three ways, because an unlocked app is not someth - +ing to leave to - 53 + /// a callback firing: it expires on its own after [_splashGr - +ace] whatever - 54 + /// happens, it is dropped the moment the app goes to the bac - +kground, and the - 55 + /// only caller releases it when the splash route is disposed - +. A crash - 56 + /// mid-animation therefore lands on a locked app, not an ope - +n one. - 57 + void holdForSplash() { - 58 + if (_splashHold) return; - 59 + _splashHold = true; - 60 + _splashDeadline?.cancel(); - 61 + _splashDeadline = Timer(_splashGrace, releaseSplashHold); - 62 + notifyListeners(); - … 34 more lines (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  146 packages have newer versions incompatible with dependency constrai - nts. - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/pages/splash_page.dart (+16 - -0) - 4 import 'package:go_router/go_router.dart'; - 5 import 'package:soplay/core/di/injection.dart'; - 6 import 'package:soplay/features/app_lock/domain/repositories/ap - p_lock_repository.dart'; - 7 +import 'package:soplay/features/app_lock/presentation/app_lock_ - +gate.dart'; - 8 import 'package:soplay/core/storage/hive_service.dart'; - 9 import 'package:soplay/features/splash/presentation/widgets/soz - o_splash.dart'; - 10 - ... - 16 } - 17 - 18 class _SplashPageState extends State { - 19 + @override - 20 + void initState() { - 21 + super.initState(); - 22 + // The PIN pad waits until the mark has been drawn. See - 23 + // [AppLockGate.holdForSplash] for why that is safe: there - +is nothing on - 24 + // this screen to cover. - 25 + getIt().holdForSplash(); - 26 + } - 27 + - 28 + @override - 29 + void dispose() { - 30 + getIt().releaseSplashHold(); - 31 + super.dispose(); - 32 + } - 33 + - 34 /// Where to go, decided while the animation is still finishi - ng. - 35 /// - 36 /// The lock check reads a file and the onboarding check read - s Hive. Starting - Listed 1 directory (ctrl+o to expand) - -⏺ Bash(mkdir -p test/features/app_lock && cat > - test/features/app_lock/app_lock_splash_hold_test.dart <<'DART' - // The splash played behind the PIN pad, so on a locked…) - ⎿   Future verify(String pin) async => const PinCheck.ok(); - ^^^^^^^^ - 00:00 +0 -1: loading /Users/azamov/Sozo-Full/sozo/test/features/app_lo - … +15 lines (ctrl+o to expand) - ⎿  Created test/features/app_lock/app_lock_splash_hold_test.dart (+95 -0) - 1 +// The splash played behind the PIN pad, so on a locked device - +nobody ever saw - 2 +// it. The lock exists to cover content, and the splash has non - +e — it is the - 3 +// app's own mark on a black field, which is what the launcher - +icon already - 4 +// shows to anybody holding the phone. - 5 +// - 6 +// Holding a lock open is not a thing to be casual about, so wh - +at is asserted - 7 +// here is mostly the ways the hold ENDS. - 8 +import 'package:flutter/widgets.dart'; - 9 +import 'package:flutter_test/flutter_test.dart'; - 10 +import 'package:soplay/features/app_lock/domain/entities/pin_ch - +eck.dart'; - 11 +import 'package:soplay/features/app_lock/domain/repositories/ap - +p_lock_repository.dart'; - 12 +import 'package:soplay/features/app_lock/presentation/app_lock_ - +gate.dart'; - 13 + - 14 +class _Repo implements AppLockRepository { - 15 + _Repo({this.enabled = true}); - 16 + - 17 + bool enabled; - 18 + - 19 + @override - 20 + bool get isEnabled => enabled; - 21 + - 22 + @override - 23 + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); - 24 + - 25 + @override - 26 + Future ensureConsistent() async {} - 27 + - 28 + @override - 29 + Future verify(String pin) async => const PinCheck.o - +k(); - 30 +} - 31 + - 32 +void main() { - 33 + TestWidgetsFlutterBinding.ensureInitialized(); - 34 + - 35 + test('a device with a PIN starts locked', () { - 36 + final gate = AppLockGate(_Repo()); - 37 + addTearDown(gate.dispose); - 38 + expect(gate.isLocked, isTrue); - 39 + }); - 40 + - … 55 more lines (ctrl+o to expand) - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Got dependencies! - 1 package is discontinued. - 146 packages have newer versions incompatible with dependency constrai - … +11 lines (ctrl+o to expand) - ⎿  Updated test/features/app_lock/app_lock_splash_hold_test.dart (+2 -7) - 7 // here is mostly the ways the hold ENDS. - 8 import 'package:flutter/widgets.dart'; - 9 import 'package:flutter_test/flutter_test.dart'; - 10 -import 'package:soplay/features/app_lock/domain/entities/pin_ch - -eck.dart'; - 10 import 'package:soplay/features/app_lock/domain/repositories/ap - p_lock_repository.dart'; - 11 import 'package:soplay/features/app_lock/presentation/app_lock_ - gate.dart'; - 12 - ... - 18 @override - 19 bool get isEnabled => enabled; - 20 - 21 + // The gate reads exactly one thing off the repository. Every - +thing else on - 22 + // the interface belongs to the PIN screen, which is not what - + is under test. - 23 @override - 24 dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); - 24 - - 25 - @override - 26 - Future ensureConsistent() async {} - 27 - - 28 - @override - 29 - Future verify(String pin) async => const PinCheck.o - -k(); - 25 } - 26 - 27 void main() { - -⏺ All 6 pass — including the important one: if the splash crashes, the app ends - up locked, not open. - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/features/app_lock/presentation/app_lock_gate.dart - lib/features/splash/pre…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 10.8s) - … +5 lines (ctrl+o to expand) - ⎿  Updated lib/features/app_lock/presentation/app_lock_gate.dart (+4 -4) - 11 /// says so — so every way into the app lands under it. - 12 class AppLockGate extends ChangeNotifier with WidgetsBindingObs - erver { - 13 AppLockGate(this._repo, {DateTime Function()? clock}) - 14 - : _now = clock ?? DateTime.now, - 15 - // Locked from the start when a PIN is set. DI builds t - -his after the - 16 - // settings box is open, so the flag is already readabl - -e here. - 17 - _locked = _repo.isEnabled; - 14 + : _now = clock ?? DateTime.now, - 15 + // Locked from the start when a PIN is set. DI builds thi - +s after the - 16 + // settings box is open, so the flag is already readable - +here. - 17 + _locked = _repo.isEnabled; - 18 - 19 final AppLockRepository _repo; - 20 final DateTime Function() _now; - - Pushed to work/2026-09-14, ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Sozo run, splash ahead of the PIN) - ⎿  Monitor started · task bzp1bes7q · timeout 900s - -⏺ Qildim — endi splash PIN'dan oldin chiqadi. Build ketyapti (1b9de91). - - Qulf mazmunni yopish uchun bor; splashda mazmun yo'q — u shunchaki markning - qora fondagi tasviri, ya'ni telefonni ushlab turgan odam launcher ikonkasida - allaqachon ko'rgan narsa. Uni yopish xavfsizlikka hech nima qo'shmasdi, lekin - ilovaning o'zini tanishtiradigan yagona animatsiyasini yeb qo'yardi — va aynan - har kuni ilovani ochadigan odamlar uni hech qachon ko'rmasdi. - - Qulfni ushlab turish jiddiy narsa, shuning uchun ushlash uch yo'l bilan - tugaydi va uchalasi ham testlangan: 4 soniyadan keyin o'z-o'zidan tugaydi, - ilova fonga o'tsa darhol tushadi, splash sahifasi yopilganda bo'shatiladi. - Ya'ni animatsiya o'rtasida crash bo'lsa ham ilova qulflangan holatda qoladi, - ochiq emas. - - flutter analyze toza, 1577 test o'tdi. - -✻ Sautéed for 24m 8s · done 10:58 AM - -⏺ Monitor event: "Sozo run, splash ahead of the PIN" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -❯ hot restart go - - Read 1 file, ran 2 shell commands (ctrl+o to expand) - -⏺ Splash endi PIN'dan oldin chiqyapti — 5 ta kadrda mark ko'rinib turibdi, va - ular bo'ylab yorug'lik tasmasi harf bo'ylab siljiyapti (porlash). PIN keyin - keladi: - - Ran 1 shell command (ctrl+o to expand) - ⎿  Interrupted · What should Claude do instead? - -❯ yana bitta qayta ko`rmay qoldm - - Ran 1 shell command (ctrl+o to expand) - -⏺ Qayta ishga tushirdim — hozir qarang. - - Ketma-ketlik: qalam markning umurtqasidan harfni chapdan pastga yozadi (~0.9s) - → o'sha shtrix kengayib harfni to'ldiradi (~1.2s) → harf joyiga qo'nadi → - shtrix bo'ylab yorug'lik yuguradi va kontur nafas oladi → so'ngra PIN ekrani - keladi. Hammasi 2.15 soniya. - - Yana ko'rmoqchi bo'lsangiz ayting, qayta ishga tushiraman. - -✻ Cogitated for 47s · done 11:01 AM - -❯ yana qayta - - Ran 1 shell command (ctrl+o to expand) - -⏺ Qayta ishga tushdi — qarang. - -✻ Baked for 33s · done 11:02 AM - -❯ ko`rmadim - -⏺ Tekshiray — ekranda nima va qachon chiqayotganini o'lchab ko'raman: - - Read 1 file, ran 2 shell commands (ctrl+o to expand) - -⏺ Sabab topildi shekilli — ekranda mening Dart splash'im emas, Android'ning - nativ launch ekrani bo'lishi mumkin: - - Searched for 1 pattern, ran 3 shell commands (ctrl+o to expand) - -⏺ Taxmin qilishni bas qilay — o'lchayman: - -⏺ Bash(cp lib/features/splash/presentation/widgets/sozo_splash.dart - /tmp/splash.bak && python3 - <<'PY' - import io…) - ⎿   info • The member 'paint' overrides an inherited member but isn't a - nnotated with '@override'. Try adding the '@override' annotation • lib - /features/splash/presentation/widgets/sozo_splash.dart:290:8 • annotat - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/pages/splash_page.dart (+4 - -0) - 48 } - 49 - 50 Future _resolveRoute() async { - 51 + // ignore: avoid_print - 52 + print('[splash] resolve start'); - 53 final lock = getIt(); - 54 await lock.ensureConsistent(); - 55 // The PIN itself is asked for by the lock overlay, which h - as covered the - 56 // app since the first frame (see AppLockGate) — so a deep - link that lands - 57 // before this runs is behind it too. - 58 + // ignore: avoid_print - 59 + print('[splash] resolve done, locked=${lock.isEnabled}'); - 60 if (lock.isEnabled) return '/main'; - 61 // Once, on a device nobody has signed in on. A PIN means t - he device has - 62 // been used before, so the question only arises on the bra - nch with no lock - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+12 - -0) - 104 if (mounted) setState(() => _geometry = SozoMarkGeomet - ry.value); - 105 }); - 106 } - 107 + // ignore: avoid_print - 108 + print('[splash] initState'); - 109 _c.addListener(_watch); - 110 _c.forward().then((_) { - 111 + // ignore: avoid_print - 112 + print('[splash] animation done'); - 113 if (mounted) widget.onDone(); - 114 }); - 115 } - ... - 285 static const double _hz = 0.42; - 286 - 287 @override - 288 + int _paints = 0; - 289 + - 290 void paint(Canvas canvas, Size size) { - 291 final b = read(); - 292 + if (_paints < 6 || _paints % 30 == 0) { - 293 + // ignore: avoid_print - 294 + print('[splash] paint $_paints write=${b.write.toStringA - +sFixed(2)} ' - 295 + 'fill=${b.fill.toStringAsFixed(2)} dim=${b.dim.toStr - +ingAsFixed(2)}'); - 296 + } - 297 + _paints++; - 298 if (b.dim <= 0.001) return; - 299 - 300 final scale = size.width / 512; - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing splash... - No issues found! (ran in 2.9s) - launched - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+1 - -1) - 284 static const double _waves = 3.0; - 285 static const double _hz = 0.42; - 286 - 287 - @override - 287 int _paints = 0; - 288 - 289 + @override - 290 void paint(Canvas canvas, Size size) { - 291 final b = read(); - 292 if (_paints < 6 || _paints % 30 == 0) { -⏺ Monitor(Watch the splash timing log) - ⎿  Monitor started · task bnh8r1rmm · timeout 900s - -⏺ Vaqtincha o'lchov qo'ydim — splash qachon boshlanib, qanday qiymatlar bilan - chizilayotganini logga yozadi. Build tugashi bilan aniq raqamlar bilan - aytaman, taxmin qilmayman. - -✻ Worked for 5m 0s · done 11:07 AM - -⏺ Monitor event: "Watch the splash timing log" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Watch the splash timing log" - -⏺ Monitor event: "Watch the splash timing log" - -⏺ Monitor event: "Watch the splash timing log" - -⏺ Monitor event: "Watch the splash timing log" - -⏺ Monitor event: "Watch the splash timing log" - -⏺ Monitor event: "Watch the splash timing log" - -⏺ O'lchov aniq javob berdi — animatsiya to'g'ri ishlayapti: write 0.00 → 0.97 - gacha o'tyapti, keyin fill 1.00. Muammo boshqa: yozish atigi ~900ms va qora - fonda ingichka qizil chiziq, debug'da kadrlar tushib ketadi — ko'z ilg'amaydi. - Yozishni sezilarli qilaman: - -⏺ Bash(cp /tmp/splash.bak - lib/features/splash/presentation/widgets/sozo_splash.dart && python3 - - <<'PY' - import io…) - ⎿  ok - Try `flutter pub outdated` for more information. - Analyzing splash... - No issues found! (ran in 3.1s) - ⎿  Updated lib/features/splash/presentation/pages/splash_page.dart (+0 - -4) - 48 } - 49 - 50 Future _resolveRoute() async { - 51 - // ignore: avoid_print - 52 - print('[splash] resolve start'); - 51 final lock = getIt(); - 52 await lock.ensureConsistent(); - 53 // The PIN itself is asked for by the lock overlay, which h - as covered the - 54 // app since the first frame (see AppLockGate) — so a deep - link that lands - 55 // before this runs is behind it too. - 58 - // ignore: avoid_print - 59 - print('[splash] resolve done, locked=${lock.isEnabled}'); - 56 if (lock.isEnabled) return '/main'; - 57 // Once, on a device nobody has signed in on. A PIN means t - he device has - 58 // been used before, so the question only arises on the bra - nch with no lock - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+32 - -24) - 73 ); - 74 - 75 /// Every beat, in milliseconds from the first frame. - 76 - static const int _writeFrom = 120; - 77 - static const int _writeTo = 900; - 78 - static const int _fillFrom = 700; - 79 - static const int _fillTo = 1160; - 80 - static const int _landFrom = 1120; - 81 - static const int _landTo = 1320; - 82 - static const int _fadeFrom = 1900; - 83 - static const int _total = 2150; - 76 + /// - 77 + /// The write used to take 780ms and nobody saw it. Measured - +on the device - 78 + /// rather than guessed at: a debug build on an emulator pain - +ts this at about - 79 + /// 30fps, so 780ms of writing is two dozen frames — and the - +first few of - 80 + /// those are lost to the jank every Flutter cold start has. - +What was left - 81 + /// was a letter that appeared to be there already. - 82 + /// - 83 + /// It is a second now, and the dead stretch after the landin - +g is gone: the - 84 + /// mark used to sit unchanged from 1320ms to 1900ms, which i - +s the part - 85 + /// everybody DID see, and seeing only that is what makes an - +animation read - 86 + /// as a static logo. - 87 + static const int _writeFrom = 100; - 88 + static const int _writeTo = 1180; - 89 + static const int _fillFrom = 980; - 90 + static const int _fillTo = 1520; - 91 + static const int _landFrom = 1480; - 92 + static const int _landTo = 1700; - 93 + static const int _fadeFrom = 2120; - 94 + static const int _total = 2380; - 95 - 96 bool _settled = false; - 97 SozoMarkGeometry? _geometry; - ... - 115 if (mounted) setState(() => _geometry = SozoMarkGeomet - ry.value); - 116 }); - 117 } - 107 - // ignore: avoid_print - 108 - print('[splash] initState'); - 118 _c.addListener(_watch); - 119 _c.forward().then((_) { - … 52 more lines (ctrl+o to expand) - -⏺ Bash(cat > test/splash_probe_test.dart <<'DART' - import 'dart:io';…) - ⎿  00:00 +0: dump - 00:00 +1: All tests passed! - ok - ⎿  Created test/splash_probe_test.dart (+39 -0) - 1 +import 'dart:io'; - 2 +import 'dart:ui' as ui; - 3 +import 'package:flutter/material.dart'; - 4 +import 'package:flutter/rendering.dart'; - 5 +import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 +import 'package:soplay/features/splash/presentation/widgets/soz - +o_splash.dart'; - 8 + - 9 +void main() { - 10 + TestWidgetsFlutterBinding.ensureInitialized(); - 11 + testWidgets('dump', (tester) async { - 12 + await SozoMarkGeometry.precache(); - 13 + final key = GlobalKey(); - 14 + tester.view.physicalSize = const Size(400, 400); - 15 + tester.view.devicePixelRatio = 1.0; - 16 + addTearDown(tester.view.reset); - 17 + await tester.pumpWidget(MaterialApp( - 18 + debugShowCheckedModeBanner: false, - 19 + home: RepaintBoundary( - 20 + key: key, - 21 + child: SozoSplash(onSettled: () {}, onDone: () {}), - 22 + ), - 23 + )); - 24 + const stops = [200, 400, 600, 800, 1000, 1200, 1400, 1650, - +1900, 2150]; - 25 + var last = 0; - 26 + for (final ms in stops) { - 27 + await tester.pump(Duration(milliseconds: ms - last)); - 28 + last = ms; - 29 + final b = key.currentContext!.findRenderObject()! as Rend - +erRepaintBoundary; - 30 + await tester.runAsync(() async { - 31 + final img = await b.toImage(); - 32 + final d = await img.toByteData(format: ui.ImageByteForm - +at.png); - 33 + File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/' - 34 + '018dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/q_ - +$ms.png') - 35 + .writeAsBytesSync(d!.buffer.asUint8List()); - 36 + }); - 37 + } - 38 + }); - 39 +} - -⏺ 16 background shell command tasks didn't finish before the previous session -ended. Task ids: byk5h29h0, b76jk0atu, b59mpajpc, bkrb4qcq0, bohol39t7, -bekee0snl, b1gn09jww, b0jdnw0bw, b5cxrj4xe, bfd0rlliu, bfxb25lgm, bbpq7utvz, -ba3jcrjvf, bxl9ew5c5, bzp1bes7q, bnh8r1rmm. - -⏺ Oldingi sessiya uzilgan joydan davom etaman: splash qayta sozlangan, kadrlar - chizilgan — tekshirib, commit qilib, qayta ishga tushiraman. - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Scratchpad tozalangan ekan. Kadrlarni qayta chizaman: - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Endi yozish aniq ko'rinadi: 200–1000ms oralig'ida yorqinroq shtrix va oq - porlagan qalam uchi S bo'ylab yuradi, keyin qalinlashib to'ladi. Commit qilib, - ishga tushiraman: - -⏺ Bash(rm -f test/splash_probe_test.dart && export - PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && dart - format lib/features/splash >/dev/null && flutte…) - ⎿  Analyzing sozo... - No issues found! (ran in 10.2s) - 1b9de91..5c17109 work/2026-09-14 -> work/2026-09-14 - ⎿  Deleted test/splash_probe_test.dart (+0 -39) - 1 -import 'dart:io'; - 2 -import 'dart:ui' as ui; - 3 -import 'package:flutter/material.dart'; - 4 -import 'package:flutter/rendering.dart'; - 5 -import 'package:flutter_test/flutter_test.dart'; - 6 -import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 -import 'package:soplay/features/splash/presentation/widgets/soz - -o_splash.dart'; - 8 - - 9 -void main() { - 10 - TestWidgetsFlutterBinding.ensureInitialized(); - 11 - testWidgets('dump', (tester) async { - 12 - await SozoMarkGeometry.precache(); - 13 - final key = GlobalKey(); - 14 - tester.view.physicalSize = const Size(400, 400); - 15 - tester.view.devicePixelRatio = 1.0; - 16 - addTearDown(tester.view.reset); - 17 - await tester.pumpWidget(MaterialApp( - 18 - debugShowCheckedModeBanner: false, - 19 - home: RepaintBoundary( - 20 - key: key, - 21 - child: SozoSplash(onSettled: () {}, onDone: () {}), - 22 - ), - 23 - )); - 24 - const stops = [200, 400, 600, 800, 1000, 1200, 1400, 1650, - -1900, 2150]; - 25 - var last = 0; - 26 - for (final ms in stops) { - 27 - await tester.pump(Duration(milliseconds: ms - last)); - 28 - last = ms; - 29 - final b = key.currentContext!.findRenderObject()! as Rend - -erRepaintBoundary; - 30 - await tester.runAsync(() async { - 31 - final img = await b.toImage(); - 32 - final d = await img.toByteData(format: ui.ImageByteForm - -at.png); - 33 - File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/' - 34 - '018dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/q_ - -$ms.png') - 35 - .writeAsBytesSync(d!.buffer.asUint8List()); - 36 - }); - 37 - } - 38 - }); - 39 -} - ⎿  (timeout 10m) - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Monitor(Sozo run with the retimed splash) - ⎿  Monitor started · task b4kqeij30 · timeout 900s - -⏺ Build ketyapti (5c17109). Nega ko'rmaganingizni o'lchab topdim — taxmin emas, - qurilma logidan: - - - Animatsiya to'g'ri ishlayotgan edi — log write 0.00 → 0.97 → 1.00 ga - o'tganini ko'rsatdi. - - Lekin debug build emulatorda ~30fps chiziladi, demak 780ms yozish — atigi - ~24 kadr, va birinchi kadrlar har bir Flutter cold start'dagi "jank"ka - ketadi. Qolgani — go'yo allaqachon turgan harf. - - Undan keyin 580ms hech narsa o'zgarmasdi — aynan shu qismni hamma ko'rardi, - va faqat shuni ko'rish animatsiyani statik logo qilib ko'rsatadi. - - Tuzatish: yozish endi 1 soniya, o'lik bo'lak olib tashlandi. Qalam yorug'lik - bilan yozadi (primaryLight) va to'ldirish boshlanganda accent rangga o'tadi — - qora fonda fill rangidagi ingichka chiziq ekrandagi eng past kontrastli narsa - edi, aynan u yagona harakatlanayotgan paytda. Qalam uchi endi nuqta emas, - halo'li yorug'lik — chiziq oxiridagi tekis nuqta harakatlanayotgan markerga - o'xshardi. - - Tayyor bo'lganda ilovani qayta ishga tushiraman — qarang. - -✻ Worked for 3m 29s · done 11:16 AM - -⏺ Monitor event: "Sozo run with the retimed splash" - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor event: "Sozo run with the retimed splash" - -⏺ Yangi build o'rnatildi va ilovani hozir qayta ishga tushirdim — emulatorga - qarang. - - Endi ~1 soniya davomida oq porlagan qalam S harfini yozib chiqadi, keyin - shtrix qalinlashib harfni to'ldiradi, ustidan yorug'lik yuguradi, so'ng PIN - ekrani chiqadi. - - ("Lost connection" — am force-stop flutter run'ning debug ulanishini uzgani - uchun; ilova o'zi ishlab turibdi.) Yana ko'rmoqchi bo'lsangiz — "qayta" deng. - -✻ Worked for 38s · done 11:17 AM - -❯ qayta - - Ran 1 shell command (ctrl+o to expand) - -⏺ Qayta ishga tushdi — qarang. -✻ Crunched for 28s · done 11:18 AM - -❯ hot restart - -⏺ Hot restart uchun flutter'ga klavish yuborish kerak — shuning uchun jarayonni - boshqariladigan kanal bilan ulayman: - - Ran 3 shell commands (ctrl+o to expand) - -⏺ Hot restart bajarildi — Restarted application in 1,279ms. Qarang. - - Endi flutter boshqariladigan kanal orqali ulangan, shuning uchun keyingi safar - "hot restart" desangiz, darhol yuboraman. - -✻ Brewed for 1m 19s · done 11:20 AM - -❯ i didnt get splash lol fck lock pagedan boshqa hech narsa ko`rinmayapti - -⏺ Tekshiraman — kodda nima bo'layotganini aniq ko'raman: - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Topdim: xato holdForSplash()ni build fazasida ishlaydigan initState ichida - chaqirganimdan kelib chiqqan, shu sabab qulf notifyListeners()ni build paytida - ishga tushirib istisno beryapti va PIN ochilmay qolyapti. Endi bayroq ushlash - o'rniga qaysi sahifa ekranda ekanini tekshiradigan to'g'ri yechimni - qo'llayman. - - Searched for 1 pattern (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 146 packages have newer versions incompatible with dependency constrai - nts. - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/app_lock/presentation/app_lock_gate.dart (+1 -48) - 1 -import 'dart:async'; - 1 import 'package:flutter/widgets.dart'; - 2 import 'package:soplay/features/app_lock/domain/repositories/app - _lock_repository.dart'; - 3 - ... - 27 bool _observing = false; - 28 DateTime? _backgroundedAt; - 29 - 31 - /// Held down while the splash is on screen. - 32 - /// - 33 - /// The lock exists to cover CONTENT. The splash has none: it - - is the app's - 34 - /// own mark on a black field, which is the same thing the la - -uncher icon - 35 - /// already shows to anybody holding the phone. Covering it b - -ought no - 36 - /// security and cost the one animation the app gets to intro - -duce itself - 37 - /// with — on a locked device the whole thing played behind t - -he PIN pad and - 38 - /// nobody ever saw it. - 39 - /// - 40 - /// Nothing else may use this. It is not a way to postpone th - -e lock; it is a - 41 - /// statement that what is on screen is not worth locking. - 42 - bool _splashHold = false; - 43 - Timer? _splashDeadline; - 30 - 31 /// True while the lock screen must cover the app. Turning th - e lock off in - 32 /// settings clears it at once; turning it on does not lock t - he session the - 33 /// user is in — they have just typed the PIN. - 48 - bool get isLocked => _locked && _repo.isEnabled && !_splashHo - -ld; - 34 + bool get isLocked => _locked && _repo.isEnabled; - 35 - 50 - /// Lets the splash play before the PIN pad covers it. - 51 - /// - 52 - /// Bounded three ways, because an unlocked app is not someth - -ing to leave to - 53 - /// a callback firing: it expires on its own after [_splashGr - -ace] whatever - 54 - /// happens, it is dropped the moment the app goes to the bac - -kground, and the - 55 - /// only caller releases it when the splash route is disposed - -. A crash - 56 - /// mid-animation therefore lands on a locked app, not an ope - -n one. - 57 - void holdForSplash() { - 58 - if (_splashHold) return; - 59 - _splashHold = true; - 60 - _splashDeadline?.cancel(); - 61 - _splashDeadline = Timer(_splashGrace, releaseSplashHold); - 62 - notifyListeners(); - … 33 more lines (ctrl+o to expand) - ⎿  Updated - lib/features/app_lock/presentation/widgets/app_lock_overlay.dart (+23 - -2) - 1 import 'package:flutter/material.dart'; - 2 import 'package:soplay/core/di/injection.dart'; - 3 +import 'package:soplay/core/router/app_router.dart'; - 4 import 'package:soplay/features/app_lock/presentation/app_lock_g - ate.dart'; - 5 import 'package:soplay/features/app_lock/presentation/pages/pin_ - verify_page.dart'; - 6 - ... - 16 - 17 final Widget child; - 18 - 19 + /// The one route the lock does not cover. - 20 + /// - 21 + /// The lock exists to cover CONTENT, and the splash has none - +: it is the - 22 + /// app's own mark on a black field, which is what the launch - +er icon already - 23 + /// shows to anybody holding the phone. Covered, the one anim - +ation the app - 24 + /// gets to introduce itself with played behind the PIN pad o - +n every locked - 25 + /// device, and nobody who uses the lock ever saw it. - 26 + static const String _uncovered = '/splash'; - 27 + - 28 @override - 29 Widget build(BuildContext context) { - 30 final gate = getIt(); - 31 + final router = AppRouter.router.routerDelegate; - 32 + // Decided by which route is on screen, and not by a flag t - +he splash raises - 33 + // and lowers. That version raised it from `initState`, whi - +ch runs during a - 34 + // build, so the notification that should have uncovered th - +e splash threw - 35 + // "setState() called during build" instead and the PIN pad - + stayed up. It - 36 + // could not lower it safely either: `dispose` runs while t - +he tree is - 37 + // locked. Reading the route has neither problem, and it ca - +nnot leak — the - 38 + // frame the app leaves the splash, for whatever reason inc - +luding a deep - 39 + // link, is the frame this covers it again, with no timer t - +o wait out. - 40 return ListenableBuilder( - 22 - listenable: gate, - 41 + listenable: Listenable.merge([gate, router]), - 42 builder: (context, _) { - 24 - final locked = gate.isLocked; - 43 + final onSplash = - 44 + router.currentConfiguration.uri.path == _uncovered; - 45 + final locked = gate.isLocked && !onSplash; - 46 return Stack( - 47 fit: StackFit.expand, - … 1 more lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/pages/splash_page.dart (+0 - -15) - 4 import 'package:go_router/go_router.dart'; - 5 import 'package:soplay/core/di/injection.dart'; - 6 import 'package:soplay/features/app_lock/domain/repositories/ap - p_lock_repository.dart'; - 7 -import 'package:soplay/features/app_lock/presentation/app_lock_ - -gate.dart'; - 7 import 'package:soplay/core/storage/hive_service.dart'; - 8 import 'package:soplay/features/splash/presentation/widgets/soz - o_splash.dart'; - 9 - ... - 15 } - 16 - 17 class _SplashPageState extends State { - 19 - @override - 20 - void initState() { - 21 - super.initState(); - 22 - // The PIN pad waits until the mark has been drawn. See - 23 - // [AppLockGate.holdForSplash] for why that is safe: there - -is nothing on - 24 - // this screen to cover. - 25 - getIt().holdForSplash(); - 26 - } - 27 - - 28 - @override - 29 - void dispose() { - 30 - getIt().releaseSplashHold(); - 31 - super.dispose(); - 32 - } - 18 - 19 /// Where to go, decided while the animation is still finishi - ng. - 20 /// - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Gate asl holiga qaytdi. Uni aynan avvalgi faylga tiklayman va qarorni - testlanadigan sof funksiyaga chiqaraman: - -⏺ Bash(git checkout 1b9de91~1 -- - lib/features/app_lock/presentation/app_lock_gate.dart && rm -f - test/features/app_lock/app_lock_splash_hold_test.dart && python3 - <<'P…) - ⎿  Try `flutter pub outdated` for more information. - Analyzing sozo... - No issues found! (ran in 11.3s) - … +3 lines (ctrl+o to expand) - ⎿  Updated - lib/features/app_lock/data/datasources/app_lock_local_data_source.dart - (+17 -14) - 16 FlutterSecureStorage? secureStorage, - 17 LocalAuthentication? localAuth, - 18 DateTime Function()? clock, - 19 - }) : _hive = hiveService, - 20 - _secure = secureStorage ?? - 21 - const FlutterSecureStorage( - 22 - aOptions: AndroidOptions(encryptedSharedPreferenc - -es: true), - 23 - iOptions: IOSOptions( - 24 - accessibility: KeychainAccessibility.first_unlo - -ck, - 25 - ), - 26 - ), - 27 - _localAuth = localAuth ?? LocalAuthentication(), - 28 - _now = clock ?? DateTime.now; - 19 + }) : _hive = hiveService, - 20 + _secure = - 21 + secureStorage ?? - 22 + const FlutterSecureStorage( - 23 + aOptions: AndroidOptions(encryptedSharedPreference - +s: true), - 24 + iOptions: IOSOptions( - 25 + accessibility: KeychainAccessibility.first_unloc - +k, - 26 + ), - 27 + ), - 28 + _localAuth = localAuth ?? LocalAuthentication(), - 29 + _now = clock ?? DateTime.now; - 30 - 31 final HiveService _hive; - 32 final FlutterSecureStorage _secure; - ... - 131 /// reset path, because no PIN will ever be accepted. - 132 Future isPinReadable() async { - 133 try { - 133 - final salt = - 134 - await _secure.read(key: AppConstants.appLockPinSaltS - -ecureKey); - 135 - final hash = - 136 - await _secure.read(key: AppConstants.appLockPinHashS - -ecureKey); - 134 + final salt = await _secure.read( - 135 + key: AppConstants.appLockPinSaltSecureKey, - 136 + ); - 137 + final hash = await _secure.read( - 138 + key: AppConstants.appLockPinHashSecureKey, - 139 + ); - … 3 more lines (ctrl+o to expand) - ⎿  Updated - lib/features/app_lock/data/repositories/app_lock_repository_impl.dart - (+1 -1) - 4 - 5 class AppLockRepositoryImpl implements AppLockRepository { - 6 AppLockRepositoryImpl(this._source, {Future Function()? - wipeProtected}) - 7 - : _wipeProtected = wipeProtected; - 7 + : _wipeProtected = wipeProtected; - 8 - 9 final AppLockLocalDataSource _source; - 10 final Future Function()? _wipeProtected; - ⎿  Updated lib/features/app_lock/domain/entities/pin_check.dart (+2 -2) - 22 const PinCheckResult.ok() : this._(PinCheck.ok); - 23 - 24 const PinCheckResult.wrong(int attemptsLeft) - 25 - : this._(PinCheck.wrong, attemptsLeft: attemptsLeft); - 25 + : this._(PinCheck.wrong, attemptsLeft: attemptsLeft); - 26 - 27 const PinCheckResult.lockedOut(DateTime retryAt) - 28 - : this._(PinCheck.lockedOut, retryAt: retryAt); - 28 + : this._(PinCheck.lockedOut, retryAt: retryAt); - 29 - 30 const PinCheckResult.unavailable() : this._(PinCheck.unavaila - ble); - 31 - ⎿  Updated lib/features/app_lock/presentation/app_lock_gate.dart (+4 -7) - 10 /// says so — so every way into the app lands under it. - 11 class AppLockGate extends ChangeNotifier with WidgetsBindingObs - erver { - 12 AppLockGate(this._repo, {DateTime Function()? clock}) - 13 - : _now = clock ?? DateTime.now, - 14 - // Locked from the start when a PIN is set. DI builds thi - -s after the - 15 - // settings box is open, so the flag is already readable - -here. - 16 - _locked = _repo.isEnabled; - 13 + : _now = clock ?? DateTime.now, - 14 + // Locked from the start when a PIN is set. DI builds t - +his after the - 15 + // settings box is open, so the flag is already readabl - +e here. - 16 + _locked = _repo.isEnabled; - 17 - 18 final AppLockRepository _repo; - 19 final DateTime Function() _now; - ... - 27 bool _observing = false; - 28 DateTime? _backgroundedAt; - 29 - 30 - - 30 /// True while the lock screen must cover the app. Turning th - e lock off in - 31 /// settings clears it at once; turning it on does not lock t - he session the - 32 /// user is in — they have just typed the PIN. - 33 bool get isLocked => _locked && _repo.isEnabled; - 34 - 36 - - 35 /// Starts watching the app's lifecycle. Idempotent. Called b - efore the first - 36 /// frame so this observer is registered ahead of the router' - s back-button - 37 /// handler and can swallow Back while locked (see [didPopRou - te]). - ... - 72 /// Back while locked would otherwise pop the pages hidden un - der the lock. - 73 @override - 74 Future didPopRoute() async => isLocked; - 77 - - 75 } - ⎿  Updated lib/features/app_lock/presentation/bloc/app_lock_bloc.dart - (+118 -92) - 10 enum AppLockMode { setup, verify, change } - 11 - 12 class AppLockBloc extends Bloc { - 13 - AppLockBloc({ - 14 - required AppLockRepository repository, - 15 - required this.mode, - 16 - }) : _repo = repository, - 17 - super(_initialFor(repository, mode)) { - 13 + AppLockBloc({required AppLockRepository repository, required - +this.mode}) - 14 + : _repo = repository, - 15 + super(_initialFor(repository, mode)) { - 16 on(_onStarted); - 17 on(_onLengthChosen); - 18 on(_onDigit); - ... - 56 final until = await _repo.lockedOutUntil(); - 57 if (until != null) _enterLockout(until, emit); - 58 if (_repo.isEnabled && !await _repo.isPinReadable()) { - 61 - emit(state.copyWith( - 62 - pinUnavailable: true, - 63 - errorTick: state.errorTick + 1, - 64 - errorMessage: 'app_lock.pin_unavailable', - 65 - )); - 59 + emit( - 60 + state.copyWith( - 61 + pinUnavailable: true, - 62 + errorTick: state.errorTick + 1, - 63 + errorMessage: 'app_lock.pin_unavailable', - 64 + ), - 65 + ); - 66 } - 67 } - 68 - 69 void _enterLockout(DateTime until, Emitter emit - ) { - 70 final seconds = until.difference(DateTime.now()).inSeconds - + 1; - 71 - emit(state.copyWith( - 72 - entered: '', - 73 - isProcessing: false, - 74 - retryAt: until, - 75 - errorTick: state.errorTick + 1, - 76 - errorMessage: 'app_lock.locked_out', - … 242 more lines (ctrl+o to expand) - ⎿  … 8 more files changed - ⎿  (timeout 10m) - - Ran 1 shell command (ctrl+o to expand) - -⏺ Gate asl holida (diff bo'sh), 1575 test o'tdi. Commit qilib, hot restart - beraman: - - Pushed to work/2026-09-14, ran 3 shell commands (ctrl+o to expand) - -⏺ Restart'dan keyin ham istisno bor — endi boshqa manbadan. Stekni o'qiyman: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Muammo topildi: Router o'zining birinchi build'ida routerDelegate'dan xabar - berib, overlay'ni build jarayonida qayta chizishga urinadi. Buning o'rniga - routeInformationProvider'ni tinglayman, chunki u build'dan oldin yaratiladi va - faqat navigatsiyada xabar beradi. - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - - @override - … +41 lines (ctrl+o to expand) - ⎿  Updated - lib/features/app_lock/presentation/widgets/app_lock_overlay.dart (+18 - -11) - 19 @override - 20 Widget build(BuildContext context) { - 21 final gate = getIt(); - 22 - final router = AppRouter.router.routerDelegate; - 23 - // Decided by which route is on screen, and not by a flag t - -he splash raises - 24 - // and lowers. That version raised it from `initState`, whi - -ch runs during a - 25 - // build, so the notification that should have uncovered th - -e splash threw - 26 - // "setState() called during build" instead and the PIN pad - - stayed up. It - 27 - // could not lower it safely either: `dispose` runs while t - -he tree is - 28 - // locked. Reading the route has neither problem, and it ca - -nnot leak — the - 29 - // frame the app leaves the splash, for whatever reason inc - -luding a deep - 30 - // link, is the frame this covers it again, with no timer t - -o wait out. - 22 + // The route information provider, and not the router deleg - +ate. - 23 + // - 24 + // Both say where the app is, but the delegate learns it du - +ring the - 25 + // Router's own first build — `setInitialRoutePath` runs fr - +om inside - 26 + // `didChangeDependencies` — and this overlay is the Router - +'s ANCESTOR, so - 27 + // being told then is "setState() called during build" on t - +he first frame - 28 + // of every launch. The provider is built with the initial - +location before - 29 + // anything is drawn, so its answer is right on frame one, - +and it only - 30 + // changes when something navigates, which never happens in - +side a build. - 31 + // - 32 + // Deciding by route rather than by a flag the splash raise - +s and lowers is - 33 + // deliberate too. That version raised it from `initState` - +— also inside a - 34 + // build — and lowered it from `dispose`, which runs while - +the tree is - 35 + // locked; neither end worked. A route cannot leak: the fra - +me the app - 36 + // leaves the splash, however it leaves, is the frame this - +covers it again. - 37 + final location = AppRouter.router.routeInformationProvider; - 38 return ListenableBuilder( - 32 - listenable: Listenable.merge([gate, router]), - 39 + listenable: Listenable.merge([gate, location]), - 40 builder: (context, _) { - 41 final locked = lockCovers( - 42 locked: gate.isLocked, - 36 - path: router.currentConfiguration.uri.path, - 43 + path: location.value.uri.path, - 44 ); - 45 return Stack( - 46 fit: StackFit.expand, - - Ran 1 shell command (ctrl+o to expand) - -⏺ Istisno yo'qoldi — restart toza. Endi splash haqiqatan PIN'dan oldin - chiqayotganini tekshiraman: - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Endi ishlayapti: 4–8-kadrlarda qalam S'ni yozyapti — nuqta → shtrix → ilmoq → - o'sib borayotgan S, ustida PIN yo'q. (1–3-kadrlar — restart tugashidan oldingi - eski holat.) - -⏺ Bash(export PATH="/Users/azamov/dev/flutter/bin:/opt/homebrew/bin:$PATH" && - dart format lib/features/app_lock >/dev/null && flutter test - test/features/app_lock/ 2>&1…) - ⎿  00:00 +4: All tests passed! - 049bb75..ed3f746 work/2026-09-14 -> work/2026-09-14 - ⎿  Updated lib/features/app_lock/presentation/app_lock_gate.dart (+4 -4) - 10 /// says so — so every way into the app lands under it. - 11 class AppLockGate extends ChangeNotifier with WidgetsBindingObs - erver { - 12 AppLockGate(this._repo, {DateTime Function()? clock}) - 13 - : _now = clock ?? DateTime.now, - 14 - // Locked from the start when a PIN is set. DI builds t - -his after the - 15 - // settings box is open, so the flag is already readabl - -e here. - 16 - _locked = _repo.isEnabled; - 13 + : _now = clock ?? DateTime.now, - 14 + // Locked from the start when a PIN is set. DI builds thi - +s after the - 15 + // settings box is open, so the flag is already readable - +here. - 16 + _locked = _repo.isEnabled; - 17 - 18 final AppLockRepository _repo; - 19 final DateTime Function() _now; - ⎿  (timeout 10m) - -⏺ Tuzatildi — endi splash PIN'dan oldin chiqadi, hot restart'da ham. Emulatorda - tekshirdim: qalam harfni yozyapti, ustida hech narsa yo'q, istisno ham yo'q. - ed3f746 push qilindi. - - Nega ko'rmadingiz — mening ikkita xatom bor edi: - - 1. Birinchi urinishim umuman ishlamagan. Qulfni SplashPage.initState ichida - ushlab turardim, u esa build paytida ishlaydi. Qulf xabar bergan zahoti - Flutter xato berardi (setState() called during build) va PIN ochilmay turib - qolardi. Unit testlar o'tgan edi, lekin ular build fazasini ko'rmaydi — - shuning uchun men "tayyor" degan edim. Aslida tayyor emas edi. - 2. Ikkinchi urinish ham xuddi shu xatoni boshqa joydan chiqardi. Router - dastlabki yo'lni o'zining birinchi build'i paytida o'rnatadi, overlay esa - Router'dan yuqorida turadi — demak xabar yana build paytida kelardi. Buni - testdan emas, stekni o'qib topdim. - - Hozirgisi: overlay route'ni routeInformationProvider orqali o'qiydi. U - boshlang'ich manzil bilan hech narsa chizilmasdan oldin yaratiladi, shuning - uchun birinchi kadrdayoq to'g'ri javob beradi, va faqat navigatsiya bo'lganda - o'zgaradi — build ichida hech qachon emas. Qulf yopiqligicha qoladi, u faqat - /splashning ustiga chizilmaydi. Splash'dan chiqilgan kadrning o'zidayoq (deep - link bilan chiqilsa ham) PIN qayta yopadi. - - Endi "hot restart" desangiz, darhol yuboraman. - -✻ Sautéed for 8m 52s · done 11:30 AM - -⏺ Monitor event: "Sozo run with the retimed splash" - -⏺ Hozircha kutyapman. Hot restart kerak bo'lsa, ayting — darhol yuboraman. - -✻ Baked for 13s · done 11:31 AM - -❯ go hot restart - - Ran 1 shell command (ctrl+o to expand) - -⏺ Hot restart bo'ldi (1.5s, xatosiz) — qarang. - -✻ Sautéed for 36s · done 11:33 AM -❯ splash loopga qoy aniq kamchiliklarni aytaman - - Searched for 1 pattern (ctrl+o to expand) - -❯ tekshirish uchun - - Ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Restarted application in 1,716ms. - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+16 - -2) - 116 }); - 117 } - 118 _c.addListener(_watch); - 119 - _c.forward().then((_) { - 120 - if (mounted) widget.onDone(); - 119 + _play(); - 120 + } - 121 + - 122 + // TEMPORARY review loop — replays forever so the animation - +can be judged. - 123 + // Remove before committing. - 124 + static const bool _reviewLoop = true; - 125 + - 126 + void _play() { - 127 + _c.forward(from: 0).then((_) async { - 128 + if (!mounted) return; - 129 + if (_reviewLoop) { - 130 + await Future.delayed(const Duration(milliseconds - +: 700)); - 131 + if (mounted) _play(); - 132 + return; - 133 + } - 134 + widget.onDone(); - 135 }); - 136 } - 137 - -⏺ Splash endi to'xtovsiz takrorlanadi — har aylanishdan keyin 0.7s qora pauza, - keyin qaytadan yoziladi. PIN chiqmaydi, ilova oldinga o'tmaydi. - - Bu vaqtincha — commit qilinmagan, faqat tekshirish uchun. Kamchiliklarni - ayting, tuzatib bo'lgach loop'ni olib tashlayman. - -✻ Worked for 1m 15s · done 11:34 AM - -❯ [Image #14] haliham bizni app logo kabi emasda chizilishdagi oddiy animation - quruq S ozi bolib qolgan boshqahca yanada yaxhsiroq nimadirlar qo`shishng - kerak demoqchiman shu tomonlarini ham to`liq ultrathink bilan implementation - qilib ber bizni logoni bilasan [Image #15] yani shu shu tmonlarni ham - to`liq qil demoqchiman tushundingmi ? shu - ⎿  [Image #14] - ⎿  [Image #15] - -⏺ Endi tushundim — va bu muhim tuzatish. Sizning logongiz tekis qizil S emas: u - S shaklidagi relyef, ichida ajdaho (tangachalar, tirnoqlar, olov) o'yib - ishlangan, shaftoli rang fon, burchagida "Sozo" yozuvi. Men esa uning faqat - siluetini chizib, ichini bitta rang bilan to'ldirganman — logoning eng muhim - qismi, ajdahoning o'zi yo'q. Avval asl rasmni topaman: - - Read 1 file, ran 2 shell commands (ctrl+o to expand) - -⏺ iOS'dagisi hali Flutter'ning standart ikonkasi ekan (bu alohida muammo — iOS - build'da Sozo logosi yo'q). Android'dagisini ko'ray: - - Read 2 files, ran 1 shell command (ctrl+o to expand) - -⏺ Manbani topdim — yuborilgan rasm (#15) eng rangdor variant, unda ajdaho - relyefi, tangachalar va "Sozo" yozuvi aniq ko'rinadi. Endi shu rasmni SVG - siluetiga piksel aniqligida moslashtirmoqchiman, toki qalam yozgan joyda aynan - ajdaho ochilsin. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Nisbatlar deyarli bir xil (1.021 va 1.025) — demak aynan bitta shakl, faqat - masshtabi boshqa. Endi IoU bo'yicha aniq moslashtiraman: - - Ran 1 shell command (ctrl+o to expand) - -⏺ IoU 0.962 — piksel darajasida mos (qolgan 4% — anti-aliasing va chetdagi - bevel). Asset'ni yasab, ustiga SVG konturini qo'yib ko'z bilan tekshiraman: - -⏺ Bash(python3 - <<'PY' - import re, io, os…) - ⎿  png 283 KB jpg 81 KB - ⎿  1 file changed (binary, mode only or too large to show) - - Read 1 file (ctrl+o to expand) - -⏺ SVG kontur ajdaho relyefiga aniq tushdi, endi glow va uchqunlar uchun logoning - o'z ranglarini ishlataman (brend rangi o'zgarmasin). - - Ran 1 shell command (ctrl+o to expand) - -⏺ Palitra tayyor (ajdaho qizil, shaftoli fon). Endi qalamni logoning o'zi bilan - (ImageShader) bo'yab, uchqun va cho'g' effektlari, yorug'lik va nafas - animatsiyasini qo'shaman, kontur tebranishini esa olib tashlayman. - - Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - geometry ok - Try `flutter pub outdated` for more information. - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/core/brand/sozo_mark_geometry.dart (+27 -0) - 85 - 86 static SozoMarkGeometry? _loaded; - 87 static Future? _loading; - 88 + static ui.Image? _art; - 89 + - 90 + /// The logo's own artwork: the dragon relief inside the let - +ter, the peach - 91 + /// ground, the small "Sozo" in the top arm. - 92 + /// - 93 + /// Registered to the same 512 box as [body], at twice the r - +esolution, so a - 94 + /// shader that samples it through any path in that box land - +s on the right - 95 + /// piece of the picture. It was aligned by maximising the o - +verlap between - 96 + /// the artwork's letter and [body] rather than by eye — 0.9 - +6 intersection - 97 + /// over union, the rest being the artwork's own bevel and a - +nti-aliasing. - 98 + /// - 99 + /// This is the logo. [body] is only its outline, and a spla - +sh that filled - 100 + /// the outline with a flat colour was drawing the one part - +of the logo that - 101 + /// is not the logo. - 102 + static ui.Image? get art => _art; - 103 + - 104 + static const String artAsset = 'assets/brand/sozo_logo_art.p - +ng'; - 105 - 106 /// The geometry, once it has been read. Null before that. - 107 /// - ... - 118 static Future precache() => _loading ??= _read(); - 119 - 120 static Future _read() async { - 121 + // Separately guarded: an artwork that will not decode cos - +ts the splash its - 122 + // texture, not its geometry. It falls back to a flat lett - +er, which is a - 123 + // worse splash and still a splash. - 124 + try { - 125 + final bytes = await rootBundle.load(artAsset); - 126 + final codec = await ui.instantiateImageCodec(bytes.buffe - +r.asUint8List()); - 127 + _art = (await codec.getNextFrame()).image; - 128 + } catch (_) { - 129 + _art = null; - 130 + } - 131 try { - 132 final svg = await rootBundle.loadString(asset); - 133 final body = _pathNamed(svg, 'body'); - ⎿  Updated lib/main.dart (+12 -9) - 57 /// is simply empty there. - 58 void main(List args) async { - 59 WidgetsFlutterBinding.ensureInitialized(); - 60 + // Started first and awaited last. The splash needs the mark' - +s geometry AND - 61 + // its artwork decoded before its first frame, and the artwor - +k is a 1024px - 62 + // image; begun here it decodes while Hive, DI and the rest o - +f startup run, - 63 + // instead of adding its own time on top of theirs. - 64 + final brand = SozoMarkGeometry.precache(); - 65 await initTvPlatform(); - 66 if (isDesktopPlatform) { - 67 MediaKit.ensureInitialized(); - ... - 224 // the one call site that matters. - 225 root = LiquidGlassWidgets.wrap(child: root, adaptiveQualit - y: true); - 226 } - 222 - // Before the first frame, and worth the wait it is not. - 223 - // - 224 - // The splash is the first thing this process draws, and it - -draws the mark as - 225 - // geometry — so the parse has to be done before it starts, - -not scheduled - 226 - // alongside it. It is one bundle string and one path walk, - -measured at a - 227 - // couple of milliseconds; awaited here, every launch is the - - animated one. - 228 - // Started after `runApp`, exactly one launch per install — - -the cold one - 229 - // everybody's first impression is made of — would fall back - - to a flat logo. - 230 - await SozoMarkGeometry.precache(); - 227 + // Before the first frame. The splash is the first thing thi - +s process draws - 228 + // and it draws the mark from geometry and artwork, so both - +have to exist - 229 + // when it starts rather than arrive alongside it. Started a - +t the top of - 230 + // `main`, so by here it has usually finished. Not awaited, - +exactly one - 231 + // launch per install — the cold one everybody's first impre - +ssion is made - 232 + // of — would fall back to a flat logo. - 233 + await brand; - 234 - 235 runApp(root); - 236 - -⏺ Hozir splash rassomini qayta yozyapman: qalam yozgan joyda ajdaho relyefi - ochiladi, uchqunlar, cho'g'lar va yorug'lik o'tishi bilan. - -⏺ Bash(cat > lib/features/splash/presentation/widgets/sozo_splash.dart <<'DART' - import 'dart:math' as math;…) - ⎿  Analyzing splash... - - info • Parameter 'repaint' could be a super parameter. Trying conve - … +4 lines (ctrl+o to expand) - ⎿  1 file changed (binary, mode only or too large to show) - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  No issues found! (ran in 3.1s) - Try `flutter pub outdated` for more information. - 00:00 +0: loading /Users/azamov/Sozo-Full/sozo/test/splash_probe_test. - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+2 - -2) - 210 - 211 class _LogoPainter extends CustomPainter { - 212 _LogoPainter({ - 213 - Listenable? repaint, - 213 + super.repaint, - 214 required this.geometry, - 215 required this.art, - 216 required this.background, - 217 required this.clock, - 218 this.still = false, - 219 - }) : super(repaint: repaint) { - 219 + }) { - 220 final image = art; - 221 if (image != null) { - 222 // The artwork is registered to the same 512 box as the - paths, at 1024, - ⎿  Created test/splash_probe_test.dart (+40 -0) - 1 +import 'dart:io'; - 2 +import 'dart:ui' as ui; - 3 +import 'package:flutter/material.dart'; - 4 +import 'package:flutter/rendering.dart'; - 5 +import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 +import 'package:soplay/features/splash/presentation/widgets/soz - +o_splash.dart'; - 8 + - 9 +void main() { - 10 + TestWidgetsFlutterBinding.ensureInitialized(); - 11 + testWidgets('dump', (tester) async { - 12 + await tester.runAsync(SozoMarkGeometry.precache); - 13 + expect(SozoMarkGeometry.art, isNotNull, reason: 'artwork di - +d not decode'); - 14 + final key = GlobalKey(); - 15 + tester.view.physicalSize = const Size(440, 440); - 16 + tester.view.devicePixelRatio = 1.0; - 17 + addTearDown(tester.view.reset); - 18 + await tester.pumpWidget(MaterialApp( - 19 + debugShowCheckedModeBanner: false, - 20 + home: RepaintBoundary( - 21 + key: key, - 22 + child: SozoSplash(onSettled: () {}, onDone: () {}), - 23 + ), - 24 + )); - 25 + const stops = [300, 550, 800, 1050, 1250, 1450, 1650, 1900, - + 2150, 2400]; - 26 + var last = 0; - 27 + for (final ms in stops) { - 28 + await tester.pump(Duration(milliseconds: ms - last)); - 29 + last = ms; - 30 + final b = key.currentContext!.findRenderObject()! as Rend - +erRepaintBoundary; - 31 + await tester.runAsync(() async { - 32 + final img = await b.toImage(); - 33 + final d = await img.toByteData(format: ui.ImageByteForm - +at.png); - 34 + File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/' - 35 + '018dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/w_ - +$ms.png') - 36 + .writeAsBytesSync(d!.buffer.asUint8List()); - 37 + }); - 38 + } - 39 + }); - 40 +} - ⎿  (timeout 10m) - - Read 1 file (ctrl+o to expand) - -⏺ Endi haqiqiy logo: qalam yozgan joyda ajdaho relyefi (to'q sariq, shaftoli - dog'lar) ochilyapti, keyin to'ladi, orqada nur, yuqori qirralardan cho'g'lar - ko'tariladi. Yaqindan ko'raman: - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Testni qo'shib commit qilaman, so'ng ko'rishingiz uchun lokalda loop'ni yoqib - hot restart beraman. - -⏺ Bash(rm -f test/splash_probe_test.dart && python3 - <<'PY' - import io…) - ⎿  00:00 +9: (tearDownAll) - 00:00 +9: All tests passed! - ⎿  Updated test/core/brand/sozo_mark_geometry_test.dart (+15 -0) - 17 - 18 setUpAll(() async { - 19 await SozoMarkGeometry.precache(); - 20 + // Image decoding completes on a real event loop, not the f - +ake one. - 21 final value = SozoMarkGeometry.value; - 22 expect(value, isNotNull, reason: 'the asset did not parse') - ; - 23 geometry = value!; - ... - 114 await SozoMarkGeometry.precache(); - 115 expect(identical(SozoMarkGeometry.value, first), isTrue); - 116 }); - 117 + - 118 + group('the artwork', () { - 119 + // The logo is not its outline. A splash that filled the o - +utline with one - 120 + // colour drew the only part of the logo that is not the l - +ogo, so the - 121 + // artwork decoding is a precondition, not a nicety. - 122 + testWidgets('decodes', (tester) async { - 123 + await tester.runAsync(SozoMarkGeometry.precache); - 124 + final art = SozoMarkGeometry.art; - 125 + expect(art, isNotNull, reason: 'the logo artwork did not - + decode'); - 126 + // Twice the 512 box the paths live in, so one scale reg - +isters it. - 127 + expect(art!.width, 1024); - 128 + expect(art.height, 1024); - 129 + }); - 130 + }); - 131 } - ⎿  Deleted test/splash_probe_test.dart (+0 -40) - 1 -import 'dart:io'; - 2 -import 'dart:ui' as ui; - 3 -import 'package:flutter/material.dart'; - 4 -import 'package:flutter/rendering.dart'; - 5 -import 'package:flutter_test/flutter_test.dart'; - 6 -import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 -import 'package:soplay/features/splash/presentation/widgets/soz - -o_splash.dart'; - 8 - - 9 -void main() { - 10 - TestWidgetsFlutterBinding.ensureInitialized(); - 11 - testWidgets('dump', (tester) async { - 12 - await tester.runAsync(SozoMarkGeometry.precache); - 13 - expect(SozoMarkGeometry.art, isNotNull, reason: 'artwork di - -d not decode'); - 14 - final key = GlobalKey(); - 15 - tester.view.physicalSize = const Size(440, 440); - 16 - tester.view.devicePixelRatio = 1.0; - 17 - addTearDown(tester.view.reset); - 18 - await tester.pumpWidget(MaterialApp( - 19 - debugShowCheckedModeBanner: false, - 20 - home: RepaintBoundary( - 21 - key: key, - 22 - child: SozoSplash(onSettled: () {}, onDone: () {}), - 23 - ), - 24 - )); - 25 - const stops = [300, 550, 800, 1050, 1250, 1450, 1650, 1900, - - 2150, 2400]; - 26 - var last = 0; - 27 - for (final ms in stops) { - 28 - await tester.pump(Duration(milliseconds: ms - last)); - 29 - last = ms; - 30 - final b = key.currentContext!.findRenderObject()! as Rend - -erRepaintBoundary; - 31 - await tester.runAsync(() async { - 32 - final img = await b.toImage(); - 33 - final d = await img.toByteData(format: ui.ImageByteForm - -at.png); - 34 - File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/' - 35 - '018dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/w_ - -$ms.png') - 36 - .writeAsBytesSync(d!.buffer.asUint8List()); - 37 - }); - 38 - } - 39 - }); - 40 -} - ⎿  (timeout 10m) -⏺ Bash(sed -i '' 's/ static const bool _reviewLoop = true;/ static const bool - _reviewLoop = false;/' - lib/features/splash/presentation/widgets/sozo_splash.dart && exp…) - ⎿  Analyzing sozo... - No issues found! (ran in 18.1s) - 00:46 +1576: /Users/azamov/Sozo-Full/sozo/test/features/sources/source - … +2 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/pages/splash_page.dart (+0 - -1) - 15 } - 16 - 17 class _SplashPageState extends State { - 18 - - 18 /// Where to go, decided while the animation is still finishi - ng. - 19 /// - 20 /// The lock check reads a file and the onboarding check read - s Hive. Starting - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+36 - -16) - 67 - 68 // TEMPORARY review loop — replays forever so the animation c - an be judged. - 69 // Must be false in any commit. - 70 - static const bool _reviewLoop = true; - 70 + static const bool _reviewLoop = false; - 71 - 72 @override - 73 void initState() { - ... - 130 final size = math.min(MediaQuery.sizeOf(context).shortestS - ide * 0.5, 260.0); - 131 - 132 if (geometry == null) { - 133 - return _Stage(child: SozoMark(size: size, color: AppColo - -rs.primary)); - 133 + return _Stage( - 134 + child: SozoMark(size: size, color: AppColors.primary), - 135 + ); - 136 } - 137 - 138 // Reduce motion gets the logo, not the performance: the f - inished relief - ... - 228 image, - 229 TileMode.clamp, - 230 TileMode.clamp, - 229 - Matrix4.diagonal3Values(512 / image.width, 512 / image - -.height, 1) - 230 - .storage, - 231 + Matrix4.diagonal3Values( - 232 + 512 / image.width, - 233 + 512 / image.height, - 234 + 1, - 235 + ).storage, - 236 filterQuality: FilterQuality.medium, - 237 ); - 238 } else { - ... - 298 ..shader = ui.Gradient.linear( - 299 const Offset(-70, -70), - 300 const Offset(70, 70), - 296 - const [ - 297 - Color(0x00FFFFFF), - 298 - Color(0x7AFFFFFF), - 299 - Color(0x00FFFFFF), - 300 - ], - 301 + const [Color(0x00FFFFFF), Color(0x7AFFFFFF), Color(0x00F - +FFFFF)], - … 76 more lines (ctrl+o to expand) - ⎿  (timeout 10m) - - Pushed to work/2026-09-14 (ctrl+o to expand) - -⏺ Commit qilindi (ce66d99, loop o'chiq). Endi ko'rishingiz uchun loop'ni lokal - yoqib, hot restart: - -⏺ Bash(sed -i '' 's/ static const bool _reviewLoop = false;/ static const bool - _reviewLoop = true;/' - lib/features/splash/presentation/widgets/sozo_splash.dart && S=/…) - ⎿  attach alive - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+1 - -1) - 67 - 68 // TEMPORARY review loop — replays forever so the animation c - an be judged. - 69 // Must be false in any commit. - 70 - static const bool _reviewLoop = false; - 70 + static const bool _reviewLoop = true; - 71 - 72 @override - 73 void initState() { - - Ran 4 shell commands (ctrl+o to expand) - -⏺ Hot restart yangi asset'ni (logo rasmi) sinxronlashda qotib qoldi. Yangi asset - bor bo'lgani uchun to'liq build qilaman — ishonchliroq: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Monitor(Full build with the logo splash) - ⎿  Monitor started · task bh1024dwm · timeout 900s - -⏺ To'liq build ketyapti, tayyor bo'lgach splash takrorlanib turadi — ko'rib - chiqasiz. - - Nima o'zgardi — endi bu haqiqatan sizning logongiz: - - Asosiy xato shu edi: logongiz tekis qizil S emas, u relyef: shaftoli fonda - o'yilgan to'q sariq ajdaho (tangachalar, tirnoqlar, olov), tepa qo'lida kichik - "Sozo". Avvalgi splash'larning barchasi faqat konturni chizib, ichini bitta - rang bilan to'ldirgan — ya'ni logoning logo bo'lmagan yagona qismini chizgan. - - - Logoning o'z rasmi asset sifatida qo'shildi va animatsiya yo'llariga piksel - aniqligida moslashtirildi (IoU 0.962). Siz yuborgan eng boy rangli - versiyadan olindi. - - Qalam ajdahoni ochib yozadi: shtrix rasm bilan bo'yaladi (ImageShader), - qalam qayerdan o'tsa — o'sha yerda ajdaho paydo bo'ladi. Chetida "qizigan" - olov jiyagi, qalam uchidan uchqunlar sachraydi. - - Harf to'lgach relyef tirik qoladi: ustidan yorug'lik o'tadi — o'yilgan - tangachalar yaltiraydi (overlay: yorug' yuzalar ko'tariladi, qorong'ilari - chuqurlashadi), yuqori qirralardan cho'g'lar ko'tariladi, orqada iliq nur - nafas oladi. - - Olov rangi rasmning o'zidan olingan, accent'dan emas: ko'k tema tanlagan - odamda ko'k olov purkayotgan ko'k ajdaho — bu logo emas. - - Kadrma-kadr chizib tekshirdim; 1576 test o'tdi; ce66d99 push qilindi (loop - o'chiq holda — hozir faqat lokal yoqilgan). - - Bir yon kuzatuv: iOS'ning 1024 ikonkasi hali ham Flutter'ning standart logosi - — iOS build Sozo logosisiz chiqyapti. Keyin to'g'rilash kerak. - -✻ Baked for 17m 46s · done 11:56 AM - -⏺ Monitor event: "Full build with the logo splash" - - Ran 1 shell command (ctrl+o to expand) - -❯ ajoyib yanada yaxshiroq qil ko`rdm - - Read 1 file (ctrl+o to expand) - -❯ ultrathink bilan yanada improve - -⏺ Qurilmada animatsiya to'g'ri ishlayapti — endi eng ta'sirli yaxshilanishlarni - qo'shaman: qalam ortidan qiziган-sovuvchi iz va olov tillari, o'yilgan logo - effekti uchun soya/bevel yorug'lik, qo'nishda chaqnash va zarba to'lqini, - kattalashib so'nuvchi chiqish, hamda ko'proq va yorqinroq cho'g'lar. - - Ran 1 shell command (ctrl+o to expand) - -❯ ajdaho effectlari tugash qismda shularni ham qarab chiq - -⏺ Skript bitta joyda to'xtadi (hech narsa yozilmadi) — format o'zgargan bo'lsa - kerak. Tekshiraman: - - Searched for 2 patterns (ctrl+o to expand) - -⏺ Formatlash xatosini to'g'rilayman va yangi talabingizni — yakuniy aktga ajdaho - olovi umurtqa bo'ylab yugurishi va dum uchidan nafas otilishi effektlarini — - qo'shaman. - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 1 package is discontinued. - 146 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart - (+273 -42) - 189 static const int fillTo = 1620; - 190 static const int landFrom = 1560; - 191 static const int landTo = 1800; - 192 - static const int sheenFrom = 1720; - 193 - static const int sheenTo = 2420; - 192 + /// Fire running the length of the dragon, head to tail, onc - +e it has landed. - 193 + static const int runFrom = 1900; - 194 + static const int runTo = 2520; - 195 + - 196 + /// And breathed out of the tail as it goes. - 197 + static const int burstFrom = 2380; - 198 static const int embersFrom = 1500; - 195 - static const int fadeFrom = 2560; - 196 - static const int total = 2800; - 199 + static const int fadeFrom = 2660; - 200 + static const int total = 2920; - 201 } - 202 - 203 /// The black field the logo sits on. Separated so both branch - es share it and - ... - 248 for (var i = 0; i < SozoMarkGeometry.outlineSamples; i++) - { - 249 if (geometry.normalY[i] < -0.35) _tops.add(i); - 250 } - 251 + // The bevel: the upward-facing edges, pulled a little ins - +ide the letter, - 252 + // as runs of line. Built once — it is the same edge on ev - +ery frame. - 253 + var open = false; - 254 + for (var i = 0; i < SozoMarkGeometry.outlineSamples; i++) - +{ - 255 + final up = geometry.normalY[i] < -0.3; - 256 + final x = geometry.outlineX[i] - geometry.normalX[i] * 2 - +.5; - 257 + final y = geometry.outlineY[i] - geometry.normalY[i] * 2 - +.5; - 258 + if (up && !open) { - 259 + _bevel.moveTo(x, y); - 260 + open = true; - 261 + } else if (up) { - 262 + _bevel.lineTo(x, y); - 263 + } else { - 264 + open = false; - 265 + } - 266 + } - 267 + _shadowPath = geometry.body.shift(const Offset(0, 12)); - 268 } - … 354 more lines (ctrl+o to expand) - -⏺ Bash(cat > test/splash_probe_test.dart <<'DART' - import 'dart:io';…) - ⎿  00:00 +0: loading /Users/azamov/Sozo-Full/sozo/test/splash_probe_test. - dart - 00:00 +0: dump - … +2 lines (ctrl+o to expand) - ⎿  Created test/splash_probe_test.dart (+36 -0) - 1 +import 'dart:io'; - 2 +import 'dart:ui' as ui; - 3 +import 'package:flutter/material.dart'; - 4 +import 'package:flutter/rendering.dart'; - 5 +import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 +import 'package:soplay/features/splash/presentation/widgets/soz - +o_splash.dart'; - 8 + - 9 +void main() { - 10 + TestWidgetsFlutterBinding.ensureInitialized(); - 11 + testWidgets('dump', (tester) async { - 12 + await tester.runAsync(SozoMarkGeometry.precache); - 13 + final key = GlobalKey(); - 14 + tester.view.physicalSize = const Size(440, 440); - 15 + tester.view.devicePixelRatio = 1.0; - 16 + addTearDown(tester.view.reset); - 17 + await tester.pumpWidget(MaterialApp( - 18 + debugShowCheckedModeBanner: false, - 19 + home: RepaintBoundary(key: key, child: SozoSplash(onSettl - +ed: () {}, onDone: () {})), - 20 + )); - 21 + const stops = [500, 900, 1250, 1500, 1700, 1850, 2050, 2250 - +, 2450, 2600, 2750, 2850]; - 22 + var last = 0; - 23 + for (final ms in stops) { - 24 + await tester.pump(Duration(milliseconds: ms - last)); - 25 + last = ms; - 26 + final b = key.currentContext!.findRenderObject()! as Rend - +erRepaintBoundary; - 27 + await tester.runAsync(() async { - 28 + final img = await b.toImage(); - 29 + final d = await img.toByteData(format: ui.ImageByteForm - +at.png); - 30 + File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/' - 31 + '018dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/x_ - +$ms.png') - 32 + .writeAsBytesSync(d!.buffer.asUint8List()); - 33 + }); - 34 + } - 35 + }); - 36 +} - ⎿  (timeout 10m) - -⏺ Monitor event: "Full build with the logo splash" - - Read 1 file (ctrl+o to expand) - -⏺ Hikoya o'qiladi: olov bilan yoziladi → muhr (chaqnash + to'lqin) → olov ajdaho - tanasi bo'ylab yuguradi → dumdan nafas chiqadi → kattalashib so'nadi. Ikki - kadrni yaqindan tekshiraman: - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Animatsiya sifatli chiqdi: muhr to'lqini S shaklida tarqaladi, o'rta chiziqda - olov yorqin yuguradi, uchqunlar ko'tariladi va dumdan cho'g'lar otiladi. Endi - commit qilaman (loop o'chiq holatda), so'ng ko'rishingiz uchun loop yoqilgan - holda hot restart qilaman. - -⏺ Bash(rm -f test/splash_probe_test.dart && sed -i '' 's/ static const bool - _reviewLoop = true;/ static const bool _reviewLoop = false;/' - lib/features/splash/present…) - ⎿  No issues found! (ran in 10.6s) - 00:33 +1576: All tests passed! - 1 - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+2 - -1) - 67 - 68 // TEMPORARY review loop — replays forever so the animation c - an be judged. - 69 // Must be false in any commit. - 70 - static const bool _reviewLoop = true; - 70 + static const bool _reviewLoop = false; - 71 - 72 @override - 73 void initState() { - ... - 189 static const int fillTo = 1620; - 190 static const int landFrom = 1560; - 191 static const int landTo = 1800; - 192 + - 193 /// Fire running the length of the dragon, head to tail, onc - e it has landed. - 194 static const int runFrom = 1900; - 195 static const int runTo = 2520; - ⎿  Deleted test/splash_probe_test.dart (+0 -36) - 1 -import 'dart:io'; - 2 -import 'dart:ui' as ui; - 3 -import 'package:flutter/material.dart'; - 4 -import 'package:flutter/rendering.dart'; - 5 -import 'package:flutter_test/flutter_test.dart'; - 6 -import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 -import 'package:soplay/features/splash/presentation/widgets/soz - -o_splash.dart'; - 8 - - 9 -void main() { - 10 - TestWidgetsFlutterBinding.ensureInitialized(); - 11 - testWidgets('dump', (tester) async { - 12 - await tester.runAsync(SozoMarkGeometry.precache); - 13 - final key = GlobalKey(); - 14 - tester.view.physicalSize = const Size(440, 440); - 15 - tester.view.devicePixelRatio = 1.0; - 16 - addTearDown(tester.view.reset); - 17 - await tester.pumpWidget(MaterialApp( - 18 - debugShowCheckedModeBanner: false, - 19 - home: RepaintBoundary(key: key, child: SozoSplash(onSettl - -ed: () {}, onDone: () {})), - 20 - )); - 21 - const stops = [500, 900, 1250, 1500, 1700, 1850, 2050, 2250 - -, 2450, 2600, 2750, 2850]; - 22 - var last = 0; - 23 - for (final ms in stops) { - 24 - await tester.pump(Duration(milliseconds: ms - last)); - 25 - last = ms; - 26 - final b = key.currentContext!.findRenderObject()! as Rend - -erRepaintBoundary; - 27 - await tester.runAsync(() async { - 28 - final img = await b.toImage(); - 29 - final d = await img.toByteData(format: ui.ImageByteForm - -at.png); - 30 - File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/' - 31 - '018dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/x_ - -$ms.png') - 32 - .writeAsBytesSync(d!.buffer.asUint8List()); - 33 - }); - 34 - } - 35 - }); - 36 -} - ⎿  (timeout 10m) - -⏺ Bash(git add -A && git -c user.name="Azamov X" -c - user.email="108933534+professorDeveloper@users.noreply.github.com" commit - -q -F - <<'MSG' && git push origin work/2…) - ⎿   ce66d99..57a6562 work/2026-09-14 -> work/2026-09-14 - ⎿  Updated lib/features/splash/presentation/widgets/sozo_splash.dart (+1 - -1) - 67 - 68 // TEMPORARY review loop — replays forever so the animation c - an be judged. - 69 // Must be false in any commit. - 70 - static const bool _reviewLoop = false; - 70 + static const bool _reviewLoop = true; - 71 - 72 @override - 73 void initState() { - ⎿  (timeout 2m) - - Ran 2 shell commands (ctrl+o to expand) - -⏺ Hot restart kanalga javob bermayapti — toza qilib qayta ishga tushiraman (loop - yoqilgan holda): - - Ran 1 shell command (ctrl+o to expand) -⏺ Monitor event: "Full build with the logo splash" - -⏺ Monitor(Fresh run with the dragon finale) - ⎿  Monitor started · task b37fgkcmj · timeout 900s - -⏺ Build ketyapti — tayyor bo'lganda splash yangi yakuniy akt bilan takrorlanib - turadi. 57a6562 push qilindi (loop o'chiq holda; hozir faqat ko'rish uchun - lokal yoqilgan). - - Yangi ketma-ketlik (2.9s): - - 1. Olov bilan yoziladi — qalam orqasidagi iz oq-issiq qolib, to'q sariqqa - sovib relyefga aylanadi. Ya'ni endi "uzayayotgan teksturali trubka" emas, - ajdaho kuydirilib paydo bo'ladi. Qalam uchida titrayotgan olov tillari - (sekundiga ~20 marta — olov silliq harakatlanmaydi, silliq harakatlangan - olov "blob"ga o'xshaydi). - 2. Chuqurlik — harf qotgach ostida yumshoq soya, yuqori qirralarda bevel - yaltirashi: yopishtirilgan rasm emas, o'yilgan buyum. - 3. Muhr — qo'nganda chetlar bo'ylab chaqnash va harf shaklidagi to'lqin - tarqaladi. - 4. Ajdahoning olovi tanasi bo'ylab yuguradi — boshdan dumgacha; o'tgan joyda - o'yma yaltiraydi, yuqoriga uchqunlar ko'tariladi. (Avvalgi diagonal - yorug'lik har qanday logoga mos keladigan umumiy effekt edi — endi bu - ajdahoning o'zi.) - 5. Nafas chiqarish — dum uchidan, shtrix yo'nalishida cho'g'lar otilib - chiqadi: kuchli boshlanib, sekinlashadi. - 6. Chiqish — logo tomoshabinga yaqinlashib so'nadi: ilovaga "kirib ketish". - - Kamchiliklarni ayting. - -✻ Crunched for 10m 58s · done 12:07 PM - -⏺ Monitor event: "Fresh run with the dragon finale" - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Tayyor — emulatorda yangi splash takrorlanib turibdi. Qurilmada ham to'g'ri - chizilyapti: chapda dumdan nafas chiqishi (cho'g'lar), o'rtada olov bilan - yozish (issiq iz), o'ngda muhr to'lqini. - - Qarab chiqing va kamchiliklarni ayting — tuzatib bo'lgach loop'ni olib - tashlayman. - -✻ Crunched for 1m 7s · done 12:09 PM - -⏺ Monitor event: "Full build with the logo splash" - -⏺ Hozircha kutyapman — fikringizni ayting. - -✻ Crunched for 17s · done 12:11 PM - -❯ juda kop galatiroq qivorgansan biroz yaxshiroq qil ultracode bilan yanada - yaxshiroq style qil design jihatdan chuqurroq narsa ishlat to`liq qilib ber - -⏺ Tushundim — ortiqcha bo'lib ketgan: uchqunlar, chaqnash, to'lqin, olov - yugurishi, nafas — hammasi birga shovqin. Premium splash'lar odatda bitta - asosiy harakat + bitta ikkinchi darajali urg'u bilan ishlaydi. Ultracode - bilan: avval yangi ko'z bilan tanqid, keyin 3 xil nafis yo'nalish (har biri - alohida worktree'da haqiqiy kod va chizilgan kadrlar bilan), keyin hakamlar - kadrlarga qarab tanlaydi. - -⏺ Workflow(Critique the busy Sozo splash, design three restrained premium - directions in worktrees with rendered frames, and judge them) - ⎿  /workflows to view dynamic workflow runs - -⏺ Ultracode ishga tushdi, fonda ishlayapti: - - 1. Tanqid — bir agent hozirgi splash'ni kadrma-kadr chizib, tajribali - motion-dizayner ko'zi bilan baholaydi: qaysi effekt kesiladi, qaysi biri - qoladi va nega. - 2. Uch yo'nalish — har biri alohida git worktree'da haqiqiy kod yozib, - kadrlarni chizib, ko'zi bilan ko'rib, kamida ikki marta tuzatadi: - - Sokin hunarmandlik — bitta qalam shtrixi, mukammal easing, uchqunsiz; - yagona urg'u — o'yma relyef ustidan sekin o'tuvchi yorug'lik. - Apple/Netflix maktabi: cheklovning o'zi hashamat. - - Qorong'ilikdan yoritish — logo allaqachon bor, lekin yoritilmagan; - yumshoq yorug'lik manbai harakatlanib relyefni yoritadi — muzeydagi o'yma - buyum yoritilgandek. Material va chuqurlik hissi, zarrachasiz. - - Bitta ajdaho harakati — qalam erigan chet bilan yozadi (asosiy harakat), - keyin bir marta, sekin, 6 tadan kam katta cho'g' ko'tarilib yo'qoladi. - Olov — shivirlab, feyerverk emas. - 3. Hakamlar — biri title-sequence qilgan art-direktor ko'zi bilan, biri - motion-muhandis ko'zi bilan (sifat, tezlik, kod tayyorligi). Ikkalasi ham - chizilgan kadrlarga qarab baholaydi, ta'riflarga emas. - - Natija kelganda g'olibini asosiy daraxtga olib, qurilmada ko'rsataman. - -✻ Brewed for 1m 49s · done 12:13 PM - -❯ ichidagi ajdaholarni ham nimadirlar qiladigan harakatlar darajasida qilib ber - zo`r boladi shunda - -⏺ Zo'r g'oya — va bu aslida "dizayn jihatdan chuqurroq" narsaning aynan o'zi. - Hozirgi barcha effektlar logoning atrofida bo'lyapti; siz esa logoning - ichidagi ajdaho harakatlanishini xohlayapsiz: tanasi to'lqinlanib, olovi - titrab, S siluet va shaftoli fon esa joyida qolsin. - - Buning to'g'ri usuli — fragment shader: rasmda ajdahoni rang bo'yicha ajratib - (to'q sariq = ajdaho, shaftoli = fon), faqat ajdaho qatlamiga harakatlanuvchi - deformatsiya berish. Workflow tugagach, g'olib dizayn ustiga shuni qo'shaman — - u "bitta asosiy harakat + bitta urg'u" qoidasiga ham mos: uchqunlar o'rniga - ichidagi ajdahoning o'zi jonlanadi. - - Shu orada zamin tayyorlayman — ajdahoni fondan toza ajratish mumkinmi, - tekshiraman: - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Ajratish aniq chiqdi: to'q sariq — ajdaho (68%), to'q ko'k — fon va tirnoq - bo'shliqlari. Demak shader faqat ajdaho qatlamini siljita oladi; endi Flutter - shader koordinatalarini tekshiraman, chunki bu yerda odatiy tuzoq bo'lishi - mumkin. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Shader koordinatalari Impeller va Skia'da farq qilishini aniqladim, shuning - uchun ikkala backend'da bir xil ishlaydigan drawVertices mesh deformatsiyasiga - o'taman. Endi ajdaho qatlamini tayyorlayman — u harfning umurtqasi bo'ylab S - shaklida to'lqinlanadi, ostidagi asl rasm esa o'zgarishsiz qoladi. - -⏺ Bash(python3 - <<'PY' - import os…) - ⎿  dragon layer 349 KB - ⎿  1 file changed (binary, mode only or too large to show) - - Read 1 file (ctrl+o to expand) - -⏺ Rasm aniq ko'rindi: S shaklidagi tana, cho'zilgan tirnoqli qo'l va olovli - shoxli bosh - shuning uchun harakatlantirish mumkin (tana to'lqinlanishi, qo'l - cho'zilishi, bosh ko'tarilishi). Endi bosh va qo'lning aniq koordinatalarini - topaman. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Ajdahoning boshi va qo'li koordinatalarini aniqlayapman — keyin har biriga - alohida harakat beraman. - - Read 1 file (ctrl+o to expand) - -⏺ Anatomiyani aniqladim: tirnoqlar (~315, 232) va shoxli bosh (~368, 232), - bo'yni ~(345, 245) atrofida. Workflow natijasini kutib, "jonli ajdaho" - modulini alohida faylga yozaman — qaysi dizayn g'olib bo'lsa ham unga toza - ulanadigan qilib. - -⏺ Bash(cat > lib/features/splash/presentation/widgets/dragon_life.dart <<'DART' - import 'dart:math' as math;…) - ⎿   error • Undefined name 'Matrix4'. Try correcting the name to one tha - t is defined, or defining the name • lib/features/splash/presentation/ - widgets/dragon_life.dart:45:9 • undefined_identifier - … +2 lines (ctrl+o to expand) - ⎿  Created lib/features/splash/presentation/widgets/dragon_life.dart - (+210 -0) - 1 +import 'dart:math' as math; - 2 +import 'dart:typed_data'; - 3 +import 'dart:ui' as ui; - 4 + - 5 +import 'package:flutter/painting.dart'; - 6 + - 7 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 8 + - 9 +/// The dragon inside the logo, moving. - 10 +/// - 11 +/// Everything else on the splash happens AROUND the logo. Thi - +s is the logo - 12 +/// itself doing something: the dragon's body slithering along - + the letter it - 13 +/// lives in, its clawed hand closing and opening, its horned - +head rearing. - 14 +/// - 15 +/// ## How, and why not a fragment shader - 16 +/// - 17 +/// The dragon is its own layer — `sozo_logo_dragon.png`, the - +artwork with the - 18 +/// peach ground made transparent by colour — drawn over the u - +ntouched artwork - 19 +/// through a mesh whose vertices move. Where the dragon moves - + out over the - 20 +/// ground it covers it; where it moves in, the original drago - +n underneath - 21 +/// shows, so no hole ever opens and nothing has to be invente - +d to fill one. - 22 +/// - 23 +/// A fragment shader was the obvious tool and the wrong one h - +ere: - 24 +/// `FlutterFragCoord` is local coordinates under Impeller and - + device - 25 +/// coordinates under Skia, so a shader that looks right on th - +e phone renders - 26 +/// somewhere else in a test, and cannot be checked frame by f - +rame. A mesh is - 27 +/// drawn in the canvas's own space on both, and needs no shad - +er compiled on - 28 +/// the first frame of a cold start. - 29 +/// - 30 +/// ## Anatomy - 31 +/// - 32 +/// Found by eye on the registered artwork, in the 512 box: th - +e clawed hand - 33 +/// reaching right sits near (315, 232), the horned head near - +(368, 232) with - 34 +/// its neck at about (345, 245). The slither follows the lett - +er's own spine, - 35 +/// because the dragon is drawn along it — a wave travelling t - +he spine is a - 36 +/// wave travelling the dragon. - 37 +class DragonLife { - 38 + DragonLife(this._geometry, ui.Image dragon, {this.grid = 30} - +) { - 39 + _paint = Paint() - 40 + ..isAntiAlias = true - … 170 more lines (ctrl+o to expand) - -⏺ Bash(sed -i '' "s|^import 'package:flutter/painting.dart';|import - 'package:flutter/painting.dart';\nimport - 'package:vector_math/vector_math_64.dart' show Matrix4;|"…) - ⎿   info • The imported package 'vector_math' isn't a dependency of the - importing package. Try adding a dependency for 'vector_math' in the ' - pubspec.yaml' file • lib/features/splash/presentation/widgets/dragon_l - … +3 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/widgets/dragon_life.dart (+1 - -0) - 3 import 'dart:ui' as ui; - 4 - 5 import 'package:flutter/painting.dart'; - 6 +import 'package:vector_math/vector_math_64.dart' show Matrix4; - 7 - 8 import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 9 - -⏺ Monitor event: "Fresh run with the dragon finale" - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  Analyzing dragon_life.dart... - No issues found! (ran in 0.5s) - ⎿  Updated lib/features/splash/presentation/widgets/dragon_life.dart (+1 - -2) - 2 import 'dart:typed_data'; - 3 import 'dart:ui' as ui; - 4 - 5 -import 'package:flutter/painting.dart'; - 6 -import 'package:vector_math/vector_math_64.dart' show Matrix4; - 5 +import 'package:flutter/widgets.dart'; - 6 - 7 import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 8 - -⏺ Modul tayyor. Endi ajdaho qatlamini geometriya bilan birga yuklash va - harakatni kadrlarda tekshirish: -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  00:00 +0: loading /Users/azamov/Sozo-Full/sozo/test/zz_dragon_probe_te - st.dart - 00:00 +0: dump - 00:00 +1: All tests passed! - ⎿  Updated lib/core/brand/sozo_mark_geometry.dart (+19 -0) - 103 - 104 static const String artAsset = 'assets/brand/sozo_logo_art.p - ng'; - 105 - 106 + static ui.Image? _dragon; - 107 + - 108 + /// The dragon alone: the same artwork with the peach ground - + made transparent - 109 + /// by colour, feathered so its edge does not shimmer when i - +t moves. Drawn - 110 + /// over [art] through a moving mesh so the dragon can move - +inside its letter - 111 + /// while the ground and the silhouette stay exactly where t - +hey are. - 112 + static ui.Image? get dragon => _dragon; - 113 + - 114 + static const String dragonAsset = 'assets/brand/sozo_logo_dr - +agon.png'; - 115 + - 116 /// The geometry, once it has been read. Null before that. - 117 /// - 118 /// A getter rather than a future at the call site: a painte - r runs on a frame - ... - 138 } catch (_) { - 139 _art = null; - 140 } - 141 + // Its own guard too: without the dragon layer the logo is - + still drawn, - 142 + // just without the dragon moving in it. - 143 + try { - 144 + final bytes = await rootBundle.load(dragonAsset); - 145 + final codec = await ui.instantiateImageCodec(bytes.buffe - +r.asUint8List()); - 146 + _dragon = (await codec.getNextFrame()).image; - 147 + } catch (_) { - 148 + _dragon = null; - 149 + } - 150 try { - 151 final svg = await rootBundle.loadString(asset); - 152 final body = _pathNamed(svg, 'body'); - ⎿  Created test/zz_dragon_probe_test.dart (+49 -0) - 1 +import 'dart:io'; - 2 +import 'dart:ui' as ui; - 3 +import 'package:flutter/material.dart'; - 4 +import 'package:flutter/rendering.dart'; - 5 +import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 +import 'package:soplay/features/splash/presentation/widgets/dra - +gon_life.dart'; - 8 + - 9 +class _P extends CustomPainter { - 10 + _P(this.life, this.t, this.s); - 11 + final DragonLife life; final double t; final double s; - 12 + @override - 13 + void paint(Canvas canvas, Size size) { - 14 + final g = SozoMarkGeometry.value!; - 15 + canvas.scale(size.width / 512); - 16 + canvas.clipPath(g.body); - 17 + final art = SozoMarkGeometry.art!; - 18 + canvas.drawImageRect(art, Rect.fromLTWH(0, 0, art.width.toD - +ouble(), art.height.toDouble()), - 19 + const Rect.fromLTWH(0, 0, 512, 512), Paint()..filterQua - +lity = FilterQuality.medium); - 20 + life.paint(canvas, t, s); - 21 + } - 22 + @override - 23 + bool shouldRepaint(covariant CustomPainter o) => true; - 24 +} - 25 + - 26 +void main() { - 27 + TestWidgetsFlutterBinding.ensureInitialized(); - 28 + testWidgets('dump', (tester) async { - 29 + await tester.runAsync(SozoMarkGeometry.precache); - 30 + expect(SozoMarkGeometry.dragon, isNotNull); - 31 + final life = DragonLife(SozoMarkGeometry.value!, SozoMarkGe - +ometry.dragon!); - 32 + tester.view.physicalSize = const Size(512, 512); - 33 + tester.view.devicePixelRatio = 1.0; - 34 + addTearDown(tester.view.reset); - 35 + for (final (t, s, name) in [(0.0, 0.0, 'rest'), (0.5, 1.0, - +'a'), (1.1, 1.0, 'b'), (1.7, 1.0, 'c'), (0.5, 3.0, 'x3')]) { - 36 + final key = GlobalKey(); - 37 + await tester.pumpWidget(Directionality(textDirection: Tex - +tDirection.ltr, - 38 + child: ColoredBox(color: const Color(0xFF181818), child - +: RepaintBoundary(key: key, - 39 + child: CustomPaint(size: const Size(512, 512), painte - +r: _P(life, t, s)))))); - 40 + final b = key.currentContext!.findRenderObject()! as Rend - +erRepaintBoundary; - … 9 more lines (ctrl+o to expand) - ⎿  (timeout 10m) - - Read 1 file, ran 1 shell command (ctrl+o to expand) - -⏺ Farq xaritasi harakatning ajdaho tanasi bo'ylab, ayniqsa tirnoq va boshda - kuchli ekanini, siluet chetlari esa o'zgarmasligini ko'rsatadi — bu to'g'ri - natija. Faqat "Sozo" imzosi biroz siljigan, uni endi mahkamlayman. - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  No issues found! (ran in 2.7s) - ⎿  Updated lib/features/splash/presentation/widgets/dragon_life.dart (+8 - -2) - 75 late final Float32List _ny; - 76 late final Float32List _body; - 77 - 78 + /// The "Sozo" in the top arm. It is orange on peach, so the - +colour mask - 79 + /// counts it as dragon — and a signature that ripples reads - +as a rendering - 80 + /// fault, not as life. Held still. - 81 + static const Offset _signature = Offset(229, 157); - 82 + - 83 static const Offset _claw = Offset(315, 232); - 84 static const Offset _head = Offset(368, 232); - 85 static const Offset _neck = Offset(345, 245); - ... - 190 dy += rx * sa + ry * ca - ry; - 191 } - 192 - 188 - _moved[v * 2] = x + dx; - 189 - _moved[v * 2 + 1] = y + dy; - 193 + final hold = 1 - _weight(x, y, _signature, 30); - 194 + _moved[v * 2] = x + dx * hold; - 195 + _moved[v * 2 + 1] = y + dy * hold; - 196 } - 197 canvas.drawVertices( - 198 ui.Vertices.raw( - -⏺ Tayyor — yozuv endi joyida. Ajdaho moduli ulanishga tayyor; ultracode workflow - (tanqid + 3 dizayn + hakamlar) hali ishlayapti. Natija kelishi bilan g'olib - dizaynni olaman, unga jonli ajdahoni ulayman, qurilmada ko'rsataman. - -✻ Churned for 9m 23s · done 12:25 PM - -⏺ Dynamic workflow "Critique the busy Sozo splash, design three restrained -premium directions in worktrees with rendered frames, and judge them" completed -· 23m 11s - -⏺ Tanqid juda aniq chiqdi. Hakamlarning hukmini o'qiyman: - - Ran 1 shell command (ctrl+o to expand) - -⏺ Ikkala hakam ham "quiet" (sokin hunarmandlik) variantini g'olib deb topdi: - harfning shakli o'zgarmasdan boshidanoq to'liq kenglikda ko'rinishi va - logoning doim jonli chaqnab turishi asosiy afzalliklar bo'ldi. Endi g'olib - kodni o'qib, siz so'ragan ichki ajdaho harakatini qo'shaman. - - Read 2 files, ran 1 shell command (ctrl+o to expand) - -⏺ G'olib kodni "quiet" versiyasi asosida, tezroq kirish bilan birlashtiraman va - og'ir relyef xaritasi o'rniga harf tugagach ichida to'lqinlanadigan jonli - ajdaho animatsiyasini qo'shaman. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Geometriyadan faqat track (terminallardan o'tib ketadigan umurtqa) qismini - olaman; ish vaqtidagi relyef hisobini olmayman: - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - Analyzing brand... - No issues found! (ran in 0.5s) - ⎿  Updated lib/core/brand/sozo_mark_geometry.dart (+33 -0) - 28 required this.spine, - 29 required this.spineMetric, - 30 required this.spineLength, - 31 + required this.track, - 32 + required this.trackLength, - 33 required this.outlineX, - 34 required this.outlineY, - 35 required this.normalX, - ... - 64 final ui.PathMetric spineMetric; - 65 final double spineLength; - 66 - 67 + /// [spine] carried straight on past both terminals by [track - +Lead], measured. - 68 + /// - 69 + /// The terminals are cut at an angle to the spine, so a wipe - + whose front is - 70 + /// square to [spine] and starts at its first point leaves th - +e far corner of - 71 + /// the top cut uncovered until the very end, and never reach - +es the far - 72 + /// corner of the bottom one at all. Run from before the lett - +er to past it, - 73 + /// the front crosses each cut the way it crosses the rest of - + the ribbon. - 74 + final ui.PathMetric track; - 75 + final double trackLength; - 76 + - 77 + /// How far [track] runs past each end of [spine]. More than - +the ribbon's - 78 + /// half-width, which is as far as a 45-degree cut can reach - +along it. - 79 + static const double trackLead = 44; - 80 + - 81 /// Points around [body]'s outline, evenly spaced by arc leng - th, and the unit - 82 /// normal at each one pointing OUT of the fill. - 83 /// - ... - 229 sy[i] = t.position.dy; - 230 } - 231 - 232 + final start = track.getTangentForOffset(0); - 233 + final end = track.getTangentForOffset(track.length); - 234 + if (start == null || end == null) return; - 235 + final run = ui.Path() - 236 + ..moveTo( - 237 + start.position.dx - start.vector.dx * trackLead, - 238 + start.position.dy - start.vector.dy * trackLead, - 239 + ) - 240 + ..extendWithPath(spine, ui.Offset.zero) - … 16 more lines (ctrl+o to expand) - -⏺ Endi g'olib splash'ni olib, yorug'lik o'tishini jonli ajdaho bilan - almashtiraman va kirishni tezlashtiraman: - -⏺ Bash(D=/private/tmp/claude-501/-Users-azamov-Sozo-Full/018dc772-5594-4bec-bb40 - -11e89f9a15f5/scratchpad/splash_v4; cp $D/quiet/sozo_splash.dart - lib/features/splash/pr…) - ⎿  ok - 1 package is discontinued. - 146 packages have newer versions incompatible with dependency constrai - … +4 lines (ctrl+o to expand) - ⎿  Updated lib/features/splash/presentation/widgets/dragon_life.dart (+5 - -2) - 42 dragon, - 43 TileMode.clamp, - 44 TileMode.clamp, - 45 - Matrix4.diagonal3Values(512 / dragon.width, 512 / drago - -n.height, 1) - 46 - .storage, - 45 + Matrix4.diagonal3Values( - 46 + 512 / dragon.width, - 47 + 512 / dragon.height, - 48 + 1, - 49 + ).storage, - 50 filterQuality: FilterQuality.medium, - 51 ); - 52 _build(); - ⎿  … 1 more file changed - -⏺ Bash(rm -f test/zz_dragon_probe_test.dart && cat > - test/zz_splash_probe_test.dart <<'DART' - import 'dart:io';…) - ⎿  00:00 +0: dump - 00:00 +1: All tests passed! - ok - ⎿  Deleted test/zz_dragon_probe_test.dart (+0 -49) - 1 -import 'dart:io'; - 2 -import 'dart:ui' as ui; - 3 -import 'package:flutter/material.dart'; - 4 -import 'package:flutter/rendering.dart'; - 5 -import 'package:flutter_test/flutter_test.dart'; - 6 -import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 -import 'package:soplay/features/splash/presentation/widgets/dra - -gon_life.dart'; - 8 - - 9 -class _P extends CustomPainter { - 10 - _P(this.life, this.t, this.s); - 11 - final DragonLife life; final double t; final double s; - 12 - @override - 13 - void paint(Canvas canvas, Size size) { - 14 - final g = SozoMarkGeometry.value!; - 15 - canvas.scale(size.width / 512); - 16 - canvas.clipPath(g.body); - 17 - final art = SozoMarkGeometry.art!; - 18 - canvas.drawImageRect(art, Rect.fromLTWH(0, 0, art.width.toD - -ouble(), art.height.toDouble()), - 19 - const Rect.fromLTWH(0, 0, 512, 512), Paint()..filterQua - -lity = FilterQuality.medium); - 20 - life.paint(canvas, t, s); - 21 - } - 22 - @override - 23 - bool shouldRepaint(covariant CustomPainter o) => true; - 24 -} - 25 - - 26 -void main() { - 27 - TestWidgetsFlutterBinding.ensureInitialized(); - 28 - testWidgets('dump', (tester) async { - 29 - await tester.runAsync(SozoMarkGeometry.precache); - 30 - expect(SozoMarkGeometry.dragon, isNotNull); - 31 - final life = DragonLife(SozoMarkGeometry.value!, SozoMarkGe - -ometry.dragon!); - 32 - tester.view.physicalSize = const Size(512, 512); - 33 - tester.view.devicePixelRatio = 1.0; - 34 - addTearDown(tester.view.reset); - 35 - for (final (t, s, name) in [(0.0, 0.0, 'rest'), (0.5, 1.0, - -'a'), (1.1, 1.0, 'b'), (1.7, 1.0, 'c'), (0.5, 3.0, 'x3')]) { - 36 - final key = GlobalKey(); - 37 - await tester.pumpWidget(Directionality(textDirection: Tex - -tDirection.ltr, - 38 - child: ColoredBox(color: const Color(0xFF181818), child - -: RepaintBoundary(key: key, - 39 - child: CustomPaint(size: const Size(512, 512), painte - -r: _P(life, t, s)))))); - 40 - final b = key.currentContext!.findRenderObject()! as Rend - -erRepaintBoundary; - … 9 more lines (ctrl+o to expand) - ⎿  Created test/zz_splash_probe_test.dart (+32 -0) - 1 +import 'dart:io'; - 2 +import 'dart:ui' as ui; - 3 +import 'package:flutter/material.dart'; - 4 +import 'package:flutter/rendering.dart'; - 5 +import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 +import 'package:soplay/features/splash/presentation/widgets/soz - +o_splash.dart'; - 8 + - 9 +void main() { - 10 + TestWidgetsFlutterBinding.ensureInitialized(); - 11 + testWidgets('dump', (tester) async { - 12 + await tester.runAsync(SozoMarkGeometry.precache); - 13 + final key = GlobalKey(); - 14 + tester.view.physicalSize = const Size(480, 480); - 15 + tester.view.devicePixelRatio = 1.0; - 16 + addTearDown(tester.view.reset); - 17 + await tester.pumpWidget(MaterialApp(debugShowCheckedModeBan - +ner: false, - 18 + home: RepaintBoundary(key: key, child: SozoSplash(onSettl - +ed: () {}, onDone: () {})))); - 19 + const stops = [200, 300, 450, 650, 850, 1100, 1300, 1500, 1 - +700, 1900, 2100, 2300, 2500, 2700]; - 20 + var last = 0; - 21 + for (final ms in stops) { - 22 + await tester.pump(Duration(milliseconds: ms - last)); las - +t = ms; - 23 + final b = key.currentContext!.findRenderObject()! as Rend - +erRepaintBoundary; - 24 + await tester.runAsync(() async { - 25 + final img = await b.toImage(); - 26 + final d = await img.toByteData(format: ui.ImageByteForm - +at.png); - 27 + File('/private/tmp/claude-501/-Users-azamov-Sozo-Full/0 - +18dc772-5594-4bec-bb40-11e89f9a15f5/scratchpad/y_$ms.png') - 28 + .writeAsBytesSync(d!.buffer.asUint8List()); - 29 + }); - 30 + } - 31 + }); - 32 +} - ⎿  (timeout 10m) - - Read 1 file (ctrl+o to expand) - -⏺ Yaxshi, harf animatsiyasi endi toza chiqmoqda — "Sozo" 200–300ms'da yuqori - kesimda ko'rinib, 1100ms'da yoritilib tugaydi, ortiqcha zarracha yoki halqa - yo'q. Endi ajdaho harakatini farq xaritasi orqali tekshiraman. - - Ran 1 shell command (ctrl+o to expand) - -⏺ Hisob-kitoblarga ko'ra tana amplitudasi taxminan 4 pikselga teng bo'lib, - kamera siljishiga aralashib ketmoqda — bu juda nozik. Endi tana, qo'l va - boshni ortiqcha "rezinaday" bo'lib ketmasligi uchun ehtiyotkorlik bilan yanada - sezilarli qilaman. - -⏺ Bash(python3 - <<'PY' - import io…) - ⎿  ok - 00:00 +1: All tests passed! - ok - ⎿  Updated lib/features/splash/presentation/widgets/dragon_life.dart (+8 - -3) - 168 // Two crests on the letter at once, travelling about a - body-length every - 169 // two seconds — slow enough to read as a creature, not - a vibration. - 170 final w = math.sin(2 * math.pi * (_along[v] * 2.0 - t * - 0.5)); - 171 - final amp = 3.2 * _body[v] * s; - 171 + // About a twelfth of the ribbon's width: enough that th - +e body is seen to - 172 + // move at phone size — at a third of this it was four p - +ixels, which the - 173 + // eye reads as noise — and not so much that the carving - + goes rubbery. - 174 + final amp = 5.0 * _body[v] * s; - 175 dx += _nx[v] * w * amp; - 176 dy += _ny[v] * w * amp; - 177 - ... - 179 // does not march in step with the body. - 180 final hc = _weight(x, y, _claw, 26); - 181 if (hc > 0) { - 179 - final reach = math.sin(2 * math.pi * t * 0.42) * 4.2 * - - s * hc; - 182 + final reach = math.sin(2 * math.pi * t * 0.42) * 7.0 * - + s * hc; - 183 dx += reach; - 184 dy -= reach * 0.35; - 185 } - ... - 187 // The head: rearing back and settling, pivoting on the - neck. - 188 final hh = _weight(x, y, _head, 30); - 189 if (hh > 0) { - 187 - final ang = math.sin(2 * math.pi * (t * 0.36 + 0.25)) - -* 0.07 * s * hh; - 190 + // Eight degrees each way: a head rearing, which a fou - +r-degree nod on a - 191 + // shape this small is not. - 192 + final ang = math.sin(2 * math.pi * (t * 0.36 + 0.25)) - +* 0.14 * s * hh; - 193 final rx = x - _neck.dx; - 194 final ry = y - _neck.dy; - 195 final ca = math.cos(ang); - ⎿  Created test/zz_dragon_probe_test.dart (+48 -0) - 1 +import 'dart:io'; - 2 +import 'dart:ui' as ui; - 3 +import 'package:flutter/material.dart'; - 4 +import 'package:flutter/rendering.dart'; - 5 +import 'package:flutter_test/flutter_test.dart'; - 6 +import 'package:soplay/core/brand/sozo_mark_geometry.dart'; - 7 +import 'package:soplay/features/splash/presentation/widgets/dra - +gon_life.dart'; - 8 + - 9 +class _P extends CustomPainter { - 10 + _P(this.life, this.t, this.s); - 11 + final DragonLife life; final double t; final double s; - 12 + @override - 13 + void paint(Canvas canvas, Size size) { - 14 + final g = SozoMarkGeometry.value!; - 15 + canvas.scale(size.width / 512); - 16 + canvas.clipPath(g.body); - 17 + final art = SozoMarkGeometry.art!; - 18 + canvas.drawImageRect(art, Rect.fromLTWH(0, 0, art.width.toD - +ouble(), art.height.toDouble()), - 19 + const Rect.fromLTWH(0, 0, 512, 512), Paint()..filterQua - +lity = FilterQuality.medium); - 20 + if (s > 0) life.paint(canvas, t, s); - 21 + } - 22 + @override - 23 + bool shouldRepaint(covariant CustomPainter o) => true; - 24 +} - 25 + - 26 +void main() { - 27 + TestWidgetsFlutterBinding.ensureInitialized(); - 28 + testWidgets('dump', (tester) async { - 29 + await tester.runAsync(SozoMarkGeometry.precache); - 30 + final life = DragonLife(SozoMarkGeometry.value!, SozoMarkGe - +ometry.dragon!); - 31 + tester.view.physicalSize = const Size(1024, 1024); - 32 + tester.view.devicePixelRatio = 1.0; - 33 + addTearDown(tester.view.reset); - 34 + for (final (t, s, name) in [(0.0, 0.0, 'rest'), (0.3, 1.0, - +'t03'), (0.9, 1.0, 't09'), (1.5, 1.0, 't15'), (2.1, 1.0, 't21') - +]) { - 35 + final key = GlobalKey(); - 36 + await tester.pumpWidget(Directionality(textDirection: Tex - +tDirection.ltr, - 37 + child: ColoredBox(color: const Color(0xFF181818), child - +: RepaintBoundary(key: key, - 38 + child: CustomPaint(size: const Size(1024, 1024), pain - +ter: _P(life, t, s)))))); - 39 + final b = key.currentContext!.findRenderObject()! as Rend - +erRepaintBoundary; - 40 + await tester.runAsync(() async { - … 8 more lines (ctrl+o to expand) - ⎿  (timeout 10m) - ⎿  You've hit your weekly limit · resets Sep 25 at 5am (Asia/Tashkent) - /usage-credits to finish what you’re working on. - -✻ Baked for 30m 37s · done 12:42 PM \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 64283fe6..ae3e3fd8 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -194,6 +194,8 @@ dependencies { // FrameExtractor: seek-bar frames for HLS (preview/HlsFramePreview.kt). implementation("androidx.media3:media3-inspector:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3") + // MediaSessionCompat for the player: headset and Bluetooth buttons. + implementation("androidx.media:media:1.7.0") // CloudStream provider runtime (Android-only feature). The `library` module // carries MainAPI/APIHolder/`app` HTTP client/extractors/BasePlugin so .cs3 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8cb51c74..04266d17 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -67,7 +67,8 @@ android:hardwareAccelerated="true" android:supportsPictureInPicture="true" android:resizeableActivity="true" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustResize" + android:enableOnBackInvokedCallback="true"> + diff --git a/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt b/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt new file mode 100644 index 00000000..46fba4cc --- /dev/null +++ b/android/app/src/main/kotlin/com/soplay/sozo/AudioFocusHelper.kt @@ -0,0 +1,96 @@ +package com.soplay.sozo + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build + +/** + * Audio focus and ACTION_AUDIO_BECOMING_NOISY for the players that do not + * handle them themselves (libmpv). Events go to [onEvent] as the wire names + * the Dart AudioFocusPolicy reads: gain, loss, loss_transient, duck, noisy. + */ +class AudioFocusHelper( + private val context: Context, + private val onEvent: (String) -> Unit, +) { + private val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var hasFocus = false + private var request: AudioFocusRequest? = null + private var noisyReceiver: BroadcastReceiver? = null + + private val listener = AudioManager.OnAudioFocusChangeListener { change -> + when (change) { + AudioManager.AUDIOFOCUS_GAIN -> { + hasFocus = true + onEvent("gain") + } + AudioManager.AUDIOFOCUS_LOSS -> { + hasFocus = false + onEvent("loss") + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> onEvent("loss_transient") + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> onEvent("duck") + } + } + + fun request(): Boolean { + if (hasFocus) return true + val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val req = request ?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) + .build() + ) + .setOnAudioFocusChangeListener(listener) + .build() + .also { request = it } + audio.requestAudioFocus(req) + } else { + @Suppress("DEPRECATION") + audio.requestAudioFocus(listener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) + } + hasFocus = granted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasFocus + } + + fun abandon() { + if (!hasFocus) return + hasFocus = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + request?.let { audio.abandonAudioFocusRequest(it) } + } else { + @Suppress("DEPRECATION") + audio.abandonAudioFocus(listener) + } + } + + fun watchNoisy() { + if (noisyReceiver != null) return + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) onEvent("noisy") + } + } + val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + context.registerReceiver(receiver, filter) + } + noisyReceiver = receiver + } + + fun release() { + abandon() + noisyReceiver?.let { runCatching { context.unregisterReceiver(it) } } + noisyReceiver = null + } +} diff --git a/android/app/src/main/kotlin/com/soplay/sozo/DownloadExporter.kt b/android/app/src/main/kotlin/com/soplay/sozo/DownloadExporter.kt index 4b68a442..a936a94f 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/DownloadExporter.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/DownloadExporter.kt @@ -7,6 +7,7 @@ import android.os.Environment import android.provider.MediaStore import android.webkit.MimeTypeMap import java.io.File +import java.io.OutputStream /** * Copies a finished download out of the app's private storage and into the @@ -53,19 +54,31 @@ object DownloadExporter { val source = File(sourcePath) require(source.exists() && source.isFile) { "The downloaded file is no longer on disk" } - val safeName = sanitise(displayName.ifBlank { source.name }) + val wanted = displayName.ifBlank { source.name } + // An HLS download is a playlist and its segments, not a file to copy. + val hls = if (source.name.endsWith(".m3u8", ignoreCase = true)) HlsFileJoin.plan(source) else null + val safeName = sanitise( + if (hls == null) wanted else wanted.substringBeforeLast('.') + hls.extension + ) val mime = mimeTypeOf(safeName) + val write: (OutputStream) -> Unit = { output -> + if (hls != null) { + HlsFileJoin.write(hls, output) + } else { + source.inputStream().use { it.copyTo(output, DEFAULT_BUFFER_SIZE) } + } + } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - exportViaMediaStore(context, source, safeName, mime) + exportViaMediaStore(context, write, safeName, mime) } else { - exportViaLegacyPath(source, safeName) + exportViaLegacyPath(write, safeName) } } private fun exportViaMediaStore( context: Context, - source: File, + write: (OutputStream) -> Unit, name: String, mime: String ): String { @@ -88,7 +101,7 @@ object DownloadExporter { try { resolver.openOutputStream(uri).use { output -> requireNotNull(output) { "Could not open Downloads for writing" } - source.inputStream().use { it.copyTo(output, DEFAULT_BUFFER_SIZE) } + write(output) } } catch (e: Exception) { // A half-written entry in the user's Downloads is worse than none: @@ -106,7 +119,7 @@ object DownloadExporter { return "Downloads/Sozo/$name" } - private fun exportViaLegacyPath(source: File, name: String): String { + private fun exportViaLegacyPath(write: (OutputStream) -> Unit, name: String): String { val dir = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "Sozo" @@ -115,8 +128,11 @@ object DownloadExporter { throw IllegalStateException("Could not create the Downloads/Sozo folder") } val target = uniqueFile(dir, name) - source.inputStream().use { input -> - target.outputStream().use { output -> input.copyTo(output, DEFAULT_BUFFER_SIZE) } + try { + target.outputStream().use(write) + } catch (e: Exception) { + target.delete() + throw e } return "Downloads/Sozo/${target.name}" } @@ -160,6 +176,8 @@ object DownloadExporter { private fun mimeTypeOf(name: String): String { val ext = name.substringAfterLast('.', "").lowercase() + // Some Android builds map .ts to Qt's translation files. + if (ext == "ts") return "video/mp2t" return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) // MKV is what most of these downloads actually are, and older // Android builds do not map the extension at all — an empty MIME @@ -167,7 +185,6 @@ object DownloadExporter { ?: when (ext) { "mkv" -> "video/x-matroska" "m3u8" -> "application/vnd.apple.mpegurl" - "ts" -> "video/mp2t" else -> "application/octet-stream" } } diff --git a/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt b/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt index a8775a33..a964c42e 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt @@ -17,9 +17,11 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import org.json.JSONArray import org.json.JSONObject +import java.io.BufferedInputStream import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.io.InputStream import java.net.HttpURLConnection import java.net.URI import java.net.URL @@ -324,7 +326,9 @@ class DownloadForegroundService : Service() { val connection = openConnection(url, headers, written) val code = connection.responseCode rejectFailedResponse(code, connection) - rejectNonMedia(connection) + // Checked before the partial is touched: a web page now must not cost + // the bytes an earlier attempt already saved. + val body = mediaStream(connection) val append = written > 0 && code == HttpURLConnection.HTTP_PARTIAL if (!append) { @@ -337,7 +341,7 @@ class DownloadForegroundService : Service() { updateState(id, title, url, artefactPath, STATUS_DOWNLOADING, written, total, written, null) updateProgressNotification(id, title, written, total) - connection.inputStream.use { input -> + body.use { input -> FileOutputStream(part, append).use { output -> val buffer = ByteArray(IO_BUFFER) while (true) { @@ -361,7 +365,7 @@ class DownloadForegroundService : Service() { */ private fun isResumable(e: IOException): Boolean { val message = e.message.orEmpty() - if (message.startsWith("HTTP ") || message.startsWith("not media")) return false + if (message.startsWith("HTTP ") || message.startsWith(NOT_MEDIA)) return false return e !is java.io.FileNotFoundException } @@ -685,14 +689,13 @@ class DownloadForegroundService : Service() { val connection = openConnection(url, headers, 0L, range) val code = connection.responseCode rejectFailedResponse(code, connection) - if (checkMedia) rejectNonMedia(connection) // A server that ignores Range sends the whole file; the slice // is cut out of it rather than every segment being saved as a // full copy of the one file they all live in. var skip = if (range != null && code != HttpURLConnection.HTTP_PARTIAL) range.offset else 0L val want = range?.length ?: -1L var written = 0L - connection.inputStream.use { input -> + mediaStream(connection, checkMedia).use { input -> FileOutputStream(part, false).use { output -> val buffer = ByteArray(IO_BUFFER) while (true) { @@ -723,8 +726,11 @@ class DownloadForegroundService : Service() { if (!part.renameTo(file)) throw IOException("could not finish a part") return } catch (e: Exception) { - lastError = e part.delete() + // A web page is the same web page on the next attempt, and + // asking twice more only spends the viewer's data and time. + if (isNotMedia(e)) throw e + lastError = e // 400ms, 800ms, 1600ms. try { Thread.sleep(400L shl attempt) @@ -737,10 +743,14 @@ class DownloadForegroundService : Service() { throw lastError ?: IOException("could not fetch $url") } + /** + * A playlist. One that is a web page fails as one, the way the Dart + * downloader's does, instead of as "no segments in the playlist". + */ private fun readText(url: String, headers: Map): String { val connection = openConnection(url, headers, 0L) rejectFailedResponse(connection.responseCode, connection) - return connection.inputStream.bufferedReader().use { it.readText() } + return mediaStream(connection).bufferedReader().use { it.readText() } } private fun openConnection( @@ -781,24 +791,48 @@ class DownloadForegroundService : Service() { } /** - * Refuses an answer that is not media. + * The body of [connection], refused if it is a web page. * * A challenge page or an error page served as 200 used to be written to * `video.mp4`: the download completed, the row said "Downloaded", and the * player failed on a file that was HTML. + * + * Decided by the first bytes, not the Content-Type. That header was the + * test, and the HLS CDNs behind vidapi and anikoto label real MPEG-TS + * segments `text/html`, so every one of their episodes failed on its first + * disguised segment while the same stream played (see + * [HlsDownloadPlaylist.looksLikeWebPage]). */ - private fun rejectNonMedia(connection: HttpURLConnection) { - val type = connection.contentType?.lowercase().orEmpty() - if (type.isEmpty()) return - if (type.startsWith("text/html") || - type.startsWith("application/xhtml") || - type.startsWith("text/plain") - ) { + private fun mediaStream(connection: HttpURLConnection, checkMedia: Boolean = true): InputStream { + val input = BufferedInputStream(connection.inputStream, IO_BUFFER) + if (!checkMedia) return input + val head = ByteArray(SNIFF_BYTES) + input.mark(SNIFF_BYTES) + var n = 0 + try { + while (n < SNIFF_BYTES) { + val read = input.read(head, n, SNIFF_BYTES - n) + if (read == -1) break + n += read + } + input.reset() + } catch (e: IOException) { + input.close() + throw e + } + if (HlsDownloadPlaylist.looksLikeWebPage(head, n)) { + val type = connection.contentType ?: "no type" + input.close() connection.disconnect() - throw IOException("not media: the server answered with $type") + throw IOException("$NOT_MEDIA: the server answered with a web page ($type)") } + return input } + /** A refusal [mediaStream] made, which no retry can change. */ + private fun isNotMedia(e: Exception): Boolean = + e is IOException && e.message.orEmpty().startsWith(NOT_MEDIA) + private fun isOnUnmeteredNetwork(): Boolean { return try { val manager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -1169,6 +1203,15 @@ class DownloadForegroundService : Service() { /** 64 KB reads: 8 KB meant eight times the syscalls for the same bytes. */ private const val IO_BUFFER = 64 * 1024 + /** + * What [mediaStream] reads ahead to tell a web page from media. Markup + * needs sixteen; the rest is room for a BOM and leading blank lines. + */ + private const val SNIFF_BYTES = 512 + + /** The prefix Dart's `DownloadFailureKind.classify` reads as notMedia. */ + private const val NOT_MEDIA = "not media" + private const val TASK_CLEAR_TIMEOUT_MS = 4_000L private const val PART_ATTEMPTS = 3 private const val NOTIFY_INTERVAL_MS = 500L diff --git a/android/app/src/main/kotlin/com/soplay/sozo/GlassPower.kt b/android/app/src/main/kotlin/com/soplay/sozo/GlassPower.kt new file mode 100644 index 00000000..cf30d342 --- /dev/null +++ b/android/app/src/main/kotlin/com/soplay/sozo/GlassPower.kt @@ -0,0 +1,161 @@ +package com.soplay.sozo + +import android.app.ActivityManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.PowerManager +import android.view.Display +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * What the phone can actually spend on the glass + * (lib/core/glass/glass_power.dart). + * + * This used to answer with one thing: `ActivityManager.isLowRamDevice`. That + * is a flag the maker sets to say the phone shipped as an Android Go phone — + * it is not a measurement, and it is the wrong question twice over. A 2019 + * mid-range phone with 6 GB and a GPU that cannot bend a bar sixty times a + * second does not carry the flag, drew the refraction, and dropped frames; a + * current 4 GB phone with a Vulkan 1.1 driver does carry it on some makers' + * builds and lost the glass for nothing. + * + * So what is sent is what can be read off the phone, and the decision is + * made in Dart where it can be tested ([GlassPowerReading.affordsRefraction]): + * + * - `read` → everything below, once at launch. + * - `powerSave` → the one thing that changes while the app runs is pushed to + * Dart as it turns. + * + * | key | what it is | why the glass cares | + * |---|---|---| + * | `lowRam` | `isLowRamDevice` | kept: a Go phone is still a Go phone | + * | `memoryBytes` | `MemoryInfo.totalMem` | the measurement the flag stands in for | + * | `sdk` | `Build.VERSION.SDK_INT` | Impeller's Vulkan backend starts at 29 | + * | `vulkan11` | Vulkan 1.1 in the driver | below it Impeller draws through GL ES, where a backdrop read per glass costs far more | + * | `glEs` | `reqGlEsVersion`, major only | a GL ES 2 driver cannot run the shader at all | + * | `refreshHz` | the panel's rate | the frame budget the step-down measures against | + * | `powerSave` | `isPowerSaveMode` | the user has asked this phone to spend less | + * | `cores` | `availableProcessors` | the one cheap hint at the class of chip | + * + * Which Impeller backend actually draws is still not said here: the engine + * picks it after its own device checks and tells no one. `vulkan11` says + * what the driver offers, which is the most the engine could have chosen; + * whether a shader filter runs at all is asked of the renderer itself, in + * Dart (`ImageFilter.isShaderFilterSupported`). + */ +class GlassPower(private val context: Context) : MethodChannel.MethodCallHandler { + private var channel: MethodChannel? = null + private var saverWatch: BroadcastReceiver? = null + + fun attach(messenger: BinaryMessenger) { + channel = MethodChannel(messenger, CHANNEL).also { + it.setMethodCallHandler(this) + } + watchPowerSave() + } + + fun detach() { + saverWatch?.let { + // Unregistering one that was never registered throws, so this is + // only tried for the receiver we are holding. + runCatching { context.unregisterReceiver(it) } + } + saverWatch = null + channel?.setMethodCallHandler(null) + channel = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + if (call.method == "read") { + result.success(reading()) + } else { + result.notImplemented() + } + } + + /** + * Battery Saver is thrown in Settings or by the phone itself as the + * battery runs down, with the app in front or behind, so it is the one + * reading that is pushed rather than asked for. Dart also re-asks on + * resume, which covers a build where this broadcast does not arrive. + */ + private fun watchPowerSave() { + val power = context.getSystemService(PowerManager::class.java) ?: return + val watch = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + channel?.invokeMethod("powerSave", power.isPowerSaveMode) + } + } + runCatching { + context.registerReceiver( + watch, + IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED), + ) + saverWatch = watch + } + } + + private fun reading(): Map { + val activities = context.getSystemService(ActivityManager::class.java) + val memory = ActivityManager.MemoryInfo().also { + runCatching { activities?.getMemoryInfo(it) } + } + // reqGlEsVersion packs the major in its high half-word; the app only + // cares whether it is 3 or better, which is where the shader stops + // being a question of speed and becomes a question of possible. + val glEs = activities?.deviceConfigurationInfo?.reqGlEsVersion ?: 0 + return mapOf( + "lowRam" to (activities?.isLowRamDevice ?: false), + "memoryBytes" to memory.totalMem, + "sdk" to Build.VERSION.SDK_INT, + "vulkan11" to hasVulkan11(), + "glEs" to (glEs shr 16), + "refreshHz" to refreshHz(), + "powerSave" to isPowerSaving(), + "cores" to Runtime.getRuntime().availableProcessors(), + ) + } + + /** + * Vulkan 1.1 in the driver, the version Flutter's Impeller Vulkan backend + * asks for. `hasSystemFeature` with a version compares against what the + * driver reports, so this is the driver's own word rather than a guess + * from the API level. + */ + private fun hasVulkan11(): Boolean = runCatching { + context.packageManager.hasSystemFeature( + PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, + VULKAN_1_1, + ) + }.getOrDefault(false) + + /** + * The default display's rate. Null rather than a made-up 60 when there is + * no display to ask — on a phone with no display the glass is not being + * drawn either, and Dart treats a missing rate as sixty hertz itself. + */ + private fun refreshHz(): Double? = runCatching { + context.getSystemService(DisplayManager::class.java) + ?.getDisplay(Display.DEFAULT_DISPLAY) + ?.refreshRate + ?.toDouble() + }.getOrNull() + + private fun isPowerSaving(): Boolean = runCatching { + context.getSystemService(PowerManager::class.java)?.isPowerSaveMode ?: false + }.getOrDefault(false) + + private companion object { + const val CHANNEL = "soplay/glass_power" + + /** Vulkan 1.1.0, as `FEATURE_VULKAN_HARDWARE_VERSION` packs it. */ + const val VULKAN_1_1 = 0x00401000 + } +} diff --git a/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt b/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt index 98469b0e..4c02a666 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt @@ -191,6 +191,35 @@ object HlsDownloadPlaylist { return out.joinToString("\n") } + /** + * Whether the first bytes of a body are a web page rather than media. + * + * The Content-Type used to decide this, and disguised CDNs label real + * MPEG-TS `text/html`: vidapi's `page-N.html`, anikoto's `seg-N-….html` + * (checked 2026-10-25, both start `47 40`). Every such episode failed as + * "the source sent a web page" while the player, which never reads the + * header, played it. Only markup is refused: an AES-128 segment is random + * bytes, so "starts like TS" cannot be the test. An SVG or XML prolog is + * not refused either, since a comic page can be one. Same rule as + * `looksLikeWebPage` in `web_page_sniff.dart` on the Dart side. + */ + fun looksLikeWebPage(head: ByteArray, length: Int = head.size): Boolean { + val end = minOf(length, head.size) + var i = 0 + if (end >= 3 && head[0] == 0xEF.toByte() && head[1] == 0xBB.toByte() && head[2] == 0xBF.toByte()) i = 3 + while (i < end && isMarkupSpace(head[i])) i++ + val n = minOf(WEB_PAGE_PREFIX_BYTES, end - i) + if (n <= 0) return false + val text = String(head, i, n, Charsets.ISO_8859_1).lowercase() + return WEB_PAGE_STARTS.any { text.startsWith(it) } + } + + private fun isMarkupSpace(b: Byte): Boolean = + b == 0x20.toByte() || b == 0x09.toByte() || b == 0x0A.toByte() || b == 0x0D.toByte() || b == 0x0C.toByte() + + private const val WEB_PAGE_PREFIX_BYTES = 16 + private val WEB_PAGE_STARTS = listOf(") + + /** Reads the local playlist. Throws, with the reason, when it cannot be one file. */ + fun plan(index: File): Plan { + val dir = requireNotNull(index.parentFile) { "The download has no folder" } + val lines = index.readLines().map { it.trim() } + check(lines.none { it.startsWith("#EXT-X-STREAM-INF") }) { + "This video keeps its sound in a separate track, so it cannot be saved as one file" + } + + val parts = ArrayList() + var sequence = 0L + var key: ByteArray? = null + var iv: ByteArray? = null + var fragmented = false + var lastMap: String? = null + for (line in lines) { + when { + line.isEmpty() -> {} + line.startsWith("#EXT-X-MEDIA-SEQUENCE:") -> + sequence = line.substringAfter(':').trim().toLongOrNull() ?: 0L + line.startsWith("#EXT-X-KEY:") -> { + val method = METHOD.find(line)?.groupValues?.get(1) + check(method == "AES-128" || method == "NONE") { + "This video is protected, so it cannot be saved as one file" + } + if (method == "NONE") { + key = null + iv = null + } else { + key = keyOf(dir, line) + iv = IV.find(line)?.groupValues?.get(1)?.let(::hexBytes) + } + } + line.startsWith("#EXT-X-MAP:") -> { + val name = requireNotNull(URI_ATTRIBUTE.find(line)) { + "A part of the download is missing" + }.groupValues[1] + fragmented = true + if (name != lastMap) { + lastMap = name + // An init section under a key always names its IV. + parts.add(Part(existing(dir, name), key, iv)) + } + } + line.startsWith("#") -> {} + else -> { + parts.add(Part(existing(dir, line), key, if (key == null) null else iv ?: sequenceIv(sequence))) + sequence++ + } + } + } + check(parts.isNotEmpty()) { "The download has no video in it" } + return Plan(if (fragmented) ".mp4" else ".ts", parts) + } + + fun write(plan: Plan, output: OutputStream) { + for (part in plan.parts) { + opened(part).use { it.copyTo(output, DEFAULT_BUFFER_SIZE) } + } + } + + private fun opened(part: Part): InputStream { + val input = part.file.inputStream() + val key = part.key ?: return input + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(part.iv ?: ByteArray(16))) + return CipherInputStream(input, cipher) + } + + private fun keyOf(dir: File, line: String): ByteArray { + val name = requireNotNull(URI_ATTRIBUTE.find(line)) { "The download's key is missing" }.groupValues[1] + val bytes = existing(dir, name).readBytes() + check(bytes.size == 16) { "The download's key is not readable" } + return bytes + } + + private fun existing(dir: File, name: String): File { + val file = File(dir, name) + check(file.isFile) { "A part of the download is missing" } + return file + } + + /** With no IV named, the segment's sequence number is the IV. */ + private fun sequenceIv(sequence: Long): ByteArray = + ByteBuffer.allocate(16).putLong(8, sequence).array() + + private fun hexBytes(hex: String): ByteArray { + val clean = hex.padStart(32, '0') + return ByteArray(16) { i -> clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() } + } + + private val METHOD = Regex("METHOD=([A-Z0-9-]+)") + private val IV = Regex("IV=0[xX]([0-9a-fA-F]+)") + private val URI_ATTRIBUTE = Regex("URI=\"([^\"]*)\"") +} diff --git a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt index 43f34fcb..e65b338f 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt @@ -11,12 +11,15 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Rect import android.net.Uri import android.media.AudioManager import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle import android.provider.Settings +import android.util.Rational +import androidx.lifecycle.Lifecycle import androidx.annotation.RequiresApi import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine @@ -63,8 +66,23 @@ class MainActivity : FlutterFragmentActivity() { private var platformChannel: MethodChannel? = null private var downloadChannel: MethodChannel? = null private var systemControlsChannel: MethodChannel? = null + private var glassPower: GlassPower? = null private var deeplinkSettingsChannel: MethodChannel? = null private var pipReceiver: BroadcastReceiver? = null + + private val playerSystemChannelName = "soplay/player_system" + private var playerSystemChannel: MethodChannel? = null + private var audioFocus: AudioFocusHelper? = null + private var mediaSession: PlayerMediaSession? = null + + // Auto-enter PiP on Home while playing. On 12+ the system does it from + // the params; before that, onUserLeaveHint does. + private var autoPip = false + private var pipAspect = Rational(16, 9) + private var pipSourceRect: Rect? = null + private var pipActions: List = emptyList() + private var pipLabels: Map = emptyMap() + private var pipExitPending = false private var notificationPermissionResult: MethodChannel.Result? = null // CloudStream plugin host (Android-only feature). Lazy so the runtime only @@ -150,6 +168,10 @@ class MainActivity : FlutterFragmentActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + // Whether this phone draws the glass's refraction or its blur. + glassPower = GlassPower(applicationContext).also { + it.attach(flutterEngine.dartExecutor.binaryMessenger) + } // Home screen widgets: the app sends a snapshot to draw, and hears // about taps that opened it. homeWidgetChannel = MethodChannel( @@ -219,11 +241,24 @@ class MainActivity : FlutterFragmentActivity() { val isPlaying = call.argument("isPlaying") ?: true val hasPrev = call.argument("hasPrev") ?: false val hasNext = call.argument("hasNext") ?: false + val seekSeconds = call.argument("seekSeconds")?.toInt() ?: 10 + call.argument>("labels")?.let { pipLabels = it } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - applyPipActions(isPlaying, hasPrev, hasNext) + applyPipActions(isPlaying, hasPrev, hasNext, seekSeconds) } result.success(true) } + "setAutoPip" -> { + autoPip = call.argument("enabled") ?: false + val num = call.argument("numerator")?.toInt() ?: 16 + val den = call.argument("denominator")?.toInt() ?: 9 + if (num > 0 && den > 0) pipAspect = Rational(num, den) + pipSourceRect = call.argument>("sourceRect") + ?.takeIf { it.size == 4 } + ?.let { Rect(it[0].toInt(), it[1].toInt(), it[2].toInt(), it[3].toInt()) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applyPipParams() + result.success(true) + } else -> result.notImplemented() } } @@ -261,6 +296,7 @@ class MainActivity : FlutterFragmentActivity() { ) ) "openNotificationSettings" -> result.success(openNotificationSettings()) + "openAppSettings" -> result.success(openAppSettings()) "apkPackageName" -> { val path = call.argument("path").orEmpty() val name = try { @@ -450,6 +486,62 @@ class MainActivity : FlutterFragmentActivity() { } } + playerSystemChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + playerSystemChannelName + ) + playerSystemChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "attach" -> { + val focus = audioFocus ?: AudioFocusHelper(applicationContext) { event -> + runOnUiThread { playerSystemChannel?.invokeMethod("audioFocus", event) } + }.also { audioFocus = it } + focus.watchNoisy() + val session = mediaSession ?: PlayerMediaSession(this) { action, pos -> + runOnUiThread { + playerSystemChannel?.invokeMethod( + "mediaCommand", + mapOf("action" to action, "positionMs" to pos) + ) + } + }.also { mediaSession = it } + session.start() + result.success(true) + } + "detach" -> { + audioFocus?.release() + mediaSession?.stop() + result.success(true) + } + "requestAudioFocus" -> result.success(audioFocus?.request() ?: true) + "abandonAudioFocus" -> { + audioFocus?.abandon() + result.success(true) + } + "setMetadata" -> { + mediaSession?.setMetadata( + call.argument("title") ?: "", + call.argument("subtitle"), + call.argument("durationMs")?.toLong() ?: 0L, + call.argument("artworkUrl"), + ) + result.success(true) + } + "setPlaybackState" -> { + mediaSession?.setState( + call.argument("playing") ?: false, + call.argument("positionMs")?.toLong() ?: 0L, + call.argument("speed")?.toFloat() ?: 1f, + call.argument("hasPrev") ?: false, + call.argument("hasNext") ?: false, + call.argument("seekable") ?: true, + ) + result.success(true) + } + else -> result.notImplemented() + } + } + deeplinkSettingsChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, deeplinkSettingsChannelName @@ -834,7 +926,8 @@ class MainActivity : FlutterFragmentActivity() { val hls = call.argument("hls") == true cloudstreamScope.launch { val token = call.argument("generation")?.toLong() - val opened = if (hls) HlsFramePreview.open(applicationContext, url, token ?: 0L) + val progressive = call.argument("progressive") == true + val opened = if (hls) HlsFramePreview.open(applicationContext, url, token ?: 0L, progressive) else if (token != null) FramePreview.open(url, headers, warmMs, token) else FramePreview.open(url, headers, warmMs) withContext(Dispatchers.Main) { result.success(opened) } @@ -1166,6 +1259,20 @@ class MainActivity : FlutterFragmentActivity() { } } + private fun openAppSettings(): Boolean { + return try { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName") + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (_: Exception) { + false + } + } + private fun openDefaultLinksSettings(): Boolean { val pkgUri = Uri.parse("package:$packageName") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -1205,14 +1312,19 @@ class MainActivity : FlutterFragmentActivity() { audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0) } + // The window override is the truth once the player has set one. Before + // that, the system level is only a guess: its range is 0..255 on most + // phones but not all, and under auto-brightness it is stale. private fun getWindowBrightness(): Double { val windowValue = window.attributes.screenBrightness if (windowValue >= 0f) return windowValue.toDouble().coerceIn(0.0, 1.0) return try { - Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS) - .toDouble() - .div(255.0) - .coerceIn(0.0, 1.0) + val auto = Settings.System.getInt( + contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE + ) == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC + val raw = Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS) + if (auto || raw !in 0..255) 0.5 else (raw / 255.0).coerceIn(0.05, 1.0) } catch (_: Exception) { 0.5 } @@ -1327,74 +1439,133 @@ class MainActivity : FlutterFragmentActivity() { private fun applyPipActions( isPlaying: Boolean, hasPrev: Boolean, - hasNext: Boolean + hasNext: Boolean, + seekSeconds: Int ) { + fun label(key: String, fallback: String) = pipLabels[key]?.takeIf { it.isNotBlank() } ?: fallback val actions = mutableListOf() - - if (hasPrev) { - actions.add( - makeAction( - android.R.drawable.ic_media_previous, - "Previous", - "Previous episode", - ACTION_PREV, - REQ_PREV - ) - ) + // Most windows take three; the episode buttons only when there is room. + val room = maxNumPictureInPictureActions + val showPrev = hasPrev && room >= 5 + val showNext = hasNext && room >= 4 + if (showPrev) { + val t = label("prev", "Previous") + actions.add(makeAction(R.drawable.ic_pip_prev, t, t, ACTION_PREV, REQ_PREV)) } + val rewind = label("rewind", "Rewind ${seekSeconds}s") actions.add( - makeAction( - android.R.drawable.ic_media_rew, - "Rewind 10", - "Rewind 10 seconds", - ACTION_REWIND, - REQ_REWIND - ) + makeAction(replayIcon(seekSeconds), rewind, rewind, ACTION_REWIND, REQ_REWIND) ) + val playPause = if (isPlaying) label("pause", "Pause") else label("play", "Play") actions.add( makeAction( - if (isPlaying) - android.R.drawable.ic_media_pause - else - android.R.drawable.ic_media_play, - if (isPlaying) "Pause" else "Play", - if (isPlaying) "Pause" else "Play", + if (isPlaying) R.drawable.ic_pip_pause else R.drawable.ic_pip_play, + playPause, + playPause, ACTION_PLAY_PAUSE, REQ_PLAY_PAUSE ) ) + val forward = label("forward", "Forward ${seekSeconds}s") actions.add( - makeAction( - android.R.drawable.ic_media_ff, - "Forward 10", - "Forward 10 seconds", - ACTION_FORWARD, - REQ_FORWARD - ) + makeAction(forwardIcon(seekSeconds), forward, forward, ACTION_FORWARD, REQ_FORWARD) ) - if (hasNext) { - actions.add( - makeAction( - android.R.drawable.ic_media_next, - "Next", - "Next episode", - ACTION_NEXT, - REQ_NEXT - ) - ) + if (showNext) { + val t = label("next", "Next") + actions.add(makeAction(R.drawable.ic_pip_next, t, t, ACTION_NEXT, REQ_NEXT)) } + pipActions = actions + applyPipParams() + } - val params = PictureInPictureParams.Builder() - .setActions(actions) - .build() + private fun replayIcon(seconds: Int) = when (seconds) { + 5 -> R.drawable.ic_pip_replay_5 + 10 -> R.drawable.ic_pip_replay_10 + 30 -> R.drawable.ic_pip_replay_30 + else -> R.drawable.ic_pip_replay + } + private fun forwardIcon(seconds: Int) = when (seconds) { + 5 -> R.drawable.ic_pip_forward_5 + 10 -> R.drawable.ic_pip_forward_10 + 30 -> R.drawable.ic_pip_forward_30 + else -> R.drawable.ic_pip_forward + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun buildPipParams(): PictureInPictureParams { + val builder = PictureInPictureParams.Builder() + .setActions(pipActions) + .setAspectRatio(pipAspect) + pipSourceRect?.let { builder.setSourceRectHint(it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setAutoEnterEnabled(autoPip) + builder.setSeamlessResizeEnabled(true) + } + return builder.build() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun applyPipParams() { try { - setPictureInPictureParams(params) + setPictureInPictureParams(buildPipParams()) } catch (_: Exception) { // Activity may not be in a state to receive PiP params yet } } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + if (!autoPip || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return + if (isInPictureInPictureMode) return + try { + enterPictureInPictureMode(buildPipParams()) + } catch (_: Exception) { + } + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + pipExitPending = false + sendPipMode(inPip = true, dismissed = false) + return + } + // Closed with its X, the activity is already stopped (12+) or about + // to be (older: onStop follows). Expanded, it resumes instead. + val stopped = !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + if (stopped || isFinishing) { + sendPipMode(inPip = false, dismissed = true) + } else { + pipExitPending = true + sendPipMode(inPip = false, dismissed = false) + } + } + + override fun onResume() { + super.onResume() + pipExitPending = false + } + + override fun onStop() { + super.onStop() + if (pipExitPending) { + pipExitPending = false + sendPipMode(inPip = false, dismissed = true) + } + } + + private fun sendPipMode(inPip: Boolean, dismissed: Boolean) { + methodChannel?.invokeMethod( + "onPipModeChanged", + mapOf("inPip" to inPip, "dismissed" to dismissed) + ) + } + @RequiresApi(Build.VERSION_CODES.O) private fun makeAction( iconRes: Int, @@ -1466,11 +1637,17 @@ class MainActivity : FlutterFragmentActivity() { // being destroyed does not mean playback is over (PiP, rotation). // Only the channel is detached. TorrentServerBridge.dispose() + glassPower?.detach() + glassPower = null // Unlike the torrent server, these must go: a decoder and a DRM session // left open outlive the texture they were drawing into, and on devices // with one secure decoder that is the next playback failing to start. drmPlayerHost?.dispose() drmPlayerHost = null + audioFocus?.release() + audioFocus = null + mediaSession?.dispose() + mediaSession = null try { bridgeServer?.stop() } catch (_: Throwable) {} bridgeServer = null pipReceiver?.let { diff --git a/android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt b/android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt new file mode 100644 index 00000000..7cd91de2 --- /dev/null +++ b/android/app/src/main/kotlin/com/soplay/sozo/PlayerMediaSession.kt @@ -0,0 +1,140 @@ +package com.soplay.sozo + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.SystemClock +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors + +/** + * The player's media session, live while the player page is open: headset + * and Bluetooth buttons reach the player, and the system knows what is + * playing. Commands go to [onCommand] as (action, positionMs). + */ +class PlayerMediaSession( + private val context: Context, + private val onCommand: (String, Long?) -> Unit, +) { + private var session: MediaSessionCompat? = null + private val artLoader = Executors.newSingleThreadExecutor() + private var artUrl: String? = null + private var art: Bitmap? = null + private var metadata = MediaMetadataCompat.Builder() + + private val callback = object : MediaSessionCompat.Callback() { + override fun onPlay() = onCommand("play", null) + override fun onPause() = onCommand("pause", null) + override fun onStop() = onCommand("pause", null) + override fun onSeekTo(pos: Long) = onCommand("seek", pos) + override fun onFastForward() = onCommand("forward", null) + override fun onRewind() = onCommand("rewind", null) + override fun onSkipToNext() = onCommand("next", null) + override fun onSkipToPrevious() = onCommand("prev", null) + } + + fun start() { + if (session != null) return + session = MediaSessionCompat(context, "SozoPlayer").apply { + setCallback(callback) + isActive = true + } + } + + fun setMetadata(title: String, subtitle: String?, durationMs: Long, artworkUrl: String?) { + val s = session ?: return + metadata = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) + .apply { + if (!subtitle.isNullOrBlank()) { + putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle) + putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, subtitle) + } + if (durationMs > 0) putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMs) + } + if (artworkUrl != artUrl) { + artUrl = artworkUrl + art = null + if (!artworkUrl.isNullOrBlank()) loadArt(artworkUrl) + } + art?.let { metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it) } + s.setMetadata(metadata.build()) + } + + fun setState(playing: Boolean, positionMs: Long, speed: Float, hasPrev: Boolean, hasNext: Boolean, seekable: Boolean) { + val s = session ?: return + var actions = PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_STOP + if (seekable) { + actions = actions or PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_REWIND + } + if (hasPrev) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + if (hasNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + s.setPlaybackState( + PlaybackStateCompat.Builder() + .setActions(actions) + .setState( + if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, + positionMs, + if (playing) speed else 0f, + SystemClock.elapsedRealtime(), + ) + .build() + ) + } + + fun stop() { + artUrl = null + art = null + session?.let { + it.isActive = false + it.setCallback(null) + it.release() + } + session = null + } + + fun dispose() { + stop() + artLoader.shutdownNow() + } + + private fun loadArt(url: String) { + artLoader.execute { + val bitmap = runCatching { fetch(url) }.getOrNull() ?: return@execute + android.os.Handler(android.os.Looper.getMainLooper()).post { + if (url != artUrl) return@post + art = bitmap + session?.setMetadata( + metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build() + ) + } + } + } + + private fun fetch(url: String): Bitmap? { + val conn = URL(url).openConnection() as HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + return try { + if (conn.responseCode !in 200..299) return null + val bytes = conn.inputStream.use { it.readBytes() } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + var sample = 1 + while (bounds.outWidth / (sample * 2) >= 512 && bounds.outHeight / (sample * 2) >= 512) sample *= 2 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } finally { + conn.disconnect() + } + } +} 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 3450163a..d4d01c9f 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 @@ -14,6 +14,7 @@ import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.MovieLoadResponse import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SearchResponseList import com.lagradost.cloudstream3.TorrentLoadResponse import com.lagradost.cloudstream3.TvSeriesLoadResponse import com.lagradost.cloudstream3.plugins.BasePlugin @@ -26,6 +27,7 @@ import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject import java.io.File +import kotlin.coroutines.Continuation /** * CloudStream plugin host (Android-only). @@ -45,6 +47,38 @@ import java.io.File * subtitle). Stable id scheme `cs:` + contentUrl=load-url so * my-list / continue-watching keep working. */ +/** + * One page of a plugin's search, asked through the overload it implements. + * + * Since library 4.8 a plugin may implement only `search(query, page)` — + * MovieBox does — and MainAPI's own `search(query)` is then + * `throw NotImplementedError()`. Calling the one-argument form, as this host + * did, failed every search on such a plugin with "NotImplementedError: An + * operation is not implemented." The paged form is safe for every plugin: its + * library default calls `search(query)` and reports `hasNext = false`. + * + * That default ignores the page, though, so for a plugin that only knows + * `search(query)` page 2 would be page 1 again. Past the first page such a + * plugin answers nothing, as it always did here. + */ +internal suspend fun searchPage(api: MainAPI, query: String, page: Int): SearchResponseList? { + if (page > 1 && !implementsPagedSearch(api)) return null + return api.search(query, page) +} + +/** Whether [api]'s class (or a parent below MainAPI) overrides the paged search. */ +internal fun implementsPagedSearch(api: MainAPI): Boolean = try { + api.javaClass.getMethod( + "search", + String::class.java, + Int::class.javaPrimitiveType, + Continuation::class.java, + ).declaringClass != MainAPI::class.java +} catch (_: Throwable) { + // Unknowable is treated as legacy: the worst case is one page, as before. + false +} + class PluginHost(private val appContext: Context) { init { @@ -467,11 +501,12 @@ class PluginHost(private val appContext: Context) { suspend fun searchJson(providerName: String, query: String, page: Int = 1): String { val api = apiByName(providerName) val items = JSONArray() - // CloudStream's MainAPI.search(query) has no paging — return the full - // result set on page 1 only; never advertise further pages. var searchError: String? = null - if (api != null && page == 1) { - val results = try { api.search(query) } catch (t: Throwable) { + var hasNext = false + if (api != null) { + val results = try { + searchPage(api, query, page)?.also { hasNext = it.hasNext }?.items + } catch (t: Throwable) { searchError = "${t.javaClass.simpleName}: ${t.message}" Log.e(TAG, "search ${api.name}: ${t.javaClass.simpleName}: ${t.message}"); null } ?: emptyList() @@ -486,7 +521,13 @@ class PluginHost(private val appContext: Context) { if (items.length() == 0) { searchError?.let { put("error", "${api?.name ?: providerName}: $it") } } - put("query", query); put("page", page); put("totalPages", page) + // A next page only when the plugin says there is one, and only + // after a page that had something on it: a plugin that answers + // hasNext = true forever would otherwise be paged past its end. + // The legacy default always says false, so a plugin that knows only + // search(query) keeps the one-page result it always had. + put("query", query); put("page", page) + put("totalPages", if (hasNext && items.length() > 0) page + 1 else page) }.toString() } @@ -604,7 +645,9 @@ class PluginHost(private val appContext: Context) { try { val q = resp.name.replace(Regex("\\(.*?\\)"), "").trim() if (q.length >= 2) { - val results = api.search(q) ?: emptyList() + // Through searchPage for the same reason as searchJson: a + // paged-only plugin throws from search(query). + val results = searchPage(api, q, 1)?.items ?: emptyList() for (sr in results) { if (sr.url == resp.url) continue related.put(cardJson(sr, api.name)) diff --git a/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt b/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt index f6cfcf10..a2f4f1c5 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/drm/DrmPlayerHost.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.view.Surface +import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -221,6 +222,14 @@ class DrmPlayerHost( .setDrmSessionManagerProvider { drmManager }, ) .build() + player.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + /* handleAudioFocus = */ true, + ) + player.setHandleAudioBecomingNoisy(true) val entry = textures.createSurfaceTexture() val surface = Surface(entry.surfaceTexture()) diff --git a/android/app/src/main/kotlin/com/soplay/sozo/preview/FramePreview.kt b/android/app/src/main/kotlin/com/soplay/sozo/preview/FramePreview.kt index 27acaf9b..fe2004a3 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/preview/FramePreview.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/preview/FramePreview.kt @@ -7,15 +7,18 @@ import android.util.Log import java.io.ByteArrayOutputStream import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean +import android.os.Handler +import android.os.Looper import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock /** - * A single, demand-driven seek-preview decoder. Never warms or prefetches frames. + * A single seek-preview decoder for progressive files, driven frame by frame by + * the Dart side, which also fills the background grid through it. * A blocked setDataSource must not create a growing collection of retrievers as - * the user scrubs. All native work shares one try-lock; close only invalidates - * the generation and schedules release, so it never waits on a network/codec call. + * the user scrubs. All native work shares one try-lock; close only schedules the + * release, so it never waits on a network/codec call. */ object FramePreview { private const val TAG = "FramePreview" @@ -33,6 +36,16 @@ object FramePreview { private var active: Session? = null private val retired = ArrayList() private val cleanupScheduled = AtomicBoolean(false) + + // Kept between drags, as HlsFramePreview keeps its extractor. Reading a + // file's index is most of what a frame costs on a slow host, and released + // when the finger lifted, every drag read it again. + private const val LINGER_MS = 60_000L + private val main = Handler(Looper.getMainLooper()) + private val release = Runnable { retireActive() } + + /** What was asked for last: an open that took its time still counts for it. */ + private var wanted: Pair>? = null private val cleanup = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "preview-release").apply { isDaemon = true } } @@ -47,10 +60,21 @@ object FramePreview { @Suppress("UNUSED_PARAMETER") fun open(url: String, headers: Map, warmMs: Long = -1L, sessionId: Long = generation.get() + 1): Boolean { - if (!accept(sessionId) || !work.tryLock()) return false + if (!accept(sessionId)) return false + synchronized(state) { wanted = url to headers } + main.removeCallbacks(release) + if (!work.tryLock()) return false try { - val existing = synchronized(state) { active } - if (existing?.generation == sessionId && existing.url == url && existing.headers == headers) return true + val reused = synchronized(state) { + val existing = active + if (existing != null && existing.url == url && existing.headers == headers) { + active = existing.copy(generation = sessionId) + true + } else { + false + } + } + if (reused) return true synchronized(state) { active?.let { retired.add(it.retriever) } active = null @@ -62,8 +86,8 @@ object FramePreview { if (headers.isEmpty()) retriever.setDataSource(url) else retriever.setDataSource(url, headers) val installed = synchronized(state) { - if (generation.get() != sessionId) false else { - active = Session(sessionId, url, headers.toMap(), retriever) + if (wanted != url to headers) false else { + active = Session(generation.get(), url, headers.toMap(), retriever) true } } @@ -99,6 +123,14 @@ object FramePreview { synchronized(state) { if (sessionId < generation.get()) return generation.set(sessionId) + } + main.removeCallbacks(release) + main.postDelayed(release, LINGER_MS) + } + + private fun retireActive() { + synchronized(state) { + wanted = null active?.let { retired.add(it.retriever) } active = null } diff --git a/android/app/src/main/kotlin/com/soplay/sozo/preview/HlsFramePreview.kt b/android/app/src/main/kotlin/com/soplay/sozo/preview/HlsFramePreview.kt index 55b9e8df..640931f8 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/preview/HlsFramePreview.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/preview/HlsFramePreview.kt @@ -33,6 +33,11 @@ object HlsFramePreview { private const val MAX_W = 360 private const val LINGER_MS = 60_000L + // A playlist, a whole segment and a decode on whatever connection there is. + // 5 seconds was never enough for a 2 MB segment on mobile data, and giving + // up meant paying for it again. Stays below the Dart side's frame allowance. + private const val FRAME_WAIT_MS = 18_000L + private val lock = Any() private val main = Handler(Looper.getMainLooper()) private var extractor: FrameExtractor? = null @@ -40,7 +45,7 @@ object HlsFramePreview { private var generation = 0L private val release = Runnable { replace(null, null) } - fun open(context: Context, url: String, sessionId: Long): Boolean { + fun open(context: Context, url: String, sessionId: Long, progressive: Boolean = false): Boolean { synchronized(lock) { if (sessionId < generation) return false generation = sessionId @@ -54,7 +59,7 @@ object HlsFramePreview { // a progressive file and failed on every frame. val item = MediaItem.Builder() .setUri(url) - .setMimeType(MimeTypes.APPLICATION_M3U8) + .apply { if (!progressive) setMimeType(MimeTypes.APPLICATION_M3U8) } .build() val built = FrameExtractor.Builder(context.applicationContext, item) .setSeekParameters(SeekParameters.CLOSEST_SYNC) @@ -93,7 +98,7 @@ object HlsFramePreview { return null } return try { - val frame = pending.get(5, TimeUnit.SECONDS) + val frame = pending.get(FRAME_WAIT_MS, TimeUnit.MILLISECONDS) if (synchronized(lock) { sessionId != generation }) null else encode(frame.bitmap) } catch (t: java.util.concurrent.TimeoutException) { // The extractor runs its requests one after another; one left diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..84f95d26 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward.xml b/android/app/src/main/res/drawable/ic_pip_forward.xml new file mode 100644 index 00000000..66eea3b3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward_10.xml b/android/app/src/main/res/drawable/ic_pip_forward_10.xml new file mode 100644 index 00000000..099e79df --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward_10.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward_30.xml b/android/app/src/main/res/drawable/ic_pip_forward_30.xml new file mode 100644 index 00000000..942ab9f4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward_30.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_forward_5.xml b/android/app/src/main/res/drawable/ic_pip_forward_5.xml new file mode 100644 index 00000000..f7ca4113 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_forward_5.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_next.xml b/android/app/src/main/res/drawable/ic_pip_next.xml new file mode 100644 index 00000000..939365a1 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_next.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_pause.xml b/android/app/src/main/res/drawable/ic_pip_pause.xml new file mode 100644 index 00000000..c984ad96 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_pause.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_play.xml b/android/app/src/main/res/drawable/ic_pip_play.xml new file mode 100644 index 00000000..d8efce31 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_play.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_prev.xml b/android/app/src/main/res/drawable/ic_pip_prev.xml new file mode 100644 index 00000000..1087fb3d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_prev.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay.xml b/android/app/src/main/res/drawable/ic_pip_replay.xml new file mode 100644 index 00000000..43dce0c0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay_10.xml b/android/app/src/main/res/drawable/ic_pip_replay_10.xml new file mode 100644 index 00000000..d642263c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay_10.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay_30.xml b/android/app/src/main/res/drawable/ic_pip_replay_30.xml new file mode 100644 index 00000000..09d66c5f --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay_30.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_pip_replay_5.xml b/android/app/src/main/res/drawable/ic_pip_replay_5.xml new file mode 100644 index 00000000..ea524253 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pip_replay_5.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 79d695e1..19e74afc 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,4 +1,5 @@ - \ No newline at end of file + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 79d695e1..19e74afc 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,4 +1,5 @@ - \ No newline at end of file + + diff --git a/android/app/src/test/kotlin/com/soplay/sozo/HlsFileJoinTest.kt b/android/app/src/test/kotlin/com/soplay/sozo/HlsFileJoinTest.kt new file mode 100644 index 00000000..618f4a80 --- /dev/null +++ b/android/app/src/test/kotlin/com/soplay/sozo/HlsFileJoinTest.kt @@ -0,0 +1,106 @@ +package com.soplay.sozo + +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.ByteBuffer +import java.nio.file.Files +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Saving an HLS download out of the app used to copy only its playlist. */ +class HlsFileJoinTest { + private val dir: File = Files.createTempDirectory("join").toFile() + + private fun index(text: String): File = File(dir, "index.m3u8").apply { writeText(text.trimIndent()) } + + private fun joined(plan: HlsFileJoin.Plan): ByteArray = + ByteArrayOutputStream().also { HlsFileJoin.write(plan, it) }.toByteArray() + + private fun encrypted(plain: ByteArray, key: ByteArray, iv: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(plain) + } + + @Test + fun `plain segments join in playlist order as ts`() { + File(dir, "seg_0.ts").writeBytes(byteArrayOf(1, 2)) + File(dir, "seg_1.ts").writeBytes(byteArrayOf(3)) + val plan = HlsFileJoin.plan( + index( + """ + #EXTM3U + #EXTINF:10, + seg_0.ts + #EXTINF:10, + seg_1.ts + #EXT-X-ENDLIST + """, + ), + ) + assertEquals(".ts", plan.extension) + assertArrayEquals(byteArrayOf(1, 2, 3), joined(plan)) + } + + @Test + fun `aes-128 is decrypted, the iv coming from the sequence number when none is named`() { + val key = ByteArray(16) { it.toByte() } + File(dir, "key.bin").writeBytes(key) + val iv = ByteBuffer.allocate(16).putLong(8, 7L).array() + File(dir, "seg_0.ts").writeBytes(encrypted(byteArrayOf(9, 8, 7), key, iv)) + val explicit = ByteArray(16).also { it[15] = 5 } + File(dir, "seg_1.ts").writeBytes(encrypted(byteArrayOf(6), key, explicit)) + val plan = HlsFileJoin.plan( + index( + """ + #EXTM3U + #EXT-X-MEDIA-SEQUENCE:7 + #EXT-X-KEY:METHOD=AES-128,URI="key.bin" + seg_0.ts + #EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x00000000000000000000000000000005 + seg_1.ts + """, + ), + ) + assertArrayEquals(byteArrayOf(9, 8, 7, 6), joined(plan)) + } + + @Test + fun `fragmented mp4 starts with its init section, once`() { + File(dir, "init.mp4").writeBytes(byteArrayOf(0)) + File(dir, "seg_0.m4s").writeBytes(byteArrayOf(1)) + File(dir, "seg_1.m4s").writeBytes(byteArrayOf(2)) + val plan = HlsFileJoin.plan( + index( + """ + #EXTM3U + #EXT-X-MAP:URI="init.mp4" + seg_0.m4s + #EXT-X-MAP:URI="init.mp4" + seg_1.m4s + """, + ), + ) + assertEquals(".mp4", plan.extension) + assertArrayEquals(byteArrayOf(0, 1, 2), joined(plan)) + } + + @Test(expected = IllegalStateException::class) + fun `refuses a separate audio track, which would come out silent`() { + HlsFileJoin.plan(index("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nvideo.m3u8")) + } + + @Test(expected = IllegalStateException::class) + fun `refuses a download missing a segment`() { + HlsFileJoin.plan(index("#EXTM3U\nseg_missing.ts")) + } + + @Test(expected = IllegalStateException::class) + fun `refuses sample-aes, which the file would still be encrypted with`() { + HlsFileJoin.plan(index("#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"k\"\nseg_0.ts")) + } +} diff --git a/android/app/src/test/kotlin/com/soplay/sozo/WebPageSniffTest.kt b/android/app/src/test/kotlin/com/soplay/sozo/WebPageSniffTest.kt new file mode 100644 index 00000000..17f04568 --- /dev/null +++ b/android/app/src/test/kotlin/com/soplay/sozo/WebPageSniffTest.kt @@ -0,0 +1,64 @@ +package com.soplay.sozo + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +/** + * Telling a web page from media by its first bytes. The Content-Type used to + * decide, and disguised HLS CDNs label real video `text/html`. + */ +class WebPageSniffTest { + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } + + @Test + fun `MPEG-TS served as page-N html is media`() { + // vidapi's page-0.html and anikoto's seg-2-f1-v1-a1.html, as served. + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(bytes(0x47, 0x40, 0x00, 0x30, 0x10, 0x00, 0x00, 0xB0))) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(bytes(0x47, 0x40, 0x00, 0x11))) + } + + @Test + fun `fMP4, Matroska and images are media`() { + assertFalse(HlsDownloadPlaylist.looksLikeWebPage("\u0000\u0000\u0000\u0018ftypmp42".toByteArray(Charsets.ISO_8859_1))) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(bytes(0x1A, 0x45, 0xDF, 0xA3, 0x9F))) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(bytes(0xFF, 0xD8, 0xFF, 0xE0))) + // A comic page can be an SVG; it is not a web page. + assertFalse(HlsDownloadPlaylist.looksLikeWebPage("".toByteArray())) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage("".toByteArray())) + } + + @Test + fun `encrypted segments, which are random bytes, are media`() { + val random = Random(7) + repeat(2000) { + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(random.nextBytes(512))) + } + } + + @Test + fun `an HTML page is refused whatever it is labelled`() { + assertTrue(HlsDownloadPlaylist.looksLikeWebPage("".toByteArray())) + assertTrue(HlsDownloadPlaylist.looksLikeWebPage("".toByteArray())) + assertTrue(HlsDownloadPlaylist.looksLikeWebPage("403".toByteArray())) + assertTrue(HlsDownloadPlaylist.looksLikeWebPage("".toByteArray())) + } + + @Test + fun `a BOM and leading blank lines do not hide a page`() { + val page = bytes(0xEF, 0xBB, 0xBF) + "\r\n\n \t".toByteArray() + assertTrue(HlsDownloadPlaylist.looksLikeWebPage(page)) + } + + @Test + fun `only the bytes read so far count`() { + val page = "".toByteArray() + ByteArray(506) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(page, 0)) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(page, 3)) + assertTrue(HlsDownloadPlaylist.looksLikeWebPage(page, 6)) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(ByteArray(0))) + assertFalse(HlsDownloadPlaylist.looksLikeWebPage(" ".toByteArray())) + } +} diff --git a/android/app/src/test/kotlin/com/soplay/sozo/cloudstream/PluginSearchTest.kt b/android/app/src/test/kotlin/com/soplay/sozo/cloudstream/PluginSearchTest.kt new file mode 100644 index 00000000..8d032446 --- /dev/null +++ b/android/app/src/test/kotlin/com/soplay/sozo/cloudstream/PluginSearchTest.kt @@ -0,0 +1,108 @@ +package com.soplay.sozo.cloudstream + +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.newMovieSearchResponse +import com.lagradost.cloudstream3.newSearchResponseList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which search overload the host asks a CloudStream plugin through. + * + * MovieBox (phisher98) implements only `search(query, page)`, and MainAPI's + * one-argument `search(query)` is `throw NotImplementedError()` in library + * 4.8 — so every MovieBox search failed with "NotImplementedError: An + * operation is not implemented." These run against the real library MainAPI, + * so a library bump that changes either default shows up here. + */ +class PluginSearchTest { + + /** Builds the cards; the library's builders hang off a MainAPI. */ + private object Maker : MainAPI() { + override var name = "Maker" + } + + private fun card(name: String): SearchResponse = + Maker.newMovieSearchResponse(name, "https://example.test/$name") + + /** Implements only the paged overload, like MovieBox. */ + private class PagedOnly(private val pages: Map) : MainAPI() { + override var name = "PagedOnly" + val asked = mutableListOf() + override suspend fun search(query: String, page: Int): SearchResponseList? { + asked += page + return pages[page] + } + } + + /** Written before paging existed: only `search(query)`. */ + private class Legacy(private val results: List?) : MainAPI() { + override var name = "Legacy" + var calls = 0 + override suspend fun search(query: String): List? { + calls++ + return results + } + } + + @Test + fun `the library still throws from the one-argument search of a paged-only plugin`() { + // The precondition for the fix. If a library bump makes this default + // delegate to the paged one, the host's choice stops mattering. + val api = PagedOnly(emptyMap()) + val thrown = runCatching { runBlocking { api.search("hellsing") } }.exceptionOrNull() + assertTrue("expected NotImplementedError, got $thrown", thrown is NotImplementedError) + } + + @Test + fun `a paged-only plugin is searched, and its own hasNext is kept`() = runBlocking { + val api = PagedOnly( + mapOf( + 1 to newSearchResponseList(listOf(card("Hellsing"), card("Hellsing Ultimate")), true), + 2 to newSearchResponseList(listOf(card("Hellsing: The Dawn")), false), + ), + ) + val first = searchPage(api, "hellsing", 1)!! + assertEquals(listOf("Hellsing", "Hellsing Ultimate"), first.items.map { it.name }) + assertTrue(first.hasNext) + + val second = searchPage(api, "hellsing", 2)!! + assertEquals(listOf("Hellsing: The Dawn"), second.items.map { it.name }) + assertFalse(second.hasNext) + assertEquals(listOf(1, 2), api.asked) + } + + @Test + fun `a legacy plugin still answers page one, through the library default`() = runBlocking { + val api = Legacy(listOf(card("Naruto"))) + val first = searchPage(api, "naruto", 1)!! + assertEquals(listOf("Naruto"), first.items.map { it.name }) + // The default never claims more, so the screen offers no page two. + assertFalse(first.hasNext) + assertEquals(1, api.calls) + } + + @Test + fun `a legacy plugin is not asked for page two, which would be page one again`() = runBlocking { + val api = Legacy(listOf(card("Naruto"))) + assertNull(searchPage(api, "naruto", 2)) + assertEquals(0, api.calls) + } + + @Test + fun `a legacy plugin answering null is no results, not a crash`() = runBlocking { + assertNull(searchPage(Legacy(null), "nothing", 1)) + } + + @Test + fun `override detection tells the two shapes apart`() { + assertTrue(implementsPagedSearch(PagedOnly(emptyMap()))) + assertFalse(implementsPagedSearch(Legacy(emptyList()))) + } +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html deleted file mode 100644 index 9626b18f..00000000 --- a/android/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 221247cf..a37719b0 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -34,7 +34,9 @@ "remove": "إزالة", "show": "إظهار", "hide": "إخفاء", - "installed": "مثبّت" + "installed": "مثبّت", + "load_more_failed": "تعذّر تحميل المزيد", + "go_home": "العودة إلى الرئيسية" }, "auth": { "login": "تسجيل الدخول", @@ -306,7 +308,11 @@ "no_source": "لا يوجد مصدر لهذه الحلقة.", "empty_url": "لم يُرجِع هذا المصدر عنوان فيديو.", "engine_unavailable": "لم يبدأ المشغّل. أعد تشغيل التطبيق بالكامل.", - "unknown": "تعذّر تحميل الفيديو." + "unknown": "تعذّر تحميل الفيديو.", + "not_found": "لم يعد هذا الفيديو موجودًا على هذا الخادم. جرّب خادمًا آخر.", + "server_error": "يواجه خادم الفيديو مشكلة. حاول مجددًا بعد قليل أو اختر خادمًا آخر.", + "resolve_failed": "تعذّر الحصول على رابط الفيديو من هذا المصدر.", + "details": "التفاصيل" }, "play": "تشغيل", "pause": "إيقاف مؤقت", @@ -529,6 +535,22 @@ "alt_guess": "تخمين", "alt_guess_spoken": "تطابق غير مؤكد — تحقق من العنوان قبل التشغيل", "alt_your_pick": "اختيارك", + "alt_auto": "تلقائي", + "alt_auto_hint": "يشغّل من أول مصدر يتوفر فيه هذا", + "alt_auto_trying": "جارٍ تجربة {}…", + "alt_auto_missed": "لم يتمكن أي مصدر مطابق من تشغيله. اختر واحدًا أدناه أو ابحث في مصدر.", + "alt_auto_checking": "جارٍ التحقق من {} · {} من {}", + "alt_auto_waiting": "بانتظار مصادر أخرى…", + "alt_auto_stop": "إيقاف", + "alt_auto_stopped": "تم الإيقاف.", + "alt_auto_capped": "تم التحقق من {} مصادر ولم يتمكن أي منها من تشغيل هذا. اختر واحدًا أدناه أو حاول مجددًا.", + "alt_auto_tried": "تم التحقق", + "alt_skip_no_match": "لا يوجد تطابق قريب", + "alt_skip_no_episode": "الحلقة غير موجودة", + "alt_skip_error": "تعذر التحميل", + "auto_switch_attempt": "المحاولة {} من {}", + "auto_switch_stop": "إيقاف", + "auto_switch_stopped": "توقف تبديل الخوادم. حاول مجددًا أو اختر خادمًا أو مصدرًا آخر.", "alt_wrong_title": "العنوان خاطئ؟", "alt_wrong_title_hint": "ابحث بنفسك في {} واختر العنوان الصحيح", "alt_corrected": "يُستخدم الآن «{}»", @@ -554,10 +576,35 @@ "subtitle_n": "ترجمة {}", "subtitle_in_video": "ضمن الفيديو", "rewind": "ترجيع", + "rewind_by": "ترجيع {} ثانية", + "forward_by": "تقديم {} ثانية", "mini_restore": "العودة إلى النافذة الكاملة", "pip_unavailable": "تعذّر تشغيل صورة داخل صورة لهذا الفيديو.", "scrub_release_cancel": "اترك للإلغاء", - "auto_quality_desc_mpv": "يبدأ بأفضل جودة يوفرها البث" + "auto_quality_desc_mpv": "يبدأ بأفضل جودة يوفرها البث", + "reconnecting": "انقطع الاتصال — جارٍ إعادة الاتصال…", + "all_servers_failed": "لم يتمكن أي خادم لهذا العنوان من تشغيله. حاول لاحقًا أو اختر مصدرًا آخر.", + "shortcut_pip": "مشغّل مصغّر (Shift: فوق التطبيقات الأخرى)", + "mini_popout_hint": "مشغّل مصغّر · اضغط مطولًا ليطفو فوق التطبيقات الأخرى", + "react": "تفاعل", + "comments": "التعليقات", + "episode_comments": "تعليقات الحلقة {}", + "reaction_text": "اكتب تفاعلاً", + "reaction_text_hint": "اكتب شيئاً قصيراً…", + "reaction_wait": "انتظر بضع ثوانٍ قبل التفاعل مجدداً", + "reaction_limit": "وصلت إلى حد التفاعلات لهذه الحلقة", + "reaction_failed": "تعذّر إرسال التفاعل", + "native_offer_title": "هل تريد تجربة المشغّل الجديد؟", + "native_offer_body": "أزرار وقوائم Liquid Glass من Apple. يمكنك تغيير ذلك في أي وقت من الإعدادات ← المشغّل.", + "native_offer_try": "جرّبه", + "native_offer_not_now": "ليس الآن", + "trying_again": "إعادة المحاولة (المحاولة {})", + "try_mpv": "جرّب باستخدام محرك mpv", + "ok_to_skip": "اضغط OK للتخطي", + "quality_lowered": "تم خفض الجودة", + "undo": "تراجع", + "crash_log": "سجل الأعطال", + "crash_log_empty": "لم تُسجَّل أي أعطال" }, "profile": { "title": "حسابي", @@ -649,6 +696,15 @@ "player_engine_section": "محرّك التشغيل", "ask_engine_on_play": "اسأل في كل مرة", "ask_engine_on_play_desc": "اختر المحرّك عند بدء كل فيديو", + "controls_style": "نمط أزرار المشغّل", + "controls_style_desc": "يُطبَّق في المرة القادمة التي تفتح فيها المشغّل.", + "controls_style_native": "أصلي (iOS 26)", + "controls_style_native_desc": "أزرار وقوائم ونوافذ Liquid Glass الخاصة بالهاتف", + "controls_style_system": "زجاج النظام", + "controls_style_system_desc": "أزرار التطبيق على زجاج Liquid Glass الخاص بالهاتف", + "controls_style_classic": "زجاج كلاسيكي", + "controls_style_classic_desc": "زجاج التطبيق الخاص، كما في الإصدارات السابقة", + "controls_style_needs_ios26": "يتطلب iOS 26 أو أحدث", "playback_section": "التشغيل", "default_speed": "السرعة الافتراضية", "default_speed_desc": "السرعة التي يبدأ بها كل فيديو", @@ -671,6 +727,8 @@ "brightness_gesture_desc": "اسحب لأعلى وأسفل على الجانب الأيسر", "volume_gesture": "سحب الصوت", "volume_gesture_desc": "اسحب لأعلى وأسفل على الجانب الأيمن", + "auto_pip": "صورة داخل صورة تلقائيًا", + "auto_pip_desc": "عند الضغط على زر الشاشة الرئيسية أثناء تشغيل فيديو، يستمر في نافذة صغيرة", "seconds_short": "{}ث", "subtitle_style_note": "تنطبق هذه على كل فيديو. أمّا توقيت الترجمة فيبقى لكل حلقة ويُضبط داخل المشغّل.", "searching_all_sources": "البحث في كل المصادر", @@ -743,7 +801,15 @@ "library_update": "التحقق من الحلقات الجديدة", "library_update_desc": "يتفقد العناوين التي تتابعها ويُعلمك عند صدور جديد", "library_update_every": "كل {} ساعات", - "library_update_daily": "مرة يوميًا" + "library_update_daily": "مرة يوميًا", + "contributors": "المساهمون", + "contributors_error": "تعذّر تحميل المساهمين", + "become_contributor": "كن مساهمًا", + "become_contributor_body": "Sozo مفتوح المصدر. الكود والترجمات وتقارير الأخطاء كلها تساعد.", + "reactions_overlay": "تفاعلات المشاهدين", + "reactions_overlay_desc": "عرض تفاعلات المشاهدين الآخرين في لحظة إرسالها، وأماكن تجمّعها على شريط التقديم", + "hide_spoilers": "إخفاء الحرق للحلقات التي لم أشاهدها", + "hide_spoilers_desc": "تبقى التعليقات عن حلقة لم تُكملها ضبابية حتى تضغط عليها" }, "appearance": { "title": "المظهر", @@ -866,8 +932,20 @@ "all_sources_failed_one": "فشل المصدر الذي بحثت فيه في الرد. هذه ليست نتيجة فارغة — حاول مرة أخرى.", "voice": "البحث الصوتي", "voice_listening": "جارٍ الاستماع…", - "voice_done": "توقّف الاستماع", "voice_hint": "قل اسم فيلم أو مسلسل", + "voice_starting": "جارٍ تجهيز الميكروفون…", + "voice_no_speech": "لم نتمكن من سماعك", + "voice_no_speech_hint": "تحدّث بصوت أعلى قليلاً وبالقرب من الميكروفون، ثم حاول مرة أخرى.", + "voice_permission": "إذن الميكروفون متوقف", + "voice_permission_hint": "اسمح لـ Sozo باستخدام الميكروفون من إعدادات النظام للبحث بالصوت.", + "voice_unavailable": "التعرّف على الكلام غير متاح على هذا الجهاز", + "voice_unavailable_hint": "لا توجد خدمة للتعرّف على الكلام على هذا الجهاز. ثبّت خدمة أو فعّلها من إعدادات النظام، مثل خدمة Google.", + "voice_network": "تعذّر الوصول إلى خدمة الكلام", + "voice_network_hint": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", + "voice_error": "حدث خطأ أثناء الاستماع", + "voice_error_hint": "ربما يستخدم تطبيق آخر الميكروفون. حاول مرة أخرى بعد قليل.", + "voice_try_again": "حاول مرة أخرى", + "voice_open_settings": "فتح الإعدادات", "try_torrents": "البحث في التورنت", "capped_note": "تم البحث في أكثر {} مصدرًا موثوقية من أصل {} — ضيّق النطاق للوصول إلى مصدر بعينه.", "rail_popular_on": "رائج على {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "نسيت الرمز؟" }, "my_list": { + "sort": "ترتيب", + "sort_added": "المضاف حديثًا", + "sort_oldest": "الأقدم إضافة", + "sort_title": "العنوان أ–ي", "saved_account": "محفوظ في الحساب", "saved_local": "على هذا الجهاز", "untitled": "بدون عنوان", @@ -1160,7 +1242,33 @@ "reconnecting": "جارٍ إعادة الاتصال…", "connecting": "جارٍ الاتصال…", "connect_failed_title": "تعذّر الاتصال", - "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى." + "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", + "settings_title": "إعدادات الغرفة", + "room_size": "حجم الغرفة", + "room_size_less": "عدد أقل من الأشخاص", + "room_size_more": "عدد أكبر من الأشخاص", + "only_host_setting": "المضيف وحده يتحكم في التشغيل", + "lock_room": "قفل الغرفة", + "lock_room_hint": "لا يمكن لأحد جديد الانضمام", + "wait_for_all": "انتظار الجميع", + "wait_for_all_hint": "إيقاف مؤقت قصير عندما يكون أحدهم في التخزين المؤقت", + "invite_friends": "دعوة الأصدقاء", + "invite_sent": "تم إرسال الدعوة", + "invite_failed": "تعذّر إرسال الدعوة", + "invite_again_in": "مرة أخرى بعد {}", + "invite_no_friends": "أضف أصدقاء لتتمكن من دعوتهم هنا", + "make_host": "تعيين كمضيف", + "remove_member": "إزالة من الحفلة", + "remove_confirm": "إزالة {} من هذه الحفلة؟ لن يتمكن من الانضمام مجددًا.", + "buffering": "جارٍ التخزين المؤقت…", + "sys_joined": "انضم {}", + "sys_left": "غادر {}", + "sys_host": "{} هو المضيف الآن", + "sys_removed": "تمت إزالة {}", + "error_banned": "تمت إزالتك من هذه الحفلة", + "error_locked": "هذه الحفلة مقفلة", + "room_closed_kicked": "أزالك المضيف من هذه الحفلة", + "waiting_for_others": "بانتظار أن يلحق الجميع…" }, "notifications": { "title": "الإشعارات", @@ -1281,7 +1389,19 @@ "highlights": "التظليلات", "remove_highlight": "إزالة التظليل", "highlight_note": "أضف ملاحظة (اختياري)", - "no_highlights": "لا توجد تظليلات بعد. حدّد نصًا واختر «تظليل»." + "no_highlights": "لا توجد تظليلات بعد. حدّد نصًا واختر «تظليل».", + "translate_page": "ترجمة الصفحة", + "translate_preview": "الصفحة المترجمة", + "translate_apply": "تطبيق", + "translate_show_original": "عرض الأصل", + "translate_no_text": "لم يُعثر على نص في هذه الصفحة.", + "translate_daily_limit": "لقد استهلكت ترجمات اليوم. حاول مجددًا غدًا.", + "translate_offline": "أنت غير متصل. اتصل بالإنترنت لترجمة هذه الصفحة.", + "translate_sign_in": "سجّل الدخول لترجمة الصفحات.", + "translate_failed": "تعذّرت ترجمة هذه الصفحة. حاول مرة أخرى.", + "translate_language": "ترجمة الصفحات إلى", + "translate_auto": "ترجمة كل صفحة أثناء القراءة", + "translate_auto_desc": "كل صفحة جديدة تستهلك ترجمة واحدة من رصيد اليوم." }, "cloudflare": { "title": "تأكيد أنك لست روبوتاً", @@ -1363,6 +1483,10 @@ "watched": "تمت المشاهدة", "follow_series": "متابعة المسلسل", "find_other_sources": "البحث عن مصادر أخرى", + "notice_title": "هذا المصدر لا يستطيع تشغيل هذا العمل", + "notice_moved_to_app": "نقل المصدر هذا العمل إلى تطبيقه الخاص، فلم يبق هنا ما يمكن تشغيله. قد يكون متوفرًا في مصدر آخر.", + "notice_no_player": "يعرض المصدر هذا العمل لكن لا يوجد له مشغّل. قد يكون متوفرًا في مصدر آخر.", + "notice_is_series": "في هذا المصدر هذا مسلسل، لذا يُشغّل من قائمة الحلقات وليس كفيلم واحد.", "anilist_track": "التتبّع على AniList", "anilist_tracked": "مُتتبَّع على AniList", "following_on": "أتابع", @@ -1393,6 +1517,8 @@ "download_batch_body": "ستُضاف الحلقات {range} إلى القائمة — {count} في المجموع. على بيانات الجوال قد يبلغ ذلك عدّة جيجابايت.", "relations": "أعمال مرتبطة", "no_relations": "لم يُعثر على أعمال مرتبطة بهذا.", + "relation_tmdb_collection": "من نفس السلسلة", + "relation_tmdb_recommended": "مقترحات", "relation_sequel": "التتمة", "relation_prequel": "الجزء السابق", "relation_parent": "القصة الأصلية", @@ -1461,6 +1587,8 @@ "offline_desc": "تُعرض القائمة المحفوظة. دون اتصال تُفتح الحلقات المنزّلة فقط.", "filter_hint": "رقم الحلقة أو عنوانها", "all_groups": "كل المجموعات", + "season_n": "الموسم {}", + "specials": "حلقات خاصة", "filter_none": "لا توجد حلقة تطابق “{query}” في هذا الجزء", "jump_to": "الانتقال إلى الحلقة {n}", "jump_loads": "سيتم تحميل {range}", @@ -1505,7 +1633,10 @@ "show_more": "عرض المزيد", "delete_confirm_title": "حذف هذا التعليق؟", "delete_confirm_body": "لا يمكن التراجع عن هذا الإجراء.", - "sign_in_prompt": "سجّل الدخول لكتابة تعليق" + "sign_in_prompt": "سجّل الدخول لكتابة تعليق", + "spoiler": "حرق", + "spoiler_hidden": "حرق للأحداث · اضغط للعرض", + "unwatched_hidden": "لم تشاهده بعد · اضغط للعرض" }, "downloads": { "view_titles": "العناوين", @@ -1721,7 +1852,7 @@ "manual_title": "أو أدخل الرمز", "confirm": "تأكيد", "approved": "تم ربط التلفاز", - "approved_named": "\\u201c{}\\u201d تم ربطه", + "approved_named": "“{}” تم ربطه", "approved_hint": "سيسجّل التلفاز الدخول إلى حسابك خلال ثوانٍ.", "linked_devices": "الأجهزة المرتبطة", "no_devices": "لا توجد أجهزة مرتبطة بعد", @@ -1959,7 +2090,8 @@ "title": "قوائمي", "remove": "إزالة", "empty_title": "لا شيء محفوظ بعد", - "empty_subtitle": "ستظهر هنا الأعمال التي تحفظها." + "empty_subtitle": "ستظهر هنا الأعمال التي تحفظها.", + "load_failed": "تعذّر تحميل هذه القائمة." }, "torrent": { "title": "التورنت", @@ -2233,7 +2365,25 @@ "settings_title": "إعدادات {}", "settings_reset": "إعادة التعيين", "settings_failed": "تعذّرت قراءة إعدادات هذا المصدر.", - "settings_none": "لا توجد إعدادات لهذا المصدر." + "settings_none": "لا توجد إعدادات لهذا المصدر.", + "loading_repo": "جارٍ تحميل المستودع…", + "add_sources": "إضافة مصادر", + "choose_sources": "اختر المصادر", + "select_all": "تحديد الكل", + "select_none": "إلغاء التحديد", + "install_selected": "تثبيت ({})", + "already_installed": "مثبّت", + "remove": "إزالة", + "remove_source": "إزالة المصدر", + "remove_source_confirm": "إزالة {}؟ ستُحذف إعداداته والشيفرة المخزّنة. يمكنك إضافته مجددًا من مستودعه.", + "remove_repo_confirm": "إزالة هذا المستودع وجميع مصادره؟", + "enable_source": "تفعيل", + "disable_source": "تعطيل", + "source_disabled": "معطّل", + "update": "تحديث", + "update_all": "تحديث الكل", + "updates_available": "تتوفر {} تحديثات", + "repo_check_failed": "تعذّر التحقق من {}: {}" }, "ux": { "quick_search": "بحث سريع ({})", @@ -2304,8 +2454,8 @@ "check_all": "فحص المصادر", "check_bad_hint": "إخفاء المصادر المتوقفة يبعدها عن هذه القوائم، وتعود وحدها عندما تعمل مجددًا.", "check_done": "فحص المصادر", - "check_hide_down": "تعذّر الوصول إلى معظم المصادر — المشكلة في الاتصال لا في المصادر. لم يُعلَّم شيء؛ أعد المحاولة على شبكة تعمل.", - "check_network": "إخفاء المصادر المتوقفة", + "check_hide_down": "إخفاء المصادر المتوقفة", + "check_network": "تعذّر الوصول إلى معظم المصادر — المشكلة في الاتصال لا في المصادر. لم يُعلَّم شيء؛ أعد المحاولة على شبكة تعمل.", "check_retrying": "إعادة محاولة المصادر الفاشلة…", "checking": "جارٍ فحص {} من {}…", "d_ago": "قبل {} يوم", @@ -2319,6 +2469,8 @@ "v_blocked": "محجوب", "v_dead": "لا يعمل", "v_empty": "فارغ", + "v_empty_body": "استجاب المصدر، لكن قائمته جاءت فارغة. هكذا يبدو غالبًا موقع حجب التطبيق.", + "v_failing_body": "فشل آخر فحص لهذا المصدر. قد يكون الفشل الواحد مجرد لحظة سيئة — أعد الفحص.", "v_failing": "لا يستجيب", "v_outdated": "يحتاج تحديثًا", "v_unknown": "لم يُفحص بعد", @@ -2531,7 +2683,18 @@ "card_single": "أضف ملفات شخصية لمن تشاركهم هذا الحساب", "load_failed": "تعذّر تحميل الملفات الشخصية", "offline": "تحقق من اتصالك وحاول مرة أخرى.", - "error_generic": "حدث خطأ ما. حاول مرة أخرى." + "error_generic": "حدث خطأ ما. حاول مرة أخرى.", + "on_this_device": "على هذا الجهاز", + "ask_on_launch": "اسأل من يشاهد عند التشغيل", + "ask_on_launch_desc": "عند الإيقاف، يفتح Sozo آخر ملف شخصي مستخدم. الملفات المحمية برمز PIN ستطلبه دائمًا.", + "pin_locked_until": "محاولات كثيرة جدًا. حاول مجددًا بعد {}.", + "pin_not_on_device": "أنت غير متصل، ولم يُدخل رمز PIN لهذا الملف على هذا الجهاز بعد. اتصل بالإنترنت مرة واحدة لفتحه.", + "biometric_reason": "فتح {}", + "biometric_offer_title": "استخدام البصمة / Face ID؟", + "biometric_offer_body": "استخدم البصمة / Face ID لـ {} على هذا الجهاز. سيبقى رمز PIN يعمل أيضًا.", + "biometric_offer_yes": "استخدامها", + "biometric_offer_no": "ليس الآن", + "biometric_toggle": "البصمة / Face ID لـ {}" }, "jellyfin": { "connect_server": "ربط خادم وسائط (Jellyfin)", @@ -2782,6 +2945,8 @@ "priming_title": "لا تفوّت أي حلقة جديدة", "priming_body": "يخبرك Sozo فور صدور حلقة أو فصل جديد لما تتابعه — ولا شيء لم تطلبه.", "priming_body_title": "اعرف فور صدور حلقة جديدة من «{}» — ولا شيء لم تطلبه.", + "priming_download_title": "اعرف متى يكتمل التنزيل", + "priming_download_body": "يعرض Sozo تقدّم التنزيل في الإشعارات ويخبرك عندما يصبح جاهزًا للمشاهدة دون اتصال. ويمكنه أيضًا إعلامك بالحلقات الجديدة لما تتابعه.", "priming_point_releases": "حلقات جديدة مع الملصق", "priming_point_quiet": "ساعات الهدوء", "priming_point_control": "كتم أي عمل", @@ -3008,6 +3173,101 @@ "error_attachment_type": "صور jpg أو png أو webp فقط.", "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", - "suggest": "تتكرر المشكلة؟ راسل الدعم" + "suggest": "تتكرر المشكلة؟ راسل الدعم", + "error_offline": "أنت غير متصل أو انقطع الاتصال. تحقق من الإنترنت ثم حاول مرة أخرى.", + "not_sent": "لم يُرسل: {}", + "diag_crash_log": "عرض سجل الأعطال" + }, + "zangetsu": { + "title": "مصادر Zangetsu", + "row_subtitle": "مصادر JavaScript من مستودعات خارجية، تعمل على جهازك", + "desc": "أضف مستودع Zangetsu لتثبيت مصادر إضافية للأنمي والأفلام. تعمل على جهازك. المصادر الموجودة في Sozo بالفعل تظهر بعلامة «مدمج» ويُستخدم إصدار Sozo الخاص بها.", + "loading_repo": "جارٍ تحميل المستودع…", + "repo_added": "تمت إضافة المستودع · {} مصدر.", + "checking": "جارٍ التحقق من تحديثات المستودعات…", + "updates_available": "{} تحديث متاح.", + "installed_one": "تم تثبيت {}.", + "removed_one": "تمت إزالة {}.", + "updated": "تم تحديث {} مصدر.", + "update_all": "تحديث الكل ({})", + "update_to": "التحديث إلى v{}", + "built_in": "مدمج", + "search": "ابحث في المصادر…", + "all_types": "الكل", + "type_movies": "أفلام", + "all_languages": "كل اللغات", + "error_invalid_url": "يمكن إضافة روابط https على raw.githubusercontent.com أو gist.githubusercontent.com فقط.", + "error_unreachable": "تعذّر الوصول إلى هذا المستودع. تحقّق من الرابط ومن اتصالك.", + "error_invalid_index": "هذا الرابط لا يشير إلى ملف index.json صالح لـ Zangetsu." + }, + "star": { + "title": "هل تريد مساعدة Sozo على النمو؟", + "body": "أضف له نجمة على GitHub ⭐", + "action": "أضف نجمة على GitHub", + "later": "لاحقًا", + "never": "لا تسألني مجددًا", + "update_button": "أضف نجمة لـ Sozo على GitHub" + }, + "progress": { + "title": "المستوى والخبرة", + "level": "المستوى", + "level_short": "مس {}", + "level_up": "ارتقاء مستوى", + "level_reached": "وصلت إلى المستوى {}", + "unlocked_new": "جديد في خزانتك", + "open_locker": "افتح الخزانة", + "unavailable": "المستويات غير متاحة بعد. عد قريبًا.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP حتى المستوى {}", + "to_next_short": "{} XP حتى المستوى التالي", + "watch_today": "مشاهدة اليوم: {} من {} XP", + "how_to_earn": "كيف تكسب XP", + "rule_watch": "المشاهدة: 1 XP لكل دقيقة، حتى 300 يوميًا", + "rule_comment": "تعليق: 5 XP، حتى 10 تعليقات يوميًا", + "rule_like": "إعجاب بتعليقك: 1 XP، حتى 50 يوميًا", + "rule_streak": "يوم سلسلة: 10 XP، وأكثر في أيام الإنجاز", + "rule_achievement": "إنجاز: من 50 إلى 400 XP حسب الدرجة", + "recent": "أحدث XP", + "reason_watch": "المشاهدة", + "reason_comment": "التعليقات", + "reason_like": "إعجابات مستلمة", + "reason_streak": "يوم سلسلة", + "reason_achievement": "إنجاز", + "reason_other": "XP", + "locker": "الخزانة", + "slot_frame": "إطارات الصورة", + "slot_title": "الألقاب", + "slot_color": "ألوان الاسم", + "equip": "اضغط للارتداء", + "equipped": "مرتدى", + "unlock_level": "المستوى {}", + "unlock_badge": "شارة: {}", + "frame_bronze": "برونزي", + "frame_silver": "فضي", + "frame_gold": "ذهبي", + "frame_neon": "نيون", + "frame_sakura": "ساكورا", + "frame_ember": "جمر", + "frame_flame": "لهب", + "frame_moon": "قمر", + "title_newcomer": "وافد جديد", + "title_regular": "معتاد", + "title_veteran": "مخضرم", + "title_legend": "أسطورة", + "title_binger": "مدمن المسلسلات", + "title_cinephile": "عاشق السينما", + "title_otaku": "أوتاكو", + "title_bookworm": "دودة كتب", + "title_night_owl": "بومة الليل", + "title_host": "مضيف الحفلات", + "color_sky": "سماوي", + "color_mint": "نعناعي", + "color_gold": "ذهبي", + "color_rose": "وردي", + "color_violet": "بنفسجي", + "color_crimson": "قرمزي", + "color_aurora": "شفق", + "color_ember": "جمر" } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index f97db42b..5073368a 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -34,7 +34,9 @@ "remove": "لابردن", "show": "پیشاندان", "hide": "شاردنەوە", - "installed": "دامەزراوە" + "installed": "دامەزراوە", + "load_more_failed": "نەتوانرا زیاتر باربکرێت", + "go_home": "بڕۆ بۆ سەرەکی" }, "auth": { "login": "چوونەژوورەوە", @@ -306,7 +308,11 @@ "no_source": "هیچ سەرچاوەیەک بۆ ئەم ئەڵقەیە نییە.", "empty_url": "ئەم سەرچاوەیە هیچ ناونیشانێکی ڤیدیۆی نەگەڕاندەوە.", "engine_unavailable": "لێدەرەکە دەستی پێنەکرد. تکایە ئەپەکە بە تەواوی دابخە و دووبارە بیکەرەوە.", - "unknown": "نەتوانرا ڤیدیۆکە بار بکرێت." + "unknown": "نەتوانرا ڤیدیۆکە بار بکرێت.", + "not_found": "ئەم ڤیدیۆیە چیتر لەسەر ئەم سێرڤەرە نییە. سێرڤەرێکی تر تاقی بکەرەوە.", + "server_error": "سێرڤەری ڤیدیۆ کێشەی هەیە. کەمێکی تر دووبارە هەوڵ بدەرەوە یان سێرڤەرێکی تر هەڵبژێرە.", + "resolve_failed": "نەتوانرا بەستەری ڤیدیۆ لەم سەرچاوەیە وەربگیرێت.", + "details": "وردەکاری" }, "play": "لێدان", "pause": "وەستاندن", @@ -529,6 +535,22 @@ "alt_guess": "گومان", "alt_guess_spoken": "هاوتایی دڵنیا نییە — پێش لێدان ناونیشانەکە بپشکنە", "alt_your_pick": "هەڵبژاردەی تۆ", + "alt_auto": "خۆکار", + "alt_auto_hint": "لە یەکەم سەرچاوە کە ئەمەی هەیە لێدەدات", + "alt_auto_trying": "تاقیکردنەوەی {}…", + "alt_auto_missed": "هیچ سەرچاوەیەکی گونجاو نەیتوانی لێیبدات. یەکێک لە خوارەوە هەڵبژێرە یان لە سەرچاوەیەک بگەڕێ.", + "alt_auto_checking": "پشکنینی {} · {} لە {}", + "alt_auto_waiting": "چاوەڕوانی سەرچاوەی زیاتر…", + "alt_auto_stop": "وەستاندن", + "alt_auto_stopped": "وەستێنرا.", + "alt_auto_capped": "{} سەرچاوە پشکنران و هیچیان نەیتوانی ئەمە لێبدات. یەکێک لە خوارەوە هەڵبژێرە یان دووبارە هەوڵ بدەرەوە.", + "alt_auto_tried": "پشکنراوەکان", + "alt_skip_no_match": "هاوتای نزیک نییە", + "alt_skip_no_episode": "ئەڵقە نەدۆزرایەوە", + "alt_skip_error": "بار نەکرا", + "auto_switch_attempt": "هەوڵی {} لە {}", + "auto_switch_stop": "وەستاندن", + "auto_switch_stopped": "گۆڕینی ڕاژەکار وەستێنرا. دووبارە هەوڵ بدەرەوە، یان ڕاژەکار یان سەرچاوەیەکی تر هەڵبژێرە.", "alt_wrong_title": "ناونیشانی هەڵە؟", "alt_wrong_title_hint": "خۆت لە {} بگەڕێ و ناونیشانی دروست هەڵبژێرە", "alt_corrected": "ئێستا “{}” بەکاردێت", @@ -554,10 +576,35 @@ "subtitle_n": "ژێرنووس {}", "subtitle_in_video": "لەناو ڤیدیۆکەدا", "rewind": "گەڕاندنەوە بۆ دواوە", + "rewind_by": "{} چرکە بۆ دواوە", + "forward_by": "{} چرکە بۆ پێشەوە", "mini_restore": "گەڕانەوە بۆ پەنجەرەی تەواو", "pip_unavailable": "وێنە لەناو وێنە نەیتوانی بۆ ئەم ڤیدیۆیە دەستپێبکات.", "scrub_release_cancel": "بەری بدە بۆ هەڵوەشاندنەوە", - "auto_quality_desc_mpv": "بە باشترین کوالیتی کە پەخشەکە پێشکەشی دەکات دەستپێدەکات" + "auto_quality_desc_mpv": "بە باشترین کوالیتی کە پەخشەکە پێشکەشی دەکات دەستپێدەکات", + "reconnecting": "پەیوەندی پچڕا — دووبارە پەیوەندی دەکرێتەوە…", + "all_servers_failed": "هیچ سێرڤەرێکی ئەم ناونیشانە نەیتوانی لێیبدات. دواتر هەوڵ بدەرەوە یان سەرچاوەیەکی تر هەڵبژێرە.", + "shortcut_pip": "لێدەری بچووک (Shift: لەسەر ئەپەکانی تر)", + "mini_popout_hint": "لێدەری بچووک · پەنجە ڕابگرە بۆ ئەوەی لەسەر ئەپەکانی تر بمێنێتەوە", + "react": "کاردانەوە", + "comments": "لێدوانەکان", + "episode_comments": "لێدوانەکانی ئەڵقەی {}", + "reaction_text": "کاردانەوەیەک بنووسە", + "reaction_text_hint": "شتێکی کورت بنووسە…", + "reaction_wait": "چەند چرکەیەک چاوەڕێ بکە پێش کاردانەوەی دووبارە", + "reaction_limit": "گەیشتیتە سنووری کاردانەوەکان بۆ ئەم ئەڵقەیە", + "reaction_failed": "نەتوانرا کاردانەوەکە بنێردرێت", + "native_offer_title": "لێدەری نوێ تاقی دەکەیتەوە؟", + "native_offer_body": "دوگمە و لیستەکانی Liquid Glassی Apple. هەر کاتێک دەتوانیت لە ڕێکخستنەکان ← لێدەر بیگۆڕیت.", + "native_offer_try": "تاقیکردنەوە", + "native_offer_not_now": "ئێستا نا", + "trying_again": "دووبارە هەوڵدانەوە (هەوڵی {})", + "try_mpv": "تاقیکردنەوە بە بزوێنەری mpv", + "ok_to_skip": "OK بۆ تێپەڕاندن", + "quality_lowered": "کوالیتی دابەزێنرا", + "undo": "پاشگەزبوونەوە", + "crash_log": "تۆماری تێکچوون", + "crash_log_empty": "هیچ تێکچوونێک تۆمار نەکراوە" }, "profile": { "title": "پرۆفایل", @@ -651,6 +698,15 @@ "player_engine_section": "بزوێنەری لێدان", "ask_engine_on_play": "هەموو جارێک بپرسە", "ask_engine_on_play_desc": "هەر جارێک ڤیدیۆیەک دەستپێدەکات بزوێنەر هەڵبژێرە", + "controls_style": "شێوازی دوگمەکانی لێدەر", + "controls_style_desc": "کاتێک جاری داهاتوو لێدەر دەکەیتەوە جێبەجێ دەبێت.", + "controls_style_native": "ڕەسەن (iOS 26)", + "controls_style_native_desc": "دوگمە و لیست و پەڕەکانی Liquid Glassی خودی مۆبایلەکە", + "controls_style_system": "شووشەی سیستەم", + "controls_style_system_desc": "دوگمەکانی ئەپ لەسەر Liquid Glassی مۆبایلەکە", + "controls_style_classic": "شووشەی کلاسیک", + "controls_style_classic_desc": "شووشەی تایبەتی ئەپ، وەک وەشانەکانی پێشوو", + "controls_style_needs_ios26": "پێویستی بە iOS 26 یان نوێترە", "playback_section": "لێدان", "default_speed": "خێرایی بنەڕەت", "default_speed_desc": "ئەو خێراییەی هەموو ڤیدیۆیەک پێی دەستپێدەکات", @@ -673,6 +729,8 @@ "brightness_gesture_desc": "لە لای چەپ بۆ سەرەوە و خوارەوە ڕایبکێشە", "volume_gesture": "ڕاکێشانی دەنگ", "volume_gesture_desc": "لە لای ڕاست بۆ سەرەوە و خوارەوە ڕایبکێشە", + "auto_pip": "وێنە لەناو وێنەی خۆکار", + "auto_pip_desc": "ئەگەر لە کاتی لێدانی ڤیدیۆ دوگمەی سەرەکی دابگریت، لە پەنجەرەیەکی بچووکدا بەردەوام دەبێت", "seconds_short": "{} چرکە", "subtitle_style_note": "ئەمانە بۆ هەموو ڤیدیۆیەک جێبەجێ دەبن. کاتبەندیی ژێرنووس بۆ هەر ئەڵقەیەک جیایە و لەناو لێدەر دادەنرێت.", "searching_all_sources": "گەڕان لە هەموو سەرچاوەکان", @@ -743,7 +801,15 @@ "library_update": "پشکنین بۆ ئەڵقە نوێکان", "library_update_desc": "سەیری ئەو ناونیشانانە دەکات کە بەدوایاندا دەچیت و کاتێک نوێیەک دەرچوو ئاگادارت دەکاتەوە", "library_update_every": "هەر {} کاتژمێر جارێک", - "library_update_daily": "ڕۆژی یەک جار" + "library_update_daily": "ڕۆژی یەک جار", + "contributors": "بەشداربووان", + "contributors_error": "نەتوانرا بەشداربووان باربکرێن", + "become_contributor": "ببە بە بەشداربوو", + "become_contributor_body": "Sozo سەرچاوە-کراوەیە. کۆد، وەرگێڕان و ڕاپۆرتی هەڵەکان هەموویان یارمەتیدەرن.", + "reactions_overlay": "کاردانەوەی بینەران", + "reactions_overlay_desc": "پیشاندانی کاردانەوەی بینەرانی تر لەو ساتەی نێردران، و شوێنی کۆبوونەوەیان لەسەر هێڵی کات", + "hide_spoilers": "شاردنەوەی سپۆیلەر بۆ ئەو ئەڵقانەی نەمبینیون", + "hide_spoilers_desc": "لێدوانەکان دەربارەی ئەڵقەیەک کە تەواوت نەکردووە لێڵ دەمێننەوە تا دەستیان لێ دەدەیت" }, "appearance": { "title": "ڕووکار", @@ -866,8 +932,20 @@ "all_sources_failed_one": "ئەو سەرچاوەیەی تێیدا گەڕایت وەڵامی نەدایەوە. ئەمە ئەنجامی بەتاڵ نییە — دووبارە هەوڵبدەرەوە.", "voice": "گەڕان بە دەنگ", "voice_listening": "گوێ دەگرێت…", - "voice_done": "گوێگرتن وەستا", "voice_hint": "ناوی فیلمێک یان زنجیرەیەک بڵێ", + "voice_starting": "مایکرۆفۆن ئامادە دەکرێت…", + "voice_no_speech": "تێنەگەیشتین", + "voice_no_speech_hint": "کەمێک بەرزتر و نزیکتر لە مایکرۆفۆن قسە بکە، پاشان دووبارە هەوڵ بدەرەوە.", + "voice_permission": "مۆڵەتی مایکرۆفۆن کوژاوەتەوە", + "voice_permission_hint": "لە ڕێکخستنەکانی سیستەمدا ڕێگە بە Sozo بدە مایکرۆفۆن بەکاربهێنێت بۆ گەڕان بە دەنگ.", + "voice_unavailable": "ناسینەوەی قسە لەم ئامێرەدا بەردەست نییە", + "voice_unavailable_hint": "ئەم ئامێرە خزمەتگوزاریی ناسینەوەی قسەی نییە. لە ڕێکخستنەکانی سیستەمدا یەکێک دابمەزرێنە یان چالاکی بکە، وەک هی Google.", + "voice_network": "نەتوانرا بگەیەنرێت بە خزمەتگوزاریی قسە", + "voice_network_hint": "پەیوەندیی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵ بدەرەوە.", + "voice_error": "هەڵەیەک ڕوویدا لە کاتی گوێگرتندا", + "voice_error_hint": "لەوانەیە ئەپێکی تر مایکرۆفۆن بەکاربهێنێت. کەمێکی تر دووبارە هەوڵ بدەرەوە.", + "voice_try_again": "دووبارە هەوڵ بدەرەوە", + "voice_open_settings": "کردنەوەی ڕێکخستنەکان", "try_torrents": "گەڕان لە تۆرێنتەکان", "capped_note": "لە {} باوەڕپێکراوترین سەرچاوە لە کۆی {} گەڕا — مەودا تەسکتر بکەرەوە بۆ گەیشتن بە یەکێکی دیاریکراو.", "rail_popular_on": "بەناوبانگ لە {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "PIN ت لەبیرچووە؟" }, "my_list": { + "sort": "ڕیزکردن", + "sort_added": "نوێترین زیادکراو", + "sort_oldest": "کۆنترین زیادکراو", + "sort_title": "ناونیشان ئ–ی", "saved_account": "لە هەژمار پاشەکەوتکراوە", "saved_local": "لەسەر ئەم ئامێرە", "untitled": "بێ ناونیشان", @@ -1160,7 +1242,33 @@ "reconnecting": "دووبارە پەیوەستبوونەوە…", "connecting": "پەیوەستبوون…", "connect_failed_title": "نەتوانرا پەیوەست بیت", - "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە." + "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", + "settings_title": "ڕێکخستنەکانی ژوور", + "room_size": "قەبارەی ژوور", + "room_size_less": "کەسی کەمتر", + "room_size_more": "کەسی زیاتر", + "only_host_setting": "تەنها خانەخوێ لێدان کۆنترۆڵ دەکات", + "lock_room": "قفڵکردنی ژوور", + "lock_room_hint": "کەسی نوێ ناتوانێت بەشداربێت", + "wait_for_all": "چاوەڕوانی هەمووان", + "wait_for_all_hint": "کاتێک کەسێک بارکردن دەکات کەمێک ڕابگرە", + "invite_friends": "بانگهێشتکردنی هاوڕێیان", + "invite_sent": "بانگهێشتەکە نێردرا", + "invite_failed": "نەتوانرا بانگهێشتەکە بنێردرێت", + "invite_again_in": "دووبارە دوای {}", + "invite_no_friends": "هاوڕێ زیاد بکە بۆ ئەوەی لێرە بانگهێشتیان بکەیت", + "make_host": "کردن بە خانەخوێ", + "remove_member": "لابردن لە کۆڕ", + "remove_confirm": "{} لەم کۆڕە لاببرێت؟ ناتوانێت دووبارە بگەڕێتەوە.", + "buffering": "بارکردن…", + "sys_joined": "{} بەشداربوو", + "sys_left": "{} ڕۆیشت", + "sys_host": "{} ئێستا خانەخوێیە", + "sys_removed": "{} لابرا", + "error_banned": "تۆ لەم کۆڕە لابرایت", + "error_locked": "ئەم کۆڕە قفڵدراوە", + "room_closed_kicked": "خانەخوێ تۆی لەم کۆڕە لابرد", + "waiting_for_others": "چاوەڕوانی ئەوەین هەمووان بگەنەوە…" }, "notifications": { "title": "ئاگادارکردنەوەکان", @@ -1281,7 +1389,19 @@ "highlights": "دیارکراوەکان", "remove_highlight": "لابردنی دیارکردن", "highlight_note": "تێبینییەک زیاد بکە (ئارەزوومەندانە)", - "no_highlights": "هێشتا هیچ دیارکراوێک نییە. هەندێک دەق هەڵبژێرە و دیارکردن هەڵبژێرە." + "no_highlights": "هێشتا هیچ دیارکراوێک نییە. هەندێک دەق هەڵبژێرە و دیارکردن هەڵبژێرە.", + "translate_page": "وەرگێڕانی پەڕە", + "translate_preview": "پەڕەی وەرگێڕدراو", + "translate_apply": "جێبەجێکردن", + "translate_show_original": "پیشاندانی ڕەسەن", + "translate_no_text": "هیچ دەقێک لەم پەڕەیەدا نەدۆزرایەوە.", + "translate_daily_limit": "وەرگێڕانەکانی ئەمڕۆت تەواو بوون. سبەی دووبارە هەوڵ بدەرەوە.", + "translate_offline": "پەیوەندیت بە ئینتەرنێتەوە نییە. بۆ وەرگێڕانی ئەم پەڕەیە پەیوەست بە.", + "translate_sign_in": "بۆ وەرگێڕانی پەڕەکان بچۆ ژوورەوە.", + "translate_failed": "نەتوانرا ئەم پەڕەیە وەربگێڕدرێت. دووبارە هەوڵ بدەرەوە.", + "translate_language": "وەرگێڕانی پەڕەکان بۆ", + "translate_auto": "وەرگێڕانی هەموو پەڕەیەک لە کاتی خوێندنەوەدا", + "translate_auto_desc": "هەر پەڕەیەکی نوێ یەکێک لە وەرگێڕانەکانی ئەمڕۆ بەکاردەهێنێت." }, "cloudflare": { "title": "پشتڕاستی بکەرەوە کە مرۆڤیت", @@ -1363,6 +1483,10 @@ "watched": "سەیرکراو", "follow_series": "بەدواداچوونی زنجیرە", "find_other_sources": "دۆزینەوەی سەرچاوەی تر", + "notice_title": "This source can't play this title", + "notice_moved_to_app": "This source has moved this title into its own app, so there is nothing left here to play. Another source may still have it.", + "notice_no_player": "This source lists this title but has no player for it. Another source may still have it.", + "notice_is_series": "On this source this is a series, so it plays from the episode list rather than as a single film.", "anilist_track": "تراککردن لە AniList", "anilist_tracked": "لە AniList تراک دەکرێت", "following_on": "بەدواداچوون", @@ -1393,6 +1517,8 @@ "download_batch_body": "ئەڵقەکانی {range} دەخرێنە ڕیز — بە گشتی {count}. بە داتای مۆبایل ئەمە دەتوانێت چەند گیگابایتێک بێت.", "relations": "پەیوەندییەکان", "no_relations": "هیچ ناونیشانێکی پەیوەندیدار بۆ ئەمە نەدۆزرایەوە.", + "relation_tmdb_collection": "هەمان زنجیرە", + "relation_tmdb_recommended": "پێشنیارکراو", "relation_sequel": "درێژەپێدەر", "relation_prequel": "پێشینە", "relation_parent": "چیرۆکی سەرەکی", @@ -1461,6 +1587,8 @@ "offline_desc": "لیستە پاشەکەوتکراوەکە پیشان دەدرێت. تەنها ئەڵقە داگیراوەکان بە ئۆفلاین دەکرێنەوە.", "filter_hint": "ژمارە یان ناونیشانی ئەڵقە", "all_groups": "هەموو گرووپەکان", + "season_n": "وەرزی {}", + "specials": "تایبەتەکان", "filter_none": "هیچ ئەڵقەیەک لەگەڵ “{query}” لەم بەشەدا ناگونجێت", "jump_to": "بڕۆ بۆ ئەڵقەی {n}", "jump_loads": "{range} بار دەکات", @@ -1505,7 +1633,10 @@ "show_more": "زیاتر پیشان بدە", "delete_confirm_title": "ئەم لێدوانە بسڕیتەوە؟", "delete_confirm_body": "ئەم کارە ناگەڕێتەوە.", - "sign_in_prompt": "بچۆ ژوورەوە بۆ نووسینی لێدوان" + "sign_in_prompt": "بچۆ ژوورەوە بۆ نووسینی لێدوان", + "spoiler": "سپۆیلەر", + "spoiler_hidden": "سپۆیلەر · بۆ پیشاندان دەستی لێ بدە", + "unwatched_hidden": "هێشتا نەتبینیوە · بۆ پیشاندان دەستی لێ بدە" }, "downloads": { "view_titles": "ناونیشانەکان", @@ -1955,7 +2086,8 @@ "title": "لیستەکانم", "remove": "لابردن", "empty_title": "هێشتا هیچ شتێک پاشەکەوت نەکراوە", - "empty_subtitle": "ئەو ناونیشانانەی پاشەکەوتیان دەکەیت لێرە دەردەکەون." + "empty_subtitle": "ئەو ناونیشانانەی پاشەکەوتیان دەکەیت لێرە دەردەکەون.", + "load_failed": "ئەم لیستە بارنەکرا." }, "torrent": { "title": "تۆرێنتەکان", @@ -2229,7 +2361,25 @@ "settings_title": "ڕێکخستنەکانی {}", "settings_reset": "ڕێکخستنەوە", "settings_failed": "نەتوانرا ڕێکخستنەکانی ئەم سەرچاوەیە بخوێندرێنەوە.", - "settings_none": "ئەم سەرچاوەیە هیچ ڕێکخستنێکی نییە." + "settings_none": "ئەم سەرچاوەیە هیچ ڕێکخستنێکی نییە.", + "loading_repo": "کۆگا بار دەکرێت…", + "add_sources": "زیادکردنی سەرچاوە", + "choose_sources": "سەرچاوەکان هەڵبژێرە", + "select_all": "هەڵبژاردنی هەموو", + "select_none": "هیچ هەڵمەبژێرە", + "install_selected": "دامەزراندن ({})", + "already_installed": "دامەزراوە", + "remove": "لابردن", + "remove_source": "لابردنی سەرچاوە", + "remove_source_confirm": "{} لاببرێت؟ ڕێکخستنەکان و کۆدە پاشەکەوتکراوەکەی دەسڕێنەوە. دەتوانیت دووبارە لە کۆگاکەیەوە زیادی بکەیتەوە.", + "remove_repo_confirm": "ئەم کۆگایە و هەموو سەرچاوەکانی لاببرێن؟", + "enable_source": "چالاککردن", + "disable_source": "ناچالاککردن", + "source_disabled": "ناچالاک", + "update": "نوێکردنەوە", + "update_all": "نوێکردنەوەی هەموو", + "updates_available": "{} نوێکردنەوە بەردەستە", + "repo_check_failed": "نەتوانرا {} بپشکنرێت: {}" }, "ux": { "quick_search": "گەڕانی خێرا ({})", @@ -2300,8 +2450,8 @@ "check_all": "پشکنینی سەرچاوەکان", "check_bad_hint": "شاردنەوەی سەرچاوە کارنەکەرەکان لەم لیستانە دووریان دەخاتەوە؛ کاتێک دووبارە کار بکەنەوە خۆیان دەگەڕێنەوە.", "check_done": "پشکنینی سەرچاوە", - "check_hide_down": "زۆربەی سەرچاوەکان هەر نەگەیشتنێ — ئەوە کێشەی پەیوەندییە، نەک سەرچاوەکان. هیچ شتێک نیشان نەکرا؛ لەسەر تۆڕێکی کارا دووبارە هەوڵبدەرەوە.", - "check_network": "شاردنەوەی ئەو سەرچاوانەی کار ناکەن", + "check_hide_down": "شاردنەوەی ئەو سەرچاوانەی کار ناکەن", + "check_network": "زۆربەی سەرچاوەکان هەر نەگەیشتنێ — ئەوە کێشەی پەیوەندییە، نەک سەرچاوەکان. هیچ شتێک نیشان نەکرا؛ لەسەر تۆڕێکی کارا دووبارە هەوڵبدەرەوە.", "check_retrying": "دووبارە تاقیکردنەوەی سەرنەکەوتووەکان…", "checking": "پشکنینی {} لە {}…", "d_ago": "{} ڕۆژ لەمەوبەر", @@ -2315,6 +2465,8 @@ "v_blocked": "بلۆککراوە", "v_dead": "کار ناکات", "v_empty": "بەتاڵە", + "v_empty_body": "This source answered, but its catalogue came back empty. That is often what a site looks like once it has shut the app out.", + "v_failing_body": "The last check of this source failed. One failure can be a bad minute — check again to find out.", "v_failing": "وەڵام ناداتەوە", "v_outdated": "پێویستی بە نوێکردنەوەیە", "v_unknown": "هێشتا نەپشکنراوە", @@ -2527,7 +2679,18 @@ "card_single": "پرۆفایل زیاد بکە بۆ ئەو کەسانەی ئەم هەژمارەیان لەگەڵ هاوبەش دەکەیت", "load_failed": "نەتوانرا پرۆفایلەکان بار بکرێن", "offline": "پەیوەندییەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", - "error_generic": "هەڵەیەک ڕوویدا. تکایە دووبارە هەوڵبدەرەوە." + "error_generic": "هەڵەیەک ڕوویدا. تکایە دووبارە هەوڵبدەرەوە.", + "on_this_device": "لەسەر ئەم ئامێرە", + "ask_on_launch": "لە کاتی کردنەوەدا بپرسە کێ سەیر دەکات", + "ask_on_launch_desc": "ئەگەر کوژایەوە، Sozo دوایین پرۆفایلی بەکارهاتوو دەکاتەوە. پرۆفایلەکانی خاوەن PIN هێشتا دەپرسن.", + "pin_locked_until": "هەوڵی زۆر. دوای {} دووبارە هەوڵ بدەرەوە.", + "pin_not_on_device": "تۆ ئۆفلاینیت، و PINی ئەم پرۆفایلە هێشتا لەسەر ئەم ئامێرە نەنووسراوە. بۆ کردنەوەی جارێک بە ئینتەرنێتەوە پەیوەست بە.", + "biometric_reason": "کردنەوەی {}", + "biometric_offer_title": "پەنجەمۆر / Face ID بەکاربهێنرێت؟", + "biometric_offer_body": "پەنجەمۆر / Face ID بۆ {} لەسەر ئەم ئامێرە بەکاربهێنە. PIN-یش هەر کار دەکات.", + "biometric_offer_yes": "بەکارهێنان", + "biometric_offer_no": "ئێستا نا", + "biometric_toggle": "پەنجەمۆر / Face ID بۆ {}" }, "jellyfin": { "connect_server": "بەستنەوەی سێرڤەری میدیا (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "هیچ ئەڵقەیەکی نوێ لەدەست مەدە", "priming_body": "Sozo هەر کە شتێک کە بەدوایدا دەچیت ئەڵقە یان بەشێکی نوێی هەبوو پێت دەڵێت — و هیچ شتێک کە داوات نەکردبێت.", "priming_body_title": "هەر کە {} ئەڵقەیەکی نوێی هەبوو ئاگادار بکرێیتەوە — و هیچ شتێک کە داوات نەکردبێت.", + "priming_download_title": "بزانە کەی داگرتنەکەت تەواو دەبێت", + "priming_download_body": "Sozo پێشکەوتنی داگرتن لە ئاگادارکردنەوەکاندا پیشان دەدات و پێت دەڵێت کەی ئامادەیە بۆ سەیرکردنی بێ ئینتەرنێت. هەروەها دەتوانێت ئاگادارت بکاتەوە لە ئەڵقە نوێیەکانی ئەوەی بەدوایدا دەچیت.", "priming_point_releases": "ئەڵقە نوێکان، لەگەڵ پۆستەرەکە", "priming_point_quiet": "کاتژمێرە بێدەنگەکان", "priming_point_control": "هەر ناونیشانێک بێدەنگ بکە", @@ -3004,6 +3169,101 @@ "error_attachment_type": "تەنها وێنەی jpg یان png یان webp.", "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", - "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە", + "error_offline": "تۆ دەرهێڵیت یان پەیوەندی پچڕا. ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵ بدەرەوە.", + "not_sent": "نەنێردرا: {}", + "diag_crash_log": "بینینی تۆماری تێکچوون" + }, + "zangetsu": { + "title": "سەرچاوەکانی Zangetsu", + "row_subtitle": "سەرچاوەی JavaScript لە ڕیپۆی دەرەکییەوە، لەسەر ئامێرەکەت کار دەکەن", + "desc": "ڕیپۆیەکی Zangetsu زیاد بکە بۆ دامەزراندنی سەرچاوەی زیاتری ئەنیمێ و فیلم. لەسەر ئامێرەکەت کار دەکەن. ئەو سەرچاوانەی Sozo پێشتر هەیەتی بە «ناوخۆیی» نیشان دەکرێن و وەشانی خودی Sozo بەکاردێت.", + "loading_repo": "ڕیپۆ بار دەکرێت…", + "repo_added": "ڕیپۆ زیاد کرا · {} سەرچاوە.", + "checking": "پشکنینی نوێکردنەوەی ڕیپۆکان…", + "updates_available": "{} نوێکردنەوە بەردەستە.", + "installed_one": "{} دامەزرا.", + "removed_one": "{} لابرا.", + "updated": "{} سەرچاوە نوێکرانەوە.", + "update_all": "هەمووی نوێ بکەرەوە ({})", + "update_to": "نوێکردنەوە بۆ v{}", + "built_in": "ناوخۆیی", + "search": "گەڕان لە سەرچاوەکان…", + "all_types": "هەموو", + "type_movies": "فیلمەکان", + "all_languages": "هەموو زمانەکان", + "error_invalid_url": "تەنها بەستەری https لەسەر raw.githubusercontent.com یان gist.githubusercontent.com زیاد دەکرێت.", + "error_unreachable": "نەتوانرا بگەیتە ئەم ڕیپۆیە. بەستەرەکە و پەیوەندییەکەت بپشکنە.", + "error_invalid_index": "ئەم بەستەرە ئاماژە بە index.json ـێکی دروستی Zangetsu ناکات." + }, + "star": { + "title": "دەتەوێت یارمەتی گەشەکردنی Sozo بدەیت؟", + "body": "لە GitHub ئەستێرەیەکی پێ بدە ⭐", + "action": "ئەستێرە بدە لە GitHub", + "later": "دواتر", + "never": "جارێکی تر مەپرسە", + "update_button": "ئەستێرە بدە بە Sozo لە GitHub" + }, + "progress": { + "title": "ئاست و XP", + "level": "ئاست", + "level_short": "ئاست {}", + "level_up": "بەرزبوونەوەی ئاست", + "level_reached": "گەیشتیتە ئاستی {}", + "unlocked_new": "نوێ لە کەبینەتەکەتدا", + "open_locker": "کردنەوەی کەبینەت", + "unavailable": "ئاستەکان هێشتا بەردەست نین. دواتر سەیر بکەرەوە.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP بۆ ئاستی {}", + "to_next_short": "{} XP بۆ ئاستی داهاتوو", + "watch_today": "سەیرکردنی ئەمڕۆ: {} لە {} XP", + "how_to_earn": "چۆن XP بەدەست بهێنیت", + "rule_watch": "سەیرکردن: هەر خولەکێک 1 XP، تا 300 لە ڕۆژێکدا", + "rule_comment": "لێدوان: 5 XP، تا 10 لێدوان لە ڕۆژێکدا", + "rule_like": "لایک لەسەر لێدوانەکەت: 1 XP، تا 50 لە ڕۆژێکدا", + "rule_streak": "ڕۆژی زنجیرە: 10 XP، زیاتر لە ڕۆژە گرنگەکاندا", + "rule_achievement": "دەستکەوت: 50 تا 400 XP بەپێی پلە", + "recent": "دوایین XP", + "reason_watch": "سەیرکردن", + "reason_comment": "لێدوانەکان", + "reason_like": "لایکی وەرگیراو", + "reason_streak": "ڕۆژی زنجیرە", + "reason_achievement": "دەستکەوت", + "reason_other": "XP", + "locker": "کەبینەت", + "slot_frame": "چوارچێوەی وێنە", + "slot_title": "نازناوەکان", + "slot_color": "ڕەنگی ناو", + "equip": "بۆ لەبەرکردن دەست لێبدە", + "equipped": "لەبەرکراوە", + "unlock_level": "ئاستی {}", + "unlock_badge": "نیشانە: {}", + "frame_bronze": "برۆنز", + "frame_silver": "زیو", + "frame_gold": "زێڕ", + "frame_neon": "نیۆن", + "frame_sakura": "ساکورا", + "frame_ember": "پشکۆ", + "frame_flame": "بڵێسە", + "frame_moon": "مانگ", + "title_newcomer": "تازەهاتوو", + "title_regular": "هەمیشەیی", + "title_veteran": "بەتەمەن", + "title_legend": "ئەفسانە", + "title_binger": "سەیرکەری بێوچان", + "title_cinephile": "خولیای سینەما", + "title_otaku": "ئۆتاکۆ", + "title_bookworm": "کتێبخوێن", + "title_night_owl": "کوندەپەپووی شەو", + "title_host": "خانەخوێی ئاهەنگ", + "color_sky": "ئاسمانی", + "color_mint": "پونگ", + "color_gold": "زێڕین", + "color_rose": "گوڵی", + "color_violet": "مۆر", + "color_crimson": "سووری تۆخ", + "color_aurora": "ئۆرۆرا", + "color_ember": "پشکۆ" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 8cc3ee27..78d358cc 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -34,7 +34,9 @@ "remove": "Entfernen", "show": "Anzeigen", "hide": "Ausblenden", - "installed": "Installiert" + "installed": "Installiert", + "load_more_failed": "Mehr konnte nicht geladen werden", + "go_home": "Zur Startseite" }, "auth": { "login": "Anmelden", @@ -306,7 +308,11 @@ "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." + "unknown": "Video konnte nicht geladen werden.", + "not_found": "Dieses Video ist auf diesem Server nicht mehr verfügbar. Versuche einen anderen Server.", + "server_error": "Der Videoserver hat Probleme. Versuche es gleich noch einmal oder wähle einen anderen Server.", + "resolve_failed": "Von dieser Quelle konnte kein Videolink abgerufen werden.", + "details": "Details" }, "play": "Abspielen", "pause": "Pause", @@ -529,6 +535,22 @@ "alt_guess": "Vermutung", "alt_guess_spoken": "Unsichere Übereinstimmung — prüfe den Titel vor dem Abspielen", "alt_your_pick": "Deine Wahl", + "alt_auto": "Automatisch", + "alt_auto_hint": "Spielt von der ersten Quelle ab, die es hat", + "alt_auto_trying": "{} wird versucht…", + "alt_auto_missed": "Keine passende Quelle konnte es abspielen. Wähle unten eine oder durchsuche eine Quelle.", + "alt_auto_checking": "Prüfe {} · {} von {}", + "alt_auto_waiting": "Warte auf weitere Quellen…", + "alt_auto_stop": "Stopp", + "alt_auto_stopped": "Gestoppt.", + "alt_auto_capped": "{} Quellen geprüft, keine konnte das abspielen. Wähle unten eine aus oder versuche es erneut.", + "alt_auto_tried": "Geprüft", + "alt_skip_no_match": "keine genaue Übereinstimmung", + "alt_skip_no_episode": "Folge nicht gefunden", + "alt_skip_error": "konnte nicht geladen werden", + "auto_switch_attempt": "Versuch {} von {}", + "auto_switch_stop": "Stopp", + "auto_switch_stopped": "Serverwechsel gestoppt. Versuche es erneut oder wähle einen anderen Server oder eine andere Quelle.", "alt_wrong_title": "Falscher Titel?", "alt_wrong_title_hint": "Suche selbst in {} und wähle den richtigen Titel", "alt_corrected": "Verwendet jetzt „{}“", @@ -554,10 +576,35 @@ "subtitle_n": "Untertitel {}", "subtitle_in_video": "Im Video", "rewind": "Zurückspulen", + "rewind_by": "{} s zurück", + "forward_by": "{} s vor", "mini_restore": "Zurück zum großen Fenster", "pip_unavailable": "Bild-in-Bild konnte für dieses Video nicht gestartet werden.", "scrub_release_cancel": "Loslassen zum Abbrechen", - "auto_quality_desc_mpv": "Startet mit der besten Qualität des Streams" + "auto_quality_desc_mpv": "Startet mit der besten Qualität des Streams", + "reconnecting": "Verbindung verloren — neu verbinden…", + "all_servers_failed": "Keiner der Server dieses Titels konnte ihn abspielen. Versuch es später oder wähl eine andere Quelle.", + "shortcut_pip": "Mini-Player (Umschalt: über anderen Apps)", + "mini_popout_hint": "Mini-Player · lange drücken, um über anderen Apps zu schweben", + "react": "Reagieren", + "comments": "Kommentare", + "episode_comments": "Kommentare zu Folge {}", + "reaction_text": "Reaktion schreiben", + "reaction_text_hint": "Kurz etwas sagen…", + "reaction_wait": "Warte ein paar Sekunden, bevor du erneut reagierst", + "reaction_limit": "Du hast das Reaktionslimit für diese Folge erreicht", + "reaction_failed": "Reaktion konnte nicht gesendet werden", + "native_offer_title": "Den neuen Player ausprobieren?", + "native_offer_body": "Apples Liquid-Glass-Bedienelemente und -Menüs. Du kannst das jederzeit unter Einstellungen → Player ändern.", + "native_offer_try": "Ausprobieren", + "native_offer_not_now": "Nicht jetzt", + "trying_again": "Neuer Versuch (Versuch {})", + "try_mpv": "Mit der mpv-Engine versuchen", + "ok_to_skip": "OK zum Überspringen", + "quality_lowered": "Qualität reduziert", + "undo": "Rückgängig", + "crash_log": "Absturzprotokoll", + "crash_log_empty": "Keine Abstürze aufgezeichnet" }, "profile": { "title": "Profil", @@ -649,6 +696,15 @@ "player_engine_section": "WIEDERGABE-ENGINE", "ask_engine_on_play": "Jedes Mal fragen", "ask_engine_on_play_desc": "Die Engine bei jedem Videostart wählen", + "controls_style": "STIL DER PLAYER-STEUERUNG", + "controls_style_desc": "Gilt ab dem nächsten Öffnen des Players.", + "controls_style_native": "Nativ (iOS 26)", + "controls_style_native_desc": "Die Liquid-Glass-Tasten, Menüs und Sheets des Geräts", + "controls_style_system": "Systemglas", + "controls_style_system_desc": "Die Tasten der App auf dem Liquid Glass des Geräts", + "controls_style_classic": "Klassisches Glas", + "controls_style_classic_desc": "Das eigene Glas der App, wie in früheren Versionen", + "controls_style_needs_ios26": "Erfordert iOS 26 oder neuer", "playback_section": "WIEDERGABE", "default_speed": "Standardtempo", "default_speed_desc": "Tempo, mit dem jedes Video startet", @@ -671,6 +727,8 @@ "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", + "auto_pip": "Automatisches Bild-im-Bild", + "auto_pip_desc": "Wenn du während eines Videos zum Startbildschirm wechselst, läuft es in einem kleinen Fenster weiter", "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", @@ -743,7 +801,15 @@ "library_update": "Nach neuen Folgen suchen", "library_update_desc": "Prüft deine gefolgten Titel und meldet neue Folgen", "library_update_every": "Alle {} Stunden", - "library_update_daily": "Einmal täglich" + "library_update_daily": "Einmal täglich", + "contributors": "Mitwirkende", + "contributors_error": "Mitwirkende konnten nicht geladen werden", + "become_contributor": "Mitwirken", + "become_contributor_body": "Sozo ist Open Source. Code, Übersetzungen und Fehlerberichte helfen gleichermaßen.", + "reactions_overlay": "Zuschauerreaktionen", + "reactions_overlay_desc": "Reaktionen anderer Zuschauer im Moment ihres Sendens zeigen, und wo sie sich auf der Zeitleiste häufen", + "hide_spoilers": "Spoiler zu Folgen ausblenden, die ich nicht gesehen habe", + "hide_spoilers_desc": "Kommentare zu einer Folge, die du nicht zu Ende gesehen hast, bleiben verschwommen, bis du sie antippst" }, "appearance": { "title": "Darstellung", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Mikrofon wird vorbereitet…", + "voice_no_speech": "Das habe ich nicht verstanden", + "voice_no_speech_hint": "Sprich etwas lauter und näher am Mikrofon und versuch es noch einmal.", + "voice_permission": "Mikrofonzugriff ist aus", + "voice_permission_hint": "Erlaube Sozo in den Systemeinstellungen den Zugriff aufs Mikrofon, um per Sprache zu suchen.", + "voice_unavailable": "Spracherkennung ist auf diesem Gerät nicht verfügbar", + "voice_unavailable_hint": "Auf diesem Gerät gibt es keinen Spracherkennungsdienst. Installiere oder aktiviere einen in den Systemeinstellungen, etwa den von Google.", + "voice_network": "Sprachdienst nicht erreichbar", + "voice_network_hint": "Prüf deine Internetverbindung und versuch es noch einmal.", + "voice_error": "Beim Zuhören ist etwas schiefgelaufen", + "voice_error_hint": "Vielleicht nutzt eine andere App das Mikrofon. Versuch es gleich noch einmal.", + "voice_try_again": "Noch einmal versuchen", + "voice_open_settings": "Einstellungen öffnen", "try_torrents": "Torrents durchsuchen", "capped_note": "Die {} verlässlichsten von {} Quellen wurden durchsucht — grenze die Auswahl ein, um eine bestimmte zu erreichen.", "rail_popular_on": "Beliebt auf {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "PIN vergessen?" }, "my_list": { + "sort": "Sortieren", + "sort_added": "Zuletzt hinzugefügt", + "sort_oldest": "Zuerst hinzugefügt", + "sort_title": "Titel A–Z", "saved_account": "Im Konto gespeichert", "saved_local": "Auf diesem Gerät", "untitled": "Ohne Titel", @@ -1160,7 +1242,33 @@ "reconnecting": "Verbindung wird wiederhergestellt …", "connecting": "Verbindung wird aufgebaut …", "connect_failed_title": "Verbindung fehlgeschlagen", - "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut." + "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut.", + "settings_title": "Raumeinstellungen", + "room_size": "Raumgröße", + "room_size_less": "Weniger Personen", + "room_size_more": "Mehr Personen", + "only_host_setting": "Nur der Host steuert die Wiedergabe", + "lock_room": "Raum sperren", + "lock_room_hint": "Niemand Neues kann beitreten", + "wait_for_all": "Auf alle warten", + "wait_for_all_hint": "Kurz pausieren, wenn jemand puffert", + "invite_friends": "Freunde einladen", + "invite_sent": "Einladung gesendet", + "invite_failed": "Einladung konnte nicht gesendet werden", + "invite_again_in": "Wieder in {}", + "invite_no_friends": "Füge Freunde hinzu, um sie hierher einzuladen", + "make_host": "Zum Host machen", + "remove_member": "Aus der Party entfernen", + "remove_confirm": "{} aus dieser Party entfernen? Ein erneuter Beitritt ist dann nicht möglich.", + "buffering": "Puffert…", + "sys_joined": "{} ist beigetreten", + "sys_left": "{} hat die Party verlassen", + "sys_host": "{} ist jetzt Host", + "sys_removed": "{} wurde entfernt", + "error_banned": "Du wurdest aus dieser Party entfernt", + "error_locked": "Diese Party ist gesperrt", + "room_closed_kicked": "Der Host hat dich aus dieser Party entfernt", + "waiting_for_others": "Warte, bis alle aufgeholt haben…" }, "notifications": { "title": "Benachrichtigungen", @@ -1281,7 +1389,19 @@ "highlights": "Markierungen", "remove_highlight": "Markierung entfernen", "highlight_note": "Notiz hinzufügen (optional)", - "no_highlights": "Noch keine Markierungen. Text auswählen und „Markieren“ wählen." + "no_highlights": "Noch keine Markierungen. Text auswählen und „Markieren“ wählen.", + "translate_page": "Seite übersetzen", + "translate_preview": "Übersetzte Seite", + "translate_apply": "Übernehmen", + "translate_show_original": "Original anzeigen", + "translate_no_text": "Auf dieser Seite wurde kein Text gefunden.", + "translate_daily_limit": "Du hast die Übersetzungen für heute aufgebraucht. Versuch es morgen wieder.", + "translate_offline": "Du bist offline. Verbinde dich mit dem Internet, um diese Seite zu übersetzen.", + "translate_sign_in": "Melde dich an, um Seiten zu übersetzen.", + "translate_failed": "Diese Seite konnte nicht übersetzt werden. Versuch es noch einmal.", + "translate_language": "Seiten übersetzen in", + "translate_auto": "Jede Seite beim Lesen übersetzen", + "translate_auto_desc": "Jede neue Seite verbraucht eine der heutigen Übersetzungen." }, "cloudflare": { "title": "Bestätige, dass du ein Mensch bist", @@ -1363,6 +1483,10 @@ "watched": "Gesehen", "follow_series": "Serie folgen", "find_other_sources": "Andere Quellen finden", + "notice_title": "Diese Quelle kann diesen Titel nicht abspielen", + "notice_moved_to_app": "Die Quelle hat diesen Titel in ihre eigene App verlegt, hier bleibt nichts zum Abspielen. Eine andere Quelle hat ihn vielleicht noch.", + "notice_no_player": "Die Quelle führt diesen Titel, hat aber keinen Player dafür. Eine andere Quelle hat ihn vielleicht.", + "notice_is_series": "Auf dieser Quelle ist das eine Serie; sie läuft über die Episodenliste und nicht als einzelner Film.", "anilist_track": "Auf AniList verfolgen", "anilist_tracked": "Auf AniList verfolgt", "following_on": "Wird verfolgt", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "GLEICHE REIHE", + "relation_tmdb_recommended": "EMPFOHLEN", "relation_sequel": "FORTSETZUNG", "relation_prequel": "VORGESCHICHTE", "relation_parent": "HAUPTGESCHICHTE", @@ -1461,6 +1587,8 @@ "offline_desc": "Gespeicherte Liste. Offline öffnen sich nur heruntergeladene Folgen.", "filter_hint": "Folgennummer oder Titel", "all_groups": "Alle Gruppen", + "season_n": "Staffel {}", + "specials": "Specials", "filter_none": "Keine Folge passt zu „{query}“ in diesem Block", "jump_to": "Zu Folge {n}", "jump_loads": "Lädt {range}", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Melde dich an, um zu kommentieren", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · zum Anzeigen tippen", + "unwatched_hidden": "Noch nicht gesehen · zum Anzeigen tippen" }, "downloads": { "view_titles": "Titel", @@ -1955,7 +2086,8 @@ "title": "Meine Listen", "remove": "Entfernen", "empty_title": "Noch nichts gespeichert", - "empty_subtitle": "Titel, die du speicherst, erscheinen hier." + "empty_subtitle": "Titel, die du speicherst, erscheinen hier.", + "load_failed": "Diese Liste konnte nicht geladen werden." }, "torrent": { "title": "Torrents", @@ -2229,7 +2361,25 @@ "settings_title": "Einstellungen: {}", "settings_reset": "Zurücksetzen", "settings_failed": "Die Einstellungen dieser Quelle konnten nicht gelesen werden.", - "settings_none": "Diese Quelle hat keine Einstellungen." + "settings_none": "Diese Quelle hat keine Einstellungen.", + "loading_repo": "Repository wird geladen…", + "add_sources": "Quellen hinzufügen", + "choose_sources": "Quellen auswählen", + "select_all": "Alle auswählen", + "select_none": "Keine auswählen", + "install_selected": "Installieren ({})", + "already_installed": "Installiert", + "remove": "Entfernen", + "remove_source": "Quelle entfernen", + "remove_source_confirm": "{} entfernen? Einstellungen und zwischengespeicherter Code werden gelöscht. Du kannst die Quelle über ihr Repository erneut hinzufügen.", + "remove_repo_confirm": "Dieses Repository und alle seine Quellen entfernen?", + "enable_source": "Aktivieren", + "disable_source": "Deaktivieren", + "source_disabled": "Deaktiviert", + "update": "Aktualisieren", + "update_all": "Alle aktualisieren", + "updates_available": "{} Update(s) verfügbar", + "repo_check_failed": "{} konnte nicht geprüft werden: {}" }, "ux": { "quick_search": "Schnellsuche ({})", @@ -2300,8 +2450,8 @@ "check_all": "Quellen prüfen", "check_bad_hint": "Ausgeblendete Quellen, die nicht laufen, verschwinden aus diesen Listen und kommen von selbst zurück, sobald sie wieder funktionieren.", "check_done": "Quellenprüfung", - "check_hide_down": "Die meisten Quellen waren gar nicht erreichbar — das liegt an der Verbindung, nicht an den Quellen. Nichts wurde markiert; versuche es in einem funktionierenden Netz.", - "check_network": "Nicht laufende ausblenden", + "check_hide_down": "Nicht laufende ausblenden", + "check_network": "Die meisten Quellen waren gar nicht erreichbar — das liegt an der Verbindung, nicht an den Quellen. Nichts wurde markiert; versuche es in einem funktionierenden Netz.", "check_retrying": "Die fehlgeschlagenen werden erneut versucht …", "checking": "Prüfe {} von {} …", "d_ago": "vor {} T", @@ -2315,6 +2465,8 @@ "v_blocked": "Blockiert", "v_dead": "Funktioniert nicht", "v_empty": "Leer", + "v_empty_body": "Die Quelle hat geantwortet, ihr Katalog kam aber leer zurück. So sieht eine Seite oft aus, die die App ausgesperrt hat.", + "v_failing_body": "Die letzte Prüfung dieser Quelle ist fehlgeschlagen. Ein Fehlschlag kann eine schlechte Minute sein — prüfe noch einmal.", "v_failing": "Antwortet nicht", "v_outdated": "Update nötig", "v_unknown": "Noch nicht geprüft", @@ -2527,7 +2679,18 @@ "card_single": "Füge Profile für die Personen hinzu, mit denen du dieses Konto teilst", "load_failed": "Profile konnten nicht geladen werden", "offline": "Prüfe deine Verbindung und versuch es erneut.", - "error_generic": "Etwas ist schiefgelaufen. Bitte versuch es erneut." + "error_generic": "Etwas ist schiefgelaufen. Bitte versuch es erneut.", + "on_this_device": "Auf diesem Gerät", + "ask_on_launch": "Beim Start fragen, wer zuschaut", + "ask_on_launch_desc": "Wenn aus, öffnet Sozo das zuletzt genutzte Profil. Profile mit PIN fragen trotzdem.", + "pin_locked_until": "Zu viele Versuche. Versuche es nach {} erneut.", + "pin_not_on_device": "Du bist offline, und die PIN dieses Profils wurde auf diesem Gerät noch nie eingegeben. Verbinde dich einmal mit dem Internet, um es zu entsperren.", + "biometric_reason": "{} entsperren", + "biometric_offer_title": "Fingerabdruck / Face ID verwenden?", + "biometric_offer_body": "Fingerabdruck / Face ID für {} auf diesem Gerät verwenden. Die PIN funktioniert weiterhin.", + "biometric_offer_yes": "Verwenden", + "biometric_offer_no": "Nicht jetzt", + "biometric_toggle": "Fingerabdruck / Face ID für {}" }, "jellyfin": { "connect_server": "Medienserver verbinden (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Keine neue Folge mehr verpassen", "priming_body": "Sozo sagt dir Bescheid, sobald etwas, dem du folgst, eine neue Folge oder ein neues Kapitel bekommt – und sonst nichts, worum du nicht gebeten hast.", "priming_body_title": "Erfahre sofort, wenn „{}“ eine neue Folge bekommt – und sonst nichts, worum du nicht gebeten hast.", + "priming_download_title": "Erfahre, wann dein Download fertig ist", + "priming_download_body": "Sozo zeigt den Download-Fortschritt in deinen Benachrichtigungen und sagt dir, wann du offline schauen kannst. Auf Wunsch auch, wenn Titel, denen du folgst, neue Folgen bekommen.", "priming_point_releases": "Neue Folgen mit Poster", "priming_point_quiet": "Ruhezeit", "priming_point_control": "Jeden Titel stummschalten", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Nur jpg-, png- oder webp-Bilder.", "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", - "suggest": "Passiert das öfter? Schreib dem Support" + "suggest": "Passiert das öfter? Schreib dem Support", + "error_offline": "Du bist offline oder die Verbindung ist abgebrochen. Prüfe dein Internet und versuche es erneut.", + "not_sent": "Nicht gesendet: {}", + "diag_crash_log": "Absturzprotokoll ansehen" + }, + "zangetsu": { + "title": "Zangetsu-Quellen", + "row_subtitle": "JavaScript-Quellen aus externen Repos, laufen auf deinem Gerät", + "desc": "Füge ein Zangetsu-Repo hinzu, um zusätzliche Anime- und Filmquellen zu installieren. Sie laufen auf deinem Gerät. Quellen, die Sozo bereits mitbringt, sind als „Integriert“ markiert und nutzen weiter Sozos eigene Version.", + "loading_repo": "Repo wird geladen…", + "repo_added": "Repo hinzugefügt · {} Quellen.", + "checking": "Repos werden auf Updates geprüft…", + "updates_available": "{} Update(s) verfügbar.", + "installed_one": "{} installiert.", + "removed_one": "{} entfernt.", + "updated": "{} Quelle(n) aktualisiert.", + "update_all": "Alle aktualisieren ({})", + "update_to": "Auf v{} aktualisieren", + "built_in": "Integriert", + "search": "Quellen durchsuchen…", + "all_types": "Alle", + "type_movies": "Filme", + "all_languages": "Alle Sprachen", + "error_invalid_url": "Nur https-Links auf raw.githubusercontent.com oder gist.githubusercontent.com können hinzugefügt werden.", + "error_unreachable": "Dieses Repo ist nicht erreichbar. Prüfe den Link und deine Verbindung.", + "error_invalid_index": "Dieser Link verweist nicht auf eine gültige Zangetsu-index.json." + }, + "star": { + "title": "Möchtest du Sozo beim Wachsen helfen?", + "body": "Gib uns einen Stern auf GitHub ⭐", + "action": "Stern auf GitHub vergeben", + "later": "Später", + "never": "Nicht mehr fragen", + "update_button": "Sozo auf GitHub einen Stern geben" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv. {}", + "level_up": "Level-Aufstieg", + "level_reached": "Du hast Level {} erreicht", + "unlocked_new": "Neu in deinem Spind", + "open_locker": "Spind öffnen", + "unavailable": "Level sind noch nicht verfügbar. Schau bald wieder vorbei.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP bis Level {}", + "to_next_short": "{} XP bis zum nächsten Level", + "watch_today": "Heute geschaut: {} von {} XP", + "how_to_earn": "So bekommst du XP", + "rule_watch": "Schauen: 1 XP pro Minute, bis zu 300 am Tag", + "rule_comment": "Ein Kommentar: 5 XP, bis zu 10 Kommentare am Tag", + "rule_like": "Ein Like auf deinen Kommentar: 1 XP, bis zu 50 am Tag", + "rule_streak": "Ein Streak-Tag: 10 XP, mehr an Meilenstein-Tagen", + "rule_achievement": "Ein Erfolg: 50 bis 400 XP je nach Stufe", + "recent": "Letzte XP", + "reason_watch": "Schauen", + "reason_comment": "Kommentare", + "reason_like": "Erhaltene Likes", + "reason_streak": "Streak-Tag", + "reason_achievement": "Erfolg", + "reason_other": "XP", + "locker": "Spind", + "slot_frame": "Avatar-Rahmen", + "slot_title": "Titel", + "slot_color": "Namensfarben", + "equip": "Zum Ausrüsten tippen", + "equipped": "Ausgerüstet", + "unlock_level": "Level {}", + "unlock_badge": "Abzeichen: {}", + "frame_bronze": "Bronze", + "frame_silver": "Silber", + "frame_gold": "Gold", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Glut", + "frame_flame": "Flamme", + "frame_moon": "Mond", + "title_newcomer": "Neuling", + "title_regular": "Stammgast", + "title_veteran": "Veteran", + "title_legend": "Legende", + "title_binger": "Binge-Watcher", + "title_cinephile": "Cineast", + "title_otaku": "Otaku", + "title_bookworm": "Bücherwurm", + "title_night_owl": "Nachteule", + "title_host": "Party-Gastgeber", + "color_sky": "Himmel", + "color_mint": "Minze", + "color_gold": "Gold", + "color_rose": "Rose", + "color_violet": "Violett", + "color_crimson": "Karmesin", + "color_aurora": "Polarlicht", + "color_ember": "Glut" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index b7828f39..c91759f6 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -34,7 +34,9 @@ "remove": "Remove", "show": "Show", "hide": "Hide", - "installed": "Installed" + "installed": "Installed", + "load_more_failed": "Couldn't load more", + "go_home": "Go home" }, "auth": { "login": "Login", @@ -306,7 +308,11 @@ "no_source": "No source for this episode.", "empty_url": "This source returned no video address.", "engine_unavailable": "The player did not start. Please fully restart the app.", - "unknown": "Could not load video." + "unknown": "Could not load video.", + "not_found": "This video is no longer on this server. Try another server.", + "server_error": "The video server is having problems. Try again in a moment, or pick another server.", + "resolve_failed": "Couldn't get a video link from this source.", + "details": "Details" }, "play": "Play", "pause": "Pause", @@ -529,6 +535,22 @@ "alt_guess": "Guess", "alt_guess_spoken": "Uncertain match — check the title before playing", "alt_your_pick": "Your pick", + "alt_auto": "Auto", + "alt_auto_hint": "Plays from the first source that has this", + "alt_auto_trying": "Trying {}…", + "alt_auto_missed": "None of the matches could play this. Pick one below or search a source.", + "alt_auto_checking": "Checking {} · {} of {}", + "alt_auto_waiting": "Waiting for more sources…", + "alt_auto_stop": "Stop", + "alt_auto_stopped": "Stopped.", + "alt_auto_capped": "Checked {} sources and none could play this. Pick one below or try again.", + "alt_auto_tried": "Checked", + "alt_skip_no_match": "not a close match", + "alt_skip_no_episode": "episode not found", + "alt_skip_error": "could not load", + "auto_switch_attempt": "Attempt {} of {}", + "auto_switch_stop": "Stop", + "auto_switch_stopped": "Stopped switching servers. Try again, or pick another server or source.", "alt_wrong_title": "Wrong title?", "alt_wrong_title_hint": "Search {} yourself and pick the right title", "alt_corrected": "Now using “{}”", @@ -554,10 +576,35 @@ "subtitle_n": "Subtitle {}", "subtitle_in_video": "In the video", "rewind": "Rewind", + "rewind_by": "Rewind {}s", + "forward_by": "Forward {}s", "mini_restore": "Back to the full window", "pip_unavailable": "Picture-in-picture could not start for this video.", "scrub_release_cancel": "Release to cancel", - "auto_quality_desc_mpv": "Starts at the best quality the stream offers" + "auto_quality_desc_mpv": "Starts at the best quality the stream offers", + "reconnecting": "Connection lost — reconnecting…", + "all_servers_failed": "None of this title's servers would play it. Try again later, or pick another source.", + "shortcut_pip": "Mini player (Shift: above other apps)", + "mini_popout_hint": "Mini player · long-press to float above other apps", + "react": "React", + "comments": "Comments", + "episode_comments": "Episode {} comments", + "reaction_text": "Write a reaction", + "reaction_text_hint": "Say something short…", + "reaction_wait": "Wait a few seconds before reacting again", + "reaction_limit": "You've reached the reaction limit for this episode", + "reaction_failed": "Couldn't send the reaction", + "native_offer_title": "Try the new player?", + "native_offer_body": "Apple's Liquid Glass controls and menus. You can change this anytime in Settings → Player.", + "native_offer_try": "Try it", + "native_offer_not_now": "Not now", + "trying_again": "Trying again (attempt {})", + "try_mpv": "Try with the mpv engine", + "ok_to_skip": "OK to skip", + "quality_lowered": "Lowered quality", + "undo": "Undo", + "crash_log": "Crash log", + "crash_log_empty": "No crashes recorded" }, "profile": { "title": "Profile", @@ -651,6 +698,15 @@ "player_engine_section": "PLAYBACK ENGINE", "ask_engine_on_play": "Ask every time", "ask_engine_on_play_desc": "Choose the engine each time a video starts", + "controls_style": "PLAYER CONTROLS STYLE", + "controls_style_desc": "Takes effect the next time you open the player.", + "controls_style_native": "Native (iOS 26)", + "controls_style_native_desc": "The phone's own Liquid Glass buttons, menus and sheets", + "controls_style_system": "System glass", + "controls_style_system_desc": "The app's buttons on the phone's own Liquid Glass", + "controls_style_classic": "Classic glass", + "controls_style_classic_desc": "The app's own glass, as in earlier versions", + "controls_style_needs_ios26": "Needs iOS 26 or later", "playback_section": "PLAYBACK", "default_speed": "Default speed", "default_speed_desc": "Speed every video starts at", @@ -673,6 +729,8 @@ "brightness_gesture_desc": "Swipe up and down on the left side", "volume_gesture": "Volume swipe", "volume_gesture_desc": "Swipe up and down on the right side", + "auto_pip": "Auto picture-in-picture", + "auto_pip_desc": "Pressing Home while a video plays keeps it playing in a small window", "seconds_short": "{}s", "subtitle_style_note": "These apply to every video. Subtitle timing stays per episode and is set inside the player.", "searching_all_sources": "searching all sources", @@ -743,7 +801,15 @@ "library_update": "Check for new episodes", "library_update_desc": "Looks at the titles you follow and notifies you when a new one is out", "library_update_every": "Every {} hours", - "library_update_daily": "Once a day" + "library_update_daily": "Once a day", + "contributors": "Contributors", + "contributors_error": "Couldn't load contributors", + "become_contributor": "Become a contributor", + "become_contributor_body": "Sozo is open source. Code, translations and bug reports all help.", + "reactions_overlay": "Viewer reactions", + "reactions_overlay_desc": "Show other viewers' reactions at the moment they were sent, and where they cluster on the seek bar", + "hide_spoilers": "Hide spoilers for episodes I haven't watched", + "hide_spoilers_desc": "Comments about an episode you haven't finished stay blurred until you tap them" }, "appearance": { "title": "Appearance", @@ -866,8 +932,20 @@ "all_sources_failed_one": "The source you searched failed to answer. This is not an empty result — try again.", "voice": "Search by voice", "voice_listening": "Listening…", - "voice_done": "Stopped listening", "voice_hint": "Say the name of a film or series", + "voice_starting": "Getting the microphone ready…", + "voice_no_speech": "Didn't catch that", + "voice_no_speech_hint": "Speak a little louder, close to the microphone, and try again.", + "voice_permission": "Microphone permission is off", + "voice_permission_hint": "Allow Sozo to use the microphone in system settings to search by voice.", + "voice_unavailable": "Speech recognition isn't available on this device", + "voice_unavailable_hint": "This device has no speech recognition service. Install or turn one on in system settings, such as Google's.", + "voice_network": "Couldn't reach the speech service", + "voice_network_hint": "Check your internet connection and try again.", + "voice_error": "Something went wrong while listening", + "voice_error_hint": "Another app may be using the microphone. Try again in a moment.", + "voice_try_again": "Try again", + "voice_open_settings": "Open settings", "try_torrents": "Search torrents", "capped_note": "Searched the {} most reliable of {} sources — narrow the scope to reach a specific one.", "rail_popular_on": "Popular on {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "Forgot PIN?" }, "my_list": { + "sort": "Sort", + "sort_added": "Recently added", + "sort_oldest": "Oldest added", + "sort_title": "Title A–Z", "saved_account": "Saved to account", "saved_local": "On this device", "untitled": "Untitled", @@ -1160,7 +1242,33 @@ "reconnecting": "Reconnecting…", "connecting": "Connecting…", "connect_failed_title": "Couldn't connect", - "connect_failed_msg": "Check your internet connection and try again." + "connect_failed_msg": "Check your internet connection and try again.", + "settings_title": "Room settings", + "room_size": "Room size", + "room_size_less": "Fewer people", + "room_size_more": "More people", + "only_host_setting": "Only host controls playback", + "lock_room": "Lock room", + "lock_room_hint": "No one new can join", + "wait_for_all": "Wait for everyone", + "wait_for_all_hint": "Pause briefly when someone is buffering", + "invite_friends": "Invite friends", + "invite_sent": "Invite sent", + "invite_failed": "Couldn't send the invite", + "invite_again_in": "Again in {}", + "invite_no_friends": "Add friends to invite them here", + "make_host": "Make host", + "remove_member": "Remove from party", + "remove_confirm": "Remove {} from this party? They won't be able to rejoin.", + "buffering": "Buffering…", + "sys_joined": "{} joined", + "sys_left": "{} left", + "sys_host": "{} is now the host", + "sys_removed": "{} was removed", + "error_banned": "You were removed from this party", + "error_locked": "This party is locked", + "room_closed_kicked": "The host removed you from this party", + "waiting_for_others": "Waiting for everyone to catch up…" }, "notifications": { "title": "Notifications", @@ -1281,7 +1389,19 @@ "highlights": "Highlights", "remove_highlight": "Remove highlight", "highlight_note": "Add a note (optional)", - "no_highlights": "No highlights yet. Select some text and choose Highlight." + "no_highlights": "No highlights yet. Select some text and choose Highlight.", + "translate_page": "Translate page", + "translate_preview": "Translated page", + "translate_apply": "Apply", + "translate_show_original": "Show original", + "translate_no_text": "No text found on this page.", + "translate_daily_limit": "You've used today's translations. Try again tomorrow.", + "translate_offline": "You're offline. Connect to the internet to translate this page.", + "translate_sign_in": "Sign in to translate pages.", + "translate_failed": "Couldn't translate this page. Try again.", + "translate_language": "Translate pages into", + "translate_auto": "Translate every page as I read", + "translate_auto_desc": "Each new page uses one of today's translations." }, "cloudflare": { "title": "Verify you're human", @@ -1363,6 +1483,10 @@ "watched": "Watched", "follow_series": "Follow series", "find_other_sources": "Find other sources", + "notice_title": "This source can't play this title", + "notice_moved_to_app": "This source has moved this title into its own app, so there is nothing left here to play. Another source may still have it.", + "notice_no_player": "This source lists this title but has no player for it. Another source may still have it.", + "notice_is_series": "On this source this is a series, so it plays from the episode list rather than as a single film.", "anilist_track": "Track on AniList", "anilist_tracked": "Tracked on AniList", "following_on": "Following", @@ -1393,6 +1517,8 @@ "download_batch_body": "Episodes {range} will be queued — {count} in all. On mobile data this can be several gigabytes.", "relations": "Relations", "no_relations": "No related titles found for this one.", + "relation_tmdb_collection": "SAME SERIES", + "relation_tmdb_recommended": "RECOMMENDED", "relation_sequel": "SEQUEL", "relation_prequel": "PREQUEL", "relation_parent": "PARENT STORY", @@ -1461,6 +1587,8 @@ "offline_desc": "Showing the saved list. Only downloaded episodes open offline.", "filter_hint": "Episode number or title", "all_groups": "All groups", + "season_n": "Season {}", + "specials": "Specials", "filter_none": "No episode matching “{query}” in this block", "jump_to": "Go to episode {n}", "jump_loads": "Loads {range}", @@ -1505,7 +1633,10 @@ "show_more": "Show more", "delete_confirm_title": "Delete this comment?", "delete_confirm_body": "This action cannot be undone.", - "sign_in_prompt": "Sign in to leave a comment" + "sign_in_prompt": "Sign in to leave a comment", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · tap to reveal", + "unwatched_hidden": "Not watched yet · tap to reveal" }, "downloads": { "view_titles": "Titles", @@ -1721,7 +1852,7 @@ "manual_title": "Or enter the code", "confirm": "Confirm", "approved": "TV linked", - "approved_named": "\\u201c{}\\u201d linked", + "approved_named": "“{}” linked", "approved_hint": "The TV will sign in to your account in a few seconds.", "linked_devices": "Linked devices", "no_devices": "No linked devices yet", @@ -1955,7 +2086,8 @@ "title": "My Lists", "remove": "Remove", "empty_title": "Nothing saved yet", - "empty_subtitle": "Titles you save show up here." + "empty_subtitle": "Titles you save show up here.", + "load_failed": "Couldn't load this list." }, "torrent": { "title": "Torrents", @@ -2229,7 +2361,25 @@ "settings_title": "{} settings", "settings_reset": "Reset", "settings_failed": "This source’s settings could not be read.", - "settings_none": "This source has no settings." + "settings_none": "This source has no settings.", + "loading_repo": "Loading repository…", + "add_sources": "Add sources", + "choose_sources": "Choose sources", + "select_all": "Select all", + "select_none": "Select none", + "install_selected": "Install {}", + "already_installed": "Installed", + "remove": "Remove", + "remove_source": "Remove source", + "remove_source_confirm": "Remove {}? Its settings and cached code are deleted. You can add it again from its repo.", + "remove_repo_confirm": "Remove this repo and all of its sources?", + "enable_source": "Enable", + "disable_source": "Disable", + "source_disabled": "Disabled", + "update": "Update", + "update_all": "Update all", + "updates_available": "{} update(s) available", + "repo_check_failed": "Could not check {}: {}" }, "ux": { "quick_search": "Quick search ({})", @@ -2300,8 +2450,8 @@ "check_all": "Check sources", "check_bad_hint": "Hiding sources that are down keeps them out of these lists; they come back on their own once they work again.", "check_done": "Source check", - "check_hide_down": "Most sources could not be reached at all — that is the connection, not the sources. Nothing was marked; try again on a working network.", - "check_network": "Hide sources that are down", + "check_hide_down": "Hide sources that are down", + "check_network": "Most sources could not be reached at all — that is the connection, not the sources. Nothing was marked; try again on a working network.", "check_retrying": "Trying the failed ones again…", "checking": "Checking {} of {}…", "d_ago": "{} d ago", @@ -2315,6 +2465,8 @@ "v_blocked": "Blocked", "v_dead": "Not working", "v_empty": "Empty", + "v_empty_body": "This source answered, but its catalogue came back empty. That is often what a site looks like once it has shut the app out.", + "v_failing_body": "The last check of this source failed. One failure can be a bad minute — check again to find out.", "v_failing": "Not responding", "v_outdated": "Needs update", "v_unknown": "Not checked yet", @@ -2527,7 +2679,18 @@ "card_single": "Add profiles for the people you share this account with", "load_failed": "Couldn't load profiles", "offline": "Check your connection and try again.", - "error_generic": "Something went wrong. Please try again." + "error_generic": "Something went wrong. Please try again.", + "on_this_device": "On this device", + "ask_on_launch": "Ask who's watching on launch", + "ask_on_launch_desc": "When off, Sozo opens the last profile used. Profiles with a PIN still ask.", + "pin_locked_until": "Too many attempts. Try again after {}.", + "pin_not_on_device": "You're offline, and this profile's PIN hasn't been entered on this device yet. Connect to the internet once to unlock it.", + "biometric_reason": "Unlock {}", + "biometric_offer_title": "Use fingerprint / Face ID?", + "biometric_offer_body": "Use fingerprint / Face ID for {} on this device. The PIN keeps working too.", + "biometric_offer_yes": "Use it", + "biometric_offer_no": "Not now", + "biometric_toggle": "Fingerprint / Face ID for {}" }, "jellyfin": { "connect_server": "Connect a media server (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Never miss a new episode", "priming_body": "Sozo tells you the moment something you follow gets a new episode or chapter — and nothing you haven't asked for.", "priming_body_title": "Get a heads-up the moment {} gets a new episode — and nothing you haven't asked for.", + "priming_download_title": "Know when your download is done", + "priming_download_body": "Sozo shows download progress in your notifications and tells you when it's ready to watch offline. It can also tell you about new episodes of what you follow.", "priming_point_releases": "New episodes, with the poster", "priming_point_quiet": "Quiet hours", "priming_point_control": "Mute any title", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Only jpg, png or webp images.", "error_attachment_size": "Each image must be 5 MB or less.", "error_attachment_count": "At most 5 screenshots per message.", - "suggest": "Keeps happening? Write to support" + "suggest": "Keeps happening? Write to support", + "error_offline": "You're offline or the connection dropped. Check your internet and try again.", + "not_sent": "Not sent: {}", + "diag_crash_log": "View crash log" + }, + "zangetsu": { + "title": "Zangetsu Sources", + "row_subtitle": "JavaScript sources from external repos, run on your device", + "desc": "Add a Zangetsu repo to install extra anime and movie sources. They run on your device. Sources Sozo already ships are marked Built in and keep using Sozo's own version.", + "loading_repo": "Loading repo…", + "repo_added": "Repo added · {} sources.", + "checking": "Checking repos for updates…", + "updates_available": "{} update(s) available.", + "installed_one": "Installed {}.", + "removed_one": "Removed {}.", + "updated": "Updated {} source(s).", + "update_all": "Update all ({})", + "update_to": "Update to v{}", + "built_in": "Built in", + "search": "Search sources…", + "all_types": "All", + "type_movies": "Movies", + "all_languages": "All languages", + "error_invalid_url": "Only https links on raw.githubusercontent.com or gist.githubusercontent.com can be added.", + "error_unreachable": "Could not reach this repo. Check the link and your connection.", + "error_invalid_index": "This link does not point to a valid Zangetsu index.json." + }, + "star": { + "title": "Want to help Sozo grow?", + "body": "Star it on GitHub ⭐", + "action": "Star on GitHub", + "later": "Later", + "never": "Don't ask again", + "update_button": "Star Sozo on GitHub" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Level up", + "level_reached": "You reached level {}", + "unlocked_new": "New in your locker", + "open_locker": "Open locker", + "unavailable": "Levels are not available yet. Check back soon.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP to level {}", + "to_next_short": "{} XP to the next level", + "watch_today": "Watching today: {} of {} XP", + "how_to_earn": "How to earn XP", + "rule_watch": "Watching: 1 XP a minute, up to 300 a day", + "rule_comment": "A comment: 5 XP, up to 10 comments a day", + "rule_like": "A like on your comment: 1 XP, up to 50 a day", + "rule_streak": "A streak day: 10 XP, more on milestone days", + "rule_achievement": "An achievement: 50 to 400 XP by tier", + "recent": "Recent XP", + "reason_watch": "Watching", + "reason_comment": "Comments", + "reason_like": "Likes received", + "reason_streak": "Streak day", + "reason_achievement": "Achievement", + "reason_other": "XP", + "locker": "Locker", + "slot_frame": "Avatar frames", + "slot_title": "Titles", + "slot_color": "Name colours", + "equip": "Tap to equip", + "equipped": "Equipped", + "unlock_level": "Level {}", + "unlock_badge": "Badge: {}", + "frame_bronze": "Bronze", + "frame_silver": "Silver", + "frame_gold": "Gold", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Ember", + "frame_flame": "Flame", + "frame_moon": "Moon", + "title_newcomer": "Newcomer", + "title_regular": "Regular", + "title_veteran": "Veteran", + "title_legend": "Legend", + "title_binger": "Binge-watcher", + "title_cinephile": "Cinephile", + "title_otaku": "Otaku", + "title_bookworm": "Bookworm", + "title_night_owl": "Night owl", + "title_host": "Party host", + "color_sky": "Sky", + "color_mint": "Mint", + "color_gold": "Gold", + "color_rose": "Rose", + "color_violet": "Violet", + "color_crimson": "Crimson", + "color_aurora": "Aurora", + "color_ember": "Ember" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index e45868f1..16ffea50 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -34,7 +34,9 @@ "remove": "Quitar", "show": "Mostrar", "hide": "Ocultar", - "installed": "Instalado" + "installed": "Instalado", + "load_more_failed": "No se pudo cargar más", + "go_home": "Ir al inicio" }, "auth": { "login": "Iniciar sesión", @@ -306,7 +308,11 @@ "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." + "unknown": "No se pudo cargar el vídeo.", + "not_found": "Este vídeo ya no está en este servidor. Prueba con otro servidor.", + "server_error": "El servidor de vídeo tiene problemas. Vuelve a intentarlo en un momento o elige otro servidor.", + "resolve_failed": "No se pudo obtener un enlace de vídeo de esta fuente.", + "details": "Detalles" }, "play": "Reproducir", "pause": "Pausar", @@ -529,6 +535,22 @@ "alt_guess": "Suposición", "alt_guess_spoken": "Coincidencia dudosa: comprueba el título antes de reproducir", "alt_your_pick": "Tu elección", + "alt_auto": "Automático", + "alt_auto_hint": "Reproduce desde la primera fuente que lo tenga", + "alt_auto_trying": "Probando {}…", + "alt_auto_missed": "Ninguna fuente coincidente pudo reproducirlo. Elige una abajo o busca en una fuente.", + "alt_auto_checking": "Comprobando {} · {} de {}", + "alt_auto_waiting": "Esperando más fuentes…", + "alt_auto_stop": "Detener", + "alt_auto_stopped": "Detenido.", + "alt_auto_capped": "Se comprobaron {} fuentes y ninguna pudo reproducir esto. Elige una abajo o vuelve a intentarlo.", + "alt_auto_tried": "Comprobadas", + "alt_skip_no_match": "sin coincidencia cercana", + "alt_skip_no_episode": "episodio no encontrado", + "alt_skip_error": "no se pudo cargar", + "auto_switch_attempt": "Intento {} de {}", + "auto_switch_stop": "Detener", + "auto_switch_stopped": "Se detuvo el cambio de servidor. Vuelve a intentarlo o elige otro servidor o fuente.", "alt_wrong_title": "¿Título incorrecto?", "alt_wrong_title_hint": "Busca tú mismo en {} y elige el título correcto", "alt_corrected": "Ahora se usa «{}»", @@ -554,10 +576,35 @@ "subtitle_n": "Subtítulo {}", "subtitle_in_video": "En el vídeo", "rewind": "Retroceder", + "rewind_by": "Retroceder {} s", + "forward_by": "Adelantar {} s", "mini_restore": "Volver a la ventana completa", "pip_unavailable": "No se pudo iniciar imagen en imagen para este vídeo.", "scrub_release_cancel": "Suelta para cancelar", - "auto_quality_desc_mpv": "Empieza con la mejor calidad que ofrece la emisión" + "auto_quality_desc_mpv": "Empieza con la mejor calidad que ofrece la emisión", + "reconnecting": "Conexión perdida — reconectando…", + "all_servers_failed": "Ninguno de los servidores de este título pudo reproducirlo. Inténtalo más tarde o elige otra fuente.", + "shortcut_pip": "Minirreproductor (Mayús: sobre otras apps)", + "mini_popout_hint": "Minirreproductor · mantén pulsado para que flote sobre otras apps", + "react": "Reaccionar", + "comments": "Comentarios", + "episode_comments": "Comentarios del episodio {}", + "reaction_text": "Escribir una reacción", + "reaction_text_hint": "Di algo breve…", + "reaction_wait": "Espera unos segundos antes de volver a reaccionar", + "reaction_limit": "Has alcanzado el límite de reacciones de este episodio", + "reaction_failed": "No se pudo enviar la reacción", + "native_offer_title": "¿Probar el nuevo reproductor?", + "native_offer_body": "Los controles y menús Liquid Glass de Apple. Puedes cambiarlo cuando quieras en Ajustes → Reproductor.", + "native_offer_try": "Probar", + "native_offer_not_now": "Ahora no", + "trying_again": "Reintentando (intento {})", + "try_mpv": "Probar con el motor mpv", + "ok_to_skip": "OK para saltar", + "quality_lowered": "Calidad reducida", + "undo": "Deshacer", + "crash_log": "Registro de fallos", + "crash_log_empty": "No hay fallos registrados" }, "profile": { "title": "Perfil", @@ -649,6 +696,15 @@ "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", + "controls_style": "ESTILO DE LOS CONTROLES DEL REPRODUCTOR", + "controls_style_desc": "Se aplica la próxima vez que abras el reproductor.", + "controls_style_native": "Nativo (iOS 26)", + "controls_style_native_desc": "Los botones, menús y hojas Liquid Glass del propio teléfono", + "controls_style_system": "Cristal del sistema", + "controls_style_system_desc": "Los botones de la app sobre el Liquid Glass del teléfono", + "controls_style_classic": "Cristal clásico", + "controls_style_classic_desc": "El cristal propio de la app, como en versiones anteriores", + "controls_style_needs_ios26": "Requiere iOS 26 o posterior", "playback_section": "REPRODUCCIÓN", "default_speed": "Velocidad por defecto", "default_speed_desc": "Velocidad con la que empieza cada vídeo", @@ -671,6 +727,8 @@ "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", + "auto_pip": "Imagen en imagen automática", + "auto_pip_desc": "Al pulsar Inicio mientras se reproduce un vídeo, sigue en una ventana pequeña", "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", @@ -743,7 +801,15 @@ "library_update": "Buscar episodios nuevos", "library_update_desc": "Revisa los títulos que sigues y te avisa cuando sale uno nuevo", "library_update_every": "Cada {} horas", - "library_update_daily": "Una vez al día" + "library_update_daily": "Una vez al día", + "contributors": "Colaboradores", + "contributors_error": "No se pudieron cargar los colaboradores", + "become_contributor": "Hazte colaborador", + "become_contributor_body": "Sozo es de código abierto. El código, las traducciones y los informes de errores ayudan.", + "reactions_overlay": "Reacciones de espectadores", + "reactions_overlay_desc": "Mostrar las reacciones de otros espectadores en el momento en que se enviaron y dónde se agrupan en la barra de progreso", + "hide_spoilers": "Ocultar spoilers de episodios que no he visto", + "hide_spoilers_desc": "Los comentarios sobre un episodio que no has terminado se ven borrosos hasta que los tocas" }, "appearance": { "title": "Apariencia", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Preparando el micrófono…", + "voice_no_speech": "No te entendí", + "voice_no_speech_hint": "Habla un poco más alto y cerca del micrófono, y vuelve a intentarlo.", + "voice_permission": "El permiso del micrófono está desactivado", + "voice_permission_hint": "Permite que Sozo use el micrófono en los ajustes del sistema para buscar por voz.", + "voice_unavailable": "El reconocimiento de voz no está disponible en este dispositivo", + "voice_unavailable_hint": "Este dispositivo no tiene un servicio de reconocimiento de voz. Instala o activa uno en los ajustes del sistema, como el de Google.", + "voice_network": "No se pudo conectar con el servicio de voz", + "voice_network_hint": "Revisa tu conexión a internet y vuelve a intentarlo.", + "voice_error": "Algo salió mal al escuchar", + "voice_error_hint": "Puede que otra app esté usando el micrófono. Vuelve a intentarlo en un momento.", + "voice_try_again": "Reintentar", + "voice_open_settings": "Abrir ajustes", "try_torrents": "Buscar torrents", "capped_note": "Se buscó en las {} fuentes más fiables de {} — acota la selección para llegar a una concreta.", "rail_popular_on": "Popular en {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "¿Olvidaste el PIN?" }, "my_list": { + "sort": "Ordenar", + "sort_added": "Añadidos recientemente", + "sort_oldest": "Añadidos primero", + "sort_title": "Título A–Z", "saved_account": "Guardado en la cuenta", "saved_local": "En este dispositivo", "untitled": "Sin título", @@ -1160,7 +1242,33 @@ "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." + "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo.", + "settings_title": "Ajustes de la sala", + "room_size": "Tamaño de la sala", + "room_size_less": "Menos personas", + "room_size_more": "Más personas", + "only_host_setting": "Solo el anfitrión controla la reproducción", + "lock_room": "Bloquear sala", + "lock_room_hint": "Nadie nuevo puede unirse", + "wait_for_all": "Esperar a todos", + "wait_for_all_hint": "Pausar un momento cuando alguien esté cargando", + "invite_friends": "Invitar amigos", + "invite_sent": "Invitación enviada", + "invite_failed": "No se pudo enviar la invitación", + "invite_again_in": "De nuevo en {}", + "invite_no_friends": "Añade amigos para invitarlos aquí", + "make_host": "Hacer anfitrión", + "remove_member": "Quitar de la fiesta", + "remove_confirm": "¿Quitar a {} de esta fiesta? No podrá volver a unirse.", + "buffering": "Cargando…", + "sys_joined": "{} se unió", + "sys_left": "{} salió", + "sys_host": "{} ahora es el anfitrión", + "sys_removed": "{} fue quitado", + "error_banned": "Te quitaron de esta fiesta", + "error_locked": "Esta fiesta está bloqueada", + "room_closed_kicked": "El anfitrión te quitó de esta fiesta", + "waiting_for_others": "Esperando a que todos se pongan al día…" }, "notifications": { "title": "Notificaciones", @@ -1281,7 +1389,19 @@ "highlights": "Resaltados", "remove_highlight": "Quitar resaltado", "highlight_note": "Añade una nota (opcional)", - "no_highlights": "Aún no hay resaltados. Selecciona texto y elige Resaltar." + "no_highlights": "Aún no hay resaltados. Selecciona texto y elige Resaltar.", + "translate_page": "Traducir página", + "translate_preview": "Página traducida", + "translate_apply": "Aplicar", + "translate_show_original": "Mostrar original", + "translate_no_text": "No se encontró texto en esta página.", + "translate_daily_limit": "Ya usaste las traducciones de hoy. Vuelve a intentarlo mañana.", + "translate_offline": "Estás sin conexión. Conéctate a internet para traducir esta página.", + "translate_sign_in": "Inicia sesión para traducir páginas.", + "translate_failed": "No se pudo traducir esta página. Inténtalo de nuevo.", + "translate_language": "Traducir páginas al", + "translate_auto": "Traducir cada página mientras leo", + "translate_auto_desc": "Cada página nueva usa una de las traducciones de hoy." }, "cloudflare": { "title": "Verifica que eres humano", @@ -1363,6 +1483,10 @@ "watched": "Visto", "follow_series": "Seguir la serie", "find_other_sources": "Buscar otras fuentes", + "notice_title": "Esta fuente no puede reproducir este título", + "notice_moved_to_app": "La fuente ha trasladado este título a su propia aplicación, así que aquí no queda nada que reproducir. Puede que otra fuente lo tenga.", + "notice_no_player": "La fuente incluye este título pero no tiene reproductor para él. Puede que otra fuente lo tenga.", + "notice_is_series": "En esta fuente esto es una serie, así que se reproduce desde la lista de episodios y no como una película.", "anilist_track": "Seguir en AniList", "anilist_tracked": "Seguido en AniList", "following_on": "Siguiendo", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "MISMA SAGA", + "relation_tmdb_recommended": "RECOMENDADOS", "relation_sequel": "SECUELA", "relation_prequel": "PRECUELA", "relation_parent": "HISTORIA PRINCIPAL", @@ -1461,6 +1587,8 @@ "offline_desc": "Lista guardada. Sin conexión solo se abren los episodios descargados.", "filter_hint": "Número o título del episodio", "all_groups": "Todos los grupos", + "season_n": "Temporada {}", + "specials": "Especiales", "filter_none": "Ningún episodio coincide con «{query}» en este bloque", "jump_to": "Ir al episodio {n}", "jump_loads": "Carga {range}", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Inicia sesión para dejar un comentario", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · toca para ver", + "unwatched_hidden": "Aún no lo has visto · toca para ver" }, "downloads": { "view_titles": "Títulos", @@ -1955,7 +2086,8 @@ "title": "Mis listas", "remove": "Quitar", "empty_title": "Nada guardado todavía", - "empty_subtitle": "Los títulos que guardes aparecerán aquí." + "empty_subtitle": "Los títulos que guardes aparecerán aquí.", + "load_failed": "No se pudo cargar esta lista." }, "torrent": { "title": "Torrents", @@ -2229,7 +2361,25 @@ "settings_title": "Ajustes de {}", "settings_reset": "Restablecer", "settings_failed": "No se pudieron leer los ajustes de esta fuente.", - "settings_none": "Esta fuente no tiene ajustes." + "settings_none": "Esta fuente no tiene ajustes.", + "loading_repo": "Cargando repositorio…", + "add_sources": "Añadir fuentes", + "choose_sources": "Elige las fuentes", + "select_all": "Seleccionar todo", + "select_none": "No seleccionar ninguna", + "install_selected": "Instalar ({})", + "already_installed": "Instalada", + "remove": "Quitar", + "remove_source": "Quitar fuente", + "remove_source_confirm": "¿Quitar {}? Se borrarán sus ajustes y el código en caché. Puedes volver a añadirla desde su repositorio.", + "remove_repo_confirm": "¿Quitar este repositorio y todas sus fuentes?", + "enable_source": "Activar", + "disable_source": "Desactivar", + "source_disabled": "Desactivada", + "update": "Actualizar", + "update_all": "Actualizar todo", + "updates_available": "{} actualización(es) disponible(s)", + "repo_check_failed": "No se pudo comprobar {}: {}" }, "ux": { "quick_search": "Búsqueda rápida ({})", @@ -2300,8 +2450,8 @@ "check_all": "Comprobar fuentes", "check_bad_hint": "Ocultar las fuentes caídas las saca de estas listas; vuelven solas cuando funcionan otra vez.", "check_done": "Comprobación de fuentes", - "check_hide_down": "No se pudo conectar con la mayoría de las fuentes — es la conexión, no las fuentes. No se marcó nada; inténtalo en una red que funcione.", - "check_network": "Ocultar las que no funcionan", + "check_hide_down": "Ocultar las que no funcionan", + "check_network": "No se pudo conectar con la mayoría de las fuentes — es la conexión, no las fuentes. No se marcó nada; inténtalo en una red que funcione.", "check_retrying": "Reintentando las que fallaron…", "checking": "Comprobando {} de {}…", "d_ago": "hace {} d", @@ -2315,6 +2465,8 @@ "v_blocked": "Bloqueada", "v_dead": "No funciona", "v_empty": "Vacía", + "v_empty_body": "La fuente respondió, pero su catálogo llegó vacío. Así suele verse un sitio que ha bloqueado la aplicación.", + "v_failing_body": "La última comprobación de esta fuente falló. Un fallo puede ser un mal momento: vuelve a comprobarlo.", "v_failing": "No responde", "v_outdated": "Necesita actualización", "v_unknown": "Aún no comprobada", @@ -2527,7 +2679,18 @@ "card_single": "Añade perfiles para las personas con las que compartes esta cuenta", "load_failed": "No se pudieron cargar los perfiles", "offline": "Comprueba tu conexión e inténtalo de nuevo.", - "error_generic": "Algo salió mal. Inténtalo de nuevo." + "error_generic": "Algo salió mal. Inténtalo de nuevo.", + "on_this_device": "En este dispositivo", + "ask_on_launch": "Preguntar quién está viendo al iniciar", + "ask_on_launch_desc": "Si está desactivado, Sozo abre el último perfil usado. Los perfiles con PIN lo seguirán pidiendo.", + "pin_locked_until": "Demasiados intentos. Inténtalo de nuevo después de las {}.", + "pin_not_on_device": "No tienes conexión y el PIN de este perfil aún no se ha introducido en este dispositivo. Conéctate a internet una vez para desbloquearlo.", + "biometric_reason": "Desbloquear {}", + "biometric_offer_title": "¿Usar huella / Face ID?", + "biometric_offer_body": "Usa huella / Face ID para {} en este dispositivo. El PIN seguirá funcionando.", + "biometric_offer_yes": "Usar", + "biometric_offer_no": "Ahora no", + "biometric_toggle": "Huella / Face ID para {}" }, "jellyfin": { "connect_server": "Conectar un servidor multimedia (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "No te pierdas ni un episodio", "priming_body": "Sozo te avisa en cuanto algo que sigues tenga un episodio o capítulo nuevo, y nada que no hayas pedido.", "priming_body_title": "Entérate en cuanto «{}» tenga un episodio nuevo, y nada que no hayas pedido.", + "priming_download_title": "Entérate cuando termine tu descarga", + "priming_download_body": "Sozo muestra el progreso de la descarga en tus notificaciones y te avisa cuando esté lista para ver sin conexión. También puede avisarte de nuevos episodios de lo que sigues.", "priming_point_releases": "Episodios nuevos, con póster", "priming_point_quiet": "Horas de silencio", "priming_point_control": "Silencia cualquier título", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Solo imágenes jpg, png o webp.", "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", "error_attachment_count": "Como máximo 5 capturas por mensaje.", - "suggest": "¿Sigue pasando? Escribe a soporte" + "suggest": "¿Sigue pasando? Escribe a soporte", + "error_offline": "No tienes conexión o se cortó. Revisa tu internet e inténtalo de nuevo.", + "not_sent": "No se envió: {}", + "diag_crash_log": "Ver registro de fallos" + }, + "zangetsu": { + "title": "Fuentes de Zangetsu", + "row_subtitle": "Fuentes JavaScript de repos externos, se ejecutan en tu dispositivo", + "desc": "Añade un repo de Zangetsu para instalar más fuentes de anime y películas. Se ejecutan en tu dispositivo. Las fuentes que Sozo ya incluye aparecen como «Integrada» y siguen usando la versión propia de Sozo.", + "loading_repo": "Cargando repo…", + "repo_added": "Repo añadido · {} fuentes.", + "checking": "Buscando actualizaciones de los repos…", + "updates_available": "{} actualización(es) disponible(s).", + "installed_one": "{} instalada.", + "removed_one": "{} eliminada.", + "updated": "{} fuente(s) actualizada(s).", + "update_all": "Actualizar todo ({})", + "update_to": "Actualizar a v{}", + "built_in": "Integrada", + "search": "Buscar fuentes…", + "all_types": "Todo", + "type_movies": "Películas", + "all_languages": "Todos los idiomas", + "error_invalid_url": "Solo se pueden añadir enlaces https de raw.githubusercontent.com o gist.githubusercontent.com.", + "error_unreachable": "No se pudo acceder a este repo. Revisa el enlace y tu conexión.", + "error_invalid_index": "Este enlace no apunta a un index.json de Zangetsu válido." + }, + "star": { + "title": "¿Quieres ayudar a que Sozo crezca?", + "body": "Dale una estrella en GitHub ⭐", + "action": "Dar estrella en GitHub", + "later": "Más tarde", + "never": "No volver a preguntar", + "update_button": "Dale una estrella a Sozo en GitHub" + }, + "progress": { + "title": "Nivel y XP", + "level": "Nivel", + "level_short": "Nv. {}", + "level_up": "Subida de nivel", + "level_reached": "Has alcanzado el nivel {}", + "unlocked_new": "Novedades en tu taquilla", + "open_locker": "Abrir taquilla", + "unavailable": "Los niveles aún no están disponibles. Vuelve pronto.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP para el nivel {}", + "to_next_short": "{} XP para el siguiente nivel", + "watch_today": "Viendo hoy: {} de {} XP", + "how_to_earn": "Cómo ganar XP", + "rule_watch": "Ver: 1 XP por minuto, hasta 300 al día", + "rule_comment": "Un comentario: 5 XP, hasta 10 comentarios al día", + "rule_like": "Un me gusta en tu comentario: 1 XP, hasta 50 al día", + "rule_streak": "Un día de racha: 10 XP, más en días hito", + "rule_achievement": "Un logro: de 50 a 400 XP según el nivel", + "recent": "XP reciente", + "reason_watch": "Viendo", + "reason_comment": "Comentarios", + "reason_like": "Me gusta recibidos", + "reason_streak": "Día de racha", + "reason_achievement": "Logro", + "reason_other": "XP", + "locker": "Taquilla", + "slot_frame": "Marcos de avatar", + "slot_title": "Títulos", + "slot_color": "Colores de nombre", + "equip": "Toca para equipar", + "equipped": "Equipado", + "unlock_level": "Nivel {}", + "unlock_badge": "Insignia: {}", + "frame_bronze": "Bronce", + "frame_silver": "Plata", + "frame_gold": "Oro", + "frame_neon": "Neón", + "frame_sakura": "Sakura", + "frame_ember": "Brasa", + "frame_flame": "Llama", + "frame_moon": "Luna", + "title_newcomer": "Recién llegado", + "title_regular": "Habitual", + "title_veteran": "Veterano", + "title_legend": "Leyenda", + "title_binger": "Maratonista", + "title_cinephile": "Cinéfilo", + "title_otaku": "Otaku", + "title_bookworm": "Ratón de biblioteca", + "title_night_owl": "Búho nocturno", + "title_host": "Anfitrión", + "color_sky": "Cielo", + "color_mint": "Menta", + "color_gold": "Oro", + "color_rose": "Rosa", + "color_violet": "Violeta", + "color_crimson": "Carmesí", + "color_aurora": "Aurora", + "color_ember": "Brasa" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index e1a4c3dd..5aa0e570 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -34,7 +34,9 @@ "remove": "Retirer", "show": "Afficher", "hide": "Masquer", - "installed": "Installé" + "installed": "Installé", + "load_more_failed": "Impossible d’en charger plus", + "go_home": "Retour à l’accueil" }, "auth": { "login": "Connexion", @@ -306,7 +308,11 @@ "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." + "unknown": "Impossible de charger la vidéo.", + "not_found": "Cette vidéo n'est plus sur ce serveur. Essayez un autre serveur.", + "server_error": "Le serveur vidéo rencontre des problèmes. Réessayez dans un instant ou choisissez un autre serveur.", + "resolve_failed": "Impossible d'obtenir un lien vidéo depuis cette source.", + "details": "Détails" }, "play": "Lire", "pause": "Pause", @@ -529,6 +535,22 @@ "alt_guess": "Supposition", "alt_guess_spoken": "Correspondance incertaine — vérifiez le titre avant de lancer", "alt_your_pick": "Votre choix", + "alt_auto": "Automatique", + "alt_auto_hint": "Lit depuis la première source qui l’a", + "alt_auto_trying": "Essai de {}…", + "alt_auto_missed": "Aucune source correspondante n’a pu le lire. Choisissez-en une ci-dessous ou cherchez dans une source.", + "alt_auto_checking": "Vérification de {} · {} sur {}", + "alt_auto_waiting": "En attente d’autres sources…", + "alt_auto_stop": "Arrêter", + "alt_auto_stopped": "Arrêté.", + "alt_auto_capped": "{} sources vérifiées, aucune n’a pu lire ceci. Choisissez-en une ci-dessous ou réessayez.", + "alt_auto_tried": "Vérifiées", + "alt_skip_no_match": "pas de correspondance proche", + "alt_skip_no_episode": "épisode introuvable", + "alt_skip_error": "chargement impossible", + "auto_switch_attempt": "Tentative {} sur {}", + "auto_switch_stop": "Arrêter", + "auto_switch_stopped": "Changement de serveur arrêté. Réessayez, ou choisissez un autre serveur ou une autre source.", "alt_wrong_title": "Mauvais titre ?", "alt_wrong_title_hint": "Cherchez vous-même dans {} et choisissez le bon titre", "alt_corrected": "Utilise maintenant « {} »", @@ -554,10 +576,35 @@ "subtitle_n": "Sous-titres {}", "subtitle_in_video": "Dans la vidéo", "rewind": "Reculer", + "rewind_by": "Reculer de {} s", + "forward_by": "Avancer de {} s", "mini_restore": "Revenir à la fenêtre complète", "pip_unavailable": "Impossible de lancer l’image dans l’image pour cette vidéo.", "scrub_release_cancel": "Relâchez pour annuler", - "auto_quality_desc_mpv": "Commence à la meilleure qualité du flux" + "auto_quality_desc_mpv": "Commence à la meilleure qualité du flux", + "reconnecting": "Connexion perdue — reconnexion…", + "all_servers_failed": "Aucun serveur de ce titre n’a pu le lire. Réessayez plus tard ou choisissez une autre source.", + "shortcut_pip": "Mini-lecteur (Maj : au-dessus des autres apps)", + "mini_popout_hint": "Mini-lecteur · appui long pour flotter au-dessus des autres apps", + "react": "Réagir", + "comments": "Commentaires", + "episode_comments": "Commentaires de l'épisode {}", + "reaction_text": "Écrire une réaction", + "reaction_text_hint": "Un mot rapide…", + "reaction_wait": "Attendez quelques secondes avant de réagir à nouveau", + "reaction_limit": "Vous avez atteint la limite de réactions pour cet épisode", + "reaction_failed": "Impossible d'envoyer la réaction", + "native_offer_title": "Essayer le nouveau lecteur ?", + "native_offer_body": "Les commandes et menus Liquid Glass d’Apple. Vous pouvez changer cela à tout moment dans Réglages → Lecteur.", + "native_offer_try": "Essayer", + "native_offer_not_now": "Plus tard", + "trying_again": "Nouvel essai (tentative {})", + "try_mpv": "Essayer avec le moteur mpv", + "ok_to_skip": "OK pour passer", + "quality_lowered": "Qualité réduite", + "undo": "Annuler", + "crash_log": "Journal des plantages", + "crash_log_empty": "Aucun plantage enregistré" }, "profile": { "title": "Profil", @@ -649,6 +696,15 @@ "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", + "controls_style": "STYLE DES COMMANDES DU LECTEUR", + "controls_style_desc": "S'applique à la prochaine ouverture du lecteur.", + "controls_style_native": "Natif (iOS 26)", + "controls_style_native_desc": "Les boutons, menus et feuilles Liquid Glass du téléphone", + "controls_style_system": "Verre système", + "controls_style_system_desc": "Les boutons de l'app sur le Liquid Glass du téléphone", + "controls_style_classic": "Verre classique", + "controls_style_classic_desc": "Le verre propre à l'app, comme dans les versions précédentes", + "controls_style_needs_ios26": "Nécessite iOS 26 ou version ultérieure", "playback_section": "LECTURE", "default_speed": "Vitesse par défaut", "default_speed_desc": "Vitesse à laquelle chaque vidéo démarre", @@ -671,6 +727,8 @@ "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", + "auto_pip": "Image dans l’image automatique", + "auto_pip_desc": "Appuyer sur Accueil pendant une vidéo la poursuit dans une petite fenêtre", "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", @@ -743,7 +801,15 @@ "library_update": "Vérifier les nouveaux épisodes", "library_update_desc": "Vérifie les titres suivis et vous prévient à chaque nouveauté", "library_update_every": "Toutes les {} heures", - "library_update_daily": "Une fois par jour" + "library_update_daily": "Une fois par jour", + "contributors": "Contributeurs", + "contributors_error": "Impossible de charger les contributeurs", + "become_contributor": "Devenir contributeur", + "become_contributor_body": "Sozo est open source. Le code, les traductions et les signalements de bugs, tout aide.", + "reactions_overlay": "Réactions des spectateurs", + "reactions_overlay_desc": "Afficher les réactions des autres spectateurs au moment où elles ont été envoyées, et où elles se concentrent sur la barre de lecture", + "hide_spoilers": "Masquer les spoilers des épisodes que je n'ai pas vus", + "hide_spoilers_desc": "Les commentaires sur un épisode que vous n'avez pas terminé restent floutés jusqu'à ce que vous les touchiez" }, "appearance": { "title": "Apparence", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Préparation du micro…", + "voice_no_speech": "Je n’ai pas compris", + "voice_no_speech_hint": "Parle un peu plus fort, près du micro, puis réessaie.", + "voice_permission": "L’autorisation du micro est désactivée", + "voice_permission_hint": "Autorise Sozo à utiliser le micro dans les réglages du système pour chercher à la voix.", + "voice_unavailable": "La reconnaissance vocale n’est pas disponible sur cet appareil", + "voice_unavailable_hint": "Cet appareil n’a aucun service de reconnaissance vocale. Installes-en ou actives-en un dans les réglages du système, comme celui de Google.", + "voice_network": "Impossible de joindre le service vocal", + "voice_network_hint": "Vérifie ta connexion internet et réessaie.", + "voice_error": "Un problème est survenu pendant l’écoute", + "voice_error_hint": "Une autre app utilise peut-être le micro. Réessaie dans un instant.", + "voice_try_again": "Réessayer", + "voice_open_settings": "Ouvrir les réglages", "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.", "rail_popular_on": "Populaire sur {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "Code oublié ?" }, "my_list": { + "sort": "Trier", + "sort_added": "Ajoutés récemment", + "sort_oldest": "Ajoutés en premier", + "sort_title": "Titre A–Z", "saved_account": "Enregistré sur le compte", "saved_local": "Sur cet appareil", "untitled": "Sans titre", @@ -1160,7 +1242,33 @@ "reconnecting": "Reconnexion…", "connecting": "Connexion…", "connect_failed_title": "Connexion impossible", - "connect_failed_msg": "Vérifie ta connexion internet et réessaie." + "connect_failed_msg": "Vérifie ta connexion internet et réessaie.", + "settings_title": "Paramètres du salon", + "room_size": "Taille du salon", + "room_size_less": "Moins de personnes", + "room_size_more": "Plus de personnes", + "only_host_setting": "Seul l'hôte contrôle la lecture", + "lock_room": "Verrouiller le salon", + "lock_room_hint": "Personne de nouveau ne peut rejoindre", + "wait_for_all": "Attendre tout le monde", + "wait_for_all_hint": "Faire une courte pause quand quelqu'un charge", + "invite_friends": "Inviter des amis", + "invite_sent": "Invitation envoyée", + "invite_failed": "Impossible d'envoyer l'invitation", + "invite_again_in": "De nouveau dans {}", + "invite_no_friends": "Ajoutez des amis pour les inviter ici", + "make_host": "Nommer hôte", + "remove_member": "Retirer de la soirée", + "remove_confirm": "Retirer {} de cette soirée ? Cette personne ne pourra plus revenir.", + "buffering": "Chargement…", + "sys_joined": "{} a rejoint", + "sys_left": "{} est parti", + "sys_host": "{} est maintenant l'hôte", + "sys_removed": "{} a été retiré", + "error_banned": "Vous avez été retiré de cette soirée", + "error_locked": "Cette soirée est verrouillée", + "room_closed_kicked": "L'hôte vous a retiré de cette soirée", + "waiting_for_others": "On attend que tout le monde rattrape…" }, "notifications": { "title": "Notifications", @@ -1281,7 +1389,19 @@ "highlights": "Surlignages", "remove_highlight": "Retirer le surlignage", "highlight_note": "Ajouter une note (facultatif)", - "no_highlights": "Aucun surlignage pour l’instant. Sélectionnez du texte et choisissez Surligner." + "no_highlights": "Aucun surlignage pour l’instant. Sélectionnez du texte et choisissez Surligner.", + "translate_page": "Traduire la page", + "translate_preview": "Page traduite", + "translate_apply": "Appliquer", + "translate_show_original": "Afficher l’original", + "translate_no_text": "Aucun texte trouvé sur cette page.", + "translate_daily_limit": "Vous avez utilisé toutes les traductions du jour. Réessayez demain.", + "translate_offline": "Vous êtes hors ligne. Connectez-vous à Internet pour traduire cette page.", + "translate_sign_in": "Connectez-vous pour traduire des pages.", + "translate_failed": "Impossible de traduire cette page. Réessayez.", + "translate_language": "Traduire les pages en", + "translate_auto": "Traduire chaque page pendant la lecture", + "translate_auto_desc": "Chaque nouvelle page utilise une des traductions du jour." }, "cloudflare": { "title": "Confirme que tu es humain", @@ -1363,6 +1483,10 @@ "watched": "Vu", "follow_series": "Suivre la série", "find_other_sources": "Chercher d’autres sources", + "notice_title": "Cette source ne peut pas lire ce titre", + "notice_moved_to_app": "La source a déplacé ce titre dans sa propre application : il n'y a plus rien à lire ici. Une autre source l'a peut-être encore.", + "notice_no_player": "La source référence ce titre mais n'a aucun lecteur pour lui. Une autre source l'a peut-être.", + "notice_is_series": "Sur cette source, il s'agit d'une série : elle se lit depuis la liste des épisodes et non comme un film.", "anilist_track": "Suivre sur AniList", "anilist_tracked": "Suivi sur AniList", "following_on": "Suivi", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "MÊME SAGA", + "relation_tmdb_recommended": "RECOMMANDÉS", "relation_sequel": "SUITE", "relation_prequel": "PRÉQUELLE", "relation_parent": "HISTOIRE PRINCIPALE", @@ -1461,6 +1587,8 @@ "offline_desc": "Liste enregistrée. Hors ligne, seuls les épisodes téléchargés s’ouvrent.", "filter_hint": "Numéro ou titre de l’épisode", "all_groups": "Tous les groupes", + "season_n": "Saison {}", + "specials": "Épisodes spéciaux", "filter_none": "Aucun épisode ne correspond à « {query} » dans ce bloc", "jump_to": "Aller à l’épisode {n}", "jump_loads": "Charge {range}", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Connecte-toi pour laisser un commentaire", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · touchez pour afficher", + "unwatched_hidden": "Pas encore vu · touchez pour afficher" }, "downloads": { "view_titles": "Titres", @@ -1955,7 +2086,8 @@ "title": "Mes listes", "remove": "Retirer", "empty_title": "Rien d’enregistré pour l’instant", - "empty_subtitle": "Les titres que tu enregistres apparaissent ici." + "empty_subtitle": "Les titres que tu enregistres apparaissent ici.", + "load_failed": "Impossible de charger cette liste." }, "torrent": { "title": "Torrents", @@ -2229,7 +2361,25 @@ "settings_title": "Réglages de {}", "settings_reset": "Réinitialiser", "settings_failed": "Impossible de lire les réglages de cette source.", - "settings_none": "Cette source n’a pas de réglages." + "settings_none": "Cette source n’a pas de réglages.", + "loading_repo": "Chargement du dépôt…", + "add_sources": "Ajouter des sources", + "choose_sources": "Choisir les sources", + "select_all": "Tout sélectionner", + "select_none": "Ne rien sélectionner", + "install_selected": "Installer ({})", + "already_installed": "Installée", + "remove": "Supprimer", + "remove_source": "Supprimer la source", + "remove_source_confirm": "Supprimer {} ? Ses réglages et son code en cache seront effacés. Vous pourrez la rajouter depuis son dépôt.", + "remove_repo_confirm": "Supprimer ce dépôt et toutes ses sources ?", + "enable_source": "Activer", + "disable_source": "Désactiver", + "source_disabled": "Désactivée", + "update": "Mettre à jour", + "update_all": "Tout mettre à jour", + "updates_available": "{} mise(s) à jour disponible(s)", + "repo_check_failed": "Impossible de vérifier {} : {}" }, "ux": { "quick_search": "Recherche rapide ({})", @@ -2300,8 +2450,8 @@ "check_all": "Vérifier les sources", "check_bad_hint": "Masquer les sources en panne les retire de ces listes ; elles reviennent d’elles-mêmes dès qu’elles fonctionnent.", "check_done": "Vérification des sources", - "check_hide_down": "La plupart des sources étaient injoignables — c’est la connexion, pas les sources. Rien n’a été marqué ; réessayez sur un réseau qui fonctionne.", - "check_network": "Masquer celles en panne", + "check_hide_down": "Masquer celles en panne", + "check_network": "La plupart des sources étaient injoignables — c’est la connexion, pas les sources. Rien n’a été marqué ; réessayez sur un réseau qui fonctionne.", "check_retrying": "Nouvel essai des sources en échec…", "checking": "Vérification {} sur {}…", "d_ago": "il y a {} j", @@ -2315,6 +2465,8 @@ "v_blocked": "Bloquée", "v_dead": "Ne fonctionne pas", "v_empty": "Vide", + "v_empty_body": "La source a répondu, mais son catalogue est revenu vide. C'est souvent l'allure d'un site qui a bloqué l'application.", + "v_failing_body": "La dernière vérification de cette source a échoué. Un échec peut n'être qu'une mauvaise minute — vérifiez à nouveau.", "v_failing": "Ne répond pas", "v_outdated": "Mise à jour requise", "v_unknown": "Pas encore vérifiée", @@ -2527,7 +2679,18 @@ "card_single": "Ajoutez des profils pour les personnes avec qui vous partagez ce compte", "load_failed": "Impossible de charger les profils", "offline": "Vérifiez votre connexion et réessayez.", - "error_generic": "Une erreur s'est produite. Veuillez réessayer." + "error_generic": "Une erreur s'est produite. Veuillez réessayer.", + "on_this_device": "Sur cet appareil", + "ask_on_launch": "Demander qui regarde au lancement", + "ask_on_launch_desc": "Désactivé, Sozo ouvre le dernier profil utilisé. Les profils avec un code PIN le demandent toujours.", + "pin_locked_until": "Trop de tentatives. Réessayez après {}.", + "pin_not_on_device": "Vous êtes hors ligne et le code PIN de ce profil n'a pas encore été saisi sur cet appareil. Connectez-vous une fois à Internet pour le déverrouiller.", + "biometric_reason": "Déverrouiller {}", + "biometric_offer_title": "Utiliser l'empreinte / Face ID ?", + "biometric_offer_body": "Utilisez l'empreinte / Face ID pour {} sur cet appareil. Le code PIN reste valable.", + "biometric_offer_yes": "Utiliser", + "biometric_offer_no": "Pas maintenant", + "biometric_toggle": "Empreinte / Face ID pour {}" }, "jellyfin": { "connect_server": "Connecter un serveur multimédia (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Ne ratez plus aucun épisode", "priming_body": "Sozo vous prévient dès qu’un titre que vous suivez a un nouvel épisode ou chapitre — et rien que vous n’ayez pas demandé.", "priming_body_title": "Soyez prévenu dès que « {} » a un nouvel épisode — et rien que vous n’ayez pas demandé.", + "priming_download_title": "Sachez quand votre téléchargement est terminé", + "priming_download_body": "Sozo affiche la progression du téléchargement dans vos notifications et vous prévient quand il est prêt à regarder hors ligne. Il peut aussi vous signaler les nouveaux épisodes de ce que vous suivez.", "priming_point_releases": "Nouveaux épisodes, avec l’affiche", "priming_point_quiet": "Heures calmes", "priming_point_control": "Coupez n’importe quel titre", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Images jpg, png ou webp uniquement.", "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", "error_attachment_count": "5 captures maximum par message.", - "suggest": "Ça se reproduit ? Écris au support" + "suggest": "Ça se reproduit ? Écris au support", + "error_offline": "Vous êtes hors ligne ou la connexion a été coupée. Vérifiez votre connexion et réessayez.", + "not_sent": "Non envoyé : {}", + "diag_crash_log": "Voir le journal des plantages" + }, + "zangetsu": { + "title": "Sources Zangetsu", + "row_subtitle": "Sources JavaScript de dépôts externes, exécutées sur votre appareil", + "desc": "Ajoutez un dépôt Zangetsu pour installer d'autres sources d'anime et de films. Elles s'exécutent sur votre appareil. Les sources déjà fournies par Sozo sont marquées « Intégrée » et continuent d'utiliser la version de Sozo.", + "loading_repo": "Chargement du dépôt…", + "repo_added": "Dépôt ajouté · {} sources.", + "checking": "Recherche de mises à jour des dépôts…", + "updates_available": "{} mise(s) à jour disponible(s).", + "installed_one": "{} installée.", + "removed_one": "{} supprimée.", + "updated": "{} source(s) mise(s) à jour.", + "update_all": "Tout mettre à jour ({})", + "update_to": "Mettre à jour vers v{}", + "built_in": "Intégrée", + "search": "Rechercher des sources…", + "all_types": "Tout", + "type_movies": "Films", + "all_languages": "Toutes les langues", + "error_invalid_url": "Seuls les liens https vers raw.githubusercontent.com ou gist.githubusercontent.com peuvent être ajoutés.", + "error_unreachable": "Impossible de joindre ce dépôt. Vérifiez le lien et votre connexion.", + "error_invalid_index": "Ce lien ne mène pas à un index.json Zangetsu valide." + }, + "star": { + "title": "Envie d'aider Sozo à grandir ?", + "body": "Mettez-lui une étoile sur GitHub ⭐", + "action": "Étoile sur GitHub", + "later": "Plus tard", + "never": "Ne plus demander", + "update_button": "Mettre une étoile à Sozo sur GitHub" + }, + "progress": { + "title": "Niveau et XP", + "level": "Niveau", + "level_short": "Niv. {}", + "level_up": "Niveau supérieur", + "level_reached": "Vous avez atteint le niveau {}", + "unlocked_new": "Nouveau dans votre casier", + "open_locker": "Ouvrir le casier", + "unavailable": "Les niveaux ne sont pas encore disponibles. Revenez bientôt.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP avant le niveau {}", + "to_next_short": "{} XP avant le niveau suivant", + "watch_today": "Visionnage aujourd’hui : {} sur {} XP", + "how_to_earn": "Comment gagner de l’XP", + "rule_watch": "Regarder : 1 XP par minute, jusqu’à 300 par jour", + "rule_comment": "Un commentaire : 5 XP, jusqu’à 10 commentaires par jour", + "rule_like": "Un j’aime sur votre commentaire : 1 XP, jusqu’à 50 par jour", + "rule_streak": "Un jour de série : 10 XP, plus les jours d’étape", + "rule_achievement": "Un succès : de 50 à 400 XP selon le palier", + "recent": "XP récente", + "reason_watch": "Visionnage", + "reason_comment": "Commentaires", + "reason_like": "J’aime reçus", + "reason_streak": "Jour de série", + "reason_achievement": "Succès", + "reason_other": "XP", + "locker": "Casier", + "slot_frame": "Cadres d’avatar", + "slot_title": "Titres", + "slot_color": "Couleurs du nom", + "equip": "Touchez pour équiper", + "equipped": "Équipé", + "unlock_level": "Niveau {}", + "unlock_badge": "Badge : {}", + "frame_bronze": "Bronze", + "frame_silver": "Argent", + "frame_gold": "Or", + "frame_neon": "Néon", + "frame_sakura": "Sakura", + "frame_ember": "Braise", + "frame_flame": "Flamme", + "frame_moon": "Lune", + "title_newcomer": "Nouveau venu", + "title_regular": "Habitué", + "title_veteran": "Vétéran", + "title_legend": "Légende", + "title_binger": "Binge-watcheur", + "title_cinephile": "Cinéphile", + "title_otaku": "Otaku", + "title_bookworm": "Rat de bibliothèque", + "title_night_owl": "Oiseau de nuit", + "title_host": "Hôte de soirée", + "color_sky": "Ciel", + "color_mint": "Menthe", + "color_gold": "Or", + "color_rose": "Rose", + "color_violet": "Violet", + "color_crimson": "Cramoisi", + "color_aurora": "Aurore", + "color_ember": "Braise" } } diff --git a/assets/translations/hi.json b/assets/translations/hi.json new file mode 100644 index 00000000..ccf57325 --- /dev/null +++ b/assets/translations/hi.json @@ -0,0 +1,3269 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "लोड हो रहा है…", + "error": "कुछ गड़बड़ हो गई", + "retry": "फिर से कोशिश करें", + "cancel": "रद्द करें", + "confirm": "पुष्टि करें", + "ok": "ठीक है", + "yes": "हाँ", + "no": "नहीं", + "back": "वापस", + "next": "आगे", + "done": "हो गया", + "save": "सेव करें", + "delete": "हटाएँ", + "edit": "बदलें", + "search": "खोजें", + "close": "बंद करें", + "settings": "सेटिंग्स", + "logout": "लॉग आउट", + "no_internet": "इंटरनेट कनेक्शन नहीं है", + "try_again": "फिर से कोशिश करें", + "see_all": "सभी देखें", + "unknown": "अज्ञात", + "off": "बंद", + "on": "चालू", + "new_badge": "नया", + "reset": "रीसेट करें", + "clear": "साफ़ करें", + "copy": "कॉपी करें", + "share": "शेयर करें", + "install": "इंस्टॉल करें", + "remove": "हटाएँ", + "show": "दिखाएँ", + "hide": "छिपाएँ", + "installed": "इंस्टॉल है", + "load_more_failed": "और लोड नहीं हो सका", + "go_home": "होम पर जाएँ" + }, + "auth": { + "login": "साइन इन", + "register": "रजिस्टर करें", + "email": "ईमेल", + "password": "पासवर्ड", + "username": "यूज़रनेम", + "email_hint": "अपना ईमेल डालें", + "password_hint": "अपना पासवर्ड डालें", + "username_hint": "अपना यूज़रनेम डालें", + "forgot_password": "पासवर्ड भूल गए?", + "dont_have_account": "अकाउंट नहीं है?", + "already_have_account": "पहले से अकाउंट है?", + "sign_up": "साइन अप करें", + "sign_in": "साइन इन करें", + "login_success": "साइन इन हो गया", + "register_success": "रजिस्ट्रेशन हो गया", + "logout_success": "लॉग आउट हो गया", + "invalid_email": "ईमेल पता सही नहीं है", + "invalid_password": "पासवर्ड कम से कम 6 अक्षरों का होना चाहिए", + "invalid_username": "यूज़रनेम कम से कम 3 अक्षरों का होना चाहिए", + "confirm_password": "पासवर्ड की पुष्टि करें", + "passwords_not_match": "पासवर्ड मेल नहीं खाते", + "terms_agree": "मैं शर्तों और प्राइवेसी पॉलिसी से सहमत हूँ", + "or_continue_with": "या इससे जारी रखें", + "welcome_back": "आपका फिर से स्वागत है", + "create_account": "अकाउंट बनाएँ", + "login_subtitle": "अपनी प्रोफ़ाइल और सेव किया हुआ डेटा इस्तेमाल करने के लिए साइन इन करें।", + "register_subtitle": "ईमेल, यूज़रनेम और पासवर्ड से अकाउंट बनाएँ।", + "forgot_password_title": "अपना पासवर्ड रीसेट करें", + "forgot_password_subtitle": "अपने अकाउंट का ईमेल डालें, हम 6 अंकों का कोड भेजेंगे।", + "reset_password_subtitle": "हमने {email} पर एक कोड भेजा है। उसे अपने नए पासवर्ड के साथ डालें।", + "send_reset_code": "कोड भेजें", + "reset_code_sent": "अगर यह ईमेल रजिस्टर्ड है, तो कोड भेजा जा रहा है", + "reset_code_hint": "6 अंकों का कोड", + "invalid_reset_code": "6 अंकों का कोड डालें", + "new_password_hint": "नया पासवर्ड", + "confirm_password_hint": "नया पासवर्ड दोबारा डालें", + "reset_password": "पासवर्ड रीसेट करें", + "resend_code": "एक और कोड भेजें", + "resend_in": "{seconds} सेकंड में फिर से भेजें", + "identifier_hint": "ईमेल या यूज़रनेम", + "identifier_required": "ईमेल या यूज़रनेम ज़रूरी है", + "continue_with_google": "Google से जारी रखें", + "google_unavailable": "इस डिवाइस पर Google साइन-इन उपलब्ध नहीं है", + "google_failed": "Google से साइन इन नहीं हो पाया। कृपया फिर से कोशिश करें", + "weak": "कमज़ोर", + "medium": "ठीक-ठाक", + "strong": "मज़बूत", + "code_resent": "कोड फिर से भेज दिया गया है। अपना ईमेल देखें।", + "verify": "वेरिफ़ाई करें", + "avatar_bad_format": "इस इमेज फ़ॉर्मेट का सपोर्ट नहीं है (jpg, png, webp)", + "verification_code": "वेरिफ़िकेशन कोड" + }, + "onboarding": { + "skip": "छोड़ें", + "continue_with_email": "ईमेल से जारी रखें", + "slide_1_title": "हज़ारों फ़िल्में और सीरीज़", + "slide_1_body": "नई रिलीज़, दुनिया भर का सिनेमा और एनिमेशन, सब एक ही ऐप में।", + "slide_2_title": "एनिमे की पूरी लाइब्रेरी", + "slide_2_body": "सीज़न के हिट, क्लासिक और सबटाइटल। AniList लिंक करें और आपकी लिस्ट अपने आप अपडेट होती रहेगी।", + "slide_3_title": "बड़ी स्क्रीन पर भी", + "slide_3_body": "लाइव चैनल, फ़ोन से टीवी पेयरिंग और वॉच पार्टी — Sozo टीवी पर भी चलता है।", + "get_started": "शुरू करें", + "continue": "जारी रखें", + "done": "हो गया", + "not_now": "अभी नहीं", + "retry": "फिर से कोशिश करें", + "progress_label": "सेटअप {}% पूरा", + "kinds_title": "आपको क्या पसंद है?", + "kinds_subtitle": "जितने चाहें उतने चुनें। Sozo आपकी पहली पसंद पर खुलेगा।", + "kinds_pick_one": "कम से कम एक चुनें", + "kinds_starts_here": "यहाँ से शुरू", + "kind_anime": "एनिमे", + "kind_anime_hint": "सीज़न के हिट और क्लासिक", + "kind_movies": "फ़िल्में और सीरीज़", + "kind_movies_hint": "हर जगह की फ़िल्में और टीवी शो", + "kind_manga": "मांगा", + "kind_manga_hint": "मांगा, मैन्हवा और वेबटून", + "kind_novels": "नॉवेल", + "kind_novels_hint": "लाइट नॉवेल और वेब नॉवेल", + "genres_title": "अपने जॉनर चुनें", + "genres_subtitle": "तीन या ज़्यादा चुनें, फिर होम पर आपको ये ज़्यादा दिखेंगे।", + "genres_count": "{} / {}", + "genres_count_done": "{} चुने गए", + "genres_pick_more": "{} और चुनें", + "genres_offline": "आप ऑफ़लाइन हैं, इसलिए ये आम जॉनर दिखाए जा रहे हैं।", + "account_title": "इसे अपने साथ रखें", + "account_subtitle": "अकाउंट से आपकी हिस्ट्री, लिस्ट और प्रोग्रेस हर स्क्रीन पर एक जैसी रहती है।", + "account_perk_sync": "फ़ोन पर जहाँ छोड़ा था, टीवी पर वहीं से देखें", + "account_perk_import": "अपनी AniList या MyAnimeList लिस्ट लाएँ", + "account_perk_profiles": "घर के लिए चार प्रोफ़ाइल तक", + "account_email": "ईमेल से साइन अप करें", + "account_sign_in": "मेरा अकाउंट पहले से है", + "account_guest": "गेस्ट के तौर पर जारी रखें", + "tv_pair_title": "अपने फ़ोन से साइन इन करें", + "tv_pair_body": "अपने फ़ोन के कैमरे से कोड स्कैन करें, या फ़ोन पर Sozo खोलें, प्रोफ़ाइल → टीवी लिंक करें पर जाएँ और कोड डालें।", + "tv_pair_waiting": "आपके फ़ोन का इंतज़ार है…", + "tv_pair_expired": "इस कोड की समय-सीमा ख़त्म हो गई है।", + "tv_pair_failed": "कोड नहीं मिल पाया। कनेक्शन जाँच लें।", + "tv_pair_new_code": "नया कोड पाएँ", + "tv_pair_qr_label": "इस टीवी पर साइन इन करने के लिए QR कोड", + "import_title": "अपनी लिस्ट लाएँ", + "import_subtitle": "आप जो देख रहे हैं वह फ़ॉलो हो जाएगा, ताकि नए एपिसोड की ख़बर आपको मिलती रहे। जो आप देखने वाले हैं वह मेरी लिस्ट में चला जाएगा।", + "import_not_connected": "कनेक्ट नहीं है", + "import_connected": "कनेक्ट है", + "import_connected_as": "{} के तौर पर कनेक्ट है", + "import_connect": "कनेक्ट करें", + "import_action": "इंपोर्ट करें", + "import_again": "फिर से इंपोर्ट करें", + "import_result": "{} फ़ॉलो किए · {} मेरी लिस्ट में", + "import_following": "फ़ॉलोइंग", + "import_my_list": "मेरी लिस्ट में जोड़े गए", + "import_unmatched": "{} टाइटल AniList पर नहीं मिले, इसलिए छोड़ दिए गए।", + "import_already": "सब कुछ पहले से यहाँ मौजूद था।", + "import_failed": "इंपोर्ट नहीं हो पाया। कनेक्शन जाँचकर फिर से कोशिश करें।", + "import_later": "बाद में करें", + "import_sheet_title": "आपकी लिस्ट इंपोर्ट हो रही है", + "import_row_title": "मेरी लिस्ट इंपोर्ट करें", + "import_row_subtitle": "जो देख रहे हैं उसे फ़ॉलो करें, जो देखने वाले हैं उसे मेरी लिस्ट में जोड़ें", + "notify_title": "कोई एपिसोड न छूटे", + "notify_body": "आप जो फ़ॉलो करते हैं, उसका नया एपिसोड या चैप्टर आते ही आपको पता चल जाएगा।", + "notify_body_title": "{} का नया एपिसोड आते ही आपको पता चल जाएगा।", + "notify_allow": "नोटिफ़िकेशन चालू करें", + "notify_later": "शायद बाद में", + "notify_mock_title": "{} का नया एपिसोड", + "notify_mock_title_generic": "नया एपिसोड आ गया है", + "notify_mock_body": "तैयार है — देखने के लिए टैप करें।", + "notify_now": "अभी", + "done_title": "सब तैयार है", + "done_title_name": "{}, सब तैयार है", + "done_subtitle": "Sozo को आपकी पसंद के हिसाब से सेट कर दिया गया है। आप सेटिंग्स में इसमें से कुछ भी बदल सकते हैं।", + "done_subtitle_profile": "इस प्रोफ़ाइल के होम पर इन्हीं पसंदों से मिलती-जुलती चीज़ें ज़्यादा दिखेंगी।", + "done_start": "देखना शुरू करें", + "summary_kinds": "आपकी पसंद", + "summary_genres": "जॉनर", + "summary_import": "इंपोर्ट किया गया", + "summary_notifications": "नोटिफ़िकेशन", + "summary_language": "भाषा", + "summary_on": "चालू", + "summary_off": "बंद", + "personalize_title": "Sozo को अपने हिसाब से बनाएँ", + "personalize_subtitle": "आपकी पसंद, आपके जॉनर, लिस्ट इंपोर्ट और नोटिफ़िकेशन", + "profile_offer_title": "{} की पसंद सेट करें?", + "profile_offer_body": "उनकी पसंद पर बस कुछ टैप, और उनका होम उसी से भर जाएगा।", + "profile_offer_yes": "चलिए, करते हैं", + "genre_names": { + "action": "एक्शन", + "adventure": "एडवेंचर", + "animation": "एनिमेशन", + "comedy": "कॉमेडी", + "crime": "क्राइम", + "documentary": "डॉक्यूमेंट्री", + "drama": "ड्रामा", + "family": "फ़ैमिली", + "fantasy": "फ़ैंटेसी", + "history": "इतिहास", + "horror": "हॉरर", + "mahou-shoujo": "मैजिकल गर्ल", + "mecha": "मेका", + "music": "म्यूज़िक", + "mystery": "मिस्ट्री", + "psychological": "साइकोलॉजिकल", + "romance": "रोमांस", + "sci-fi": "साइ-फ़ाई", + "slice-of-life": "स्लाइस ऑफ़ लाइफ़", + "sports": "स्पोर्ट्स", + "supernatural": "सुपरनैचुरल", + "thriller": "थ्रिलर", + "war": "युद्ध", + "western": "वेस्टर्न" + }, + "badges_title": "हर दिन मायने रखता है", + "badges_body": "अपनी स्ट्रीक बनाए रखने के लिए हर दिन थोड़ा देखें या पढ़ें। स्ट्रीक और माइलस्टोन पर बैज मिलते हैं, जिन्हें आप अपनी प्रोफ़ाइल पर दिखा सकते हैं।", + "badges_week": "लगातार 7 दिन पर आपको पहला बैज मिलेगा", + "widgets_title": "आपकी होम स्क्रीन पर Sozo", + "widgets_body": "एक टैप में वहीं से शुरू करें जहाँ छोड़ा था, और ऐप खोले बिना अपनी स्ट्रीक पर नज़र रखें।", + "widgets_add_streak": "स्ट्रीक विजेट जोड़ें", + "widgets_add_continue": "“जारी रखें” विजेट जोड़ें", + "widgets_added": "जोड़ दिया गया — अपनी होम स्क्रीन देखें", + "widgets_manual": "अपनी होम स्क्रीन पर किसी ख़ाली जगह को दबाकर रखें, विजेट पर टैप करें, फिर Sozo ढूँढें।", + "widgets_later": "अभी नहीं" + }, + "home": { + "explore_anime": "एनिमे एक्सप्लोर करें", + "explore_manga": "मांगा एक्सप्लोर करें", + "explore_novel": "नॉवेल एक्सप्लोर करें", + "title": "होम", + "continue_watching": "देखना जारी रखें", + "continue_reading": "पढ़ना जारी रखें", + "trending": "अभी ट्रेंडिंग", + "new_releases": "नई रिलीज़", + "top_rated": "टॉप रेटेड", + "for_you": "आपके लिए", + "all_categories": "सभी कैटेगरी", + "genres": "जॉनर", + "genres_no_match": "इससे मेल खाता कोई जॉनर नहीं है।", + "get_all": "सभी पाएँ", + "popular": "लोकप्रिय", + "watch_now": "अभी देखें", + "more_info": "ज़्यादा जानकारी", + "add_to_list": "लिस्ट में जोड़ें", + "my_list": "मेरी लिस्ट", + "featured": "फ़ीचर्ड", + "action": "एक्शन", + "comedy": "कॉमेडी", + "drama": "ड्रामा", + "thriller": "थ्रिलर", + "horror": "हॉरर", + "romance": "रोमांस", + "sci_fi": "साइ-फ़ाई", + "documentary": "डॉक्यूमेंट्री", + "animation": "एनिमेशन", + "telegram_promo_title": "Telegram पर Sozo से जुड़ें", + "telegram_promo_subtitle": "अपडेट, नए फ़ीचर और कंटेंट के नोटिफ़िकेशन पाएँ।", + "telegram_join_channel": "चैनल से जुड़ें", + "dont_show_again": "दोबारा न दिखाएँ", + "downloaded": "डाउनलोड किए गए", + "view_all": "सभी देखें", + "remove_from_continue": "“देखना जारी रखें” से हटाएँ", + "ep_number": "एपिसोड {}", + "untitled": "बिना टाइटल", + "empty_title": "अभी यहाँ कुछ नहीं है", + "empty_subtitle": "कोई दूसरा सोर्स चुनें, या रीफ़्रेश करने के लिए नीचे खींचें।", + "list_empty_title": "यह लिस्ट ख़ाली है", + "list_empty_subtitle": "इस सेक्शन में अभी दिखाने के लिए कुछ नहीं है।", + "suggest_services_title": "होम पर स्ट्रीमिंग सर्विस जोड़ें?", + "suggest_services_body": "देखें कि आपके इलाके में Netflix, Disney+ और बाकी सर्विस पर क्या-क्या उपलब्ध है। आप इसे कभी भी फिर से हटा सकते हैं।", + "suggest_add": "होम में जोड़ें", + "suggest_no": "नहीं, रहने दें", + "remove_from_home": "होम से हटाएँ", + "removed_undo_hint": "हटा दिया गया। इसे अपीयरेंस → होम लेआउट से वापस जोड़ें।", + "airing_on_air": "ऑन एयर", + "airing_title": "प्रसारण कैलेंडर", + "airing_next": "अगला: {} · एपिसोड {}", + "airing_in_hm": "{} घं {} मि में", + "airing_in_m": "{} मिनट में", + "airing_now": "अभी चल रहा है", + "airing_today_count": "अगले 24 घंटों में {}", + "airing_hint": "देखें कि इस हफ़्ते क्या प्रसारित होगा", + "airing_loading": "शेड्यूल देखा जा रहा है…" + }, + "player": { + "needs_media_kit": "media_kit इंजन ज़रूरी है", + "auto_quality_desc": "प्लेयर आपके कनेक्शन के हिसाब से सबसे अच्छी क्वालिटी चुनता है", + "on": "चालू", + "info_title": "प्लेयर की जानकारी", + "info_resolution": "रेज़ोल्यूशन", + "info_engine": "इंजन", + "info_provider": "सोर्स", + "info_server": "सर्वर", + "info_quality": "क्वालिटी", + "info_codec": "कोडेक", + "info_hdr": "HDR", + "info_audio": "ऑडियो", + "info_container": "कंटेनर", + "info_size": "फ़ाइल साइज़", + "info_host": "होस्ट", + "info_position": "पोज़िशन", + "info_buffer": "आगे तक बफ़र", + "info_speed": "स्पीड", + "info_state": "स्थिति", + "switching_to": "{} पर स्विच हो रहा है…", + "fault": { + "decoder": "यह डिवाइस वीडियो डिकोड नहीं कर पाया। कोई दूसरी क्वालिटी चुनें या फिर से कोशिश करें।", + "unsupported_format": "यह वीडियो फ़ॉर्मेट आपके डिवाइस पर सपोर्ट नहीं होता। आप इसे अपने ब्राउज़र में चलाकर देख सकते हैं।", + "source_blocked": "वीडियो सोर्स नहीं खुल पाया — हो सकता है सर्वर ने इसे ब्लॉक किया हो। कोई दूसरी क्वालिटी आज़माएँ।", + "network": "नेटवर्क में गड़बड़ी — अपना कनेक्शन जाँचें।", + "no_source": "इस एपिसोड के लिए कोई सोर्स नहीं है।", + "empty_url": "इस सोर्स से वीडियो का कोई पता नहीं मिला।", + "engine_unavailable": "प्लेयर शुरू नहीं हुआ। कृपया ऐप को पूरी तरह बंद करके फिर से खोलें।", + "unknown": "वीडियो लोड नहीं हो पाया।", + "not_found": "यह वीडियो अब इस सर्वर पर नहीं है। कोई दूसरा सर्वर आज़माएँ।", + "server_error": "वीडियो सर्वर में समस्या है। थोड़ी देर में फिर कोशिश करें या कोई दूसरा सर्वर चुनें।", + "resolve_failed": "इस स्रोत से वीडियो लिंक नहीं मिल पाया।", + "details": "विवरण" + }, + "play": "चलाएँ", + "pause": "रोकें", + "resume": "जारी रखें", + "forward": "10 सेकंड आगे", + "backward": "10 सेकंड पीछे", + "fullscreen": "फ़ुलस्क्रीन", + "exit_fullscreen": "फ़ुलस्क्रीन से बाहर", + "quality": "क्वालिटी", + "subtitles": "सबटाइटल", + "audio": "ऑडियो", + "speed": "प्लेबैक स्पीड", + "auto": "ऑटो", + "off": "बंद", + "episodes": "एपिसोड", + "next_episode": "अगला एपिसोड", + "season": "सीज़न {n}", + "fit_original": "ओरिजिनल", + "fit_fill": "फ़िल", + "fit_stretch": "स्ट्रेच", + "play_in_browser": "ब्राउज़र में चलाएँ", + "view_logs": "लॉग देखें", + "speed_2x": "2x स्पीड", + "tap_to_unlock": "अनलॉक करने के लिए टैप करें", + "previous": "पिछला", + "live": "लाइव", + "go_live": "लाइव पर जाएँ", + "lang_sub": "सबटाइटल वाला (सब)", + "lang_dub": "डब किया हुआ (डब)", + "lang_softsub": "सॉफ़्ट सबटाइटल", + "audio_language": "ऑडियो की भाषा", + "speed_short": "स्पीड", + "aspect": "आस्पेक्ट", + "aspect_ratio": "आस्पेक्ट रेशियो", + "speed_now": "स्पीड: {}", + "subtitle_style": "सबटाइटल स्टाइल", + "audio_track": "ऑडियो ट्रैक", + "shortcuts_title": "कीबोर्ड शॉर्टकट", + "shortcut_play_pause": "चलाएँ / रोकें", + "shortcut_seek": "{} सेकंड पीछे / आगे जाएँ", + "shortcut_seek_long": "{} सेकंड पीछे / आगे जंप करें", + "shortcut_frame": "एक फ़्रेम पीछे / आगे जाएँ", + "shortcut_decile": "वीडियो के 0-90% पर जाएँ", + "shortcut_volume": "आवाज़ बढ़ाएँ / घटाएँ", + "shortcut_mute": "म्यूट करें", + "shortcut_speed": "धीमा / तेज़", + "shortcut_episode": "पिछला / अगला एपिसोड", + "shortcut_start_end": "शुरुआत / अंत पर जाएँ", + "shortcut_escape": "पैनल बंद करें, फ़ुलस्क्रीन छोड़ें या बाहर निकलें", + "shortcut_help": "यह लिस्ट दिखाएँ", + "key_space": "स्पेस", + "diagnostics_logs": "डायग्नोस्टिक्स / लॉग", + "extracting_media": "मीडिया निकाला जा रहा है…", + "loading_video": "वीडियो लोड हो रहा है…", + "fetching_link": "प्रोवाइडर से प्लेबैक लिंक लिया जा रहा है", + "preparing_stream": "वीडियो स्ट्रीम तैयार हो रही है", + "episode_n": "एपिसोड {}", + "search_online": "ऑनलाइन खोजें", + "subtitle_sync": "सबटाइटल सिंक", + "subtitle_sync_desc": "ऑडियो से मिलाने के लिए टाइमिंग आगे-पीछे करें", + "subtitle_sync_help": "सबटाइटल बहुत जल्दी आ रहे हैं? बढ़ाएँ (+)। बहुत देर से आ रहे हैं? घटाएँ (−)।", + "search_subtitles": "सबटाइटल खोजें", + "subtitle_search_hint": "टाइटल / फ़िल्म का नाम", + "searching_subtitles": "सबटाइटल खोजे जा रहे हैं…", + "no_subtitles_found": "कोई सबटाइटल नहीं मिला", + "search_failed": "खोज नहीं हो पाई: {}", + "online_subtitles_count": "ऑनलाइन सबटाइटल · {}", + "loading_subtitle": "सबटाइटल लोड हो रहा है…", + "subtitle_loaded": "सबटाइटल लोड हो गया", + "subtitle_failed_download": "यह सबटाइटल डाउनलोड नहीं हो पाया", + "subtitle_failed_empty": "इस सबटाइटल फ़ाइल में कोई कैप्शन नहीं है", + "subtitle_failed_archive": "आर्काइव किए गए सबटाइटल (.zip / .rar) सपोर्ट नहीं होते", + "subtitle_failed_html": "सर्वर ने सबटाइटल की जगह वेब पेज भेजा", + "subtitle_failed_format": "यह सबटाइटल फ़ॉर्मेट सपोर्ट नहीं होता", + "subtitle_rate": "फ़्रेम रेट कन्वर्ज़न", + "subtitle_rate_help": "समय के साथ फ़र्क़ बढ़ता जा रहा है? यह सबटाइटल किसी दूसरे फ़्रेम रेट के हिसाब से बना था — कन्वर्ज़न चुनें।", + "subtitle_rate_off": "बंद", + "reset": "रीसेट करें", + "font_size": "फ़ॉन्ट साइज़", + "size_small": "छोटा", + "size_medium": "मीडियम", + "size_large": "बड़ा", + "size_extra_large": "बहुत बड़ा", + "text_color": "टेक्स्ट का रंग", + "background": "बैकग्राउंड", + "none": "कोई नहीं", + "solid": "सॉलिड", + "edge": "किनारा", + "shadow": "शैडो", + "outline": "आउटलाइन", + "position": "पोज़िशन", + "lower": "नीचे", + "default": "डिफ़ॉल्ट", + "higher": "ऊपर", + "bold": "बोल्ड", + "audio_track_engine_only": "सिर्फ़ media_kit में", + "audio_track_single": "सिर्फ़ एक ट्रैक", + "external_player": "बाहरी प्लेयर में चलाएँ", + "external_player_gated": "इस सोर्स को खास रिक्वेस्ट हेडर चाहिए, जो VLC और MX Player नहीं भेज सकते। ज़्यादा संभावना है कि यह लोड नहीं होगा। फिर भी कोशिश करें?", + "external_player_try": "फिर भी कोशिश करें", + "external_player_none": "कोई बाहरी वीडियो प्लेयर नहीं मिला", + "audio_track_numbered": "ऑडियो {}", + "engine_sheet_title": "इसे कैसे चलाएँ?", + "engine_sheet_subtitle": "इस वीडियो के लिए इंजन चुनें। आप इसे कभी भी सेटिंग्स → प्लेयर में बदल सकते हैं।", + "engine_sheet_dont_ask": "दोबारा न पूछें", + "engine_sheet_play": "चलाएँ", + "subtitle_preview_text": "The quick brown fox jumps over the lazy dog", + "server": "सर्वर", + "change_server": "सर्वर बदलें", + "stream_not_found": "यह सोर्स अपनी स्ट्रीम को एक सुरक्षित प्लेयर के पीछे छिपाता है। कोई दूसरा सोर्स आज़माएँ या इसे अपने ब्राउज़र में खोलें।", + "subtitle_translation": "सबटाइटल अनुवाद", + "auto_translate": "ऑटो-अनुवाद", + "auto_translate_desc": "जब सोर्स में आपकी भाषा का सबटाइटल न हो, तो AI से उसका अनुवाद करें", + "translate_to": "इसमें अनुवाद करें", + "translate_note": "अनुवाद मशीन से होता है और ग़लत हो सकता है। रोज़ की एक लिमिट लागू है।", + "translate_ai": "AI अनुवाद", + "translating_subtitle": "अनुवाद हो रहा है…", + "translate_failed": "अनुवाद नहीं हो पाया", + "ai_translate_title": "AI से {} में अनुवाद करें", + "ai_translate_hint": "लिस्ट से कोई सबटाइटल चुनें और AI पर टैप करें", + "ai_translate_remaining": "आज {}/{} अनुवाद बचे हैं", + "ai_translate_used_up": "आपने आज की अनुवाद लिमिट पूरी कर ली है", + "ai_translate_menu": "AI अनुवाद", + "ai_translate_menu_desc": "सबटाइटल का {} में अनुवाद करता है", + "ready_translations": "तैयार अनुवाद", + "lines": "लाइनें", + "sleep_timer": "स्लीप टाइमर", + "sleep_minutes": "{} मिनट", + "sleep_hours": "{} घंटे", + "sleep_minutes_short": "{} मि", + "sleep_episode_end": "एपिसोड ख़त्म होने पर", + "sleep_fired": "स्लीप टाइमर — प्लेबैक रोक दिया गया", + "skip_opening": "ओपनिंग स्किप करें", + "skip_ending": "एंडिंग स्किप करें", + "alt_sources": "दूसरा सोर्स आज़माएँ", + "alt_searching": "दूसरे सोर्स में खोजा जा रहा है…", + "alt_none": "किसी और सोर्स में यह टाइटल नहीं है", + "alt_failed": "यह सोर्स नहीं खुल पाया", + "alt_no_episode": "इस सोर्स में एपिसोड {} नहीं है", + "alt_search_all": "सभी सोर्स में खोजें", + "guide": "गाइड", + "change_source": "सोर्स बदलें", + "cast": "टीवी पर कास्ट करें", + "cast_to": "इस पर कास्ट करें", + "cast_searching": "इस Wi-Fi पर डिवाइस खोजे जा रहे हैं…", + "cast_none": "कोई डिवाइस नहीं मिला। टीवी का इस फ़ोन वाले Wi-Fi पर होना ज़रूरी है।", + "cast_failed": "{} से कनेक्ट नहीं हो पाया", + "cast_unsupported": "यह स्ट्रीम कास्ट नहीं की जा सकती", + "cast_active": "{} पर चल रहा है", + "cast_stop": "कास्ट करना बंद करें", + "color_profile": "पिक्चर", + "color_natural": "नैचुरल", + "color_cinema": "सिनेमा", + "color_night": "नाइट", + "color_anime": "एनिमे", + "color_restore": "रीस्टोर", + "shaders": "एन्हांस (Anime4K)", + "shaders_note": "एनिमेशन के लिए GPU अपस्केलिंग। शेडर फ़ाइलें पहली बार इस्तेमाल पर एक बार डाउनलोड होती हैं।", + "shader_off": "बंद", + "shader_off_desc": "कोई एन्हांसमेंट नहीं", + "shader_sharpen": "शार्प करें", + "shader_sharpen_desc": "बारीकियाँ वापस लाता है — साफ़ सोर्स के लिए सबसे अच्छा", + "shader_deblur": "डी-ब्लर", + "shader_deblur_desc": "हल्की रीस्टोरिंग — धुंधले सोर्स के लिए", + "shader_denoise": "डीनॉइज़", + "shader_denoise_desc": "ग्रेन साफ़ करता है — कंप्रेस्ड सोर्स के लिए", + "shader_tier": "GPU बजट", + "shader_tier_mid": "बैलेंस्ड", + "shader_tier_mid_desc": "ज़्यादातर फ़ोन पर स्मूद", + "shader_tier_high": "मैक्सिमम", + "shader_tier_high_desc": "सबसे शार्प, दमदार GPU चाहिए", + "font_family": "फ़ॉन्ट", + "font_system": "डिफ़ॉल्ट", + "font_sans": "सैंस", + "font_serif": "सेरिफ़", + "font_condensed": "कंडेंस्ड", + "font_mono": "मोनो", + "second_track": "दूसरा ट्रैक", + "second_track_on": "{} को दूसरे ट्रैक के तौर पर दिखाया जा रहा है", + "second_track_same": "यह ट्रैक पहले से मुख्य ट्रैक है", + "second_track_hint": "किसी ट्रैक को पहले वाले के साथ दिखाने के लिए उसे देर तक दबाएँ", + "share_frame": "यह फ़्रेम शेयर करें", + "share_frame_failed": "फ़्रेम कैप्चर नहीं हो पाया", + "save_frame": "फ़्रेम सेव करें", + "save_frame_done": "गैलरी में सेव हो गया", + "save_frame_failed": "फ़्रेम सेव नहीं हो पाया", + "save_frame_denied": "फ़्रेम सेव करने के लिए गैलरी का एक्सेस चाहिए", + "switching_server": "सर्वर बदला जा रहा है…", + "recommended": "सुझाया गया", + "episode_page_failed": "सीरीज़ का अगला हिस्सा लोड नहीं हो पाया", + "info_fields_desc": "चुनें कि वीडियो के ऊपर क्या दिखे। यह कंट्रोल के साथ दिखता है।", + "layout_title": "प्लेयर कंट्रोल", + "layout_desc": "क्रम बदलने के लिए खींचें, या किसी कंट्रोल को बार के दूसरे हिस्से में ले जाएँ।", + "layout_top_bar": "ऊपरी बार", + "layout_bottom_left": "नीचे बाएँ", + "layout_bottom_right": "नीचे दाएँ", + "layout_hidden": "छिपे हुए", + "layout_hidden_desc": "प्लेयर के अंदर सेटिंग्स से अब भी खोले जा सकते हैं।", + "layout_pinned": "सेटिंग्स अपनी जगह पर रहती है — बाकी सब तक वापस पहुँचने का यही रास्ता है।", + "layout_top_bar_full": "ऊपरी बार भरी हुई है। पहले उससे कुछ हटाएँ।", + "layout_unknown_control": "वह कंट्रोल अब उपलब्ध नहीं है।", + "layout_move_to": "ले जाएँ", + "layout_empty": "यहाँ कुछ नहीं है।", + "rotate": "घुमाएँ", + "lock": "लॉक करें", + "enhance": "एन्हांस", + "fit": "फ़िट", + "pip": "पिक्चर इन पिक्चर", + "layout_pinned_badge": "पिन किया हुआ", + "info_fields_title": "इन्फ़ो ओवरले की पंक्तियाँ", + "logs_title": "प्लेयर लॉग ({})", + "logs_copied": "लॉग कॉपी हो गए", + "logs_empty": "अभी कोई लॉग नहीं", + "alt_unavailable": "सोर्स लिस्ट तक नहीं पहुँच पाए। अपना कनेक्शन जाँचें और फिर से कोशिश करें।", + "alt_all_failed": "किसी भी सोर्स ने जवाब नहीं दिया। आम तौर पर वजह कनेक्शन होता है, टाइटल नहीं।", + "alt_some_failed": "कोई मैच नहीं मिला, हालाँकि {} सोर्स ने जवाब नहीं दिया।", + "alt_no_answer": "खोज बिना जवाब के ख़त्म हो गई। फिर से कोशिश करें, या किसी सोर्स में ख़ुद खोजें।", + "alt_none_asked": "आपका कोई भी दूसरा सोर्स इस तरह के टाइटल नहीं संभालता, इसलिए किसी में नहीं खोजा गया। ऐसा कोई सोर्स जोड़ें जो इन्हें संभालता हो।", + "alt_no_match": "{} सोर्स ने जवाब दिया, पर किसी में भी यह टाइटल नहीं है — सबसे क़रीबी नतीजे दूसरे शो थे। अगर आपको पता है कि किसी सोर्स में यह है, तो नीचे उसमें ख़ुद खोजें।", + "alt_guess": "अनुमान", + "alt_guess_spoken": "मैच पक्का नहीं — चलाने से पहले टाइटल जाँच लें", + "alt_your_pick": "आपकी पसंद", + "alt_auto": "ऑटो", + "alt_auto_hint": "जिस सोर्स में यह सबसे पहले मिले, वहीं से चलाता है", + "alt_auto_trying": "{} आज़माया जा रहा है…", + "alt_auto_missed": "कोई भी मैच इसे नहीं चला पाया। नीचे से कोई एक चुनें या किसी सोर्स में खोजें।", + "alt_auto_checking": "{} जाँचा जा रहा है · {} / {}", + "alt_auto_waiting": "और स्रोतों की प्रतीक्षा…", + "alt_auto_stop": "रोकें", + "alt_auto_stopped": "रोक दिया गया।", + "alt_auto_capped": "{} स्रोत जाँचे गए, कोई भी इसे चला नहीं सका। नीचे से एक चुनें या फिर से कोशिश करें।", + "alt_auto_tried": "जाँचे गए", + "alt_skip_no_match": "करीबी मिलान नहीं", + "alt_skip_no_episode": "एपिसोड नहीं मिला", + "alt_skip_error": "लोड नहीं हो सका", + "auto_switch_attempt": "प्रयास {} / {}", + "auto_switch_stop": "रोकें", + "auto_switch_stopped": "सर्वर बदलना रोक दिया गया। फिर से कोशिश करें, या कोई दूसरा सर्वर या स्रोत चुनें।", + "alt_wrong_title": "ग़लत टाइटल?", + "alt_wrong_title_hint": "{} में ख़ुद खोजें और सही टाइटल चुनें", + "alt_corrected": "अब “{}” इस्तेमाल हो रहा है", + "alt_unmatched": "खोजा, पर कुछ मैच नहीं हुआ ({})", + "alt_unmatched_hint": "इन पर कोई संभावित टाइटल नहीं मिला। अगर आपको पता है कि यह वहाँ है, तो किसी एक में ख़ुद खोजें।", + "alt_search_by_hand": "ख़ुद खोजें", + "src_search_title": "{} में खोजें", + "src_search_hint": "खोजने के लिए टाइटल", + "src_search_go": "खोजें", + "src_searching": "{} से पूछा जा रहा है…", + "src_search_empty": "{} पर “{}” के लिए कुछ नहीं मिला। दूसरी स्पेलिंग आज़माएँ — रोमाजी, या वह नाम जो डब में इस्तेमाल होता है।", + "src_search_slow": "{} ने समय पर जवाब नहीं दिया। हो सकता है वह बस धीमा हो, यह ज़रूरी नहीं कि उसमें टाइटल न हो — फिर से कोशिश करें।", + "src_search_failed": "{} तक नहीं पहुँच पाए। सोर्स सेक्शन में इस सोर्स को जाँचें, फिर दोबारा कोशिश करें।", + "src_search_unavailable": "{} अब इस डिवाइस पर इंस्टॉल नहीं है।", + "resolve_timeout": "सोर्स ने समय पर जवाब नहीं दिया। कोई दूसरा सर्वर या दूसरा सोर्स आज़माएँ।", + "layout_center": "प्ले के आसपास", + "layout_center_full": "प्ले के आसपास जगह नहीं बची", + "open_subtitle_file": "सबटाइटल फ़ाइल खोलें…", + "media_kit_emulator": "एमुलेटर पर नहीं — प्लेटफ़ॉर्म प्लेयर चल रहा है", + "media_kit_fell_back": "media_kit यहाँ शुरू नहीं हो पाया — प्लेटफ़ॉर्म प्लेयर चल रहा है", + "segment_opening": "ओपनिंग", + "segment_ending": "एंडिंग", + "subtitle_n": "सबटाइटल {}", + "subtitle_in_video": "वीडियो में", + "rewind": "रिवाइंड", + "rewind_by": "{} सेकंड पीछे", + "forward_by": "{} सेकंड आगे", + "mini_restore": "वापस पूरी विंडो में", + "pip_unavailable": "इस वीडियो के लिए पिक्चर-इन-पिक्चर शुरू नहीं हो पाया।", + "scrub_release_cancel": "रद्द करने के लिए छोड़ें", + "auto_quality_desc_mpv": "स्ट्रीम में उपलब्ध सबसे अच्छी क्वालिटी से शुरू होता है", + "reconnecting": "कनेक्शन टूट गया — फिर से कनेक्ट हो रहा है…", + "all_servers_failed": "इस टाइटल का कोई भी सर्वर इसे नहीं चला पाया। बाद में फिर से कोशिश करें, या कोई दूसरा सोर्स चुनें।", + "shortcut_pip": "मिनी प्लेयर (Shift: दूसरे ऐप के ऊपर)", + "mini_popout_hint": "मिनी प्लेयर · दूसरे ऐप के ऊपर रखने के लिए देर तक दबाएँ", + "react": "प्रतिक्रिया दें", + "comments": "कमेंट्स", + "episode_comments": "एपिसोड {} के कमेंट्स", + "reaction_text": "प्रतिक्रिया लिखें", + "reaction_text_hint": "कुछ छोटा लिखें…", + "reaction_wait": "फिर से प्रतिक्रिया देने से पहले कुछ सेकंड रुकें", + "reaction_limit": "इस एपिसोड के लिए प्रतिक्रियाओं की सीमा पूरी हो गई", + "reaction_failed": "प्रतिक्रिया नहीं भेजी जा सकी", + "native_offer_title": "नया प्लेयर आज़माएँ?", + "native_offer_body": "Apple के Liquid Glass कंट्रोल और मेन्यू। आप इसे कभी भी सेटिंग्स → प्लेयर में बदल सकते हैं।", + "native_offer_try": "आज़माएँ", + "native_offer_not_now": "अभी नहीं", + "trying_again": "फिर से कोशिश हो रही है (प्रयास {})", + "try_mpv": "mpv इंजन से आज़माएँ", + "ok_to_skip": "छोड़ने के लिए OK", + "quality_lowered": "क्वालिटी कम की गई", + "undo": "पूर्ववत करें", + "crash_log": "क्रैश लॉग", + "crash_log_empty": "कोई क्रैश दर्ज नहीं" + }, + "profile": { + "title": "प्रोफ़ाइल", + "my_account": "मेरा अकाउंट", + "notifications": "नोटिफ़िकेशन", + "language": "भाषा", + "theme": "थीम", + "privacy": "प्राइवेसी पॉलिसी", + "help": "मदद और सपोर्ट", + "about": "जानकारी", + "version": "वर्शन", + "subscription": "सब्सक्रिप्शन", + "downloads": "डाउनलोड", + "watch_history": "देखने की हिस्ट्री", + "manage_profiles": "प्रोफ़ाइल मैनेज करें", + "parental_controls": "पैरेंटल कंट्रोल", + "guest_title": "प्रोफ़ाइल के लिए साइन इन करें", + "guest_subtitle": "पसंदीदा और देखने की हिस्ट्री सिंक रखने के लिए साइन इन करें।", + "auth_provider": "साइन इन प्रोवाइडर", + "joined": "जुड़े", + "providers": "सोर्स", + "providers_error": "प्रोवाइडर लोड नहीं हो पाए।", + "offline_title": "सर्वर से संपर्क नहीं हो पा रहा", + "offline_local_available": "इस डिवाइस पर इंस्टॉल किए गए {} सोर्स अब भी काम कर रहे हैं। सर्वर वापस आने तक क्लाउड सोर्स उपलब्ध नहीं हैं।", + "offline_no_local": "सर्वर जवाब नहीं दे रहा और इस डिवाइस पर कोई सोर्स इंस्टॉल नहीं है। ऑफ़लाइन देखते रहने के लिए कोई CloudStream प्लगइन इंस्टॉल करें।", + "offline_cached_at": "{} की क्लाउड लिस्ट", + "offline_install_plugins": "प्लगइन इंस्टॉल करें", + "offline_badge": "ऑफ़लाइन", + "provider_needs_server": "इस सोर्स को सर्वर चाहिए, जिससे अभी संपर्क नहीं हो पा रहा। इस डिवाइस पर इंस्टॉल कोई सोर्स चुनें।", + "logout_confirm": "क्या आप अपने अकाउंट से लॉग आउट करना चाहते हैं?", + "connections": "कनेक्शन", + "coming_soon": "जल्द आ रहा है", + "favorites": "पसंदीदा", + "all_providers": "सभी सोर्स", + "add_favorite": "पसंदीदा में जोड़ें", + "cloudstream_sources": "CloudStream सोर्स", + "aniyomi_sources": "Aniyomi सोर्स", + "signin_account_title": "अपने अकाउंट में साइन इन करें", + "signin_account_subtitle": "अपने पसंदीदा और देखने की हिस्ट्री सेव करने के लिए साइन इन करें।", + "sign_in": "साइन इन करें", + "sign_out": "लॉग आउट करें", + "sign_out_confirm": "क्या आप वाकई लॉग आउट करना चाहते हैं?", + "section_providers": "सोर्स", + "section_content": "कंटेंट", + "sources_row": "एक्सटेंशन सोर्स", + "sources_row_subtitle": "सभी सोर्स देखें और नए इंस्टॉल करें", + "provider": "सोर्स", + "choose_provider": "सोर्स बदलें", + "search_providers_hint": "प्रोवाइडर खोजें…", + "count_of_total_shown": "{}/{} दिखाए जा रहे हैं", + "no_providers_in_category": "इस कैटेगरी में कोई प्रोवाइडर नहीं है", + "try_select_all": "सभी प्रोवाइडर देखने के लिए \"सभी\" चुनकर देखें", + "section_activity": "ऐक्टिविटी", + "share_sources_desktop": "सोर्स डेस्कटॉप पर शेयर करें", + "desktop_sources": "डेस्कटॉप सोर्स", + "developer_role": "मोबाइल डेवलपर", + "section_about": "जानकारी", + "developer": "डेवलपर", + "website": "वेबसाइट", + "server": "सर्वर", + "expired": "एक्सपायर हो गया", + "support_title": "Sozo को चालू रखें", + "server_expired": "सर्वर एक्सपायर हो गया", + "days": "दिन", + "hours": "घंटे", + "min": "मिनट", + "sec": "सेकंड", + "support_body_expired": "सर्वर एक्सपायर हो गया है और अब कंटेंट लोड नहीं हो सकता। आपका सपोर्ट इसे फिर से ऑनलाइन लाने में मदद कर सकता है।", + "support_body": "Sozo एक ऐसे सर्वर पर चलता है जिसे समय-समय पर रिन्यू करना पड़ता है। अगर आपको ऐप पसंद है, तो आपका सपोर्ट सब कुछ ठीक से चलाते रहने में मदद करता है।", + "support_developer": "डेवलपर को सपोर्ट करें", + "section_appearance": "दिखावट", + "native_window_bar": "नेटिव विंडो बार", + "native_window_bar_subtitle": "ऐप के कस्टम टाइटल बार की जगह Windows का स्टैंडर्ड टाइटल बार इस्तेमाल करें।", + "liquid_glass_nav": "लिक्विड ग्लास नेविगेशन", + "liquid_glass_nav_subtitle": "फ़्रॉस्टेड ग्लास वाला फ़्लोटिंग टैब बार (iOS 26)। सादे सॉलिड बार के लिए इसे बंद करें।", + "nav_compact_scroll": "स्क्रोल पर कॉम्पैक्ट नेविगेशन", + "nav_compact_scroll_hint": "स्क्रोल करते समय ऊँचाई कम करें। लिक्विड ग्लास और सॉलिड पर लागू होता है।", + "nav_style": "नेविगेशन बार", + "nav_style_glass": "लिक्विड ग्लास", + "nav_style_solid": "सॉलिड", + "nav_style_classic": "क्लासिक", + "section_player": "प्लेयर", + "player_engine_native": "सिस्टम प्लेयर", + "player_engine_native_desc": "प्लेटफ़ॉर्म का अपना प्लेयर। बैटरी लाइफ़ और हार्डवेयर डिकोडिंग सबसे अच्छी। ऑडियो ट्रैक नहीं बदले जा सकते।", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "ज़्यादा फ़ॉर्मेट चलाता है और ऑडियो व सबटाइटल ट्रैक बदलने देता है। बैटरी थोड़ी ज़्यादा लेता है।", + "player_engine_external": "बाहरी प्लेयर", + "player_engine_external_desc": "वीडियो यहाँ चलाने की जगह VLC या MX Player पर भेजें।", + "player_engine_external_note": "कुछ सोर्स सिर्फ़ Sozo के अंदर ही चलते हैं, क्योंकि उन्हें ख़ास रिक्वेस्ट हेडर चाहिए। वे बाहरी ऐप में नहीं चलेंगे।", + "player_engine_badge_default": "डिफ़ॉल्ट", + "player_engine_section": "प्लेबैक इंजन", + "ask_engine_on_play": "हर बार पूछें", + "ask_engine_on_play_desc": "हर बार वीडियो शुरू होने पर इंजन चुनें", + "controls_style": "प्लेयर कंट्रोल की शैली", + "controls_style_desc": "अगली बार प्लेयर खोलने पर लागू होगा।", + "controls_style_native": "नेटिव (iOS 26)", + "controls_style_native_desc": "फ़ोन के अपने Liquid Glass बटन, मेन्यू और शीट", + "controls_style_system": "सिस्टम ग्लास", + "controls_style_system_desc": "फ़ोन के अपने Liquid Glass पर ऐप के बटन", + "controls_style_classic": "क्लासिक ग्लास", + "controls_style_classic_desc": "ऐप का अपना ग्लास, पिछले वर्ज़न की तरह", + "controls_style_needs_ios26": "iOS 26 या नया चाहिए", + "playback_section": "प्लेबैक", + "default_speed": "डिफ़ॉल्ट स्पीड", + "default_speed_desc": "हर वीडियो इसी स्पीड से शुरू होगा", + "default_fit": "डिफ़ॉल्ट फ़िल मोड", + "default_fit_desc": "वीडियो स्क्रीन में कैसे फ़िट हो", + "preferred_quality": "पसंदीदा क्वालिटी", + "preferred_quality_desc": "स्ट्रीम में कई क्वालिटी होने पर प्लेबैक किससे शुरू हो", + "quality_data_saver": "डेटा सेवर (सबसे कम)", + "auto_next_episode": "अगला एपिसोड अपने-आप चलाएँ", + "auto_next_episode_desc": "एक एपिसोड ख़त्म होने पर अगला शुरू करें", + "start_paused": "रुका हुआ शुरू करें", + "keep_screen_on": "स्क्रीन चालू रखें", + "keep_screen_on_desc": "देखते समय स्क्रीन बंद न होने दें", + "gestures_section": "जेस्चर", + "seek_step": "डबल टैप से सीक", + "seek_step_desc": "डबल टैप से कितना आगे-पीछे जाना है", + "long_press_boost": "देर तक दबाने पर स्पीड", + "long_press_boost_desc": "वीडियो पर दबाकर रखने के दौरान की स्पीड", + "brightness_gesture": "ब्राइटनेस स्वाइप", + "brightness_gesture_desc": "बाईं तरफ़ ऊपर-नीचे स्वाइप करें", + "volume_gesture": "वॉल्यूम स्वाइप", + "volume_gesture_desc": "दाईं तरफ़ ऊपर-नीचे स्वाइप करें", + "auto_pip": "स्वचालित पिक्चर-इन-पिक्चर", + "auto_pip_desc": "वीडियो चलते समय होम दबाने पर वह एक छोटी विंडो में चलता रहता है", + "seconds_short": "{} से", + "subtitle_style_note": "ये हर वीडियो पर लागू होते हैं। सबटाइटल की टाइमिंग हर एपिसोड के लिए अलग रहती है और प्लेयर के अंदर सेट होती है।", + "searching_all_sources": "सभी सोर्स में खोज रहे हैं", + "section_connections": "कनेक्शन", + "section_extensions": "सोर्स", + "section_settings": "सेटिंग्स", + "remove_favorite": "पसंदीदा से हटाएँ", + "edit_profile": "प्रोफ़ाइल बदलें", + "display_name_hint": "आपका नाम", + "display_name_too_long": "नाम 60 अक्षरों या उससे कम का होना चाहिए", + "pick_from_gallery": "गैलरी से चुनें", + "take_photo": "फ़ोटो लें", + "remove_photo": "फ़ोटो हटाएँ", + "photo_pick_failed": "फ़ोटो नहीं चुनी जा सकी", + "incognito": "इनकॉग्निटो", + "incognito_desc": "हिस्ट्री या ट्रैकर में जोड़े बिना देखें", + "incognito_off": "इनकॉग्निटो बंद हो गया", + "auto_skip_intro": "ओपनिंग अपने-आप स्किप करें", + "auto_skip_intro_desc": "बिना पूछे एनिमे की ओपनिंग और एंडिंग स्किप करें", + "incognito_active": "इनकॉग्निटो चालू है। आप जो भी देखेंगे, वह न हिस्ट्री में सेव होगा और न AniList और MAL को भेजा जाएगा।", + "incognito_turn_off": "बंद करें", + "all_languages": "सभी भाषाएँ", + "section_library": "आपकी लाइब्रेरी", + "section_watch": "देखें", + "hero_trailer": "हेडर में ट्रेलर चलाएँ", + "hero_trailer_desc": "टाइटल खुलने के कुछ पल बाद पोस्टर की जगह ट्रेलर बिना आवाज़ के चलने लगता है", + "section_overview": "ओवरव्यू", + "section_app": "ऐप", + "library": "लाइब्रेरी", + "library_subtitle": "पसंदीदा, बाद में देखें, प्राइवेट लिस्ट", + "sources_title": "सोर्स", + "section_active_source": "ऐक्टिव सोर्स", + "active_source_footnote": "वह क्लाउड सोर्स जिससे Sozo खोजता और चलाता है। नीचे दिए गए एक्सटेंशन ऐसे सोर्स जोड़ते हैं जो इसी डिवाइस पर चलते हैं।", + "settings_title": "सेटिंग्स", + "settings_subtitle": "भाषा, दिखावट, प्लेयर, सुरक्षा", + "section_general": "सामान्य", + "section_data": "डेटा", + "section_lists": "लिस्ट", + "section_accounts": "अकाउंट", + "section_devices": "डिवाइस", + "language_desc": "इंटरफ़ेस की भाषा", + "not_connected": "कनेक्ट नहीं है", + "joined_on": "जुड़े: {}", + "watch_later": "बाद में देखें", + "watched": "देख लिया", + "stat_hours": "घंटे देखा", + "stat_completed": "पूरे किए", + "stat_streak": "दिन की स्ट्रीक", + "guest_benefit_sync": "पसंदीदा और हिस्ट्री आपके सभी डिवाइस पर सिंक", + "guest_benefit_streak": "स्ट्रीक, आँकड़े और ट्रिविया रैंक", + "guest_benefit_trackers": "AniList, MyAnimeList और वॉच पार्टी", + "activity": "ऐक्टिविटी", + "activity_subtitle": "हिस्ट्री, डाउनलोड, ट्रैकिंग, आँकड़े", + "connections_subtitle": "ट्रैकर, Discord, टीवी, डेस्कटॉप", + "trackers": "ट्रैकर", + "stat_days": "दिन देखा", + "mangayomi_sources": "Mangayomi सोर्स", + "view_only": "सिर्फ़ देखने के लिए", + "start_paused_desc": "प्लेयर खुलेगा पर वीडियो नहीं चलेगा। अगले एपिसोड पर अपने-आप जाने पर वीडियो चलेगा।", + "date_format": "तारीख़ का फ़ॉर्मेट", + "date_format_desc": "जब तक आप कुछ और न चुनें, यह भाषा से तय होता है।", + "date_format_auto": "भाषा के अनुसार", + "adult_content": "18+ कंटेंट", + "adult_content_desc": "एडल्ट टाइटल और सोर्स हर जगह दिखाएँ — AniList, TMDB, CloudStream, Aniyomi, मांगा और टोरेंट।", + "adult_confirm_title": "क्या आपकी उम्र 18 साल या उससे ज़्यादा है?", + "adult_confirm_body": "एडल्ट टाइटल और सोर्स पूरे ऐप में दिखेंगे। आप इसे सेटिंग्स में कभी भी बंद कर सकते हैं।", + "adult_confirm_yes": "मेरी उम्र 18 या उससे ज़्यादा है", + "library_update": "नए एपिसोड चेक करें", + "library_update_desc": "आपके फ़ॉलो किए गए टाइटल देखता है और नया एपिसोड आने पर आपको नोटिफ़िकेशन भेजता है", + "library_update_every": "हर {} घंटे में", + "library_update_daily": "दिन में एक बार", + "contributors": "योगदानकर्ता", + "contributors_error": "योगदानकर्ता लोड नहीं हो सके", + "become_contributor": "योगदानकर्ता बनें", + "become_contributor_body": "Sozo ओपन सोर्स है। कोड, अनुवाद और बग रिपोर्ट — सब मदद करते हैं।", + "reactions_overlay": "दर्शकों की प्रतिक्रियाएं", + "reactions_overlay_desc": "दूसरे दर्शकों की प्रतिक्रियाएं उसी पल दिखाएं जब भेजी गई थीं, और सीक बार पर वे कहां ज़्यादा हैं", + "hide_spoilers": "जो एपिसोड नहीं देखे उनके स्पॉइलर छिपाएं", + "hide_spoilers_desc": "जो एपिसोड पूरा नहीं देखा उसके कमेंट्स तब तक धुंधले रहेंगे जब तक आप उन पर टैप न करें" + }, + "appearance": { + "title": "दिखावट", + "entry_subtitle": "ऐक्सेंट कलर, प्योर ब्लैक", + "section_preview": "प्रीव्यू", + "section_accent": "ऐक्सेंट कलर", + "accent_footnote": "ऐक्सेंट कलर बटन, हाइलाइट, प्रोग्रेस बार और ऐक्टिव टैब को रंगता है — पूरे ऐप में।", + "accent_custom": "कस्टम", + "accent_system": "Material You", + "material_you": "Material You", + "material_you_desc": "आपके वॉलपेपर से रंग", + "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": "फ़िल्में, सीरीज़ खोजें...", + "no_results": "कोई नतीजा नहीं मिला", + "no_results_for": "\"{query}\" के लिए कोई नतीजा नहीं", + "recent": "हाल की खोजें", + "popular_searches": "लोकप्रिय खोजें", + "categories": "जॉनर", + "categories_failed": "कैटेगरी लोड नहीं हो पाईं", + "all": "सभी", + "movies": "फ़िल्में", + "series": "सीरीज़", + "filter": "फ़िल्टर", + "sort": "क्रम से लगाएँ", + "sort_newest": "सबसे नई", + "sort_oldest": "सबसे पुरानी", + "sort_rating": "रेटिंग", + "type": "टाइप", + "apply": "लागू करें", + "clear_filter": "साफ़ करें", + "clear_genre": "जॉनर फ़िल्टर साफ़ करें", + "sources": "सोर्स", + "sources_n": "{} सोर्स", + "all_sources": "सभी चुनें", + "this_server": "यह सर्वर", + "local_group": "लोकल", + "search_in": "इसमें खोजें", + "sources_hint": "एक साथ कई सोर्स में खोजें। अपना मौजूदा प्रोवाइडर इस्तेमाल करने के लिए इसे ख़ाली छोड़ दें।", + "searching": "खोज रहे हैं…", + "all_source_search": "सभी सोर्स में खोज", + "cross_hint": "अपने सोर्स में खोजें…", + "pick_sources": "चुनें कि किन सोर्स में खोजना है।", + "choose_sources": "सोर्स चुनें", + "no_sources_selected": "कोई सोर्स नहीं चुना — चुनने के लिए टैप करें", + "sources_selected": "{} चुने गए — बदलने के लिए टैप करें", + "type_to_search_n": "{} सोर्स में एक साथ खोजने के लिए टाइप करें।", + "found_in": "{}/{} सोर्स में मिला", + "results_n": "{} नतीजे", + "results_so_far": "अब तक {} नतीजे", + "waiting_for": "{} में खोज रहे हैं…", + "no_results_any": "चुने गए किसी भी सोर्स में कोई नतीजा नहीं।", + "offline_note": "ऑफ़लाइन — सिर्फ़ डिवाइस पर मौजूद सोर्स में खोजा जा सकता है।", + "group_by_source": "सोर्स के अनुसार ग्रुप करें", + "merge_titles": "टाइटल मिलाएँ", + "open_from": "यहाँ से खोलें", + "load_more": "और लोड करें", + "search_failed": "खोज विफल", + "source_failed": "यह सोर्स अभी उपलब्ध नहीं है", + "try_all_sources": "सभी सोर्स आज़माएँ", + "weak_results": "यहाँ “{query}” जैसा कुछ नहीं है — इस सोर्स ने किसी और चीज़ से मैच किया। हो सकता है दूसरे सोर्स में मिल जाए।", + "did_you_mean": "क्या आपका मतलब था", + "search_sources": "सोर्स खोजें", + "filter_providers": "प्रोवाइडर फ़िल्टर करें…", + "selected_n": "{} चुने गए", + "apply_n": "लागू करें ({})", + "many_sources_warning": "{} सोर्स में खोजने में समय लग सकता है। ऐप तब भी ठीक चलता रहेगा, पर कम सोर्स होने पर खोज ज़्यादा तेज़ होगी।", + "all_sources_n": "सभी सोर्स ({})", + "more_sources": "और…", + "select_all_sources": "सभी चुनें", + "search_all_sources": "सभी सोर्स में खोजें", + "apply_all": "सभी सोर्स में खोजें", + "selected_n_of_m": "{}/{} सोर्स", + "loading_sources": "आपके सोर्स लोड हो रहे हैं…", + "no_sources_available": "कोई सोर्स उपलब्ध नहीं है। खोजने के लिए कोई सोर्स जोड़ें या चालू करें।", + "min_query_n": "कम से कम {} अक्षर टाइप करें।", + "retry_failed": "विफल सोर्स दोबारा आज़माएँ", + "status_searching": "खोज रहे हैं…", + "status_searching_n": "{} में खोज जारी", + "status_with_results_n": "{} में नतीजे मिले", + "status_no_match_n": "{} में कोई मैच नहीं", + "status_timed_out_n": "{} का समय ख़त्म", + "status_failed_n": "{} विफल", + "all_sources_failed": "सभी {} सोर्स जवाब नहीं दे पाए। यह ख़ाली नतीजा नहीं है — फिर से कोशिश करें।", + "no_results_with_failures": "जवाब देने वाले सोर्स ({}) में कुछ नहीं मिला। {} से संपर्क नहीं हो पाया।", + "all_sources_failed_one": "जिस सोर्स में आपने खोजा, वह जवाब नहीं दे पाया। यह ख़ाली नतीजा नहीं है — फिर से कोशिश करें।", + "voice": "आवाज़ से खोजें", + "voice_listening": "सुन रहे हैं…", + "voice_hint": "किसी फ़िल्म या सीरीज़ का नाम बोलें", + "voice_starting": "माइक्रोफ़ोन तैयार हो रहा है…", + "voice_no_speech": "समझ नहीं आया", + "voice_no_speech_hint": "थोड़ा ज़ोर से और माइक्रोफ़ोन के पास बोलें, फिर दोबारा कोशिश करें।", + "voice_permission": "माइक्रोफ़ोन की अनुमति बंद है", + "voice_permission_hint": "आवाज़ से खोजने के लिए सिस्टम सेटिंग में Sozo को माइक्रोफ़ोन इस्तेमाल करने की अनुमति दें।", + "voice_unavailable": "इस डिवाइस पर आवाज़ पहचान उपलब्ध नहीं है", + "voice_unavailable_hint": "इस डिवाइस पर कोई आवाज़ पहचान सेवा नहीं है। सिस्टम सेटिंग में कोई सेवा इंस्टॉल या चालू करें, जैसे Google की।", + "voice_network": "आवाज़ सेवा तक नहीं पहुँच सके", + "voice_network_hint": "अपना इंटरनेट कनेक्शन जाँचें और दोबारा कोशिश करें।", + "voice_error": "सुनते समय कुछ गड़बड़ हो गई", + "voice_error_hint": "हो सकता है कोई दूसरा ऐप माइक्रोफ़ोन इस्तेमाल कर रहा हो। थोड़ी देर बाद दोबारा कोशिश करें।", + "voice_try_again": "दोबारा कोशिश करें", + "voice_open_settings": "सेटिंग खोलें", + "try_torrents": "टोरेंट खोजें", + "capped_note": "{} सबसे भरोसेमंद सोर्स में खोजा गया (कुल {} में से) — किसी खास सोर्स तक पहुँचने के लिए दायरा छोटा करें।", + "rail_popular_on": "{source} पर लोकप्रिय", + "rail_browse_on": "{source} ब्राउज़ करें", + "rail_no_source": "खोजने से पहले", + "nothing_to_browse": "यहाँ ब्राउज़ करने के लिए कुछ नहीं है — खोजने के लिए कोई टाइटल टाइप करें।", + "filter_genres_hint": "कोई कैटेगरी ढूँढें", + "discovery": { + "title": "डिस्कवर", + "hint": "फ़िल्टर के साथ कैटलॉग ब्राउज़ करें। कोई टाइटल खोजने पर अलग खोज शुरू होती है।", + "type": "मीडिया टाइप", + "movie": "फ़िल्में", + "tv": "टीवी सीरीज़", + "sort": "क्रम से लगाएँ", + "popular": "लोकप्रिय", + "highest": "सबसे ज़्यादा रेटिंग", + "newest": "सबसे नई", + "trending": "ट्रेंडिंग", + "rating": "कम से कम रेटिंग · /10", + "any": "कोई भी", + "votes": "रेटिंग से डिस्कवर करने के लिए कम से कम 100 वोट ज़रूरी हैं।", + "year": "रिलीज़ का साल", + "genre": "जॉनर", + "status": "स्टेटस", + "releasing": "रिलीज़ हो रही है", + "finished": "पूरी हो चुकी", + "upcoming": "आने वाली", + "hiatus": "फ़िलहाल रुकी हुई", + "cancelled": "रद्द", + "format": "फ़ॉर्मेट", + "special": "स्पेशल", + "music": "म्यूज़िक", + "season": "सीज़न", + "winter": "सर्दी", + "spring": "वसंत", + "summer": "गर्मी", + "fall": "पतझड़", + "show": "नतीजे दिखाएँ", + "active": "फ़िल्टर किया गया कैटलॉग", + "min_rating": "कम से कम रेटिंग", + "other_year": "अन्य…", + "any_rating": "कोई भी रेटिंग", + "r_low": "सिर्फ़ सबसे ख़राब हटते हैं", + "r5": "देखने लायक", + "r6": "ठीक-ठाक", + "r7": "अच्छी", + "r8": "शानदार", + "r9": "सिर्फ़ मास्टरपीस" + } + }, + "movie": { + "rating": "रेटिंग", + "year": "साल", + "quality": "क्वालिटी", + "duration": "अवधि", + "genre": "जॉनर", + "director": "डायरेक्टर", + "cast": "कास्ट", + "description": "विवरण", + "similar": "मिलते-जुलते टाइटल", + "trailer": "ट्रेलर", + "share": "शेयर करें", + "download": "डाउनलोड करें", + "rate": "रेटिंग दें", + "episodes": "एपिसोड", + "seasons": "सीज़न" + }, + "errors": { + "network": "नेटवर्क में गड़बड़ी। अपना कनेक्शन जाँचें।", + "server": "सर्वर में गड़बड़ी। कृपया फिर से कोशिश करें।", + "unauthorized": "जारी रखने के लिए कृपया साइन इन करें।", + "not_found": "कंटेंट नहीं मिला।", + "unknown": "कोई अज्ञात गड़बड़ी हुई।", + "timeout": "रिक्वेस्ट का समय ख़त्म हो गया। कृपया फिर से कोशिश करें।", + "empty": "कोई कंटेंट उपलब्ध नहीं है।", + "source_http": "सोर्स के सर्वर ने गड़बड़ी के साथ जवाब दिया ({})।", + "no_browser": "इस डिवाइस पर कोई ऐप यह लिंक नहीं खोल सकता।" + }, + "navigation": { + "home": "होम", + "search": "खोज", + "shorts": "शॉर्ट्स", + "downloads": "डाउनलोड", + "profile": "प्रोफ़ाइल", + "my_list": "मेरी लिस्ट", + "history": "हिस्ट्री", + "following": "फ़ॉलोइंग", + "premiere": "प्रीमियर", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "लाइव टीवी" + }, + "premiere": { + "tab_title": "प्रीमियर", + "live_now": "अभी लाइव", + "upcoming": "आने वाले", + "live": "लाइव", + "catching_up": "लाइव तक पहुँच रहा है", + "starts_in": "शुरू होने में", + "starts_at": "शुरू होगा: {when}", + "days": "दिन", + "hours": "घंटे", + "minutes": "मिनट", + "seconds": "सेकंड", + "waiting": "{count} इंतज़ार कर रहे हैं", + "watching": "{count} देख रहे हैं", + "join": "प्रीमियर में शामिल हों", + "join_now": "अभी शामिल हों", + "set_reminder": "रिमाइंडर सेट करें", + "reminder_set": "रिमाइंडर सेट हो गया", + "notify_me": "लाइव होने पर नोटिफ़िकेशन भेजें", + "guest_watch_hint": "आप गेस्ट के तौर पर देख सकते हैं।", + "login_to_chat_react": "चैट और रिएक्ट करने के लिए साइन इन करें", + "login_to_chat": "चैट करने के लिए साइन इन करें", + "chat_hint": "हाय बोलें — चैट खुली है", + "chat_empty": "हाय बोलें 👋 प्रीमियर शुरू होते ही चैट शुरू होगी", + "chat_placeholder": "मैसेज…", + "slow_mode": "स्लो मोड: {sec} से", + "react": "रिएक्ट करें", + "paused_by_host": "होस्ट ने रोका है", + "buffering": "बफ़र हो रहा है…", + "reconnecting": "फिर से कनेक्ट हो रहा है…", + "connecting": "प्रीमियर से कनेक्ट हो रहा है…", + "ended_title": "प्रीमियर ख़त्म हो गया", + "ended_watch_vod": "ऑन डिमांड देखें", + "canceled": "यह प्रीमियर रद्द कर दिया गया", + "empty_title": "अभी कोई प्रीमियर नहीं है", + "empty_subtitle": "लाइव इवेंट यहाँ दिखेंगे", + "error_generic": "कुछ गड़बड़ हो गई", + "error_full": "यह प्रीमियर फ़ुल है", + "error_not_found": "प्रीमियर नहीं मिला", + "retry": "फिर से कोशिश करें", + "share": "शेयर करें", + "back": "वापस", + "resolve_failed": "इस डिवाइस पर प्लेबैक शुरू नहीं हो सका" + }, + "nav_customize": { + "entry_title": "टैब कस्टमाइज़ करें", + "entry_subtitle": "चुनें कि कौन-से टैब दिखें और किस क्रम में", + "title": "बॉटम बार", + "shown": "दिख रहे हैं", + "available": "उपलब्ध", + "min_hint": "कम से कम {count} टैब रखें", + "max_hint": "ज़्यादा से ज़्यादा {count} टैब", + "pick_range": "{min} से {max} टैब चुनें", + "reset": "रीसेट करें", + "cancel": "रद्द करें", + "save": "सेव करें" + }, + "app_lock": { + "section_label": "सुरक्षा", + "app_lock": "ऐप लॉक", + "settings_title": "ऐप लॉक", + "setup_title": "पिन सेट करें", + "enable": "ऐप लॉक चालू करें", + "state_on": "चालू", + "state_off": "बंद", + "choose_length_title": "पिन की लंबाई", + "choose_length_subtitle": "चुनें कि आपके पिन में कितने अंक होंगे।", + "length_n_digits": "{} अंकों का पिन", + "create_pin_title": "नया पिन बनाएँ", + "create_pin_subtitle": "ऐसा पिन चुनें जो आप किसी से शेयर नहीं करेंगे।", + "confirm_pin_title": "अपने पिन की पुष्टि करें", + "confirm_pin_subtitle": "अपना नया पिन एक बार फिर डालें।", + "verify_title": "ऐप लॉक है", + "verify_subtitle": "जारी रखने के लिए अपना पिन डालें।", + "verify_current_pin": "अपना मौजूदा पिन डालें", + "change_pin": "पिन बदलें", + "biometric": "बायोमेट्रिक से अनलॉक करें", + "biometric_unavailable": "इस डिवाइस पर बायोमेट्रिक उपलब्ध नहीं है", + "biometric_enable_reason": "बायोमेट्रिक अनलॉक चालू करने के लिए पहचान की पुष्टि करें", + "biometric_unlock_reason": "ऐप खोलने के लिए पहचान की पुष्टि करें", + "pin_mismatch": "पिन मेल नहीं खाते", + "pin_wrong": "पिन ग़लत है", + "disable_title": "ऐप लॉक बंद करें?", + "disable_subtitle": "आपका पिन और बायोमेट्रिक सेटिंग हटा दी जाएगी।", + "enabled_hint": "ऐप शुरू होने पर आपसे पिन माँगा जाएगा।", + "disabled_hint": "ऐप लॉक बंद है। आप जो देखते हैं उसे सुरक्षित रखने के लिए इसे चालू करें।", + "private_list": "प्राइवेट लिस्ट", + "move_to_private": "प्राइवेट में ले जाएँ", + "moved_to_private": "प्राइवेट लिस्ट में ले जाया गया", + "removed_from_private": "प्राइवेट से हटाया गया", + "move_to_my_list": "वापस मेरी लिस्ट में ले जाएँ", + "private_empty": "अभी प्राइवेट में कुछ नहीं है", + "private_setup_required": "प्राइवेट लिस्ट इस्तेमाल करने के लिए पहले पिन सेट करें", + "private_long_press_hint": "प्राइवेट लिस्ट में ले जाने के लिए देर तक दबाएँ — यह आपके पिन के पीछे छिपी रहती है और इसकी वॉच हिस्ट्री सेव नहीं होती।", + "private_always_ask": "प्राइवेट लिस्ट के लिए हमेशा पिन पूछें", + "private_always_ask_hint": "हर सेशन में सिर्फ़ एक बार नहीं, हर बार पिन दोबारा डालें", + "reset_title": "ऐप लॉक रीसेट करें?", + "reset_body": "इससे पिन हट जाएगा और इस कंप्यूटर पर Sozo अनलॉक हो जाएगा। आपकी लाइब्रेरी और हिस्ट्री बनी रहेंगी। आप बाद में प्रोफ़ाइल → ऐप लॉक में नया पिन सेट कर सकते हैं।", + "reset": "रीसेट करें", + "forgot_pin": "पिन भूल गए?" + }, + "my_list": { + "sort": "क्रम", + "sort_added": "हाल ही में जोड़े गए", + "sort_oldest": "सबसे पहले जोड़े गए", + "sort_title": "टाइटल A–Z", + "saved_account": "अकाउंट में सेव है", + "saved_local": "इस डिवाइस पर", + "untitled": "बिना टाइटल", + "empty_title": "आपकी लिस्ट खाली है", + "empty_subtitle": "यहाँ सेव करने के लिए किसी भी टाइटल पर + बटन टैप करें।", + "unauth_title": "अपनी लिस्ट देखने के लिए साइन इन करें", + "unauth_subtitle": "साइन इन करने पर आपके सेव किए गए टाइटल यहाँ दिखेंगे।", + "error_title": "लिस्ट लोड नहीं हो सकी" + }, + "deeplink": { + "opt_in_title": "Sozo लिंक इस ऐप में खोलें?", + "opt_in_body_android": "शेयर किए गए sozo.azamov.me लिंक सीधे Sozo में खुलेंगे। अपनी सिस्टम सेटिंग्स में \"Open supported links\" चालू करें।", + "opt_in_body_ios": "Sozo लिंक अपने आप ऐप में खुलते हैं। अगर कोई लिंक Safari में खुले, तो उसे देर तक दबाएँ और \"Open in Sozo\" चुनें।", + "opt_in_enable": "चालू करें", + "opt_in_later": "बाद में" + }, + "streak": { + "section_label": "स्ट्रीक", + "empty_title": "स्ट्रीक शुरू करें", + "empty_subtitle": "अपनी डेली स्ट्रीक शुरू करने के लिए आज एक एपिसोड देखें।", + "current_n_days": "{} दिन", + "locked_today": "आज की स्ट्रीक पक्की हो गई। कल फिर आएँ!", + "keep_going": "अपनी स्ट्रीक बनाए रखें — आज देखना न भूलें।", + "risk_subtitle": "आधी रात को आपकी स्ट्रीक रीसेट हो जाएगी। इसे बचाने के लिए अभी कुछ देखें।", + "record_label": "बेस्ट", + "next_milestone": "अगला लक्ष्य", + "weekday_mon": "सोम", + "weekday_tue": "मंगल", + "weekday_wed": "बुध", + "weekday_thu": "गुरु", + "weekday_fri": "शुक्र", + "weekday_sat": "शनि", + "weekday_sun": "रवि", + "milestone_title": "{} दिन की स्ट्रीक!", + "milestone_subtitle": "ग़ज़ब की निरंतरता। ऐसे ही आते रहें और अपना नया बेस्ट बनाएँ।", + "share_text": "Sozo पर {} दिन की स्ट्रीक पूरी की! 🔥", + "cta_continue": "जारी रखें", + "cta_share": "शेयर करें", + "freeze_label": "फ़्रीज़", + "freeze_count": "{} / {}", + "total_days_label": "कुल दिन", + "this_week_label": "इस हफ़्ते", + "calendar_label": "पिछले 35 दिन", + "days_to_milestone": "अगले माइलस्टोन तक {} दिन", + "freeze_saved_title": "स्ट्रीक बच गई!", + "freeze_saved_subtitle": "एक फ़्रीज़ ने आपका छूटा हुआ दिन कवर कर लिया — आपकी स्ट्रीक जारी है।", + "freeze_awarded_title": "आपने एक फ़्रीज़ कमाया!", + "freeze_awarded_subtitle": "एक दिन छूटने पर फ़्रीज़ आपकी स्ट्रीक बचाते हैं।", + "freeze_explainer": "जिस दिन आप नहीं देखते, उस दिन फ़्रीज़ अपने आप आपकी स्ट्रीक बचा लेता है। नियमित रूप से देखने पर आपको नए फ़्रीज़ मिलते हैं।" + }, + "watch_party": { + "title": "वॉच पार्टी", + "entry_subtitle": "दोस्तों के साथ सिंक में देखें — एक चलाता है, बाकी सब साथ चलते हैं।", + "entry_create": "पार्टी बनाएँ", + "entry_create_hint": "रूम शुरू करें और कोड से दोस्तों को बुलाएँ", + "entry_join": "कोड से जुड़ें", + "entry_join_hint": "दोस्त का शेयर किया हुआ कोड डालें", + "not_available": "इस टाइटल को साथ में देखने की सुविधा अभी उपलब्ध नहीं है", + "create": "पार्टी बनाएँ", + "join": "पार्टी में शामिल हों", + "join_hint": "पार्टी कोड डालें", + "invalid_code": "कोड ग़लत है", + "code_label": "पार्टी कोड", + "copy_code": "कोड कॉपी करें", + "copied": "कॉपी हो गया", + "share_invite": "इनवाइट शेयर करें", + "invite": "इनवाइट करें", + "lobby_waiting_host": "होस्ट के शुरू करने का इंतज़ार है…", + "host_label": "होस्ट", + "members": "मेंबर", + "member_count": "{} / {}", + "you": "आप", + "start_watching": "देखना शुरू करें", + "close_party": "पार्टी बंद करें", + "leave_party": "पार्टी छोड़ें", + "leave_confirm": "यह वॉच पार्टी छोड़ें?", + "only_host_controls": "सिर्फ़ होस्ट ही प्लेबैक कंट्रोल कर सकते हैं", + "host_changed": "होस्ट बदल गए हैं", + "room_closed": "पार्टी बंद कर दी गई", + "room_closed_empty": "सबके चले जाने से पार्टी बंद हो गई", + "content_none": "अभी कोई टाइटल नहीं चुना गया", + "resolving": "वीडियो तैयार हो रहा है…", + "resolve_failed": "यह टाइटल आपके डिवाइस पर लोड नहीं हो सका", + "plugin_required_title": "प्लगइन ज़रूरी है", + "plugin_required_body": "इसे साथ में देखने के लिए ज़रूरी सोर्स प्लगइन इंस्टॉल करें।", + "install_plugin": "प्लगइन इंस्टॉल करें", + "chat_hint": "कुछ लिखें…", + "chat_empty": "अभी कोई मैसेज नहीं", + "reactions": "रिएक्शन", + "error_not_found": "पार्टी नहीं मिली या पहले ही बंद हो चुकी है", + "error_full": "यह पार्टी फ़ुल है", + "error_forbidden": "आपको इस पार्टी का एक्सेस नहीं है", + "error_rate": "थोड़ा धीरे-धीरे करें", + "error_unauthorized": "वॉच पार्टी में शामिल होने के लिए साइन इन करें", + "error_generic": "कुछ गड़बड़ हो गई", + "reconnecting": "फिर से कनेक्ट हो रहा है…", + "connecting": "कनेक्ट हो रहा है…", + "connect_failed_title": "कनेक्ट नहीं हो सका", + "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।", + "settings_title": "रूम सेटिंग्स", + "room_size": "रूम का आकार", + "room_size_less": "कम लोग", + "room_size_more": "ज़्यादा लोग", + "only_host_setting": "सिर्फ़ होस्ट प्लेबैक नियंत्रित करता है", + "lock_room": "रूम लॉक करें", + "lock_room_hint": "कोई नया शामिल नहीं हो सकता", + "wait_for_all": "सबका इंतज़ार करें", + "wait_for_all_hint": "किसी के बफ़र होने पर थोड़ी देर रोकें", + "invite_friends": "दोस्तों को आमंत्रित करें", + "invite_sent": "आमंत्रण भेजा गया", + "invite_failed": "आमंत्रण नहीं भेजा जा सका", + "invite_again_in": "{} बाद फिर से", + "invite_no_friends": "यहाँ आमंत्रित करने के लिए दोस्त जोड़ें", + "make_host": "होस्ट बनाएँ", + "remove_member": "पार्टी से हटाएँ", + "remove_confirm": "{} को इस पार्टी से हटाएँ? वे दोबारा शामिल नहीं हो पाएँगे।", + "buffering": "बफ़र हो रहा है…", + "sys_joined": "{} शामिल हुए", + "sys_left": "{} चले गए", + "sys_host": "{} अब होस्ट हैं", + "sys_removed": "{} को हटाया गया", + "error_banned": "आपको इस पार्टी से हटा दिया गया", + "error_locked": "यह पार्टी लॉक है", + "room_closed_kicked": "होस्ट ने आपको इस पार्टी से हटा दिया", + "waiting_for_others": "सबके साथ आने का इंतज़ार…" + }, + "notifications": { + "title": "नोटिफ़िकेशन", + "mark_all_read": "सभी को पढ़ा हुआ मार्क करें", + "empty_title": "अभी कोई नोटिफ़िकेशन नहीं", + "empty_subtitle": "कुछ नया आने पर आपको यहाँ दिखेगा।", + "time_now": "अभी", + "time_minutes": "{} मिनट", + "time_hours": "{} घं", + "time_days": "{} दिन" + }, + "manga": { + "sources_title": "मांगा सोर्स", + "sources_desc": "अतिरिक्त मांगा, मनह्वा और वेबटून प्रोवाइडर इंस्टॉल करने के लिए मांगा एक्सटेंशन रिपॉज़िटरी जोड़ें। ये आपके डिवाइस पर चलते हैं (सिर्फ़ Android)।", + "repo_url": "रिपॉज़िटरी URL", + "add_source": "सोर्स जोड़ें", + "installing": "इंस्टॉल हो रहा है…", + "installing_long": "एक्सटेंशन इंस्टॉल हो रहे हैं… बड़ी रिपॉज़िटरी में थोड़ा समय लग सकता है।", + "installing_progress": "{} / {} एक्सटेंशन इंस्टॉल हो रहे हैं…", + "no_extensions": "उस URL पर कोई एक्सटेंशन नहीं मिला।", + "added_sources": "{} सोर्स · {} प्रोवाइडर जोड़े गए।", + "source_removed": "सोर्स हटा दिया गया।", + "recommended": "सुझाए गए", + "installed_sources": "इंस्टॉल किए गए सोर्स", + "source_settings": "सोर्स सेटिंग्स", + "no_sources": "अभी कोई सोर्स नहीं है", + "show_nsfw": "एडल्ट सोर्स", + "show_nsfw_hint": "18+ मार्क किए गए सोर्स इस लिस्ट और प्रोवाइडर पिकर में दिखाएँ। डिफ़ॉल्ट रूप से बंद।", + "show": "दिखाएँ", + "hide": "छिपाएँ", + "installed": "इंस्टॉल है", + "android_only": "यह सुविधा सिर्फ़ Android पर उपलब्ध है", + "error_prefix": "गड़बड़ी: {}", + "no_preferences": "इस सोर्स में बदलने लायक कोई सेटिंग नहीं है", + "not_selected": "नहीं चुना गया", + "n_selected": "{} चुने गए", + "reading_mode": "पढ़ने का मोड", + "mode_continuous": "लगातार", + "mode_paged": "पेज-दर-पेज", + "direction": "दिशा", + "dir_ltr": "बाएँ से दाएँ (LTR)", + "dir_rtl": "दाएँ से बाएँ (RTL)", + "background": "बैकग्राउंड", + "bg_black": "काला", + "bg_gray": "ग्रे", + "bg_white": "सफ़ेद", + "chapters": "चैप्टर", + "chapter_ended": "चैप्टर ख़त्म हुआ", + "next_chapter": "अगला चैप्टर", + "first_chapter": "पहला चैप्टर", + "last_chapter": "आख़िरी चैप्टर", + "no_pages": "कोई पेज नहीं मिला", + "tap_to_reload": "फिर से लोड करने के लिए टैप करें", + "download_started": "डाउनलोड शुरू हो गया", + "downloaded_pages": "{} / {} पेज", + "check_updates": "अपडेट जाँचें", + "checking_updates": "एक्सटेंशन के अपडेट जाँचे जा रहे हैं…", + "up_to_date": "सभी एक्सटेंशन अप टू डेट हैं।", + "updated_n": "{} एक्सटेंशन अपडेट किए गए।", + "spread": "दो पेज", + "spread_desc": "लैंडस्केप में अगल-बगल, जैसे किताब में बनाए गए थे", + "text_size": "टेक्स्ट साइज़", + "line_height": "लाइन स्पेसिंग", + "typeface": "फ़ॉन्ट", + "typeface_default": "डिफ़ॉल्ट", + "typeface_serif": "सेरिफ़", + "typeface_mono": "मोनो", + "alignment": "अलाइनमेंट", + "align_left": "बाएँ अलाइन", + "align_justify": "जस्टिफ़ाइड", + "novel_download_unavailable": "नॉवेल के लिए ऑफ़लाइन डाउनलोड अभी सपोर्ट नहीं है।", + "open_on_site": "सोर्स की साइट पर खोलें", + "mark_read": "पढ़ा हुआ मार्क करें", + "mark_unread": "अनपढ़ा मार्क करें", + "marked_read_n": "{} पढ़े हुए मार्क किए गए", + "marked_unread_n": "{} अनपढ़े मार्क किए गए", + "volume_n": "वॉल्यूम {}", + "other_chapters": "अन्य", + "find_chapter": "चैप्टर खोजें…", + "no_chapter_match": "इससे मेल खाता कोई चैप्टर नहीं मिला।", + "find_in_chapter": "चैप्टर में खोजें", + "tts_listen": "सुनें", + "tts_read_aloud": "पढ़कर सुनाएँ", + "tts_play": "चलाएँ", + "tts_pause": "रोकें", + "tts_stop": "पढ़कर सुनाना बंद करें", + "tts_prev_sentence": "पिछला वाक्य", + "tts_next_sentence": "अगला वाक्य", + "tts_speed": "स्पीड", + "tts_pitch": "पिच", + "tts_voice": "आवाज़", + "tts_voice_default": "सिस्टम डिफ़ॉल्ट", + "tts_voice_network": "ऑनलाइन आवाज़ — कनेक्शन ज़रूरी है", + "tts_no_voices": "इस डिवाइस पर इस भाषा की कोई आवाज़ नहीं है। सिस्टम की टेक्स्ट-टू-स्पीच सेटिंग्स में कोई आवाज़ इंस्टॉल करें; तब तक डिफ़ॉल्ट आवाज़ इसे पढ़ेगी।", + "tts_auto_next": "अगले चैप्टर पर जारी रखें", + "tts_auto_next_desc": "चैप्टर ख़त्म होने पर भी पढ़कर सुनाना जारी रखें", + "tts_sleep_timer": "स्लीप टाइमर", + "tts_sleep_off": "बंद", + "tts_sleep_minutes": "{} मिनट", + "tts_sleep_end_of_chapter": "चैप्टर के अंत में", + "tts_sleep_stopped": "स्लीप टाइमर: पढ़कर सुनाना बंद हुआ", + "tts_loading_next": "अगला चैप्टर लोड हो रहा है…", + "tts_no_voice_for_lang": "इस डिवाइस पर {} की कोई आवाज़ नहीं है — डिफ़ॉल्ट आवाज़ से पढ़ा जा रहा है", + "tts_failed": "पढ़कर सुनाना रुक गया: स्पीच इंजन जवाब नहीं दे रहा", + "layout": "लेआउट", + "mode_scroll": "स्क्रॉल", + "mode_book": "किताब", + "reading_theme": "थीम", + "theme_dark": "डार्क", + "theme_black": "ब्लैक", + "theme_sepia": "सीपिया", + "theme_light": "लाइट", + "margins": "मार्जिन", + "margin_narrow": "कम", + "margin_normal": "सामान्य", + "margin_wide": "ज़्यादा", + "highlight": "हाइलाइट करें", + "highlights": "हाइलाइट", + "remove_highlight": "हाइलाइट हटाएँ", + "highlight_note": "नोट जोड़ें (वैकल्पिक)", + "no_highlights": "अभी कोई हाइलाइट नहीं है। कुछ टेक्स्ट चुनें और “हाइलाइट करें” चुनें।", + "translate_page": "पेज का अनुवाद करें", + "translate_preview": "अनुवादित पेज", + "translate_apply": "लागू करें", + "translate_show_original": "मूल दिखाएँ", + "translate_no_text": "इस पेज पर कोई टेक्स्ट नहीं मिला।", + "translate_daily_limit": "आज के अनुवाद खत्म हो गए हैं। कल फिर कोशिश करें।", + "translate_offline": "आप ऑफ़लाइन हैं। इस पेज का अनुवाद करने के लिए इंटरनेट से जुड़ें।", + "translate_sign_in": "पेजों का अनुवाद करने के लिए साइन इन करें।", + "translate_failed": "इस पेज का अनुवाद नहीं हो सका। फिर से कोशिश करें।", + "translate_language": "पेजों का अनुवाद इस भाषा में करें", + "translate_auto": "पढ़ते समय हर पेज का अनुवाद करें", + "translate_auto_desc": "हर नया पेज आज के अनुवादों में से एक इस्तेमाल करता है।" + }, + "cloudflare": { + "title": "पुष्टि करें कि आप इंसान हैं", + "hint": "नीचे दी गई जाँच पूरी करें — पूरा होते ही यह अपने आप बंद हो जाएगा।", + "solve": "Cloudflare हल करें", + "done": "हो गया" + }, + "desktop": { + "saved_refresh_on_pc": "सेव हो गया — इसे अपडेट करने के लिए PC पर \"सोर्स रीफ़्रेश करें\" पर टैप करें।", + "sources_turned_off": "डेस्कटॉप सोर्स बंद कर दिए गए।", + "saved_loading_sources": "सेव हो गया — आपके फ़ोन से सोर्स लोड हो रहे हैं…", + "refreshing_sources": "आपके फ़ोन से सोर्स रीफ़्रेश हो रहे हैं…", + "share_title": "डेस्कटॉप पर सोर्स शेयर करें", + "sources_title": "डेस्कटॉप सोर्स", + "share_with_desktop": "डेस्कटॉप के साथ सोर्स शेयर करें", + "let_pc_use_sources": "Sozo PC ऐप को इस फ़ोन के सोर्स इस्तेमाल करने दें।", + "your_link": "आपका लिंक", + "link_copied": "लिंक कॉपी हो गया", + "no_wifi_address": "चालू है, लेकिन कोई Wi‑Fi पता नहीं मिला। इस फ़ोन को Wi‑Fi से कनेक्ट करें (मोबाइल डेटा से नहीं), फिर यह स्क्रीन दोबारा खोलें।", + "step_host_same_wifi": "यह फ़ोन और आपका PC एक ही Wi‑Fi नेटवर्क पर रखें।", + "step_host_turn_on": "ऊपर दिया गया स्विच चालू करें।", + "step_host_pick_sources": "चुनें कि कौन-से सोर्स शेयर करने हैं (या “सभी सोर्स शेयर करें” चालू रहने दें)।", + "step_host_paste_on_pc": "PC पर Sozo → प्रोफ़ाइल → डेस्कटॉप सोर्स खोलें और लिंक पेस्ट करें।", + "step_host_keep_open": "जब तक आप PC पर ब्राउज़ करें, इस फ़ोन पर Sozo खुला रखें।", + "sources_to_share": "शेयर करने वाले सोर्स", + "reload_from_phone": "फ़ोन से फिर लोड करें", + "share_all_sources": "सभी सोर्स शेयर करें", + "every_source_shared": "इस फ़ोन के सभी सोर्स शेयर किए गए हैं", + "sharing_count": "{}/{} शेयर हो रहे हैं", + "save_send_to_desktop": "सेव करें और डेस्कटॉप पर भेजें", + "n_selected": "{} चुने गए", + "select_all": "सभी चुनें", + "clear": "साफ़ करें", + "no_sources_yet": "इस फ़ोन पर अभी कोई सोर्स नहीं है। प्रोफ़ाइल → CloudStream / Aniyomi / मांगा सोर्स में कुछ जोड़ें, फिर यहाँ दोबारा लोड करें।", + "phone_link": "फ़ोन लिंक", + "refresh_sources": "सोर्स रीफ़्रेश करें", + "step_client_same_wifi": "यह PC और आपका फ़ोन एक ही Wi‑Fi नेटवर्क पर रखें।", + "step_client_open_on_phone": "फ़ोन पर Sozo → प्रोफ़ाइल → डेस्कटॉप पर सोर्स शेयर करें खोलें और इसे चालू करें।", + "step_client_choose_sources": "फ़ोन पर चुनें कि कौन-से सोर्स शेयर करने हैं (या “सभी सोर्स शेयर करें” चालू रहने दें)।", + "step_client_copy_link": "वहाँ दिखाया गया लिंक कॉपी करें, ऊपर पेस्ट करें, फिर सेव करें पर टैप करें।", + "step_client_refresh": "फ़ोन पर शेयर किए गए सोर्स बदले हैं? यहाँ \"सोर्स रीफ़्रेश करें\" पर टैप करें।", + "step_client_keep_open": "जब तक आप यहाँ ब्राउज़ करें, फ़ोन पर Sozo खुला रखें।", + "step_client_emulator": "Android एम्युलेटर इस्तेमाल कर रहे हैं? उसके IP तक सीधे नहीं पहुँचा जा सकता — इस PC पर \"adb forward tcp:8765 tcp:8765\" चलाएँ, फिर ऊपर http://127.0.0.1:8765 पेस्ट करें (एम्युलेटर में स्विच फिर भी चालू करें)।", + "how_it_works": "यह कैसे काम करता है", + "how_it_works_desc": "अपने CloudStream, Aniyomi और मांगा सोर्स Sozo डेस्कटॉप ऐप पर इस्तेमाल करें। सोर्स फ़ोन पर चलते हैं; PC पर सिर्फ़ दिखते हैं।", + "internet_still_unavailable": "इंटरनेट अभी भी उपलब्ध नहीं है", + "offline_watch_downloads": "कनेक्शन ऑफ़लाइन होने पर भी आप डाउनलोड किए गए वीडियो देखते रह सकते हैं।", + "refresh": "रीफ़्रेश करें" + }, + "detail": { + "offline_copy": "ऑफ़लाइन कॉपी", + "offline_copy_desc": "सोर्स तक पहुँच नहीं हो पाई, इसलिए यह आपके डाउनलोड के साथ सेव की गई कॉपी है।", + "download_will_resolve": "लिंक ढूँढा जा रहा है…", + "download_needs_playback": "यह सोर्स अपनी स्ट्रीम को एक सुरक्षित प्लेयर के पीछे छिपाता है। एपिसोड एक बार चलाएँ, फिर उसे प्लेयर से डाउनलोड करें।", + "download_choose_server": "डाउनलोड · सर्वर चुनें", + "download_playing_now": "अभी चल रहा है", + "similar": "मिलते-जुलते", + "comments": "कमेंट", + "screenshots": "स्क्रीनशॉट", + "play": "चलाएँ", + "no_episodes": "कोई एपिसोड उपलब्ध नहीं", + "no_playable_source": "चलाने लायक कोई सोर्स नहीं", + "added_to_my_list": "मेरी लिस्ट में जोड़ा गया", + "removed_from_my_list": "मेरी लिस्ट से हटाया गया", + "failed_resolve_pages": "पेज लोड नहीं हो पाए", + "episodes_count": "{} एपिसोड", + "episodes_count_of": "{}/{} एपिसोड", + "episode_n": "एपिसोड {}", + "no_films": "कोई फ़िल्म नहीं मिली", + "filmography": "फ़िल्मोग्राफ़ी", + "actor": "एक्टर", + "continue_watching": "जारी रखें", + "continue_ep": "जारी रखें · एपिसोड {}", + "no_cast": "कास्ट की जानकारी उपलब्ध नहीं", + "no_recommendations": "कोई सुझाव उपलब्ध नहीं", + "untitled": "बिना टाइटल", + "read": "पढ़ें", + "watch_later": "बाद में देखें", + "watched": "देख लिया", + "follow_series": "सीरीज़ फ़ॉलो करें", + "find_other_sources": "दूसरे सोर्स खोजें", + "notice_title": "यह सोर्स इस टाइटल को नहीं चला सकता", + "notice_moved_to_app": "इस सोर्स ने यह टाइटल अपने खुद के ऐप में ले लिया है, इसलिए यहाँ चलाने के लिए कुछ नहीं बचा। हो सकता है किसी दूसरे सोर्स पर यह अब भी मिल जाए।", + "notice_no_player": "इस सोर्स की लिस्ट में यह टाइटल है, लेकिन इसके लिए कोई प्लेयर नहीं है। हो सकता है किसी दूसरे सोर्स पर यह अब भी मिल जाए।", + "notice_is_series": "इस सोर्स पर यह एक सीरीज़ है, इसलिए यह अकेली फ़िल्म की तरह नहीं, बल्कि एपिसोड लिस्ट से चलेगी।", + "anilist_track": "AniList पर ट्रैक करें", + "anilist_tracked": "AniList पर ट्रैक हो रहा है", + "following_on": "फ़ॉलोइंग", + "following_off": "फ़ॉलो नहीं कर रहे", + "no_screenshots": "कोई स्क्रीनशॉट नहीं", + "show_more": "और दिखाएँ", + "show_less": "कम दिखाएँ", + "time_left": "{} बाकी", + "liked": "{}% को पसंद आया", + "watched_pct": "{}% देखा गया", + "copy_link": "लिंक कॉपी करें", + "link_copied": "लिंक कॉपी हो गया", + "report_problem": "समस्या की रिपोर्ट करें", + "selected_n": "{} चुने गए", + "select_all": "सभी चुनें", + "select_none": "साफ़ करें", + "download_n": "{} डाउनलोड करें", + "download_queueing": "कतार में जोड़ रहे हैं…", + "download_action": "डाउनलोड करें", + "download_already": "पहले से डाउनलोड है", + "download_in_progress": "डाउनलोड चल रहा है", + "download_started": "डाउनलोड शुरू हो गया", + "download_needs_permission": "डाउनलोड के लिए नोटिफ़िकेशन की अनुमति ज़रूरी है", + "download_resolve_failed": "इस एपिसोड का डाउनलोड लिंक नहीं मिल पाया", + "download_queued_n": "{} डाउनलोड में जोड़े गए", + "download_none_queued": "कुछ नहीं जोड़ा गया — हो सकता है ये पहले से डाउनलोड हों", + "download_batch_title": "कई एपिसोड डाउनलोड करें?", + "download_batch_body": "एपिसोड {range} कतार में जोड़े जाएँगे — कुल {count}। मोबाइल डेटा पर यह कई गीगाबाइट हो सकता है।", + "relations": "संबंधित", + "no_relations": "इसके लिए कोई संबंधित टाइटल नहीं मिला।", + "relation_tmdb_collection": "एक ही सीरीज़", + "relation_tmdb_recommended": "सुझाया गया", + "relation_sequel": "सीक्वल", + "relation_prequel": "प्रीक्वल", + "relation_parent": "मुख्य कहानी", + "relation_side_story": "साइड स्टोरी", + "relation_alternative": "दूसरा वर्शन", + "relation_spin_off": "स्पिन-ऑफ़", + "relation_summary": "सारांश", + "relation_adaptation": "एडैप्टेशन", + "relation_source": "मूल सोर्स", + "relation_character": "साझा किरदार", + "relation_other": "अन्य", + "trailer_action": "ट्रेलर", + "more_options": "और विकल्प", + "in_private_list": "आपकी प्राइवेट लिस्ट में", + "add_to_my_list_action": "मेरी लिस्ट में जोड़ें", + "remove_from_my_list_action": "मेरी लिस्ट से हटाएँ", + "trailer_mute": "ट्रेलर म्यूट करें", + "trailer_unmute": "ट्रेलर अनम्यूट करें", + "my_list": "मेरी लिस्ट", + "save_action": "सेव करें", + "next_episode_in": "एपिसोड {}, {} में", + "about": "जानकारी", + "about_studio": "स्टूडियो", + "about_source": "मूल सोर्स", + "about_episodes": "एपिसोड", + "about_rank": "रैंक", + "about_status": "स्टेटस", + "about_format": "फ़ॉर्मैट", + "not_on_list": "आपकी लिस्ट में नहीं है", + "add_to_list": "लिस्ट में जोड़ें", + "about_network": "नेटवर्क", + "about_seasons": "सीज़न", + "about_budget": "बजट", + "about_box_office": "बॉक्स ऑफ़िस", + "about_collection": "कलेक्शन", + "about_chapters": "चैप्टर", + "about_volumes": "वॉल्यूम", + "tracking_score": "स्कोर", + "tracking_progress": "प्रोग्रेस", + "tracking_status": "स्टेटस", + "tracking_remove": "लिस्ट से हटाएँ", + "tracking_removed": "{} से हटाया गया", + "tracking_saved": "{} में सेव किया गया", + "tracking_not_scored": "स्कोर नहीं दिया", + "tracking_chapters_read": "पढ़े गए चैप्टर", + "tracking_episodes_watched": "देखे गए एपिसोड", + "status_reading": "पढ़ रहे हैं", + "status_rereading": "दोबारा पढ़ रहे हैं", + "about_author": "लेखक", + "cd_days": "दिन", + "cd_hours": "घंटे", + "cd_minutes": "मिनट", + "cd_seconds": "सेकंड", + "next_episode_label": "एपिसोड {} आने में बाकी", + "airing_now": "अभी प्रसारित हो रहा है", + "tracking_pick_status": "अपनी लिस्ट में जोड़ने के लिए कोई स्टेटस चुनें।", + "score_bad": "ख़राब", + "score_meh": "औसत", + "score_fine": "ठीक-ठाक", + "score_good": "अच्छा", + "score_great": "बहुत अच्छा", + "score_excellent": "शानदार", + "score_masterpiece": "मास्टरपीस" + }, + "episodes": { + "offline_desc": "सेव की गई लिस्ट दिखाई जा रही है। ऑफ़लाइन सिर्फ़ डाउनलोड किए गए एपिसोड ही खुलेंगे।", + "filter_hint": "एपिसोड नंबर या टाइटल", + "all_groups": "सभी ग्रुप", + "season_n": "सीज़न {}", + "specials": "स्पेशल", + "filter_none": "इस ब्लॉक में “{query}” से मेल खाता कोई एपिसोड नहीं", + "jump_to": "एपिसोड {n} पर जाएँ", + "jump_loads": "{range} लोड होंगे", + "jump_missing": "एपिसोड {n} इस पेज पर नहीं है", + "range_of": "{total} में से एपिसोड {from}–{to}" + }, + "shorts": { + "refresh": "रीफ़्रेश करें", + "opening": "खुल रहा है...", + "content_unavailable": "कंटेंट उपलब्ध नहीं", + "loading": "शॉर्ट्स लोड हो रहे हैं...", + "empty_title": "अभी कोई शॉर्ट्स नहीं", + "empty_subtitle": "शॉर्ट क्लिप उपलब्ध होते ही यहाँ दिखेंगी।", + "error_title": "शॉर्ट्स लोड नहीं हो पाए", + "video_unavailable": "वीडियो उपलब्ध नहीं", + "speed_2x": "2x स्पीड", + "report_submitted": "रिपोर्ट भेज दी गई, धन्यवाद", + "watch_full_movie": "पूरी फ़िल्म देखें", + "browse_movies": "फ़िल्में ब्राउज़ करें", + "sign_in_to_like": "लाइक करने के लिए कृपया साइन इन करें" + }, + "comments": { + "please_sign_in": "कृपया पहले साइन इन करें", + "report_submitted": "रिपोर्ट भेज दी गई, धन्यवाद", + "edited": "· बदला गया", + "like": "लाइक", + "reply": "जवाब दें", + "hide": "छिपाएँ", + "replies_one": "1 जवाब", + "replies_other": "{} जवाब", + "report": "रिपोर्ट करें", + "edit_hint": "अपना कमेंट बदलें...", + "reply_hint": "जवाब लिखें...", + "comment_hint": "कमेंट लिखें...", + "editing": "बदल रहे हैं", + "replying_to": "{} को जवाब दे रहे हैं", + "sign_in_to_comment": "कमेंट करने के लिए साइन इन करें", + "empty_title": "अभी कोई कमेंट नहीं", + "empty_subtitle": "अपनी राय शेयर करने वाले पहले व्यक्ति बनें", + "count_one": "1 कमेंट", + "count_other": "{} कमेंट", + "show_more": "और दिखाएँ", + "delete_confirm_title": "यह कमेंट हटाएँ?", + "delete_confirm_body": "यह कार्रवाई वापस नहीं ली जा सकती।", + "sign_in_prompt": "कमेंट करने के लिए साइन इन करें", + "spoiler": "स्पॉइलर", + "spoiler_hidden": "स्पॉइलर · देखने के लिए टैप करें", + "unwatched_hidden": "अभी तक नहीं देखा · देखने के लिए टैप करें" + }, + "downloads": { + "view_titles": "टाइटल", + "view_files": "सभी डाउनलोड", + "titles_empty_title": "अभी कुछ पूरा नहीं हुआ", + "titles_empty_subtitle": "किसी टाइटल का कोई डाउनलोड पूरा होते ही वह यहाँ दिखेगा। कतार “सभी डाउनलोड” में है।", + "title_movie": "फ़िल्म", + "title_episodes_n": "एपिसोड: {}", + "title_chapters_n": "चैप्टर: {}", + "title_actions": "और विकल्प", + "open_title": "टाइटल खोलें", + "relink": "दूसरे सोर्स पर ले जाएँ", + "relink_short": "फ़ाइलें और आपकी प्रोग्रेस बनी रहेंगी", + "relink_desc": "चुनें कि अब यह टाइटल कहाँ से आए। डाउनलोड इसी डिवाइस पर रहेंगे और आपकी प्रोग्रेस बनी रहेगी।", + "relink_searching": "आपके दूसरे सोर्स पर {} खोजा जा रहा है…", + "relink_none": "आपके किसी भी दूसरे सोर्स पर यह टाइटल नहीं है।", + "relink_guess": "पक्का मिलान नहीं — पहले टाइटल जाँच लें", + "relink_failed": "{} से एपिसोड लिस्ट लोड नहीं हो पाई।", + "relink_no_match": "कोई भी डाउनलोड {} के किसी एपिसोड से मेल नहीं खाता।", + "relink_busy": "कुछ डाउनलोड अभी चल रहे हैं। उन्हें पूरा होने दें या रोक दें, फिर दोबारा कोशिश करें।", + "relink_confirm_title": "{} पर ले जाएँ?", + "relink_confirm_body": "{total} में से {matched} डाउनलोड वहाँ के एपिसोड से मेल खाते हैं। उनकी फ़ाइलें और आपकी प्रोग्रेस बनी रहेंगी।", + "relink_confirm_left": "मौजूदा सोर्स पर ही रहेंगे: {}।", + "relink_action": "ले जाएँ", + "relink_done": "डाउनलोड ले जाए गए: {}।", + "relink_done_left": "पुराने सोर्स पर रह गए: {}।", + "delete_downloads": "डाउनलोड हटाएँ", + "delete_downloads_confirm": "{title} के सभी डाउनलोड ({count}) हटाएँ?", + "offline_not_downloaded": "यह डाउनलोड नहीं है, और सोर्स तक पहुँचा नहीं जा सकता।", + "delete_all_title": "सभी डाउनलोड हटाएँ?", + "file_not_found": "फ़ाइल नहीं मिली", + "clear_all": "सभी साफ़ करें", + "segments_progress": "{} / {} सेगमेंट", + "downloaded_amount": "{} डाउनलोड हुआ", + "pages_count": "{} पेज", + "downloaded": "डाउनलोड हो गया", + "failed": "विफल", + "pending": "बाकी", + "remove": "हटाएँ", + "empty_title": "अभी कोई डाउनलोड नहीं", + "empty_subtitle": "डाउनलोड किए गए वीडियो यहाँ दिखेंगे", + "paused": "रुका हुआ", + "wifi_only": "सिर्फ़ Wi‑Fi पर डाउनलोड करें", + "wifi_only_desc": "कतार के डाउनलोड तब तक रुके रहेंगे जब तक आप Wi‑Fi पर नहीं आ जाते", + "waiting_for_wifi": "Wi‑Fi का इंतज़ार — डाउनलोड अपने आप फिर शुरू हो जाएँगे", + "export": "डाउनलोड में सेव करें", + "export_hint": "फ़ाइल दूसरे ऐप को दिखने लगती है", + "export_running": "सेव हो रहा है…", + "export_progress": "EPUB बन रही है… {}/{} चैप्टर", + "export_done": "{} में सेव हो गया", + "export_failed": "डाउनलोड में सेव नहीं हो पाया", + "downloading": "डाउनलोड हो रहा है", + "missing": "फ़ाइल नहीं मिल रही — दोबारा डाउनलोड करने के लिए टैप करें", + "redownload_started": "फिर से डाउनलोड हो रहा है…", + "file_missing_retry": "वह फ़ाइल अब नहीं है। उसे फिर से डाउनलोड किया जा रहा है।", + "download_again": "फिर से डाउनलोड करें", + "pause": "रोकें", + "resume": "फिर शुरू करें", + "retry_all": "विफल हुए सभी डाउनलोड फिर से आज़माएँ", + "sort_by": "सॉर्ट करें", + "sort_newest": "सबसे नए पहले", + "sort_title": "टाइटल", + "sort_size": "सबसे बड़े पहले", + "filter_all": "सभी", + "filter_active": "चल रहे हैं", + "filter_done": "पूरे हुए", + "filter_problems": "ध्यान देने लायक", + "filter_empty_title": "इस फ़िल्टर से कुछ मेल नहीं खाता", + "filter_empty_subtitle": "आपके डाउनलोड अब भी यहीं हैं — बस यह व्यू सीमित है।", + "filter_clear": "सभी डाउनलोड दिखाएँ", + "storage_used": "डाउनलोड ने {} इस्तेमाल किया", + "storage_free": "{} खाली", + "orphans_found": "रद्द किए गए डाउनलोड की बची हुई फ़ाइलों में {}", + "orphans_clean": "साफ़ करें", + "group_summary": "{}/{} डाउनलोड हुए", + "group_problems": "ध्यान देने लायक", + "error": { + "network": "कनेक्शन टूट गया", + "refused": "सोर्स ने डाउनलोड से मना कर दिया — इसे एक बार चलाएँ, फिर दोबारा कोशिश करें", + "gone": "फ़ाइल अब सोर्स पर नहीं है", + "no_space": "इस डिवाइस पर पर्याप्त जगह नहीं है", + "not_media": "सोर्स ने वीडियो की जगह वेब पेज भेजा", + "incomplete": "डाउनलोड अधूरा रह गया", + "unknown": "डाउनलोड विफल रहा" + }, + "location": "डाउनलोड कहाँ रखे जाएँ", + "location_hint": "सिर्फ़ वे जगहें जहाँ ऐप बिना अनुमति के सेव कर सकता है। सेटिंग बदलने पर डाउनलोड भी साथ चले जाते हैं; जब तक हर फ़ाइल पहुँच नहीं जाती, कुछ भी हटाया नहीं जाता।", + "location_internal": "फ़ोन स्टोरेज (ऐप)", + "location_external": "फ़ोन स्टोरेज", + "location_sdcard": "SD कार्ड", + "location_moved": "डाउनलोड ले जाए गए", + "location_unchanged": "पहले से वहीं हैं", + "location_failed": "डाउनलोड नहीं ले जाए जा सके — वे अब भी वहीं हैं जहाँ पहले थे", + "cooldown": "डाउनलोड के बीच विराम", + "cooldown_desc": "कुछ साइटें उस क्लाइंट को ब्लॉक कर देती हैं जो एक के बाद एक फ़ाइल शुरू करता है। बीच का अंतर इसे एक धीमी कतार बना देता है।", + "cooldown_off": "कोई विराम नहीं", + "cooldown_n": "{} सेकंड" + }, + "history": { + "clear_title": "हिस्ट्री साफ़ करें", + "clear_confirm": "क्या आप वाकई देखने की अपनी हिस्ट्री साफ़ करना चाहते हैं?", + "clear": "साफ़ करें", + "clear_all": "सभी साफ़ करें", + "remove": "हटाएँ", + "empty_title": "देखने की अभी कोई हिस्ट्री नहीं", + "empty_subtitle": "देखना शुरू करें, आपकी हिस्ट्री यहाँ दिखेगी" + }, + "main": { + "shorts_refresh_title": "शॉर्ट्स रीफ़्रेश", + "shorts_refresh_body": "शॉर्ट्स टैब पर फिर से टैप करें। फ़ीड रीफ़्रेश होगी और नए वीडियो लोड होंगे।", + "double_tap": "डबल टैप करें", + "tap_again": "फिर से टैप करें" + }, + "reports": { + "title": "रिपोर्ट करें", + "reason_spam": "स्पैम", + "reason_abuse": "उत्पीड़न", + "reason_sexual": "यौन कंटेंट", + "reason_violence": "हिंसा", + "reason_copyright": "कॉपीराइट", + "reason_other": "अन्य", + "note_hint": "अतिरिक्त नोट (वैकल्पिक)", + "submit": "भेजें" + }, + "ios": { + "sources_title": "एक्सटेंशन सोर्स", + "how_it_works_desc": "iOS के लिए Sozo में अपने CloudStream, Aniyomi और मांगा सोर्स इस्तेमाल करें। सोर्स आपके Android फ़ोन पर चलते हैं; यह डिवाइस उन्हें सिर्फ़ दिखाता है।", + "step_client_same_wifi": "इस डिवाइस और अपने Android फ़ोन को एक ही Wi‑Fi नेटवर्क पर रखें।" + }, + "trivia": { + "fan_test_title": "फ़ैन टेस्ट", + "fan_test_subtitle": "साबित करें कि आप सबसे बड़े फ़ैन हैं", + "hub_tagline": "आप सिनेमा को वाकई कितना जानते हैं?", + "view_leaderboards": "लीडरबोर्ड देखें", + "my_daily_rank": "मेरी आज की रैंक", + "rank_value": "#{}", + "unranked": "कोई रैंक नहीं", + "points_value": "{} पॉइंट", + "popular_now": "अभी लोकप्रिय", + "results": "नतीजे", + "searching": "खोज रहे हैं…", + "search_cast_hint": "एक्टर खोजें", + "no_results": "कोई नतीजा नहीं", + "no_results_hint": "कोई दूसरा नाम आज़माएँ", + "something_wrong": "कुछ गड़बड़ हो गई", + "filmography": "फ़िल्मोग्राफ़ी", + "n_titles": "{count} टाइटल", + "start_fan_test": "फ़ैन टेस्ट शुरू करें", + "challenge_friend": "दोस्त को चैलेंज करें", + "top_fans": "टॉप फ़ैन", + "no_fans_yet": "अभी कोई फ़ैन नहीं", + "be_first_fan": "पहले फ़ैन बनें", + "fans_meta": "{rounds} राउंड · औसत फ़ैंडम {fandom}%", + "you": "आप", + "you_rank": "आप · #{rank}", + "best_score": "बेस्ट {score}", + "fandom": "फ़ैंडम", + "challenge_share_text": "क्या आप {name} के मामले में मुझसे बड़े फ़ैन निकल सकते हैं? Sozo पर फ़ैन टेस्ट दें!", + "scope_daily": "रोज़ाना", + "scope_weekly": "साप्ताहिक", + "scope_all_time": "अब तक के", + "scope_friends": "दोस्त", + "leaderboard": "लीडरबोर्ड", + "no_friends_board": "बोर्ड पर अभी कोई दोस्त नहीं", + "no_scores_yet": "अभी कोई स्कोर नहीं", + "correct_of": "{count} सही", + "forfeit_title": "गेम छोड़ें?", + "forfeit_body": "इस राउंड की आपकी प्रोग्रेस चली जाएगी।", + "keep_playing": "खेलते रहें", + "forfeit_confirm": "छोड़ें", + "correct": "सही", + "the_answer": "जवाब", + "watch_full": "पूरी फ़िल्म देखें", + "building_round": "आपका राउंड तैयार हो रहा है…", + "round_failed": "राउंड शुरू नहीं हो पाया", + "go_back": "वापस जाएँ", + "fan_test_done": "फ़ैन टेस्ट पूरा हुआ", + "round_done": "राउंड पूरा हुआ", + "score": "स्कोर", + "rank": "रैंक", + "play_again": "फिर से खेलें", + "share_result": "नतीजा शेयर करें", + "share_text": "मैंने अभी Sozo पर Buff खेला!", + "challenge_text": "लगता है आप फ़िल्मों को मुझसे बेहतर जानते हैं? Sozo पर Buff में मुझे चैलेंज करें!", + "share_tagline": "Sozo पर Buff खेलें", + "challenge_from": "{name} का चैलेंज", + "same_clips_head_to_head": "वही क्लिप, आमने-सामने", + "score_to_beat": "हराने के लिए स्कोर", + "play_challenge": "चैलेंज खेलें", + "challenge_not_found": "चैलेंज नहीं मिला", + "no_actors_yet": "अभी कोई एक्टर नहीं", + "no_actors_yet_body": "Buff की तैयारी चल रही है। क्लिप एक-एक करके जाँची जाती हैं — जैसे ही किसी एक्टर की क्लिप मंज़ूर होंगी, वह यहाँ दिखेगा।", + "actor_not_ready": "यह एक्टर अभी तैयार नहीं है", + "actor_not_ready_body": "एक राउंड के लिए अभी काफ़ी मंज़ूर क्लिप नहीं हैं। कोई दूसरा एक्टर आज़माएँ।", + "round_short_note": "इस राउंड में {count} क्लिप थीं — इस एक्टर की अब तक की सभी मंज़ूर क्लिप।", + "todays_top": "आज के टॉप", + "how_it_works": "Buff कैसे काम करता है", + "how_step_1": "कोई एक्टर चुनें", + "how_step_2": "एक छोटी क्लिप देखें", + "how_step_3": "फ़िल्म का नाम बताएँ" + }, + "outage": { + "body_with_local": "Sozo सर्वर तक पहुँचा नहीं जा सकता। आपके डिवाइस के {} सोर्स अब भी काम करते हैं।", + "body_no_local": "Sozo सर्वर तक पहुँचा नहीं जा सकता। हमारे API के बिना ब्राउज़ करते रहने के लिए कोई CloudStream, Aniyomi या मांगा सोर्स जोड़ें।", + "pick_source": "सोर्स चुनें" + }, + "link_tv": { + "title": "टीवी लिंक करें", + "tile_subtitle": "अपने टीवी का QR स्कैन करें या उसका कोड टाइप करें", + "subtitle": "अपने टीवी पर दिख रहा QR कोड स्कैन करें, या उसके नीचे दिखाया गया 8 अक्षरों का कोड टाइप करें।", + "scan_button": "QR कोड स्कैन करें", + "scan_hint": "कैमरे को टीवी स्क्रीन पर दिख रहे QR कोड की ओर करें", + "manual_title": "या कोड डालें", + "confirm": "पुष्टि करें", + "approved": "टीवी लिंक हो गया", + "approved_named": "“{}” लिंक हो गया", + "approved_hint": "टीवी कुछ ही सेकंड में आपके अकाउंट में साइन इन हो जाएगा।", + "linked_devices": "लिंक किए गए डिवाइस", + "no_devices": "अभी कोई डिवाइस लिंक नहीं है", + "unnamed_device": "बिना नाम का डिवाइस", + "last_seen": "आख़िरी बार देखा गया: {}", + "unlink_title": "डिवाइस अनलिंक करें", + "unlink_message": "यह डिवाइस लॉग आउट हो जाएगा और दोबारा लिंक करने के लिए नया QR कोड चाहिए होगा।", + "unlink_confirm": "अनलिंक करें", + "camera_denied": "कैमरे की अनुमति नहीं मिली। इसे सेटिंग्स में चालू करें, या इसकी जगह कोड टाइप करें।", + "camera_error": "कैमरा शुरू नहीं हो पाया।", + "error_bad_code": "कोड 8 अक्षरों का होना चाहिए।", + "error_not_found": "वह कोड नहीं मिला। टीवी पर नया कोड लें।", + "error_expired": "कोड की समय-सीमा ख़त्म हो गई है। टीवी पर रीफ़्रेश दबाएँ।", + "error_already_used": "वह कोड पहले ही इस्तेमाल हो चुका है।", + "error_unauthorized": "कृपया पहले साइन इन करें।", + "error_rate_limited": "बहुत ज़्यादा कोशिशें हो गईं। थोड़ी देर बाद फिर से कोशिश करें।", + "error_network": "इंटरनेट कनेक्शन नहीं है।", + "error_generic": "कुछ गड़बड़ हो गई।" + }, + "anilist": { + "connections_title": "कनेक्शन", + "connect": "AniList कनेक्ट करें", + "disconnect": "डिसकनेक्ट करें", + "disconnect_confirm": "आपका AniList अकाउंट डिसकनेक्ट हो जाएगा और लिंक किए गए टाइटल भुला दिए जाएँगे। आपकी लिस्ट AniList पर बनी रहेगी।", + "not_connected": "कनेक्ट नहीं है", + "connect_explainer": "AniList कनेक्ट करें, फिर आपके देखे हुए एपिसोड अपने आप मार्क होंगे और आपकी लिस्ट यहाँ दिखेगी।", + "connected_explainer": "देखे गए एपिसोड आपकी AniList लिस्ट में लिखे जाते हैं। कनेक्शन आपके अकाउंट पर सेव है, इसलिए आपका टीवी और दूसरे डिवाइस दोबारा साइन इन करने को नहीं कहेंगे।", + "connect_prompt": "अपनी AniList लाइब्रेरी यहाँ देखने के लिए अपना अकाउंट कनेक्ट करें।", + "upcoming_connect_prompt": "AniList कनेक्ट करें और देखें कि आपके फ़ॉलो किए गए शो के अगले एपिसोड कब प्रसारित होंगे।", + "sign_in_first": "पहले अपने Sozo अकाउंट में साइन इन करें", + "browser_failed": "ब्राउज़र नहीं खुल पाया", + "open_library": "मेरी AniList लाइब्रेरी", + "linked_titles": "लिंक किए गए टाइटल", + "linked_titles_count": "{} टाइटल लिंक हैं", + "linked_titles_none": "अभी तक कुछ लिंक नहीं है", + "unlink": "अनलिंक करें", + "unlinked": "{} अनलिंक हो गया", + "auto_matched": "ऑटोमैटिक", + "privacy_note": "आपका AniList टोकन आपके Sozo अकाउंट पर सेव रहता है। ऐप सिर्फ़ आपके देखे हुए एपिसोड का नंबर ही लिखता है।", + "status_label": "स्टेटस", + "status_current": "देख रहे हैं", + "status_planning": "प्लानिंग", + "status_completed": "पूरा हुआ", + "status_paused": "रुका हुआ", + "status_dropped": "छोड़ दिया", + "status_repeating": "दोबारा देख रहे हैं", + "episodes_watched": "एपिसोड देखे", + "chapters_read": "चैप्टर पढ़े", + "empty_status": "आपकी \"{}\" लिस्ट खाली है", + "behind_n": "{} नए", + "mark_episode_watched": "एपिसोड {} को देखा हुआ मार्क करें", + "mark_chapter_read": "चैप्टर {} को पढ़ा हुआ मार्क करें", + "find_in_sources": "सोर्स में खोजें", + "find_in_sources_hint": "इस टाइटल को सभी सोर्स में खोजें", + "open_on_anilist": "AniList पर खोलें", + "refresh": "रीफ़्रेश करें", + "retry": "फिर से कोशिश करें", + "upcoming_title": "आने वाले", + "upcoming_empty": "आपके फ़ॉलो किए गए किसी भी शो का अगला एपिसोड अभी घोषित नहीं हुआ है।", + "today": "आज", + "tomorrow": "कल", + "episode_n": "एपिसोड {}", + "aired": "प्रसारित", + "in_days": "{} दिन में", + "in_hours": "{} घंटे में", + "in_minutes": "{} मिनट में", + "link_title": "AniList से लिंक करें", + "link_hint": "चुनें कि यह टाइटल AniList का कौन-सा एनिमे है।", + "link_search_hint": "एनिमे का नाम", + "link_no_results": "कुछ नहीं मिला", + "linked_to": "{} से लिंक है", + "tracking_on": "AniList पर ट्रैक हो रहा है", + "track_on_anilist": "AniList पर ट्रैक करें", + "n_episodes_short": "{} एपि", + "n_chapters_short": "{} चै", + "open_anilist": "AniList", + "connect_tagline": "आप जो देखते हैं, उसे अपने आप ट्रैक करें", + "connect_short": "कनेक्ट करें", + "calendar_title": "प्रसारण कैलेंडर", + "calendar_all": "सभी", + "calendar_mine": "मेरी लिस्ट", + "calendar_episode": "एपिसोड {episode}", + "calendar_empty": "इस दिन कुछ भी प्रसारित नहीं हो रहा।", + "calendar_empty_mine": "इस दिन आपकी लिस्ट से कुछ भी प्रसारित नहीं हो रहा।", + "calendar_open": "प्रसारण कैलेंडर", + "calendar_error": "प्रसारण शेड्यूल लोड नहीं हो पाया।", + "calendar_rate_limited": "AniList अभी व्यस्त है। थोड़ी देर में फिर से कोशिश करें।", + "calendar_list_error": "आपकी AniList लिस्ट लोड नहीं हो पाई।", + "calendar_now": "अभी", + "calendar_count": "{count} प्रसारण", + "calendar_count_mine": "{total} में से {mine} आपके", + "calendar_episode_range": "एपिसोड {from}-{to}", + "calendar_add_planning": "प्लानिंग में जोड़ें", + "calendar_added_planning": "आपकी प्लानिंग लिस्ट में जोड़ दिया गया", + "calendar_add_failed": "इसे आपकी लिस्ट में नहीं जोड़ा जा सका", + "calendar_already_on_list": "पहले से आपकी लिस्ट में है", + "browse_title": "डिस्कवर", + "browse_trending": "ट्रेंडिंग", + "browse_season": "यह सीज़न", + "browse_popular": "अब तक के सबसे लोकप्रिय", + "browse_upcoming": "जल्द आ रहे हैं", + "browse_empty": "यहाँ दिखाने के लिए कुछ नहीं है।", + "browse_failed": "AniList से संपर्क नहीं हो पाया।", + "episodes_count": "{count} एपिसोड", + "reminder_body": "एपिसोड {episode} 10 मिनट में प्रसारित होगा", + "reminders_on": "रिमाइंडर चालू — एपिसोड प्रसारित होने से पहले और होते समय", + "reminders_off": "एपिसोड रिमाइंडर बंद हैं", + "connect_first": "पहले AniList कनेक्ट करें", + "save_failed": "AniList में सेव नहीं हो पाया", + "search_in_source": "एक सोर्स में खोजें", + "no_sources": "अभी तक कोई सोर्स इंस्टॉल नहीं है", + "released_body": "एपिसोड {episode} आ गया है", + "remove_entry": "मेरी लिस्ट से हटाएँ", + "remove_confirm": "\"{}\" को अपनी AniList से हटाएँ? इसकी आपकी प्रोग्रेस भी डिलीट हो जाएगी।", + "removed": "AniList से हटा दिया गया", + "retry_in": "{} सेकंड में फिर से कोशिश करें", + "kind_anime": "एनिमे", + "kind_manga": "मांगा", + "kind_novel": "नॉवेल", + "pending_updates": "{} प्रोग्रेस अपडेट भेजे जाने बाकी हैं", + "pending_updates_note": "कनेक्शन वापस आने पर अपने आप भेज दिए जाएँगे। अभी भेजने के लिए टैप करें।", + "pending_sending": "भेजा जा रहा है…", + "pending_all_sent": "{} भेज दिए गए। अब कुछ भी बाकी नहीं है।", + "pending_some_left": "{} अभी बाकी हैं — ये अपने आप भेज दिए जाएँगे।" + }, + "mal": { + "connect": "MyAnimeList कनेक्ट करें", + "disconnect": "MyAnimeList डिसकनेक्ट करें", + "disconnect_confirm": "आपका MyAnimeList अकाउंट डिसकनेक्ट हो जाएगा और मैच किए गए टाइटल भुला दिए जाएँगे। आपकी लिस्ट MyAnimeList पर बनी रहेगी।", + "connect_explainer": "MyAnimeList कनेक्ट करें, फिर आपके देखे हुए एपिसोड अपने आप मार्क होंगे।", + "connected_explainer": "देखे गए एपिसोड आपकी MyAnimeList में लिखे जाते हैं। कनेक्शन आपके अकाउंट पर सेव है, इसलिए आपका टीवी और दूसरे डिवाइस दोबारा साइन इन करने को नहीं कहेंगे।", + "not_configured": "MyAnimeList अभी सर्वर पर कॉन्फ़िगर नहीं है", + "matching_title": "टाइटल कैसे मैच होते हैं", + "matching_note": "मैचिंग AniList के ज़रिए होती है, जिसमें उसी एंट्री का MyAnimeList id भी होता है। एक बार टाइटल लिंक करें और दोनों ट्रैकर उसे फ़ॉलो करेंगे।", + "privacy_note": "आपके AniList और MyAnimeList टोकन आपके Sozo अकाउंट पर सेव रहते हैं। ऐप सिर्फ़ आपके देखे हुए एपिसोड का नंबर ही लिखता है।", + "tracking_on": "MyAnimeList पर ट्रैक हो रहा है", + "save_failed": "MyAnimeList में सेव नहीं हो पाया", + "open_library": "मेरी MyAnimeList", + "linked_titles": "लिंक किए गए टाइटल", + "connect_prompt": "अपनी MyAnimeList यहाँ देखने के लिए अपना अकाउंट कनेक्ट करें।", + "connect_first": "पहले MyAnimeList कनेक्ट करें", + "search_failed": "MyAnimeList से संपर्क नहीं हो पाया।", + "link_title": "MyAnimeList से लिंक करें", + "link_hint": "चुनें कि यह टाइटल MyAnimeList का कौन-सा एनिमे है।", + "linked_to": "{} से लिंक है", + "track": "MyAnimeList पर ट्रैक करें", + "tracked": "MyAnimeList पर ट्रैक हो रहा है", + "score_label": "स्कोर", + "remove_entry": "मेरी लिस्ट से हटाएँ", + "remove_confirm": "\"{}\" को अपनी MyAnimeList से हटाएँ? इसकी आपकी प्रोग्रेस भी डिलीट हो जाएगी।", + "removed": "MyAnimeList से हटा दिया गया", + "rewatching_note": "आप इसे दोबारा देख रहे हैं। Sozo स्टेटस को नहीं छूता, ताकि आपका दोबारा देखना ख़त्म न हो जाए।" + }, + "tracker": { + "title": "ट्रैकिंग", + "tab_following": "फ़ॉलोइंग", + "tab_upcoming": "आने वाले", + "following_empty": "किसी सीरीज़ को फ़ॉलो करें और नए एपिसोड आने पर नोटिफ़िकेशन पाएँ।", + "new_episodes_found": "{} टाइटल में नए एपिसोड आए हैं", + "no_new_episodes": "कोई नया एपिसोड नहीं", + "n_episodes": "{} एपिसोड", + "not_checked": "अभी चेक नहीं किया गया", + "unfollow": "अनफ़ॉलो करें", + "unfollowed": "{} अनफ़ॉलो किया गया", + "undo": "वापस लें", + "new_episode_one": "एपिसोड {} आ गया है", + "new_episodes_n": "{} नए — एपिसोड {} तक" + }, + "remote": { + "title": "टीवी रिमोट", + "no_devices": "अभी तक कोई टीवी लिंक नहीं है। पहले टीवी ऐप से एक टीवी पेयर करें।", + "load_failed": "आपके टीवी लोड नहीं हो पाए।", + "tv_offline": "टीवी ऑफ़लाइन है", + "idle": "कुछ नहीं चल रहा", + "command_failed": "यह टीवी तक नहीं पहुँचा", + "search_on_tv": "टीवी पर खोजें", + "search_hint": "यहाँ टाइप करें, खोज टीवी पर चलेगी", + "back": "वापस", + "home": "होम", + "open_on_tv": "टीवी पर चलाएँ", + "sent_to_tv": "{device} पर भेजा गया", + "connected_to": "{device} से कनेक्टेड", + "not_connected_to": "{device} कनेक्ट नहीं है", + "turn_tv_on": "कंट्रोल करने के लिए टीवी पर Sozo खोलें", + "retry": "फिर से कोशिश करें", + "navigation": "नेविगेशन", + "playback": "प्लेबैक", + "pick_device": "कौन-सा टीवी?", + "device_online": "ऑनलाइन", + "device_offline": "ऑफ़लाइन — उस पर Sozo खोलें" + }, + "live_tv": { + "channels": "चैनल", + "all_channels": "सभी चैनल", + "title": "लाइव टीवी", + "favourites": "पसंदीदा", + "recent": "हाल ही में देखे गए", + "categories": "कैटेगरी", + "countries": "देश", + "results": "खोज के नतीजे", + "search_hint": "चैनल का नाम…", + "search_in": "{folder} में खोजें…", + "no_match": "इससे मेल खाता कोई चैनल नहीं है।", + "empty": "अभी तक कोई चैनल नहीं है।", + "load_failed": "चैनल लोड नहीं हो पाए।", + "retry": "फिर से कोशिश करें", + "now": "अभी", + "next": "इसके बाद", + "guide": "टीवी गाइड", + "no_guide": "इस चैनल की कोई गाइड नहीं है।", + "watch": "देखें", + "favourite_add": "पसंदीदा में जोड़ें", + "favourite_remove": "पसंदीदा से हटाएँ", + "hint_long_press": "गाइड और पसंदीदा के लिए चैनल को देर तक दबाएँ।", + "channel_count": { + "one": "{} चैनल", + "other": "{} चैनल" + }, + "up_next": "इसके बाद", + "minutes_left": "{} मिनट बाकी", + "last_opened": "आख़िरी बार खोला गया चैनल", + "recent_scopes": "हाल ही में खोले गए" + }, + "time": { + "now": "अभी-अभी", + "minutes": "{} मि पहले", + "hours": "{} घं पहले", + "days": "{} दिन पहले" + }, + "user_lists": { + "title": "मेरी लिस्ट", + "remove": "हटाएँ", + "empty_title": "अभी तक कुछ सेव नहीं किया", + "empty_subtitle": "आपके सेव किए गए टाइटल यहाँ दिखेंगे।", + "load_failed": "यह सूची लोड नहीं हो सकी।" + }, + "torrent": { + "title": "टोरेंट", + "search_hint": "टोरेंट खोजें…", + "empty_hint": "Nyaa, Tokyo Toshokan और nekoBT में एक साथ खोजें।", + "searching": "ट्रैकर में खोजा जा रहा है…", + "no_results": "चालू ट्रैकर पर कुछ नहीं मिला", + "no_results_hint": "छोटा टाइटल आज़माएँ या फ़िल्टर ढीले करें।", + "results_n": "{} नतीजे", + "seeders": "सीडर", + "leechers": "लीचर", + "unknown_swarm": "स्वॉर्म का साइज़ अज्ञात", + "dead": "कोई सीडर नहीं", + "dead_warning": "इस टोरेंट का कोई सीडर नहीं है और इसे स्ट्रीम नहीं किया जा सकता।", + "trusted": "भरोसेमंद", + "remake": "दोबारा अपलोड", + "filters": "फ़िल्टर", + "category": "कैटेगरी", + "cat_all": "सभी", + "cat_anime": "एनिमे", + "cat_english": "अंग्रेज़ी सब", + "cat_non_english": "दूसरी भाषाएँ", + "cat_raw": "रॉ", + "cat_amv": "AMV", + "cat_live_action": "लाइव एक्शन", + "sort": "क्रम से लगाएँ", + "sort_seeders": "सीडर", + "sort_date": "सबसे नए", + "sort_size": "साइज़", + "sort_downloads": "डाउनलोड", + "min_seeders": "कम से कम सीडर", + "quality": "क्वालिटी", + "any_quality": "कोई भी", + "trusted_only": "सिर्फ़ भरोसेमंद अपलोडर", + "exclude_hardsubs": "हार्डसब वाली रिलीज़ छिपाएँ", + "exclude_mini": "मिनी-एनकोड छिपाएँ", + "exclude_remakes": "दोबारा अपलोड छिपाएँ", + "require_dual_audio": "सिर्फ़ डुअल ऑडियो", + "exclude_batches": "सीज़न पैक छिपाएँ", + "trackers": "ट्रैकर", + "reset_filters": "रीसेट करें", + "consent_title": "BitTorrent पर स्ट्रीम करें?", + "consent_body": "टोरेंट पीयर-टू-पीयर होते हैं। जब कोई टोरेंट स्ट्रीम हो रहा हो, तब स्वॉर्म के दूसरे लोग आपका IP पता और यह देख सकते हैं कि आप क्या डाउनलोड कर रहे हैं, और आपका डिवाइस भी उन्हें अपलोड करता है।\n\nअगर आपके यहाँ इससे फ़र्क़ पड़ता है, तो VPN इस्तेमाल करें।", + "consent_accept": "मुझे समझ आ गया", + "consent_remember": "दोबारा न पूछें", + "preparing": "टोरेंट इंजन शुरू हो रहा है…", + "fetching_metadata": "स्वॉर्म से फ़ाइल लिस्ट ली जा रही है…", + "metadata_timeout": "स्वॉर्म से कोई जवाब नहीं मिला। हो सकता है इस टोरेंट का कोई सीडर न बचा हो।", + "engine_failed": "टोरेंट इंजन शुरू नहीं हो पाया।", + "unsupported": "टोरेंट स्ट्रीमिंग सिर्फ़ Android पर उपलब्ध है।", + "pick_file": "कोई फ़ाइल चुनें", + "pick_file_hint": "इस टोरेंट में {} वीडियो हैं।", + "play": "चलाएँ", + "copy_magnet": "मैग्नेट लिंक कॉपी करें", + "magnet_copied": "मैग्नेट लिंक कॉपी हो गया", + "open_external": "टोरेंट ऐप में खोलें", + "open_page": "ट्रैकर पर खोलें", + "stats_peers": "{} पीयर", + "buffering": "बफ़र हो रहा है {}%", + "still_searching": "{} ट्रैकर का जवाब आना बाकी है", + "buffering_title": "बफ़र हो रहा है…", + "no_video_in_torrent": "इस टोरेंट में चलाने लायक कोई वीडियो नहीं है।", + "tracker_failed": "{} में खोज नहीं हो पाई — नतीजे अधूरे हो सकते हैं।" + }, + "backup": { + "title": "बैकअप और रीस्टोर", + "subtitle": "देखने की हिस्ट्री, लिस्ट, सोर्स और सेटिंग्स। साइन इन शामिल नहीं हैं।", + "export": "बैकअप बनाएँ", + "export_desc": "एक फ़ाइल सेव करें, जिसे आप रख सकते हैं या ख़ुद को भेज सकते हैं", + "restore": "रीस्टोर करें", + "restore_desc": "बैकअप फ़ाइल को वापस ऐप में लाएँ", + "restore_warning": "बैकअप की वैल्यू इस डिवाइस की वैल्यू को ओवरराइट कर देंगी। जो कुछ फ़ाइल में नहीं है, वह जैसा है वैसा ही रहेगा।", + "restored": "{} वैल्यू रीस्टोर की गईं", + "not_a_backup": "यह फ़ाइल Sozo बैकअप नहीं है", + "too_new": "यह बैकअप ऐप के नए वर्शन से बनाया गया था", + "export_failed": "बैकअप नहीं बन पाया", + "restore_failed": "यह बैकअप पढ़ा नहीं जा सका", + "file_subject": "Sozo बैकअप", + "last_export": "पिछला बैकअप: {}", + "never_exported": "इस डिवाइस पर अभी तक कोई बैकअप नहीं बना है।", + "reinstalling_sources": "सोर्स उनकी रिपॉज़िटरी से दोबारा इंस्टॉल हो रहे हैं…", + "repos_added": "जोड़ी गई रिपॉज़िटरी: {}", + "plugins_installed": "इंस्टॉल किए गए प्लगइन: {}", + "prefs_restored": "रीस्टोर की गई सोर्स सेटिंग्स: {}", + "restore_done": "रीस्टोर पूरा हुआ", + "restore_partial": "रीस्टोर हुआ, पर कुछ चीज़ें छूट गईं", + "missing_sources": "ये सोर्स वापस नहीं आए:", + "failed_repos": "इन रिपॉज़िटरी तक नहीं पहुँचा जा सका:" + }, + "provider_test": { + "title": "सोर्स टेस्ट करें", + "intro": "यह असली खोज चलाता है, एक टाइटल खोलता है और CDN से शुरुआती बाइट माँगता है। नतीजे इसी डिवाइस और नेटवर्क के लिए हैं, और सिर्फ़ इस समय के।", + "summary": "{}/{} चले", + "run_all": "सभी टेस्ट करें", + "run_one": "टेस्ट करें", + "stop": "रोकें", + "stage_catalogue": "कैटलॉग", + "stage_detail": "टाइटल", + "stage_stream": "लिंक", + "stage_playback": "प्लेबैक", + "entry": "सोर्स टेस्ट करें" + }, + "catalog": { + "title": "भाषा के हिसाब से सोर्स", + "subtitle": "चारों इकोसिस्टम के सभी एक्सटेंशन सोर्स ब्राउज़ करें", + "type_all": "सभी", + "type_anime": "एनिमे", + "type_video": "फ़िल्में और सीरीज़", + "type_manga": "मांगा", + "type_novel": "नॉवेल", + "add_repo": "रिपॉज़िटरी जोड़ें", + "installed": "{} सोर्स जोड़े गए, {} से", + "install_empty": "उस रिपॉज़िटरी में इंस्टॉल करने लायक कुछ नहीं मिला।", + "install_failed": "इंस्टॉल नहीं हो पाया: {}", + "needs_js": "Dart सोर्स — यहाँ नहीं चल सकता", + "android_only": "सिर्फ़ Android", + "empty": "यहाँ अभी कुछ नहीं है। कोई दूसरी भाषा आज़माएँ या फ़िल्टर साफ़ करें।", + "unreachable": "सोर्स कैटलॉग तक नहीं पहुँचा जा सका।" + }, + "mode": { + "video": "देखें", + "manga": "मांगा", + "novel": "नॉवेल", + "none_installed": "अभी तक कोई {} सोर्स इंस्टॉल नहीं है।", + "novels_hint": "नॉवेल टेक्स्ट होते हैं, तस्वीरें नहीं — लाइट नॉवेल और वेब नॉवेल, जो मांगा वाले रीडर में ही पढ़े जाते हैं। नॉवेल सोर्स Mangayomi रिपॉज़िटरी से आते हैं: एक रिपॉज़िटरी जोड़ें और उसके सभी नॉवेल सोर्स यहाँ दिखेंगे।", + "manga_hint": "मांगा सोर्स इंस्टॉल किए जाते हैं, ऐप में पहले से नहीं होते। कोई रिपॉज़िटरी जोड़ें और उसके सभी सोर्स यहाँ दिखेंगे।" + }, + "home_rails": { + "title": "होम लेआउट", + "entry_title": "होम कस्टमाइज़ करें", + "entry_subtitle": "चुनें कि कौन-सी रो दिखें और किस क्रम में", + "hint": "क्रम बदलने के लिए खींचें। रो छिपाने के लिए आँख पर टैप करें — उसकी जगह बनी रहती है, इसलिए वापस चालू करने पर वह वहीं आ जाती है जहाँ थी।", + "hero": "फ़ीचर्ड बैनर", + "resume": "देखना जारी रखें", + "new_releases": "नए एपिसोड", + "genres": "जॉनर", + "picked_for_you": "आपके लिए चुने गए", + "live_tv": "लाइव टीवी", + "catalogue": "कैटलॉग रो", + "services": "स्ट्रीमिंग सर्विस", + "watch_services": "स्ट्रीमिंग सर्विस" + }, + "stats": { + "title": "आँकड़े", + "entry_subtitle": "देखने का समय, स्ट्रीक, समय कहाँ बीता", + "watched": "देखा", + "hours_short": "घं", + "minutes_short": "मि", + "since": "{} से गिनती जारी", + "completed": "पूरे किए", + "streak": "दिन की स्ट्रीक", + "last_days": "पिछले {} दिन", + "by_source": "सोर्स के हिसाब से", + "empty_title": "अभी तक कुछ गिना नहीं गया", + "empty_subtitle": "कुछ देखें और कुल आँकड़े यहाँ से शुरू होंगे। इनकॉग्निटो में देखा गया कुछ भी नहीं गिना जाता।" + }, + "discord": { + "sign_in": "Discord से साइन इन करें", + "sign_in_desc": "Discord के अपने लॉगिन पेज पर साइन इन करें। टोकन अपने आप ले लिया जाएगा और सिर्फ़ इसी डिवाइस पर सेव रहेगा।", + "or_paste": "या खुद टोकन पेस्ट करें", + "web_login_title": "Discord कनेक्ट करें", + "web_login_paste": "टोकन पेस्ट करें", + "web_login_paste_title": "Discord टोकन पेस्ट करें", + "web_login_paste_hint": "आपका Discord यूज़र टोकन", + "title": "Discord", + "what_it_does": "आप क्या देख रहे हैं, यह आपकी Discord प्रोफ़ाइल पर दिखता है — टाइटल, एपिसोड और आप कितना देख चुके हैं। आपके दोस्त इसे अपनी फ़्रेंड्स लिस्ट में देखते हैं।", + "enable": "Discord पर दिखाएँ", + "enable_desktop_desc": "इस कंप्यूटर पर Discord ऐप चालू होना ज़रूरी है", + "enable_mobile_desc": "आपका Discord टोकन चाहिए — पहले नीचे दी गई चेतावनी पढ़ लें", + "connected": "कनेक्टेड", + "not_connected": "कनेक्ट नहीं है", + "risk_title": "आगे बढ़ने से पहले इसे पढ़ लें", + "risk_body": "फ़ोन पर कनेक्ट करने के लिए कोई Discord ऐप नहीं होता, इसलिए Sozo को आपके अपने टोकन से आपकी तरफ़ से साइन इन करना पड़ता है। Discord इसकी इजाज़त नहीं देता: इससे आपका अकाउंट सस्पेंड हो सकता है। टोकन से आपके Discord अकाउंट का पूरा एक्सेस मिल जाता है, और Sozo इसे सिर्फ़ इसी डिवाइस पर रखता है — कभी सर्वर पर नहीं। “हटाएँ” से आप इसे कभी भी मिटा सकते हैं।", + "token_label": "Discord टोकन", + "token_hint": "अपना टोकन पेस्ट करें", + "token_saved": "एक टोकन सेव है — बदलने के लिए नया टोकन पेस्ट करें", + "token_save": "सेव करें", + "token_forget": "हटाएँ", + "brand_title": "Rich Presence", + "brand_subtitle": "आप क्या देख रहे हैं, यह आपके दोस्तों को आपकी प्रोफ़ाइल पर लाइव दिखता है", + "preview_heading": "देख रहे हैं", + "preview_title": "टाइटल यहाँ दिखेगा", + "preview_subtitle": "सीज़न और एपिसोड", + "preview_elapsed": "{} बीत चुके", + "preview_elapsed_placeholder": "बीता हुआ समय", + "row_off": "बंद", + "row_live": "आपकी प्रोफ़ाइल पर दिख रहा है", + "row_waiting": "चालू — Discord का इंतज़ार है" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — इस डिवाइस पर तस्वीर हरी या बैंगनी दिख सकती है", + "atmos": "Atmos ऑडियो — हो सकता है इस डिवाइस पर आवाज़ न आए", + "indirect_host": "यह मिरर वीडियो की जगह एक पेज खोलता है", + "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": "रिपॉज़िटरी URL", + "index_url": "इंडेक्स 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": "इस URL पर कोई एक्सटेंशन नहीं मिला।", + "none_found_js": "इस URL पर कोई 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 और मांगा सोर्स के उलट, ये 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 रिपॉज़िटरी URL से इंस्टॉल होती हैं — डाउनलोड की गई कॉपी की जगह इंडेक्स लिंक खोलें।", + "skipped_dart": "सिर्फ़ Dart वाले {} सोर्स छोड़ दिए गए — ये Mangayomi में ही बने होते हैं और यहाँ नहीं चल सकते।", + "type_manga": "मांगा", + "type_anime": "एनिमे", + "type_novel": "नॉवेल", + "settings": "सोर्स सेटिंग्स", + "settings_title": "{} सेटिंग्स", + "settings_reset": "रीसेट करें", + "settings_failed": "इस सोर्स की सेटिंग्स पढ़ी नहीं जा सकीं।", + "settings_none": "इस सोर्स की कोई सेटिंग्स नहीं हैं।", + "loading_repo": "रिपॉज़िटरी लोड हो रही है…", + "add_sources": "स्रोत जोड़ें", + "choose_sources": "स्रोत चुनें", + "select_all": "सभी चुनें", + "select_none": "कोई नहीं चुनें", + "install_selected": "इंस्टॉल करें ({})", + "already_installed": "इंस्टॉल है", + "remove": "हटाएँ", + "remove_source": "स्रोत हटाएँ", + "remove_source_confirm": "{} हटाएँ? इसकी सेटिंग्स और कैश किया गया कोड मिटा दिया जाएगा। आप इसे इसकी रिपॉज़िटरी से फिर जोड़ सकते हैं।", + "remove_repo_confirm": "यह रिपॉज़िटरी और इसके सभी स्रोत हटाएँ?", + "enable_source": "चालू करें", + "disable_source": "बंद करें", + "source_disabled": "बंद है", + "update": "अपडेट करें", + "update_all": "सभी अपडेट करें", + "updates_available": "{} अपडेट उपलब्ध", + "repo_check_failed": "{} की जाँच नहीं हो सकी: {}" + }, + "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": "क्वालिटी अज्ञात", + "single_quality": "सिर्फ़ एक क्वालिटी उपलब्ध है", + "size_unknown": "सोर्स ने साइज़ नहीं बताया", + "interface_language_note": "सिर्फ़ ऐप का इंटरफ़ेस बदलता है। फ़िल्मों के टाइटल, डिस्क्रिप्शन और सोर्स का बाक़ी कंटेंट अपनी मूल भाषा में ही रहता है।", + "continue": "जारी रखें", + "use_source": "यह सोर्स इस्तेमाल करें", + "in_use": "इस्तेमाल में", + "now_using": "अब {} इस्तेमाल हो रहा है", + "add_or_manage_sources": "सोर्स जोड़ें या मैनेज करें" + }, + "sources": { + "fail_gone": "इस सोर्स की साइट अब नहीं रही", + "fail_blocked": "साइट ऐप को ब्लॉक कर रही है", + "fail_rate_limited": "साइट रिक्वेस्ट लिमिट की वजह से मना कर रही है", + "fail_unreachable": "साइट तक नहीं पहुँचा जा सका", + "fail_incompatible": "इस सोर्स के लिए ऐप का नया वर्शन चाहिए", + "fail_outdated": "इस सोर्स को अपडेट करना होगा — इसकी साइट बदल गई है और इंस्टॉल किया गया वर्शन उसे अब पढ़ नहीं पाता।", + "fail_broken": "यह सोर्स अपनी ही वजह से विफल हुआ", + "eco_sozo": "Sozo", + "eco_cloudstream": "CloudStream", + "eco_aniyomi": "Aniyomi", + "eco_manga": "मांगा", + "eco_mangayomi": "Mangayomi", + "eco_jellyfin": "Jellyfin", + "eco_all": "सभी", + "language_not_stated": "भाषा नहीं बताई गई · {}", + "installed": "इंस्टॉल किए गए", + "section": "सोर्स", + "filter_title": "कहाँ के सोर्स दिखाएँ", + "repo_any": "कोई भी रिपॉज़िटरी", + "repo_pick": "रिपॉज़िटरी चुनें", + "filter_type": "सोर्स टाइप", + "filter_language": "भाषा", + "health_down": "डाउन", + "health_cloudflare": "Cloudflare", + "health_down_title": "यह सोर्स डाउन लगता है", + "health_cloudflare_title": "Cloudflare जाँच के पीछे", + "health_down_body": "Sozo हर रात एक्सटेंशन साइटों की जाँच करता है, और यह साइट जाँच में विफल रही। इसमें अब भी खोज होती है, बस सबसे आख़िर में और कम टाइमआउट के साथ, ताकि पता चल सके कि यह वापस आ गई है। अगर यह आपके लिए काम करता है, तो बैज हट जाएगा।", + "health_cloudflare_body": "साइट ने हमारी जाँच का जवाब Cloudflare चैलेंज से दिया। आम तौर पर यह फ़ोन से फिर भी चल जाता है। अगर सोर्स वेरिफ़िकेशन माँगे, तो उसे एक बार पूरा कर लें, फिर यह लोड हो जाना चाहिए।", + "health_reason": "वजह", + "health_reason_parked": "डोमेन पार्क है या बिक्री के लिए है", + "health_reason_dns": "डोमेन अब रिज़ॉल्व नहीं होता", + "health_reason_tls": "साइट का सिक्योरिटी सर्टिफ़िकेट ख़राब है", + "health_reason_timeout": "साइट ने समय पर जवाब नहीं दिया", + "health_reason_unreachable": "साइट तक नहीं पहुँचा जा सका", + "health_reason_http_5xx": "साइट से सर्वर की गड़बड़ियाँ आ रही हैं", + "health_reason_blocked": "Cloudflare चैलेंज ने जाँच को रोक दिया", + "health_reason_slow_response": "साइट बहुत धीरे जवाब देती है", + "health_reason_maintainer_down": "इसके मेंटेनर ने प्लगइन को डाउन मार्क किया है", + "health_reason_maintainer_slow": "इसके मेंटेनर ने प्लगइन को धीमा मार्क किया है", + "health_reason_unknown": "पिछली सर्वर जाँच में यह विफल मिला", + "health_checked": "पिछली जाँच {}", + "hide_down": "डाउन सोर्स छिपाएँ · {}", + "check_all": "सोर्स जाँचें", + "check_bad_hint": "डाउन सोर्स छिपाने से वे इन लिस्ट में नहीं दिखेंगे; दोबारा चलने लगने पर वे अपने आप वापस आ जाएँगे।", + "check_done": "सोर्स जाँच", + "check_hide_down": "डाउन सोर्स छिपाएँ", + "check_network": "ज़्यादातर सोर्स तक पहुँचा ही नहीं जा सका — यह कनेक्शन की समस्या है, सोर्स की नहीं। कुछ भी मार्क नहीं किया गया; चालू नेटवर्क पर फिर से कोशिश करें।", + "check_retrying": "विफल सोर्स फिर से जाँचे जा रहे हैं…", + "checking": "{}/{} की जाँच हो रही है…", + "d_ago": "{} दिन पहले", + "h_ago": "{} घं पहले", + "health_down_body_device": "इस फ़ोन से की गई जाँच में यह काम करता नहीं मिला। इसमें अब भी खोज होती है, बस सबसे आख़िर में, ताकि पता चल सके कि यह वापस आ गया है — और नया जवाब मिलने पर बैज हट जाता है।", + "health_reason_device_dead": "यह इस फ़ोन पर एक से ज़्यादा बार विफल रहा", + "health_reason_device_outdated": "इसका कोड अब इसकी साइट से मेल नहीं खाता — इसकी रिपॉज़िटरी से अपडेट करने पर ठीक हो जाएगा", + "just_now": "अभी-अभी", + "min_ago": "{} मि पहले", + "v_alive": "काम कर रहा है", + "v_blocked": "ब्लॉक है", + "v_dead": "काम नहीं कर रहा", + "v_empty": "ख़ाली", + "v_empty_body": "इस सोर्स ने जवाब दिया, लेकिन इसका कैटलॉग ख़ाली आया। अक्सर जब कोई साइट ऐप को बाहर कर देती है, तो ऐसा ही दिखता है।", + "v_failing_body": "इस सोर्स की पिछली जाँच विफल रही। एक बार विफल होना कुछ देर की दिक्कत भी हो सकती है — पता करने के लिए फिर से जाँचें।", + "v_failing": "जवाब नहीं दे रहा", + "v_outdated": "अपडेट ज़रूरी", + "v_unknown": "अभी जाँचा नहीं गया", + "auto_sweep": "Wi-Fi पर थोड़े-थोड़े करके जाँचें", + "check_now": "अभी जाँचें", + "check_shown": "जो दिख रहे हैं, सभी · {}", + "check_stale": "नए और पुरानी जाँच वाले · {}", + "check_troubled": "डाउन और विफल · {}", + "checking_one": "जाँच हो रही है…", + "never_checked": "इस फ़ोन पर अभी जाँचा नहीं गया", + "problems_only": "सिर्फ़ समस्याएँ दिखाएँ" + }, + "settings": { + "dns_title": "DNS", + "dns_subtitle": "सोर्स के एड्रेस किसी दूसरे प्रोवाइडर से रिज़ॉल्व करें। जब कोई साइट DNS से ब्लॉक हो, तब यह काम आता है। यह VPN नहीं है — यह आपका ट्रैफ़िक नहीं छिपाता और न ही आपका IP बदलता है।", + "dns_system": "सिस्टम डिफ़ॉल्ट", + "dns_cloudflare": "Cloudflare (1.1.1.1)", + "dns_google": "Google (8.8.8.8)", + "dns_adguard": "AdGuard (बिना फ़िल्टर)", + "dns_quad9": "Quad9 (9.9.9.9)", + "dns_mullvad": "Mullvad", + "dns_fallback": "उस रिज़ॉल्वर तक नहीं पहुँचा जा सका, इसलिए सिस्टम वाला ही इस्तेमाल हो रहा है।" + }, + "source_manager": { + "profile_hint": "सोर्स चुनें, जोड़ें और मैनेज करें", + "manage_repositories": "रिपॉज़िटरी मैनेज करें", + "manage": "मैनेज करें", + "browse": "ब्राउज़ करें", + "catalog_title": "सोर्स ब्राउज़ करें", + "add_short": "जोड़ें", + "use_short": "चुनें", + "selection_hint": "सोर्स इस्तेमाल करने के लिए उस पर टैप करें। “ब्राउज़ करें” से उसके कंटेंट की झलक मिलती है।", + "recommended_repositories": "सुझाई गई रिपॉज़िटरी", + "load_more": "और लोड करें", + "added": "जोड़ा गया" + }, + "catalogue": { + "section": "कैटलॉग", + "anilist": "AniList", + "tmdb": "TMDB", + "found_on": "{} पर मिला", + "change_source": "बदलें", + "no_source": "आपके किसी भी सोर्स में यह टाइटल नहीं है। ऐसा सोर्स जोड़ें जिसमें यह हो, या दूसरे सोर्स में खोजें।", + "no_resolver": "कैटलॉग के टाइटल यहाँ नहीं खोले जा सकते।", + "find_source": "सोर्स ढूँढें", + "finding_source": "सोर्स ढूँढा जा रहा है…", + "hint_anilist": "एनिमे कैटलॉग", + "hint_tmdb": "फ़िल्में और सीरीज़", + "hint_anilist_manga": "मांगा कैटलॉग", + "hint_anilist_novel": "लाइट नॉवेल", + "miss_no_sources_of_kind": "इस तरह का कोई सोर्स इंस्टॉल नहीं है, इसलिए कुछ भी नहीं खोजा गया। ऐसा सोर्स जोड़ें जो इस तरह के टाइटल पढ़ सके।", + "miss_unreachable": "आपके सोर्स ने समय पर जवाब नहीं दिया। इससे यह पता नहीं चलता कि उनमें यह टाइटल है या नहीं — फिर से कोशिश करें।", + "miss_nothing_to_search": "यहाँ ऐसा कुछ नहीं है जिससे सोर्स खोजा जा सके।", + "approximate_source": "सबसे क़रीबी अंदाज़ा", + "approximate_source_hint": "कोई लाइट नॉवेल सोर्स इंस्टॉल नहीं है, इसलिए यह मांगा सोर्स इस्तेमाल किया गया। हो सकता है यह नॉवेल की जगह उसका अडैप्टेशन हो।" + }, + "watch": { + "services_title": "स्ट्रीमिंग सर्विस", + "services_entry_hint": "देखें कि आपके इलाक़े में Netflix, Disney+ और बाक़ी सर्विस पर क्या-क्या है", + "services_search_hint": "सर्विस खोजें", + "top_services": "यहाँ सबसे ज़्यादा इस्तेमाल होने वाली", + "all_services": "सभी सर्विस", + "no_match": "इससे कोई सर्विस मेल नहीं खाती।", + "type_movies": "फ़िल्में", + "type_series": "सीरीज़", + "empty_region_title": "यहाँ कोई सर्विस लिस्टेड नहीं", + "empty_region_subtitle": "TMDB के पास अभी इस इलाक़े की स्ट्रीमिंग लाइन-अप नहीं है।", + "region_fell_back": "आपके देश के लिए अभी कोई स्ट्रीमिंग लाइन-अप लिस्टेड नहीं है, इसलिए ये अमेरिका की सर्विस हैं।", + "load_failed": "स्ट्रीमिंग सर्विस लोड नहीं हो पाईं।" + }, + "trakt": { + "connect_title": "Trakt कनेक्ट करें", + "connect": "Trakt कनेक्ट करें", + "connect_explainer": "आप जो फ़िल्में और सीरीज़ देखते हैं, वे आपकी Trakt हिस्ट्री में जुड़ जाती हैं, और आपकी वॉचलिस्ट और रेटिंग यहाँ भी आपके साथ रहती हैं।", + "connected_explainer": "आप जो देखते हैं, वह देखते-देखते Trakt पर स्क्रॉबल हो जाता है।", + "step_open": "किसी भी डिवाइस पर trakt.tv/activate खोलें", + "step_enter": "यह कोड डालें", + "copy_code": "कॉपी करने के लिए टैप करें", + "copied": "कॉपी हो गया", + "open_activate": "trakt.tv/activate खोलें", + "waiting": "आपकी मंज़ूरी का इंतज़ार है…", + "reconnecting": "कनेक्शन टूट गया — कोशिश जारी है…", + "expires_in": "{} में ख़त्म होगा", + "code_expired": "इस कोड की समय-सीमा ख़त्म हो गई है।", + "denied": "Trakt पर एक्सेस नहीं दिया गया।", + "new_code": "नया कोड लें", + "unavailable": "अभी Trakt तक नहीं पहुँचा जा सकता।", + "connected_as": "{} के रूप में कनेक्टेड", + "disconnect": "Trakt डिस्कनेक्ट करें", + "disconnect_confirm": "आप जो देखते हैं, उसे Sozo अब Trakt पर नहीं भेजेगा। आपकी Trakt हिस्ट्री जैसी है वैसी ही रहेगी।", + "action_mark_next": "{} को देखा हुआ मार्क करें", + "action_mark_next_episode": "अगला एपिसोड देखा हुआ मार्क करें", + "action_mark_watched": "देखा हुआ मार्क करें", + "action_not_interested": "दिलचस्पी नहीं", + "action_open": "Sozo में खोलें", + "action_rate": "आपकी रेटिंग", + "action_remove_history": "हिस्ट्री से हटाएँ", + "action_remove_playback": "“देखना जारी रखें” से हटाएँ", + "action_watchlist_add": "वॉचलिस्ट में जोड़ें", + "action_watchlist_remove": "वॉचलिस्ट से हटाएँ", + "added": "आपकी Trakt वॉचलिस्ट में जोड़ा गया", + "all_caught_up": "आपने अपने सभी शो देख लिए हैं।", + "continue_watching": "देखना जारी रखें", + "empty_calendar": "अगले दो हफ़्तों में आपका कुछ भी नहीं आ रहा है।", + "empty_history": "आपकी Trakt हिस्ट्री में अभी कुछ नहीं है।", + "empty_ratings": "आपने अभी तक Trakt पर कुछ भी रेट नहीं किया है।", + "empty_recs": "अभी कोई सुझाव नहीं — कुछ टाइटल रेट करें और बाद में देखें।", + "empty_trending": "Trakt पर अभी कुछ भी ट्रेंड नहीं कर रहा है।", + "empty_watchlist": "आपकी Trakt वॉचलिस्ट ख़ाली है।", + "episodes_progress": "{}/{} एपिसोड", + "filter_all": "सभी", + "filter_movies": "फ़िल्में", + "filter_shows": "शो", + "hub_connect_prompt": "अपनी वॉचलिस्ट, हिस्ट्री, आगे क्या देखना है और जल्द क्या आने वाला है, यह सब यहीं देखने के लिए Trakt कनेक्ट करें।", + "link_action": "लिंक करें", + "link_change": "दूसरे टाइटल से लिंक करें", + "link_explainer": "“{}” के लिए Trakt टाइटल चुनें। आप यहाँ जो देखेंगे, वह उसी में गिना जाएगा।", + "link_none": "Trakt पर कुछ नहीं मिला।", + "link_season": "सीज़न", + "link_season_all": "सभी सीज़न", + "link_season_n": "सीज़न {}", + "link_title": "Trakt से लिंक करें", + "marked": "Trakt पर देखा हुआ मार्क किया गया", + "movie": "फ़िल्म", + "next_up": "आगे क्या", + "open_hub": "मेरा Trakt खोलें", + "open_hub_desc": "आगे क्या देखना है, वॉचलिस्ट, हिस्ट्री, कैलेंडर और सुझाव", + "rated": "Trakt पर {}/10 रेटिंग दी", + "rating_cleared": "रेटिंग हटा दी गई", + "removed": "हटा दिया गया", + "season_premiere": "सीज़न प्रीमियर", + "series_premiere": "सीरीज़ प्रीमियर", + "show": "शो", + "stat_episodes": "एपिसोड", + "stat_hours": "घंटे", + "stat_movies": "फ़िल्में", + "stat_ratings": "रेटिंग", + "status_episodes": "{}/{} देखे", + "status_not_watched": "नहीं देखा", + "status_watched": "देखा हुआ", + "status_watchlist": "वॉचलिस्ट में", + "tab_calendar": "कैलेंडर", + "tab_for_you": "आपके लिए", + "tab_history": "हिस्ट्री", + "tab_ratings": "रेटिंग", + "tab_trending": "ट्रेंडिंग", + "tab_up_next": "आगे क्या", + "tab_watchlist": "वॉचलिस्ट", + "today": "आज", + "tomorrow": "कल", + "yesterday": "कल" + }, + "webview": { + "back": "वापस", + "forward": "आगे", + "reload": "रीलोड करें", + "open_outside": "ब्राउज़र में खोलें" + }, + "profiles": { + "title": "प्रोफ़ाइल", + "who_watching": "कौन देख रहा है?", + "manage": "प्रोफ़ाइल मैनेज करें", + "manage_subtitle": "इस अकाउंट पर हर किसी की अपनी हिस्ट्री, मेरी लिस्ट और देखना जारी रखें होती है।", + "add": "प्रोफ़ाइल जोड़ें", + "edit": "प्रोफ़ाइल बदलें", + "new_title": "नई प्रोफ़ाइल", + "name": "नाम", + "name_hint": "प्रोफ़ाइल का नाम", + "name_required": "नाम डालें", + "name_taken": "यह नाम किसी दूसरी प्रोफ़ाइल का पहले से है।", + "avatar": "अवतार", + "color": "रंग", + "kids": "किड्स प्रोफ़ाइल", + "kids_badge": "किड्स", + "kids_desc": "18+ टाइटल और सोर्स छिपे रहते हैं, और इससे प्रोफ़ाइल मैनेज नहीं की जा सकतीं।", + "kids_default_blocked": "मुख्य प्रोफ़ाइल किड्स प्रोफ़ाइल नहीं हो सकती।", + "kids_locked_title": "किसी बड़े से पूछें", + "kids_locked_body": "किड्स प्रोफ़ाइल से प्रोफ़ाइल नहीं बदली जा सकतीं। उन्हें मैनेज करने के लिए किसी दूसरी प्रोफ़ाइल पर स्विच करें।", + "adult_kids_off": "किड्स प्रोफ़ाइल में हमेशा बंद।", + "pin": "प्रोफ़ाइल पिन", + "pin_desc": "यह प्रोफ़ाइल खुलने से पहले पिन पूछें।", + "pin_on": "चालू", + "pin_off": "बंद", + "pin_protected": "पिन", + "pin_change": "पिन बदलें", + "pin_remove": "पिन हटाएँ", + "pin_enter_title": "पिन डालें", + "pin_enter_subtitle": "{} का पिन डालें।", + "pin_create_title": "पिन चुनें", + "pin_create_subtitle": "4 अंक।", + "pin_confirm_title": "पिन की पुष्टि करें", + "pin_confirm_subtitle": "वही पिन दोबारा डालें।", + "pin_current_title": "मौजूदा पिन", + "pin_current_subtitle": "जारी रखने के लिए इस प्रोफ़ाइल का मौजूदा पिन डालें।", + "pin_mismatch": "पिन मेल नहीं खा रहे", + "pin_wrong": "ग़लत पिन", + "pin_too_many": "बहुत ज़्यादा कोशिशें हो गईं। एक मिनट बाद फिर से कोशिश करें।", + "continue": "जारी रखें", + "save": "सेव करें", + "saved": "प्रोफ़ाइल सेव हो गई", + "delete": "प्रोफ़ाइल हटाएँ", + "delete_title": "{} को हटाएँ?", + "delete_body": "इस प्रोफ़ाइल की हिस्ट्री, मेरी लिस्ट और सभी लिस्ट हर डिवाइस से हट जाएँगी। इसे वापस नहीं लाया जा सकता।", + "main_badge": "मुख्य", + "current": "अभी देख रहे हैं", + "limit": "एक अकाउंट में ज़्यादा से ज़्यादा {} प्रोफ़ाइल हो सकती हैं।", + "switch": "प्रोफ़ाइल बदलें", + "switch_short": "बदलें", + "count": "{} प्रोफ़ाइल", + "card_single": "जिनके साथ आप यह अकाउंट शेयर करते हैं, उनके लिए प्रोफ़ाइल जोड़ें", + "load_failed": "प्रोफ़ाइल लोड नहीं हो पाईं", + "offline": "अपना कनेक्शन जाँचें और फिर से कोशिश करें।", + "error_generic": "कुछ गड़बड़ हो गई। कृपया फिर से कोशिश करें।", + "on_this_device": "इस डिवाइस पर", + "ask_on_launch": "शुरू होने पर पूछें कि कौन देख रहा है", + "ask_on_launch_desc": "बंद होने पर, Sozo आख़िरी इस्तेमाल की गई प्रोफ़ाइल खोलता है। PIN वाली प्रोफ़ाइल फिर भी पूछेंगी।", + "pin_locked_until": "बहुत ज़्यादा कोशिशें। {} के बाद फिर कोशिश करें।", + "pin_not_on_device": "आप ऑफ़लाइन हैं, और इस प्रोफ़ाइल का PIN अभी तक इस डिवाइस पर डाला नहीं गया है। इसे खोलने के लिए एक बार इंटरनेट से जुड़ें।", + "biometric_reason": "{} खोलें", + "biometric_offer_title": "फ़िंगरप्रिंट / Face ID इस्तेमाल करें?", + "biometric_offer_body": "इस डिवाइस पर {} के लिए फ़िंगरप्रिंट / Face ID इस्तेमाल करें। PIN भी काम करता रहेगा।", + "biometric_offer_yes": "इस्तेमाल करें", + "biometric_offer_no": "अभी नहीं", + "biometric_toggle": "{} के लिए फ़िंगरप्रिंट / Face ID" + }, + "jellyfin": { + "connect_server": "मीडिया सर्वर कनेक्ट करें (Jellyfin)", + "row_title": "Jellyfin सर्वर", + "row_subtitle": "Jellyfin सर्वर से अपनी फ़िल्में और शो देखें", + "title": "मीडिया सर्वर", + "servers_label": "सर्वर", + "connect": "सर्वर कनेक्ट करें", + "connect_title": "Jellyfin से कनेक्ट करें", + "connect_intro": "अपने Jellyfin सर्वर का पता और उस पर इस्तेमाल होने वाला अकाउंट डालें। Sozo सीधे सर्वर से बात करता है; Sozo के अपने सर्वरों से कुछ भी नहीं गुज़रता।", + "connect_footnote": "सादे http:// पते आपके होम नेटवर्क पर काम करते हैं। उसके बाहर से, वह https पता इस्तेमाल करें जिस पर आपका सर्वर पब्लिश है।", + "url_hint": "सर्वर का पता, जैसे 192.168.1.20:8096", + "username_hint": "यूज़रनेम", + "password_hint": "पासवर्ड (खाली भी हो सकता है)", + "test_connection": "कनेक्शन टेस्ट करें", + "testing": "जाँच हो रही है…", + "test_ok": "कनेक्ट हो गया · {ms} ms", + "found_server": "{name} मिला · Jellyfin {version} · {ms} ms", + "sign_in": "साइन इन करें", + "sign_in_again": "फिर से साइन इन करें", + "signed_in_to": "{name} पर {user} के रूप में साइन इन है", + "libraries_title": "होम पर लाइब्रेरी", + "libraries_label": "लाइब्रेरी", + "libraries_intro": "चालू की गई हर लाइब्रेरी की होम पर अपनी एक पंक्ति होती है। खोज पूरे सर्वर पर होती है।", + "libraries_empty": "सर्वर पर इस अकाउंट की कोई फ़िल्म या टीवी लाइब्रेरी नहीं है।", + "saved": "{name} आपके सोर्स में जोड़ दिया गया", + "use_now": "अभी देखें", + "empty_title": "अभी कोई सर्वर नहीं", + "empty_body": "अपनी फ़िल्में और शो ब्राउज़ करने और चलाने के लिए Jellyfin सर्वर कनेक्ट करें, आपकी प्रोग्रेस सिंक में रहेगी।", + "servers_footnote": "आपका पासवर्ड सिर्फ़ सर्वर को भेजा जाता है। Sozo सेशन कुंजी को इस डिवाइस पर एन्क्रिप्ट करके रखता है।", + "remove": "सर्वर हटाएँ", + "remove_confirm": "{name} को हटाएँ? इस डिवाइस पर आप इससे लॉग आउट हो जाएँगे।", + "continue_watching": "देखना जारी रखें", + "next_up": "आगे", + "quality_original": "ओरिजिनल", + "quality_transcode": "ट्रांसकोड", + "quality_auto": "ऑटो (HLS)", + "empty_server": "इस सर्वर पर अभी दिखाने के लिए कुछ नहीं है। मीडिया सर्वर में देखें कि कौन-सी लाइब्रेरी चालू हैं।", + "error_unreachable": "सर्वर तक नहीं पहुँच पाए। पता जाँचें, और देखें कि यह डिवाइस उसी नेटवर्क पर है।", + "error_not_jellyfin": "उस पते से जवाब मिला, लेकिन वह Jellyfin सर्वर नहीं है।", + "error_login": "यूज़रनेम या पासवर्ड ग़लत है।", + "error_session": "सर्वर अब इस डिवाइस का सेशन स्वीकार नहीं करता। मीडिया सर्वर में जाकर फिर से साइन इन करें।", + "error_server": "सर्वर ने गड़बड़ी लौटाई", + "error_server_removed": "यह Jellyfin सर्वर Sozo से हटा दिया गया था।", + "error_no_stream": "सर्वर ने इस आइटम के लिए चलाने लायक कोई स्ट्रीम नहीं दी।", + "error_url": "सर्वर का पता डालें।", + "error_username": "अपना यूज़रनेम डालें।" + }, + "automation": { + "title": "ऑटोमेशन", + "entry_subtitle": "ऑटो-डाउनलोड, क्लीन-अप और आगे क्या चलेगा", + "section_downloads": "ऑटो-डाउनलोड", + "auto_download": "नए एपिसोड डाउनलोड करें", + "auto_download_desc": "उन फ़ॉलो किए गए टाइटल के लिए जिनमें आपने इसे चालू किया है, जब लाइब्रेरी अपडेट में कुछ नया मिले", + "wifi_only": "सिर्फ़ Wi-Fi", + "wifi_only_desc": "ऑटोमैटिक डाउनलोड के लिए कभी मोबाइल डेटा इस्तेमाल न करें", + "keep_last": "सबसे नए रखें", + "keep_last_value": "{} प्रति टाइटल", + "storage_cap": "स्टोरेज लिमिट", + "storage_cap_desc": "जब डाउनलोड इतनी जगह ले लें, तब रुक जाएँ", + "no_limit": "कोई लिमिट नहीं", + "gb": "{} GB", + "titles": "टाइटल", + "titles_value": "{} चालू", + "titles_desc": "फ़ॉलोइंग में हर टाइटल के लिए इसे चालू करें", + "status_never": "अभी तक नहीं चला", + "status_queued": "पिछली बार {}: {} कतार में", + "status_nothing": "पिछली बार {} को चला: कुछ नया नहीं", + "hold_wifi": "Wi-Fi का इंतज़ार है", + "hold_cap": "स्टोरेज लिमिट पूरी हो गई", + "hold_busy": "दूसरे डाउनलोड शुरू होने का इंतज़ार है", + "skipped_n": "{} एपिसोड डाउनलोड होने से पहले उन्हें एक बार चलाना होगा", + "library_off": "लाइब्रेरी अपडेट बंद हैं, इसलिए कुछ भी चेक नहीं होता। इन्हें सेटिंग्स में चालू करें।", + "section_cleanup": "क्लीन-अप", + "delete_watched": "देखे हुए डाउनलोड हटाएँ", + "delete_watched_desc": "सिर्फ़ ऑटोमैटिक डाउनलोड। आपने जो ख़ुद सेव किया है, वह बना रहता है।", + "section_playback": "आगे क्या", + "prefetch_episode": "अगला एपिसोड तैयार रखें", + "prefetch_episode_desc": "एपिसोड ख़त्म होने के क़रीब अगला एपिसोड ढूँढ लें, ताकि वह जल्दी शुरू हो", + "up_next_prompt": "अगले एपिसोड का प्रॉम्प्ट", + "up_next_prompt_desc": "अगले एपिसोड से पहले काउंटडाउन, रद्द करने के विकल्प के साथ", + "seconds": "{} से", + "prefetch_chapter": "अगला चैप्टर पहले से लोड करें", + "prefetch_chapter_desc": "पढ़ते समय अगले चैप्टर के शुरुआती पेज ले आएँ", + "up_next": "आगे", + "play_now": "अभी चलाएँ", + "episode_n": "एपिसोड {}", + "notify_title": "आपके लिए डाउनलोड किया गया", + "notify_body": "{} नए एपिसोड डाउनलोड हो रहे हैं", + "follow_auto_on": "{} के नए एपिसोड अपने-आप डाउनलोड होंगे", + "follow_auto_off": "{} के लिए ऑटोमैटिक डाउनलोड बंद", + "follow_auto_tooltip": "नए एपिसोड अपने-आप डाउनलोड करें", + "global_off": "ऑटोमैटिक डाउनलोड बंद हैं", + "open_settings": "सेटिंग्स", + "auto_chip": "ऑटो-डाउनलोड" + }, + "social": { + "title": "दोस्त", + "tab_feed": "एक्टिविटी", + "tab_friends": "दोस्त", + "tab_requests": "रिक्वेस्ट", + "hub_subtitle": "देखें कि आपके दोस्त क्या देखते और पढ़ते हैं", + "hub_requests": "{} नई", + "privacy_title": "दोस्त और प्राइवेसी", + "privacy_entry_subtitle": "एक्टिविटी शेयरिंग, प्रोफ़ाइल कौन देखे, ब्लॉक किए गए लोग", + "search_title": "लोगों को ढूँढें", + "search_hint": "यूज़रनेम से खोजें", + "search_intro_title": "अपने दोस्तों को ढूँढें", + "search_intro_body": "उनके यूज़रनेम के कम से कम 2 अक्षर टाइप करें। प्राइवेट प्रोफ़ाइल नहीं दिखतीं।", + "search_empty": "कोई नहीं मिला", + "search_empty_body": "स्पेलिंग जाँचें, या उनसे उनका सही यूज़रनेम पूछें।", + "action_add": "दोस्त जोड़ें", + "action_requested": "रिक्वेस्ट भेजी", + "action_accept": "स्वीकार करें", + "action_decline": "मना करें", + "action_cancel_request": "रिक्वेस्ट रद्द करें", + "action_friends": "दोस्त", + "action_remove": "दोस्त हटाएँ", + "action_block": "ब्लॉक करें", + "action_unblock": "अनब्लॉक करें", + "action_closed": "रिक्वेस्ट नहीं ले रहे", + "remove_title": "{} को दोस्तों से हटाएँ?", + "remove_body": "आप एक-दूसरे की एक्टिविटी देखना बंद कर देंगे। उन्हें इसकी सूचना नहीं मिलेगी।", + "block_title": "{} को ब्लॉक करें?", + "block_body": "वे आपको ढूँढ नहीं पाएँगे, आपकी प्रोफ़ाइल नहीं देख पाएँगे और आपको रिक्वेस्ट नहीं भेज पाएँगे, और आपके बीच की दोस्ती ख़त्म हो जाएगी। उन्हें इसकी सूचना नहीं मिलेगी।", + "cancel_request_title": "फ़्रेंड रिक्वेस्ट रद्द करें?", + "cancel_request_body": "{} को भेजी गई आपकी रिक्वेस्ट वापस ले ली जाएगी।", + "sent_snack": "फ़्रेंड रिक्वेस्ट भेज दी गई", + "accepted_snack": "अब आप और {} दोस्त हैं", + "declined_snack": "रिक्वेस्ट अस्वीकार कर दी गई", + "removed_snack": "दोस्तों से हटा दिया गया", + "blocked_snack": "{} ब्लॉक हो गया", + "unblocked_snack": "{} अनब्लॉक हो गया", + "requests_incoming": "मिली हुई", + "requests_outgoing": "भेजी हुई", + "requests_empty_title": "कोई रिक्वेस्ट बाकी नहीं", + "requests_empty_body": "आपकी भेजी या मिली फ़्रेंड रिक्वेस्ट यहाँ दिखती हैं।", + "friends_empty_title": "अभी कोई दोस्त नहीं", + "friends_empty_body": "लोगों को उनके यूज़रनेम से ढूँढें और उन्हें रिक्वेस्ट भेजें।", + "find_friends": "दोस्त ढूँढें", + "friends_since": "{} जोड़ा गया", + "feed_empty_title": "अभी यहाँ शांति है", + "feed_empty_body": "जब आपके दोस्त शेयर करेंगे कि वे क्या देखते और पढ़ते हैं, तो वह यहाँ दिखेगा।", + "feed_no_friends_title": "आपकी फ़ीड ख़ाली है", + "feed_no_friends_body": "दोस्त जोड़ें और देखें कि वे क्या देख और पढ़ रहे हैं।", + "share_nudge": "आपकी एक्टिविटी प्राइवेट है। दोस्त नहीं देख सकते कि आप क्या देखते हैं।", + "share_nudge_action": "शेयर करें", + "verb_watched": "देख चुके हैं", + "verb_finished": "ख़त्म कर चुके हैं", + "verb_read": "पढ़ चुके हैं", + "verb_favorited": "पसंदीदा में जोड़ चुके हैं", + "verb_planned": "देखने वाले हैं", + "verb_completed": "पूरा कर चुके हैं", + "verb_achieved": "बैज हासिल कर चुके हैं", + "episode_one": "एपिसोड {}", + "episode_range": "एपिसोड {}–{}", + "chapter_one": "चैप्टर {}", + "chapter_range": "चैप्टर {}–{}", + "activity_delete": "एक्टिविटी से हटाएँ", + "activity_delete_body": "दोस्तों को यह एंट्री अब नहीं दिखेगी।", + "activity_hidden_title": "एक्टिविटी प्राइवेट है", + "activity_hidden_body": "वे क्या देखते और पढ़ते हैं, यह सिर्फ़ वही लोग देख सकते हैं जिन्हें उन्होंने अनुमति दी है।", + "activity_hidden_friend": "उन्होंने एक्टिविटी शेयरिंग चालू नहीं की है।", + "profile_activity": "हाल की एक्टिविटी", + "profile_activity_empty": "कोई हाल की एक्टिविटी नहीं", + "profile_activity_empty_self": "दोस्त और प्राइवेसी में शेयरिंग चालू करें, और अब से आप जो देखेंगे वह यहाँ दिखेगा।", + "profile_self": "दूसरों को आप ऐसे दिखते हैं", + "profile_unavailable": "प्रोफ़ाइल उपलब्ध नहीं है", + "profile_unavailable_body": "यह अकाउंट मौजूद नहीं है या आपको नहीं दिख सकता।", + "section_activity": "एक्टिविटी", + "section_profile": "प्रोफ़ाइल", + "share_title": "मेरी एक्टिविटी शेयर करें", + "share_desc": "दोस्तों को देखने दें कि आप क्या देखते और पढ़ते हैं। डिफ़ॉल्ट रूप से बंद।", + "share_private_note": "आपकी प्रोफ़ाइल सिर्फ़ मैं पर सेट है, इसलिए कुछ भी शेयर नहीं होता।", + "share_footnote": "इनकॉग्निटो सेशन और आपकी प्राइवेट लिस्ट के टाइटल कभी शेयर नहीं होते। सिर्फ़ पिछले 24 घंटों में देखा गया कंटेंट पोस्ट होता है, और एंट्री 90 दिन बाद ग़ायब हो जाती हैं।", + "share_footnote_profile": "सिर्फ़ अकाउंट की मुख्य प्रोफ़ाइल एक्टिविटी शेयर करती है। इस प्रोफ़ाइल पर आप जो देखते हैं, वह कभी शेयर नहीं होता।", + "share_on_title": "अपनी एक्टिविटी शेयर करें?", + "share_on_body": "अब से दोस्तों को वे टाइटल दिखेंगे जो आप देखते और पढ़ते हैं, और जिन्हें आप पसंदीदा में जोड़ते हैं, प्लान करते हैं या पूरा करते हैं। इसमें पेयर किए गए टीवी पर देखना भी शामिल है। इनकॉग्निटो सेशन और आपकी प्राइवेट लिस्ट कभी शेयर नहीं होते।", + "share_on_confirm": "शेयर करें", + "share_off_title": "शेयर करना बंद करें?", + "share_off_body": "दोस्तों को आपकी एक्टिविटी दिखना बंद हो जाएगी, और आपने जो कुछ भी पहले शेयर किया है वह हटा दिया जाएगा।", + "share_off_confirm": "शेयर करना बंद करें", + "my_activity": "मेरी शेयर की गई एक्टिविटी", + "my_activity_desc": "दोस्त जो देख सकते हैं, उसे देखें या हटाएँ", + "visibility_title": "मेरी प्रोफ़ाइल कौन देख सकता है", + "visibility_public": "सभी", + "visibility_friends": "सिर्फ़ दोस्त", + "visibility_private": "सिर्फ़ मैं", + "visibility_public_desc": "कोई भी आपको ढूँढ सकता है, और शेयरिंग चालू होने पर कोई भी आपकी एक्टिविटी देख सकता है।", + "visibility_friends_desc": "कोई भी आपको ढूँढ सकता है, लेकिन आपकी एक्टिविटी सिर्फ़ दोस्त देखते हैं।", + "visibility_private_desc": "आप खोज में नहीं दिखेंगे और आपकी एक्टिविटी कोई नहीं देखेगा। मौजूदा दोस्त बने रहेंगे।", + "requests_title": "मुझे रिक्वेस्ट कौन भेज सकता है", + "requests_everyone": "सभी", + "requests_nobody": "कोई नहीं", + "blocked_title": "ब्लॉक किए गए लोग", + "blocked_empty": "कोई ब्लॉक नहीं है", + "blocked_empty_body": "जिन लोगों को आप ब्लॉक करते हैं, वे आपको ढूँढ नहीं सकते और न ही रिक्वेस्ट भेज सकते हैं।", + "error_network": "सर्वर तक नहीं पहुँच पा रहे। अपना कनेक्शन जाँचें।", + "error_signin": "दोस्त फ़ीचर इस्तेमाल करने के लिए साइन इन करें।", + "error_not_found": "यूज़र नहीं मिला", + "error_requests_closed": "यह व्यक्ति फ़्रेंड रिक्वेस्ट नहीं ले रहा", + "error_already_friends": "आप पहले से दोस्त हैं", + "error_friend_limit": "आप दोस्तों की लिमिट तक पहुँच गए हैं", + "error_too_many_pending": "बहुत ज़्यादा रिक्वेस्ट बाकी हैं। पहले कुछ रद्द करें।", + "error_rate_limited": "बहुत ज़्यादा कोशिशें हो गईं। थोड़ा रुककर फिर से कोशिश करें।", + "error_generic": "कुछ गड़बड़ हो गई। फिर से कोशिश करें।" + }, + "recap": { + "action": "रीकैप", + "action_hint": "एपिसोड {} से पहले की पूरी कहानी जान लें", + "previously_on": "अब तक", + "up_to": "{} तक", + "code_season_episode": "S{} · E{}", + "code_episode": "एपिसोड {}", + "season_n": "सीज़न {}", + "loading": "आपको कहानी बता रहे हैं…", + "spoiler_safe": "स्पॉइलर-फ़्री: {} और उसके बाद का कुछ भी शामिल नहीं है।", + "covers": "शामिल एपिसोड", + "source": "सारांश: {}", + "ai_note": "इन सारांशों से AI ने तैयार किया", + "unavailable_title": "इसका रीकैप अभी नहीं है", + "unavailable": "सुरक्षित रीकैप के लिए एपिसोड के पर्याप्त सारांश नहीं हैं, इसलिए हम अंदाज़ा नहीं लगाएँगे।", + "error": "रीकैप लोड नहीं हो पाया" + }, + "release_notify": { + "channel_general": "नोटिफ़िकेशन", + "channel_general_desc": "अकाउंट, दोस्त और घोषणाएँ", + "channel_releases": "नए एपिसोड", + "channel_releases_desc": "आपके फ़ॉलो किए गए टाइटल के नए एपिसोड और चैप्टर", + "channel_airing": "प्रसारण रिमाइंडर", + "channel_airing_desc": "आपके फ़ॉलो किए गए एपिसोड के प्रसारण से थोड़ा पहले", + "channel_quiet": "शांत समय", + "channel_quiet_desc": "आपके शांत समय में चुपचाप पहुँचाए जाते हैं", + "episode_one": "एपिसोड {} आ गया", + "episodes_many": "{} नए एपिसोड · {} तक", + "chapter_one": "चैप्टर {} आ गया", + "chapters_many": "{} नए चैप्टर · {} तक", + "action_watch": "देखें", + "action_read": "पढ़ें", + "action_seen": "देखा मार्क करें", + "summary_title": "नए एपिसोड", + "summary_more": "{} टाइटल में कुछ नया है", + "badge_episode": "नया एपिसोड", + "badge_chapter": "नया चैप्टर", + "ep_short": "एपि", + "ch_short": "चै", + "test_body": "नोटिफ़िकेशन काम कर रहे हैं। कुछ नया आते ही हम आपको बता देंगे।", + "priming_title": "कोई नया एपिसोड न छूटे", + "priming_body": "आपकी फ़ॉलो की हुई किसी चीज़ का नया एपिसोड या चैप्टर आते ही Sozo आपको बताता है — और ऐसा कुछ नहीं जो आपने माँगा न हो।", + "priming_body_title": "{} का नया एपिसोड आते ही आपको ख़बर मिलेगी — और ऐसा कुछ नहीं जो आपने माँगा न हो।", + "priming_download_title": "जानें कि आपका डाउनलोड कब पूरा हुआ", + "priming_download_body": "Sozo सूचनाओं में डाउनलोड की प्रगति दिखाता है और बताता है कि ऑफ़लाइन देखने के लिए कब तैयार है। यह आपके फ़ॉलो किए गए शो के नए एपिसोड के बारे में भी बता सकता है।", + "priming_point_releases": "नए एपिसोड, पोस्टर के साथ", + "priming_point_quiet": "शांत समय", + "priming_point_control": "कोई भी टाइटल म्यूट करें", + "priming_turn_on": "नोटिफ़िकेशन चालू करें", + "priming_not_now": "अभी नहीं", + "priming_blocked_title": "Sozo के लिए नोटिफ़िकेशन बंद हैं", + "priming_blocked_body": "Android दोबारा नहीं पूछेगा। सिस्टम सेटिंग्स में इन्हें चालू करके वापस आएँ — हमें पता चल जाएगा।", + "priming_open_settings": "सेटिंग्स खोलें", + "priming_enabled": "सब तैयार है", + "priming_sample_show": "आपका अगला पसंदीदा शो", + "follow": "फ़ॉलो करें", + "following": "फ़ॉलोइंग", + "muted": "म्यूट", + "follow_tooltip": "नए एपिसोड के लिए फ़ॉलो करें", + "notify_on": "नए एपिसोड की सूचना दें", + "notify_on_reading": "नए चैप्टर की सूचना दें", + "notify_on_desc": "दोनों ही तरह से यह फ़ॉलो रहेगा", + "unfollow": "अनफ़ॉलो करें", + "mute": "नोटिफ़िकेशन म्यूट करें", + "unmute": "नोटिफ़िकेशन अनम्यूट करें", + "muted_snack": "{} के नोटिफ़िकेशन म्यूट हो गए", + "unmuted_snack": "{} के नोटिफ़िकेशन चालू हो गए", + "callout_new_episode": "नया एपिसोड", + "callout_new_episodes": "{} नए एपिसोड", + "callout_new_chapter": "नया चैप्टर", + "callout_new_chapters": "{} नए चैप्टर", + "callout_watch": "एपिसोड {} देखें", + "callout_read": "चैप्टर {} पढ़ें", + "feed_title": "नई रिलीज़", + "feed_today": "आज", + "feed_yesterday": "कल", + "feed_week": "इस हफ़्ते", + "feed_earlier": "पहले", + "feed_empty_title": "अभी कुछ नया नहीं", + "feed_empty_body": "कोई सीरीज़ या मांगा फ़ॉलो करें, और उसके नए एपिसोड और चैप्टर आते ही यहाँ दिखेंगे।", + "feed_check": "अभी जाँचें", + "feed_mark_all": "सभी को देखा मार्क करें", + "feed_remove": "हटाएँ", + "feed_removed": "फ़ीड से हटा दिया गया", + "feed_none_found": "अभी कुछ नया नहीं है", + "feed_watch": "देखें", + "feed_read": "पढ़ें", + "feed_card_new": "{} टाइटल में कुछ नया", + "feed_card_none": "आप सब देख चुके हैं", + "ep_one": "एपि {}", + "ep_range": "एपि {}–{}", + "ch_one": "चै {}", + "ch_range": "चै {}–{}", + "mode_video": "सीरीज़", + "mode_manga": "मांगा", + "mode_novel": "नॉवेल", + "soon": "जल्द", + "in_hours": "{} घं में", + "in_days": "{} दिन में", + "badge_new": "नया", + "home_rail": "आपके लिए नए एपिसोड", + "settings_title": "नोटिफ़िकेशन", + "settings_entry_subtitle": "नए एपिसोड, रिमाइंडर, शांत समय", + "master": "नोटिफ़िकेशन की अनुमति दें", + "master_desc": "Sozo के सभी नोटिफ़िकेशन, चालू या बंद", + "section_types": "मुझे इनकी सूचना दें", + "type_releases": "नए एपिसोड और चैप्टर", + "type_releases_desc": "आपके फ़ॉलो किए गए टाइटल से", + "type_airing": "प्रसारण रिमाइंडर", + "type_airing_desc": "एपिसोड के प्रसारण से पहले और प्रसारण के समय, AniList से", + "type_friends": "दोस्त और जवाब", + "type_friends_desc": "फ़्रेंड रिक्वेस्ट, जवाब और लाइक", + "type_streak": "स्ट्रीक रिमाइंडर", + "type_streak_desc": "आपकी स्ट्रीक ख़त्म होने से पहले", + "type_announcements": "घोषणाएँ", + "type_announcements_desc": "Sozo की ख़बरें और अपडेट", + "section_quiet": "शांत समय", + "quiet": "शांत समय", + "quiet_desc": "रात भर फ़ोन नहीं बजेगा; पुश नोटिफ़िकेशन चुपचाप आएँगे", + "quiet_from": "कब से", + "quiet_to": "कब तक", + "section_checks": "नए एपिसोड की जाँच", + "check_background_note": "Android पर, जब आप ऑनलाइन हों और बैटरी कम न हो, तब Sozo हर कुछ घंटों में बैकग्राउंड में भी जाँच करता है। एक्सटेंशन सोर्स की जाँच तब होती है जब ऐप खुला हो।", + "permission": "सिस्टम अनुमति", + "permission_on": "नोटिफ़िकेशन की अनुमति है", + "permission_off": "नोटिफ़िकेशन बंद हैं", + "permission_blocked": "सिस्टम सेटिंग्स में ब्लॉक है", + "permission_fix": "चालू करें", + "test": "टेस्ट नोटिफ़िकेशन भेजें", + "test_desc": "देखें कि नए एपिसोड का अलर्ट कैसा दिखता है", + "test_sent": "टेस्ट नोटिफ़िकेशन भेज दिया गया", + "airing_needs_anilist": "प्रसारण रिमाइंडर पाने के लिए कनेक्शन में AniList कनेक्ट करें" + }, + "achievements": { + "title": "अचीवमेंट", + "new_badge": "नया बैज", + "rare": "दुर्लभ", + "tier_n": "{} टियर", + "tier_locked": "लॉक है", + "tier_bronze": "ब्रॉन्ज़", + "tier_silver": "सिल्वर", + "tier_gold": "गोल्ड", + "tier_ember": "अंगारा", + "share": "शेयर करें", + "great": "बढ़िया!", + "see_all": "सभी अचीवमेंट देखें", + "also_earned": "यह भी मिला", + "share_text": "मैंने अभी Sozo पर “{}” हासिल किया", + "next_at": "अगला: {} — {} पर", + "next_tiers": "अगले टियर", + "singles": "अनोखे बैज", + "streak_days": "लगातार {} दिन", + "streak_best": "सबसे अच्छी: {} दिन", + "days_short": "{} दिन", + "streak_to_go": "{} दिन और, फिर {}", + "streak_maxed": "लौ के सभी बैज आपके हैं", + "unlocked_n": "{}/{} अनलॉक", + "showcase": "शोकेस", + "showcase_edit": "बदलें", + "showcase_empty": "आपके चुने हुए बैज आपके नाम के बगल में दिखते हैं — आपकी प्रोफ़ाइल पर और दोस्तों को।", + "showcase_hint": "ज़्यादा से ज़्यादा छह, आपके नाम के बगल में दिखेंगे।", + "maxed": "सभी टियर हासिल", + "to_next": "{}: अभी {} बाकी", + "signed_out": "आप जो देखते और पढ़ते हैं उसके लिए बैज पाने और उन्हें हर डिवाइस पर रखने के लिए साइन इन करें।", + "sign_in": "साइन इन करें", + "entry_hint": "आपकी स्ट्रीक, एपिसोड और चैप्टर के लिए बैज", + "earned_on": "{} को मिला", + "not_yet": "अभी नहीं मिला", + "save": "सेव करें", + "save_failed": "सेव नहीं हो पाया। फिर से कोशिश करें।", + "streak_note": "आपकी सबसे लंबी स्ट्रीक ही गिनी जाती है: एक बार मिला बैज स्ट्रीक टूटने पर भी बना रहता है। फ़्रीज़ से स्ट्रीक जारी रहती है।", + "name_streak": "लौ", + "name_episodes": "एपिसोड हंटर", + "name_series_done": "आख़िर तक", + "name_movies": "फ़िल्म प्रेमी", + "name_chapters": "पेज टर्नर", + "name_manga_done": "मांगा मास्टर", + "name_novel_chapters": "किताबी कीड़ा", + "name_active_days": "वफ़ादार", + "name_iron_will": "फ़ौलादी इरादा", + "name_binge": "बिंज", + "name_night_owl": "रात का राही", + "name_party_host": "पार्टी होस्ट", + "name_explorer": "खोजी", + "name_friendly": "मिलनसार", + "unit_streak": "लगातार {} दिन", + "unit_episodes": "{} एपिसोड", + "unit_series_done": "{} सीरीज़ पूरी", + "unit_movies": "{} फ़िल्में", + "unit_chapters": "{} मांगा चैप्टर", + "unit_manga_done": "{} मांगा पूरे", + "unit_novel_chapters": "{} नॉवेल चैप्टर", + "unit_active_days": "{} एक्टिव दिन", + "rule_iron_will": "100 दिन की स्ट्रीक, जिसमें कभी फ़्रीज़ की ज़रूरत नहीं पड़ी", + "rule_binge": "एक ही दिन में एक सीरीज़ के 10 एपिसोड", + "rule_night_owl": "आधी रात से सुबह 4 बजे के बीच कोई एपिसोड पूरा करें।", + "rule_party_host": "वॉच पार्टी होस्ट करें", + "rule_explorer": "10 अलग-अलग सोर्स पर कुछ पूरा करें", + "rule_friendly": "5 दोस्त बनाएँ", + "viewer_hint": "इसे खींचें या फ़ोन झुकाएँ · पलटने के लिए डबल-टैप करें" + }, + "home_widget": { + "next": "अगला एपिसोड", + "out_now": "अभी आया है", + "locked": "Sozo लॉक है", + "locked_hint": "खोलने के लिए टैप करें", + "at_risk": "{} दिन की स्ट्रीक आज रात ख़त्म हो जाएगी", + "empty": "देखना शुरू करें, और वह यहाँ दिखेगा", + "days": "{} दिन", + "episode": "एपि {}", + "chapter": "चै {}", + "d": "{} दिन", + "h": "{} घं", + "m": "{} मि", + "streak_days": "दिन की स्ट्रीक", + "streak_start": "स्ट्रीक शुरू करें", + "streak_done": "आज का हो गया", + "streak_tonight": "आज रात ख़त्म", + "streak_next": "{target} दिन के बैज तक {left} और" + }, + "support": { + "entry_subtitle": "समस्या बताएँ या कोई आइडिया सुझाएँ", + "intro_title": "हम आपकी क्या मदद कर सकते हैं?", + "intro_body": "हमें बताएँ कि क्या गड़बड़ हुई या आप क्या देखना चाहते हैं। हम हर मैसेज पढ़ते हैं और आमतौर पर एक दिन के अंदर जवाब देते हैं।", + "new_request": "नई रिक्वेस्ट", + "my_requests": "आपकी रिक्वेस्ट", + "empty": "आपने अभी तक हमें नहीं लिखा है।", + "new_answer": "नया जवाब", + "team": "Sozo सपोर्ट", + "category_title": "यह किस बारे में है?", + "cat_playback": "वीडियो प्लेबैक", + "cat_account": "अकाउंट और साइन इन", + "cat_content": "सोर्स और कंटेंट", + "cat_bug": "कुछ ख़राब है", + "cat_idea": "आइडिया या सुझाव", + "cat_other": "कुछ और", + "message_label": "आपका मैसेज", + "message_hint": "क्या हुआ? आपने क्या किया, आपको क्या उम्मीद थी, और इसकी जगह क्या दिखा?", + "contact_label": "संपर्क (ज़रूरी नहीं)", + "contact_hint": "ईमेल या Telegram यूज़रनेम", + "contact_note": "आपने साइन इन नहीं किया है — हमारा जवाब यहीं, मदद और सपोर्ट में दिखेगा। अगर आप वहाँ भी जवाब चाहते हैं, तो अपना संपर्क छोड़ दें।", + "diag_title": "डायग्नोस्टिक जानकारी जोड़ें", + "diag_body": "ऐप वर्ज़न, सिस्टम, नेटवर्क टाइप और प्लेयर लॉग, जिनमें से पासवर्ड, टोकन और कुकीज़ हटा दी जाती हैं। इससे हमें वजह जल्दी ढूँढने में मदद मिलती है।", + "diag_show": "देखें कि क्या भेजा जाएगा", + "diag_log_lines": "प्लेयर लॉग: {} लाइनें", + "send": "भेजें", + "sent": "भेज दिया — हम यहीं जवाब देंगे।", + "status_open": "जवाब का इंतज़ार है", + "status_answered": "जवाब मिल गया", + "status_closed": "बंद", + "reply_hint": "मैसेज लिखें…", + "close": "रिक्वेस्ट बंद करें", + "close_confirm_title": "यह रिक्वेस्ट बंद करें?", + "close_confirm_body": "आप कभी भी दोबारा लिख सकते हैं — इससे यह फिर खुल जाएगी।", + "waiting_note": "हमें आपका मैसेज मिल गया है और हम यहीं जवाब देंगे। अगर आपने साइन इन किया है, तो आपको नोटिफ़िकेशन भी मिलेगा।", + "closed_note": "यह रिक्वेस्ट बंद है। दोबारा लिखने पर यह फिर खुल जाएगी।", + "contact_support": "सपोर्ट को लिखें", + "error_generic": "कुछ गड़बड़ हो गई। कृपया फिर से कोशिश करें।", + "error_too_many": "अभी बहुत ज़्यादा रिक्वेस्ट हैं — कृपया थोड़ा इंतज़ार करें।", + "error_short": "कृपया कुछ और शब्द लिखें।", + "error_thread_full": "यह बातचीत बहुत लंबी हो गई है — कृपया नई रिक्वेस्ट शुरू करें।", + "error_not_found": "यह रिक्वेस्ट अब मौजूद नहीं है।", + "screenshots_title": "स्क्रीनशॉट (ज़रूरी नहीं)", + "screenshots_hint": "ज़्यादा से ज़्यादा 5 इमेज, jpg, png या webp, हर एक 5 MB तक। इनसे हमें समस्या समझने में मदद मिलती है।", + "screenshots_skipped": "कुछ इमेज छोड़ दी गईं: सिर्फ़ jpg, png या webp, 5 MB तक।", + "uploading": "स्क्रीनशॉट अपलोड हो रहे हैं {}/{}…", + "attach": "स्क्रीनशॉट जोड़ें", + "error_attachment": "एक स्क्रीनशॉट अपलोड नहीं हो पाया। कृपया फिर से कोशिश करें।", + "error_attachment_type": "सिर्फ़ jpg, png या webp इमेज।", + "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", + "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", + "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें", + "error_offline": "आप ऑफ़लाइन हैं या कनेक्शन टूट गया। इंटरनेट जाँचें और फिर से कोशिश करें।", + "not_sent": "नहीं भेजा गया: {}", + "diag_crash_log": "क्रैश लॉग देखें" + }, + "zangetsu": { + "title": "Zangetsu स्रोत", + "row_subtitle": "बाहरी रिपो से JavaScript स्रोत, आपके डिवाइस पर चलते हैं", + "desc": "अतिरिक्त ऐनिमे और फ़िल्म स्रोत इंस्टॉल करने के लिए Zangetsu रिपो जोड़ें। ये आपके डिवाइस पर चलते हैं। जो स्रोत Sozo में पहले से हैं, उन्हें «बिल्ट-इन» दिखाया जाता है और उनका Sozo वाला संस्करण ही इस्तेमाल होता है।", + "loading_repo": "रिपो लोड हो रहा है…", + "repo_added": "रिपो जोड़ा गया · {} स्रोत।", + "checking": "रिपो के अपडेट जाँचे जा रहे हैं…", + "updates_available": "{} अपडेट उपलब्ध हैं।", + "installed_one": "{} इंस्टॉल हुआ।", + "removed_one": "{} हटाया गया।", + "updated": "{} स्रोत अपडेट हुए।", + "update_all": "सभी अपडेट करें ({})", + "update_to": "v{} पर अपडेट करें", + "built_in": "बिल्ट-इन", + "search": "स्रोत खोजें…", + "all_types": "सभी", + "type_movies": "फ़िल्में", + "all_languages": "सभी भाषाएँ", + "error_invalid_url": "केवल raw.githubusercontent.com या gist.githubusercontent.com के https लिंक जोड़े जा सकते हैं।", + "error_unreachable": "इस रिपो तक नहीं पहुँच सके। लिंक और अपना कनेक्शन जाँचें।", + "error_invalid_index": "यह लिंक किसी मान्य Zangetsu index.json की ओर नहीं ले जाता।" + }, + "star": { + "title": "क्या आप Sozo को बढ़ने में मदद करना चाहते हैं?", + "body": "GitHub पर इसे स्टार दें ⭐", + "action": "GitHub पर स्टार दें", + "later": "बाद में", + "never": "फिर से न पूछें", + "update_button": "GitHub पर Sozo को स्टार दें" + }, + "progress": { + "title": "लेवल और XP", + "level": "लेवल", + "level_short": "Lv {}", + "level_up": "लेवल अप", + "level_reached": "आप लेवल {} पर पहुँच गए", + "unlocked_new": "आपके लॉकर में नया", + "open_locker": "लॉकर खोलें", + "unavailable": "लेवल अभी उपलब्ध नहीं हैं। थोड़ी देर बाद देखें।", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP और, फिर लेवल {}", + "to_next_short": "अगले लेवल तक {} XP", + "watch_today": "आज देखा: {} / {} XP", + "how_to_earn": "XP कैसे कमाएँ", + "rule_watch": "देखना: हर मिनट 1 XP, दिन में 300 तक", + "rule_comment": "कमेंट: 5 XP, दिन में 10 कमेंट तक", + "rule_like": "आपके कमेंट पर लाइक: 1 XP, दिन में 50 तक", + "rule_streak": "स्ट्रीक का दिन: 10 XP, माइलस्टोन वाले दिन ज़्यादा", + "rule_achievement": "अचीवमेंट: टियर के हिसाब से 50 से 400 XP", + "recent": "हाल के XP", + "reason_watch": "देखना", + "reason_comment": "कमेंट", + "reason_like": "मिले लाइक", + "reason_streak": "स्ट्रीक का दिन", + "reason_achievement": "अचीवमेंट", + "reason_other": "XP", + "locker": "लॉकर", + "slot_frame": "अवतार फ़्रेम", + "slot_title": "टाइटल", + "slot_color": "नाम के रंग", + "equip": "पहनने के लिए टैप करें", + "equipped": "पहना हुआ", + "unlock_level": "लेवल {}", + "unlock_badge": "बैज: {}", + "frame_bronze": "कांस्य", + "frame_silver": "चाँदी", + "frame_gold": "सोना", + "frame_neon": "नियॉन", + "frame_sakura": "साकुरा", + "frame_ember": "अंगारा", + "frame_flame": "ज्वाला", + "frame_moon": "चाँद", + "title_newcomer": "नवागंतुक", + "title_regular": "नियमित", + "title_veteran": "अनुभवी", + "title_legend": "लीजेंड", + "title_binger": "बिंज-वॉचर", + "title_cinephile": "सिनेप्रेमी", + "title_otaku": "ओटाकू", + "title_bookworm": "किताबी कीड़ा", + "title_night_owl": "रात का उल्लू", + "title_host": "पार्टी होस्ट", + "color_sky": "आसमानी", + "color_mint": "पुदीना", + "color_gold": "सुनहरा", + "color_rose": "गुलाबी", + "color_violet": "बैंगनी", + "color_crimson": "गहरा लाल", + "color_aurora": "ऑरोरा", + "color_ember": "अंगारा" + } +} diff --git a/assets/translations/id.json b/assets/translations/id.json index d3996d02..f1993c42 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -34,7 +34,9 @@ "remove": "Hapus", "show": "Tampilkan", "hide": "Sembunyikan", - "installed": "Terpasang" + "installed": "Terpasang", + "load_more_failed": "Tidak dapat memuat lebih banyak", + "go_home": "Ke beranda" }, "auth": { "login": "Masuk", @@ -306,7 +308,11 @@ "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." + "unknown": "Video tidak bisa dimuat.", + "not_found": "Video ini sudah tidak ada di server ini. Coba server lain.", + "server_error": "Server video sedang bermasalah. Coba lagi sebentar lagi atau pilih server lain.", + "resolve_failed": "Tidak bisa mendapatkan tautan video dari sumber ini.", + "details": "Detail" }, "play": "Putar", "pause": "Jeda", @@ -529,6 +535,22 @@ "alt_guess": "Perkiraan", "alt_guess_spoken": "Kecocokan tidak pasti — periksa judulnya sebelum diputar", "alt_your_pick": "Pilihan Anda", + "alt_auto": "Otomatis", + "alt_auto_hint": "Memutar dari sumber pertama yang memilikinya", + "alt_auto_trying": "Mencoba {}…", + "alt_auto_missed": "Tidak ada sumber yang cocok bisa memutarnya. Pilih salah satu di bawah atau cari di sumber.", + "alt_auto_checking": "Memeriksa {} · {} dari {}", + "alt_auto_waiting": "Menunggu sumber lain…", + "alt_auto_stop": "Berhenti", + "alt_auto_stopped": "Dihentikan.", + "alt_auto_capped": "{} sumber diperiksa dan tidak ada yang bisa memutar ini. Pilih salah satu di bawah atau coba lagi.", + "alt_auto_tried": "Sudah diperiksa", + "alt_skip_no_match": "tidak cukup cocok", + "alt_skip_no_episode": "episode tidak ditemukan", + "alt_skip_error": "gagal dimuat", + "auto_switch_attempt": "Percobaan {} dari {}", + "auto_switch_stop": "Berhenti", + "auto_switch_stopped": "Pergantian server dihentikan. Coba lagi, atau pilih server atau sumber lain.", "alt_wrong_title": "Judul salah?", "alt_wrong_title_hint": "Cari sendiri di {} dan pilih judul yang benar", "alt_corrected": "Sekarang memakai “{}”", @@ -554,10 +576,35 @@ "subtitle_n": "Subtitel {}", "subtitle_in_video": "Di dalam video", "rewind": "Mundur", + "rewind_by": "Mundur {} dtk", + "forward_by": "Maju {} dtk", "mini_restore": "Kembali ke jendela penuh", "pip_unavailable": "Gambar-dalam-gambar tidak bisa dimulai untuk video ini.", "scrub_release_cancel": "Lepas untuk membatalkan", - "auto_quality_desc_mpv": "Mulai dari kualitas terbaik yang tersedia" + "auto_quality_desc_mpv": "Mulai dari kualitas terbaik yang tersedia", + "reconnecting": "Koneksi terputus — menyambungkan ulang…", + "all_servers_failed": "Tidak ada server judul ini yang bisa memutarnya. Coba lagi nanti atau pilih sumber lain.", + "shortcut_pip": "Pemutar mini (Shift: di atas aplikasi lain)", + "mini_popout_hint": "Pemutar mini · tekan lama agar melayang di atas aplikasi lain", + "react": "Bereaksi", + "comments": "Komentar", + "episode_comments": "Komentar episode {}", + "reaction_text": "Tulis reaksi", + "reaction_text_hint": "Tulis sesuatu yang singkat…", + "reaction_wait": "Tunggu beberapa detik sebelum bereaksi lagi", + "reaction_limit": "Kamu sudah mencapai batas reaksi untuk episode ini", + "reaction_failed": "Gagal mengirim reaksi", + "native_offer_title": "Coba pemutar baru?", + "native_offer_body": "Kontrol dan menu Liquid Glass dari Apple. Anda bisa mengubahnya kapan saja di Pengaturan → Pemutar.", + "native_offer_try": "Coba", + "native_offer_not_now": "Nanti saja", + "trying_again": "Mencoba lagi (percobaan {})", + "try_mpv": "Coba dengan mesin mpv", + "ok_to_skip": "OK untuk melewati", + "quality_lowered": "Kualitas diturunkan", + "undo": "Urungkan", + "crash_log": "Log kerusakan", + "crash_log_empty": "Tidak ada kerusakan tercatat" }, "profile": { "title": "Profil", @@ -649,6 +696,15 @@ "player_engine_section": "MESIN PEMUTARAN", "ask_engine_on_play": "Tanya setiap kali", "ask_engine_on_play_desc": "Pilih mesinnya setiap kali video dimulai", + "controls_style": "GAYA KONTROL PEMUTAR", + "controls_style_desc": "Berlaku saat pemutar dibuka berikutnya.", + "controls_style_native": "Bawaan (iOS 26)", + "controls_style_native_desc": "Tombol, menu, dan lembar Liquid Glass milik ponsel sendiri", + "controls_style_system": "Kaca sistem", + "controls_style_system_desc": "Tombol aplikasi di atas Liquid Glass milik ponsel", + "controls_style_classic": "Kaca klasik", + "controls_style_classic_desc": "Kaca milik aplikasi, seperti di versi sebelumnya", + "controls_style_needs_ios26": "Perlu iOS 26 atau lebih baru", "playback_section": "PEMUTARAN", "default_speed": "Kecepatan bawaan", "default_speed_desc": "Kecepatan awal setiap video", @@ -671,6 +727,8 @@ "brightness_gesture_desc": "Geser naik-turun di sisi kiri", "volume_gesture": "Geser untuk volume", "volume_gesture_desc": "Geser naik-turun di sisi kanan", + "auto_pip": "Gambar-dalam-gambar otomatis", + "auto_pip_desc": "Menekan Beranda saat video diputar membuatnya tetap berjalan di jendela kecil", "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", @@ -743,7 +801,15 @@ "library_update": "Periksa episode baru", "library_update_desc": "Memeriksa judul yang kamu ikuti dan memberi tahu saat ada yang baru", "library_update_every": "Setiap {} jam", - "library_update_daily": "Sekali sehari" + "library_update_daily": "Sekali sehari", + "contributors": "Kontributor", + "contributors_error": "Gagal memuat kontributor", + "become_contributor": "Jadi kontributor", + "become_contributor_body": "Sozo bersifat open source. Kode, terjemahan, dan laporan bug semuanya membantu.", + "reactions_overlay": "Reaksi penonton", + "reactions_overlay_desc": "Tampilkan reaksi penonton lain pada saat dikirim, dan di mana reaksi itu berkumpul di bilah waktu", + "hide_spoilers": "Sembunyikan spoiler untuk episode yang belum kutonton", + "hide_spoilers_desc": "Komentar tentang episode yang belum selesai kamu tonton tetap buram sampai kamu mengetuknya" }, "appearance": { "title": "Tampilan", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Menyiapkan mikrofon…", + "voice_no_speech": "Tidak terdengar jelas", + "voice_no_speech_hint": "Bicaralah sedikit lebih keras dan dekat ke mikrofon, lalu coba lagi.", + "voice_permission": "Izin mikrofon nonaktif", + "voice_permission_hint": "Izinkan Sozo memakai mikrofon di pengaturan sistem untuk mencari dengan suara.", + "voice_unavailable": "Pengenalan suara tidak tersedia di perangkat ini", + "voice_unavailable_hint": "Perangkat ini tidak punya layanan pengenalan suara. Pasang atau aktifkan satu di pengaturan sistem, misalnya milik Google.", + "voice_network": "Tidak dapat menghubungi layanan suara", + "voice_network_hint": "Periksa koneksi internet lalu coba lagi.", + "voice_error": "Terjadi kesalahan saat mendengarkan", + "voice_error_hint": "Mungkin aplikasi lain sedang memakai mikrofon. Coba lagi sebentar lagi.", + "voice_try_again": "Coba lagi", + "voice_open_settings": "Buka pengaturan", "try_torrents": "Cari torrent", "capped_note": "Mencari di {} sumber paling andal dari {} — persempit cakupan untuk menjangkau sumber tertentu.", "rail_popular_on": "Populer di {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "Lupa PIN?" }, "my_list": { + "sort": "Urutkan", + "sort_added": "Baru ditambahkan", + "sort_oldest": "Terlama ditambahkan", + "sort_title": "Judul A–Z", "saved_account": "Tersimpan di akun", "saved_local": "Di perangkat ini", "untitled": "Tanpa judul", @@ -1160,7 +1242,33 @@ "reconnecting": "Menyambung ulang…", "connecting": "Menyambung…", "connect_failed_title": "Tidak bisa tersambung", - "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi." + "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi.", + "settings_title": "Pengaturan ruang", + "room_size": "Ukuran ruang", + "room_size_less": "Lebih sedikit orang", + "room_size_more": "Lebih banyak orang", + "only_host_setting": "Hanya host yang mengontrol pemutaran", + "lock_room": "Kunci ruang", + "lock_room_hint": "Tidak ada orang baru yang bisa bergabung", + "wait_for_all": "Tunggu semua", + "wait_for_all_hint": "Jeda sebentar saat ada yang buffering", + "invite_friends": "Undang teman", + "invite_sent": "Undangan terkirim", + "invite_failed": "Gagal mengirim undangan", + "invite_again_in": "Lagi dalam {}", + "invite_no_friends": "Tambahkan teman untuk mengundang mereka ke sini", + "make_host": "Jadikan host", + "remove_member": "Keluarkan dari party", + "remove_confirm": "Keluarkan {} dari party ini? Mereka tidak bisa bergabung lagi.", + "buffering": "Buffering…", + "sys_joined": "{} bergabung", + "sys_left": "{} keluar", + "sys_host": "{} sekarang host", + "sys_removed": "{} dikeluarkan", + "error_banned": "Kamu dikeluarkan dari party ini", + "error_locked": "Party ini dikunci", + "room_closed_kicked": "Host mengeluarkanmu dari party ini", + "waiting_for_others": "Menunggu semua menyusul…" }, "notifications": { "title": "Notifikasi", @@ -1281,7 +1389,19 @@ "highlights": "Sorotan", "remove_highlight": "Hapus sorotan", "highlight_note": "Tambahkan catatan (opsional)", - "no_highlights": "Belum ada sorotan. Pilih teks lalu pilih Sorot." + "no_highlights": "Belum ada sorotan. Pilih teks lalu pilih Sorot.", + "translate_page": "Terjemahkan halaman", + "translate_preview": "Halaman terjemahan", + "translate_apply": "Terapkan", + "translate_show_original": "Tampilkan asli", + "translate_no_text": "Tidak ada teks di halaman ini.", + "translate_daily_limit": "Jatah terjemahan hari ini sudah habis. Coba lagi besok.", + "translate_offline": "Kamu sedang offline. Sambungkan ke internet untuk menerjemahkan halaman ini.", + "translate_sign_in": "Masuk untuk menerjemahkan halaman.", + "translate_failed": "Halaman ini tidak dapat diterjemahkan. Coba lagi.", + "translate_language": "Terjemahkan halaman ke", + "translate_auto": "Terjemahkan setiap halaman saat membaca", + "translate_auto_desc": "Setiap halaman baru memakai satu jatah terjemahan hari ini." }, "cloudflare": { "title": "Verifikasi bahwa kamu manusia", @@ -1363,6 +1483,10 @@ "watched": "Sudah ditonton", "follow_series": "Ikuti serial", "find_other_sources": "Cari sumber lain", + "notice_title": "Sumber ini tidak bisa memutar judul ini", + "notice_moved_to_app": "Sumber telah memindahkan judul ini ke aplikasinya sendiri, jadi tidak ada lagi yang bisa diputar di sini. Sumber lain mungkin masih punya.", + "notice_no_player": "Sumber mencantumkan judul ini tetapi tidak punya pemutar untuknya. Sumber lain mungkin punya.", + "notice_is_series": "Di sumber ini judul ini adalah serial, jadi diputar dari daftar episode, bukan sebagai satu film.", "anilist_track": "Lacak di AniList", "anilist_tracked": "Dilacak di AniList", "following_on": "Diikuti", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "SERI YANG SAMA", + "relation_tmdb_recommended": "DIREKOMENDASIKAN", "relation_sequel": "SEKUEL", "relation_prequel": "PREKUEL", "relation_parent": "CERITA UTAMA", @@ -1461,6 +1587,8 @@ "offline_desc": "Menampilkan daftar tersimpan. Saat offline hanya episode yang diunduh yang bisa dibuka.", "filter_hint": "Nomor atau judul episode", "all_groups": "Semua grup", + "season_n": "Musim {}", + "specials": "Spesial", "filter_none": "Tidak ada episode yang cocok dengan “{query}” di blok ini", "jump_to": "Ke episode {n}", "jump_loads": "Memuat {range}", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Masuk untuk meninggalkan komentar", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · ketuk untuk melihat", + "unwatched_hidden": "Belum ditonton · ketuk untuk melihat" }, "downloads": { "view_titles": "Judul", @@ -1955,7 +2086,8 @@ "title": "Daftar saya", "remove": "Singkirkan", "empty_title": "Belum ada yang disimpan", - "empty_subtitle": "Judul yang kamu simpan muncul di sini." + "empty_subtitle": "Judul yang kamu simpan muncul di sini.", + "load_failed": "Tidak dapat memuat daftar ini." }, "torrent": { "title": "Torrent", @@ -2229,7 +2361,25 @@ "settings_title": "Pengaturan {}", "settings_reset": "Atur ulang", "settings_failed": "Pengaturan sumber ini tidak dapat dibaca.", - "settings_none": "Sumber ini tidak punya pengaturan." + "settings_none": "Sumber ini tidak punya pengaturan.", + "loading_repo": "Memuat repositori…", + "add_sources": "Tambah sumber", + "choose_sources": "Pilih sumber", + "select_all": "Pilih semua", + "select_none": "Batalkan semua", + "install_selected": "Pasang ({})", + "already_installed": "Terpasang", + "remove": "Hapus", + "remove_source": "Hapus sumber", + "remove_source_confirm": "Hapus {}? Pengaturan dan kode tersimpannya akan dihapus. Anda dapat menambahkannya lagi dari repositorinya.", + "remove_repo_confirm": "Hapus repositori ini beserta semua sumbernya?", + "enable_source": "Aktifkan", + "disable_source": "Nonaktifkan", + "source_disabled": "Nonaktif", + "update": "Perbarui", + "update_all": "Perbarui semua", + "updates_available": "{} pembaruan tersedia", + "repo_check_failed": "Tidak dapat memeriksa {}: {}" }, "ux": { "quick_search": "Pencarian cepat ({})", @@ -2300,8 +2450,8 @@ "check_all": "Periksa sumber", "check_bad_hint": "Menyembunyikan sumber yang mati mengeluarkannya dari daftar ini; akan kembali sendiri begitu berfungsi lagi.", "check_done": "Pemeriksaan sumber", - "check_hide_down": "Sebagian besar sumber tidak bisa dijangkau sama sekali — masalahnya koneksi, bukan sumber. Tidak ada yang ditandai; coba lagi di jaringan yang berfungsi.", - "check_network": "Sembunyikan yang mati", + "check_hide_down": "Sembunyikan yang mati", + "check_network": "Sebagian besar sumber tidak bisa dijangkau sama sekali — masalahnya koneksi, bukan sumber. Tidak ada yang ditandai; coba lagi di jaringan yang berfungsi.", "check_retrying": "Mencoba ulang yang gagal…", "checking": "Memeriksa {} dari {}…", "d_ago": "{} hari lalu", @@ -2315,6 +2465,8 @@ "v_blocked": "Diblokir", "v_dead": "Tidak berfungsi", "v_empty": "Kosong", + "v_empty_body": "Sumber menjawab, tetapi katalognya kosong. Begitulah biasanya tampilan situs yang sudah menutup akses aplikasi.", + "v_failing_body": "Pemeriksaan terakhir sumber ini gagal. Satu kegagalan bisa saja kebetulan — periksa lagi.", "v_failing": "Tidak merespons", "v_outdated": "Perlu pembaruan", "v_unknown": "Belum diperiksa", @@ -2527,7 +2679,18 @@ "card_single": "Tambahkan profil untuk orang yang berbagi akun ini denganmu", "load_failed": "Gagal memuat profil", "offline": "Periksa koneksimu lalu coba lagi.", - "error_generic": "Terjadi kesalahan. Silakan coba lagi." + "error_generic": "Terjadi kesalahan. Silakan coba lagi.", + "on_this_device": "Di perangkat ini", + "ask_on_launch": "Tanya siapa yang menonton saat dibuka", + "ask_on_launch_desc": "Jika nonaktif, Sozo membuka profil terakhir yang dipakai. Profil dengan PIN tetap akan meminta PIN.", + "pin_locked_until": "Terlalu banyak percobaan. Coba lagi setelah {}.", + "pin_not_on_device": "Kamu sedang offline, dan PIN profil ini belum pernah dimasukkan di perangkat ini. Sambungkan ke internet sekali untuk membukanya.", + "biometric_reason": "Buka {}", + "biometric_offer_title": "Pakai sidik jari / Face ID?", + "biometric_offer_body": "Pakai sidik jari / Face ID untuk {} di perangkat ini. PIN tetap berfungsi.", + "biometric_offer_yes": "Pakai", + "biometric_offer_no": "Nanti saja", + "biometric_toggle": "Sidik jari / Face ID untuk {}" }, "jellyfin": { "connect_server": "Hubungkan server media (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Jangan lewatkan episode baru", "priming_body": "Sozo memberi tahu begitu judul yang kamu ikuti punya episode atau bab baru — tanpa hal lain yang tidak kamu minta.", "priming_body_title": "Langsung tahu begitu “{}” punya episode baru — tanpa hal lain yang tidak kamu minta.", + "priming_download_title": "Ketahui saat unduhanmu selesai", + "priming_download_body": "Sozo menampilkan progres unduhan di notifikasi dan memberi tahu saat siap ditonton offline. Sozo juga bisa memberi tahu episode baru dari yang kamu ikuti.", "priming_point_releases": "Episode baru, lengkap dengan poster", "priming_point_quiet": "Jam tenang", "priming_point_control": "Bisukan judul mana pun", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Hanya gambar jpg, png, atau webp.", "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", - "suggest": "Terus terjadi? Hubungi dukungan" + "suggest": "Terus terjadi? Hubungi dukungan", + "error_offline": "Kamu sedang offline atau koneksi terputus. Periksa internet lalu coba lagi.", + "not_sent": "Tidak terkirim: {}", + "diag_crash_log": "Lihat log kerusakan" + }, + "zangetsu": { + "title": "Sumber Zangetsu", + "row_subtitle": "Sumber JavaScript dari repo eksternal, berjalan di perangkatmu", + "desc": "Tambahkan repo Zangetsu untuk memasang sumber anime dan film tambahan. Sumber ini berjalan di perangkatmu. Sumber yang sudah ada di Sozo ditandai «Bawaan» dan tetap memakai versi Sozo sendiri.", + "loading_repo": "Memuat repo…", + "repo_added": "Repo ditambahkan · {} sumber.", + "checking": "Memeriksa pembaruan repo…", + "updates_available": "{} pembaruan tersedia.", + "installed_one": "{} terpasang.", + "removed_one": "{} dihapus.", + "updated": "{} sumber diperbarui.", + "update_all": "Perbarui semua ({})", + "update_to": "Perbarui ke v{}", + "built_in": "Bawaan", + "search": "Cari sumber…", + "all_types": "Semua", + "type_movies": "Film", + "all_languages": "Semua bahasa", + "error_invalid_url": "Hanya tautan https di raw.githubusercontent.com atau gist.githubusercontent.com yang bisa ditambahkan.", + "error_unreachable": "Repo ini tidak dapat dijangkau. Periksa tautan dan koneksimu.", + "error_invalid_index": "Tautan ini tidak mengarah ke index.json Zangetsu yang valid." + }, + "star": { + "title": "Ingin membantu Sozo berkembang?", + "body": "Beri bintang di GitHub ⭐", + "action": "Beri bintang di GitHub", + "later": "Nanti", + "never": "Jangan tanya lagi", + "update_button": "Beri bintang Sozo di GitHub" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Naik level", + "level_reached": "Kamu mencapai level {}", + "unlocked_new": "Baru di lokermu", + "open_locker": "Buka loker", + "unavailable": "Level belum tersedia. Cek lagi nanti.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP menuju level {}", + "to_next_short": "{} XP menuju level berikutnya", + "watch_today": "Menonton hari ini: {} dari {} XP", + "how_to_earn": "Cara mendapatkan XP", + "rule_watch": "Menonton: 1 XP per menit, hingga 300 per hari", + "rule_comment": "Komentar: 5 XP, hingga 10 komentar per hari", + "rule_like": "Suka pada komentarmu: 1 XP, hingga 50 per hari", + "rule_streak": "Hari beruntun: 10 XP, lebih banyak di hari pencapaian", + "rule_achievement": "Pencapaian: 50 hingga 400 XP sesuai tingkat", + "recent": "XP terbaru", + "reason_watch": "Menonton", + "reason_comment": "Komentar", + "reason_like": "Suka diterima", + "reason_streak": "Hari beruntun", + "reason_achievement": "Pencapaian", + "reason_other": "XP", + "locker": "Loker", + "slot_frame": "Bingkai avatar", + "slot_title": "Gelar", + "slot_color": "Warna nama", + "equip": "Ketuk untuk memakai", + "equipped": "Dipakai", + "unlock_level": "Level {}", + "unlock_badge": "Lencana: {}", + "frame_bronze": "Perunggu", + "frame_silver": "Perak", + "frame_gold": "Emas", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Bara", + "frame_flame": "Api", + "frame_moon": "Bulan", + "title_newcomer": "Pendatang baru", + "title_regular": "Pelanggan tetap", + "title_veteran": "Veteran", + "title_legend": "Legenda", + "title_binger": "Maraton tontonan", + "title_cinephile": "Pencinta film", + "title_otaku": "Otaku", + "title_bookworm": "Kutu buku", + "title_night_owl": "Burung hantu malam", + "title_host": "Tuan rumah pesta", + "color_sky": "Langit", + "color_mint": "Mint", + "color_gold": "Emas", + "color_rose": "Mawar", + "color_violet": "Ungu", + "color_crimson": "Merah tua", + "color_aurora": "Aurora", + "color_ember": "Bara" } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 556dc6e5..5e4e47f1 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -34,7 +34,9 @@ "remove": "Rake", "show": "Nîşan bide", "hide": "Veşêre", - "installed": "Sazkirî" + "installed": "Sazkirî", + "load_more_failed": "Bêtir nehat barkirin", + "go_home": "Biçe destpêkê" }, "auth": { "login": "Têkeve", @@ -306,7 +308,11 @@ "no_source": "Ji bo vê beşê çavkanî tune.", "empty_url": "Vê çavkaniyê tu navnîşana vîdyoyê neda.", "engine_unavailable": "Lêdar dest pê nekir. Ji kerema xwe sepanê bi tevahî ji nû ve bide destpêkirin.", - "unknown": "Vîdyo nehat barkirin." + "unknown": "Vîdyo nehat barkirin.", + "not_found": "Ev vîdyo êdî li ser vê serverê nîne. Serverek din biceribîne.", + "server_error": "Servera vîdyoyê pirsgirêk heye. Piştî demekê dîsa biceribîne an serverek din hilbijêre.", + "resolve_failed": "Ji vê çavkaniyê girêdana vîdyoyê nehat wergirtin.", + "details": "Hûrgilî" }, "play": "Lê bide", "pause": "Rawestîne", @@ -529,6 +535,22 @@ "alt_guess": "Texmîn", "alt_guess_spoken": "Lihevhatina ne bawer — berî lêdanê sernavê kontrol bike", "alt_your_pick": "Hilbijartina te", + "alt_auto": "Otomatîk", + "alt_auto_hint": "Ji çavkaniya yekem a ku ev heye dilîze", + "alt_auto_trying": "{} tê ceribandin…", + "alt_auto_missed": "Tu çavkaniyek lihevhatî nikarî wê bilîze. Yekê li jêr hilbijêre an di çavkaniyekê de bigere.", + "alt_auto_checking": "{} tê kontrolkirin · {} ji {}", + "alt_auto_waiting": "Li benda çavkaniyên din…", + "alt_auto_stop": "Rawestîne", + "alt_auto_stopped": "Hate rawestandin.", + "alt_auto_capped": "{} çavkanî hatin kontrolkirin û yek jî nikarîbû vê lê bide. Yekê ji jêr hilbijêre an jî dîsa biceribîne.", + "alt_auto_tried": "Kontrolkirî", + "alt_skip_no_match": "lihevhatineke nêzîk nîne", + "alt_skip_no_episode": "beş nehate dîtin", + "alt_skip_error": "nehate barkirin", + "auto_switch_attempt": "Hewl {} ji {}", + "auto_switch_stop": "Rawestîne", + "auto_switch_stopped": "Guhertina serveran hate rawestandin. Dîsa biceribîne, an serverek an çavkaniyek din hilbijêre.", "alt_wrong_title": "Sernav şaş e?", "alt_wrong_title_hint": "Bi xwe li {} bigere û sernavê rast hilbijêre", "alt_corrected": "Niha “{}” tê bikaranîn", @@ -554,10 +576,35 @@ "subtitle_n": "Binnivîs {}", "subtitle_in_video": "Di vîdyoyê de", "rewind": "Paşve bibe", + "rewind_by": "{}s paş", + "forward_by": "{}s pêş", "mini_restore": "Vegere pencereya tevahî", "pip_unavailable": "Wêne di nav wêneyê de ji bo vê vîdyoyê dest pê nekir.", "scrub_release_cancel": "Ji bo betalkirinê berde", - "auto_quality_desc_mpv": "Bi kalîteya herî baş a ku weşan pêşkêş dike dest pê dike" + "auto_quality_desc_mpv": "Bi kalîteya herî baş a ku weşan pêşkêş dike dest pê dike", + "reconnecting": "Girêdan qut bû — ji nû ve tê girêdan…", + "all_servers_failed": "Tu serverê vê sernavê nikarî wê bilîze. Paşê dîsa biceribîne an çavkaniyek din hilbijêre.", + "shortcut_pip": "Lîstikvana biçûk (Shift: li ser sepanên din)", + "mini_popout_hint": "Lîstikvana biçûk · dirêj bitikîne da ku li ser sepanên din bisekine", + "react": "Bertek", + "comments": "Şîrove", + "episode_comments": "Şîroveyên beşa {}", + "reaction_text": "Bertekek binivîse", + "reaction_text_hint": "Tiştekî kurt binivîse…", + "reaction_wait": "Berî ku dîsa bertek bidî çend saniyan bisekine", + "reaction_limit": "Te sînorê berteka vê beşê tije kir", + "reaction_failed": "Bertek nehat şandin", + "native_offer_title": "Lêdara nû biceribînî?", + "native_offer_body": "Bişkok û menuyên Liquid Glass ên Apple. Tu dikarî vê her dem di Mîheng → Lêdar de biguherînî.", + "native_offer_try": "Biceribîne", + "native_offer_not_now": "Ne niha", + "trying_again": "Dîsa tê ceribandin (ceribandina {})", + "try_mpv": "Bi motora mpv biceribîne", + "ok_to_skip": "Ji bo derbaskirinê OK", + "quality_lowered": "Kalîte hat kêmkirin", + "undo": "Paşve vegerîne", + "crash_log": "Tomara têkçûnan", + "crash_log_empty": "Tu têkçûn nehatiye tomarkirin" }, "profile": { "title": "Profîl", @@ -651,6 +698,15 @@ "player_engine_section": "MOTORA LÊDANÊ", "ask_engine_on_play": "Her car bipirse", "ask_engine_on_play_desc": "Her car ku vîdyoyek dest pê dike motorê hilbijêre", + "controls_style": "ŞÊWAZA BIŞKOJÊN LÊXISTOKÊ", + "controls_style_desc": "Gava cara din lêxistokê vekî dê derbasdar bibe.", + "controls_style_native": "Xwecihî (iOS 26)", + "controls_style_native_desc": "Bişkok, menû û rûpelên Liquid Glass ên telefonê bi xwe", + "controls_style_system": "Cama pergalê", + "controls_style_system_desc": "Bişkojên sepanê li ser Liquid Glass a telefonê", + "controls_style_classic": "Cama klasîk", + "controls_style_classic_desc": "Cama sepanê bi xwe, wek di guhertoyên berê de", + "controls_style_needs_ios26": "iOS 26 an nûtir pêwîst e", "playback_section": "LÊDAN", "default_speed": "Leza standard", "default_speed_desc": "Leza ku her vîdyo pê dest pê dike", @@ -673,6 +729,8 @@ "brightness_gesture_desc": "Li aliyê çepê ber bi jor û jêr bişemitîne", "volume_gesture": "Şemitandina deng", "volume_gesture_desc": "Li aliyê rastê ber bi jor û jêr bişemitîne", + "auto_pip": "Wêne di nav wêneyê de bixweber", + "auto_pip_desc": "Dema vîdyo dilîze û tu Destpêkê bitikînî, ew di pencereyeke biçûk de didome", "seconds_short": "{}s", "subtitle_style_note": "Ev ji bo her vîdyoyê derbasdar in. Demkirina binnivîsê ji bo her beşê cuda dimîne û di nav lêdarê de tê sazkirin.", "searching_all_sources": "li hemû çavkaniyan tê gerîn", @@ -743,7 +801,15 @@ "library_update": "Li beşên nû kontrol bike", "library_update_desc": "Li sernavên ku tu dişopînî dinêre û dema yekî nû derkeve te agahdar dike", "library_update_every": "Her {} saetan", - "library_update_daily": "Rojê carekê" + "library_update_daily": "Rojê carekê", + "contributors": "Beşdar", + "contributors_error": "Beşdar nehatin barkirin", + "become_contributor": "Bibe beşdar", + "become_contributor_body": "Sozo çavkaniya vekirî ye. Kod, werger û raporên çewtiyan hemû alîkar in.", + "reactions_overlay": "Bertekên temaşevanan", + "reactions_overlay_desc": "Bertekên temaşevanên din di wê kêliyê de ku hatine şandin nîşan bide, û cihê ku li ser darika demê kom dibin", + "hide_spoilers": "Spoilerên beşên ku min temaşe nekirine veşêre", + "hide_spoilers_desc": "Şîroveyên li ser beşeke ku te neqedandiye heta ku tu wan bitikînî şêlû dimînin" }, "appearance": { "title": "Xuyang", @@ -866,8 +932,20 @@ "all_sources_failed_one": "Çavkaniya ku te lê geriya bersiv neda. Ev ne encameke vala ye — dîsa biceribîne.", "voice": "Bi deng bigere", "voice_listening": "Guhdarî dike…", - "voice_done": "Guhdarî rawestiya", "voice_hint": "Navê fîlmek an rêzefîlmekê bibêje", + "voice_starting": "Mîkrofon tê amadekirin…", + "voice_no_speech": "Me fêm nekir", + "voice_no_speech_hint": "Hinekî bilindtir û nêzîkî mîkrofonê biaxive, paşê dîsa biceribîne.", + "voice_permission": "Destûra mîkrofonê girtî ye", + "voice_permission_hint": "Ji bo lêgerîna bi deng, di mîhengên pergalê de destûrê bide Sozo ku mîkrofonê bi kar bîne.", + "voice_unavailable": "Naskirina axaftinê li ser vê cîhazê tune ye", + "voice_unavailable_hint": "Li ser vê cîhazê xizmeteke naskirina axaftinê tune ye. Di mîhengên pergalê de yekê saz bike an veke, wek ya Google.", + "voice_network": "Negihîşt xizmeta axaftinê", + "voice_network_hint": "Girêdana xwe ya înternetê kontrol bike û dîsa biceribîne.", + "voice_error": "Di dema guhdarîkirinê de tiştek xelet çû", + "voice_error_hint": "Dibe ku sepaneke din mîkrofonê bi kar tîne. Piştî demekê dîsa biceribîne.", + "voice_try_again": "Dîsa biceribîne", + "voice_open_settings": "Mîhengan veke", "try_torrents": "Li torrentan bigere", "capped_note": "Li {} çavkaniyên herî pêbawer ji {} hat gerîn — qadê teng bike da ku bigihîjî yeka taybet.", "rail_popular_on": "Populer li ser {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "Te PIN ji bîr kir?" }, "my_list": { + "sort": "Rêzkirin", + "sort_added": "Nû hatine zêdekirin", + "sort_oldest": "Kevntirîn hatine zêdekirin", + "sort_title": "Sernav A–Z", "saved_account": "Li hesabê hat tomarkirin", "saved_local": "Li ser vê cîhazê", "untitled": "Bê sernav", @@ -1160,7 +1242,33 @@ "reconnecting": "Ji nû ve tê girêdan…", "connecting": "Tê girêdan…", "connect_failed_title": "Girêdan pêk nehat", - "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne." + "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne.", + "settings_title": "Mîhengên odeyê", + "room_size": "Mezinahiya odeyê", + "room_size_less": "Kesên kêmtir", + "room_size_more": "Kesên zêdetir", + "only_host_setting": "Tenê mazûvan lêdanê kontrol dike", + "lock_room": "Odeyê kilît bike", + "lock_room_hint": "Kesekî nû nikare tevlî bibe", + "wait_for_all": "Li hemûyan bisekine", + "wait_for_all_hint": "Dema yek bar dike, kurt rawestîne", + "invite_friends": "Hevalan vexwîne", + "invite_sent": "Vexwendin hat şandin", + "invite_failed": "Vexwendin nehat şandin", + "invite_again_in": "Dîsa piştî {}", + "invite_no_friends": "Hevalan lê zêde bike da ku li vir wan vexwînî", + "make_host": "Bike mazûvan", + "remove_member": "Ji şahiyê derxe", + "remove_confirm": "{} ji vê şahiyê were derxistin? Ew nikare dîsa tevlî bibe.", + "buffering": "Tê barkirin…", + "sys_joined": "{} tevlî bû", + "sys_left": "{} derket", + "sys_host": "{} niha mazûvan e", + "sys_removed": "{} hat derxistin", + "error_banned": "Tu ji vê şahiyê hatî derxistin", + "error_locked": "Ev şahî kilîtkirî ye", + "room_closed_kicked": "Mazûvan tu ji vê şahiyê derxistî", + "waiting_for_others": "Li benda hemûyan in ku bigihîjin…" }, "notifications": { "title": "Agahdarî", @@ -1281,7 +1389,19 @@ "highlights": "Ronîkirin", "remove_highlight": "Ronîkirinê rake", "highlight_note": "Notekê zêde bike (bijarte)", - "no_highlights": "Hîn ronîkirin tune. Hinek nivîsê hilbijêre û Ronî bike bitikîne." + "no_highlights": "Hîn ronîkirin tune. Hinek nivîsê hilbijêre û Ronî bike bitikîne.", + "translate_page": "Rûpelê wergerîne", + "translate_preview": "Rûpela wergerandî", + "translate_apply": "Bisepîne", + "translate_show_original": "Orîjînalê nîşan bide", + "translate_no_text": "Di vê rûpelê de tu nivîs nehat dîtin.", + "translate_daily_limit": "Wergerên te yên îro qediyan. Sibê dîsa biceribîne.", + "translate_offline": "Tu ne girêdayî yî. Ji bo wergerandina vê rûpelê bi înternetê ve girê bide.", + "translate_sign_in": "Ji bo wergerandina rûpelan têkeve.", + "translate_failed": "Ev rûpel nehat wergerandin. Dîsa biceribîne.", + "translate_language": "Rûpelan wergerîne bo", + "translate_auto": "Dema dixwînim her rûpelê wergerîne", + "translate_auto_desc": "Her rûpela nû yek ji wergerên îro bi kar tîne." }, "cloudflare": { "title": "Piştrast bike ku tu mirov î", @@ -1363,6 +1483,10 @@ "watched": "Temaşekirî", "follow_series": "Rêzefîlmê bişopîne", "find_other_sources": "Çavkaniyên din bibîne", + "notice_title": "This source can't play this title", + "notice_moved_to_app": "This source has moved this title into its own app, so there is nothing left here to play. Another source may still have it.", + "notice_no_player": "This source lists this title but has no player for it. Another source may still have it.", + "notice_is_series": "On this source this is a series, so it plays from the episode list rather than as a single film.", "anilist_track": "Li ser AniList bişopîne", "anilist_tracked": "Li ser AniList tê şopandin", "following_on": "Tê şopandin", @@ -1393,6 +1517,8 @@ "download_batch_body": "Beşên {range} dê bikevin rêzê — bi tevahî {count}. Bi daneyên mobîl ev dikare çend gîgabayt be.", "relations": "Têkilî", "no_relations": "Ji bo vê tu sernavên têkildar nehatin dîtin.", + "relation_tmdb_collection": "HEMAN RÊZE", + "relation_tmdb_recommended": "PÊŞNIYARKIRÎ", "relation_sequel": "DOMAHÎ", "relation_prequel": "BERÎ", "relation_parent": "ÇÎROKA SEREKE", @@ -1461,6 +1587,8 @@ "offline_desc": "Lîsteya tomarkirî tê nîşandan. Tenê beşên daxistî offline vedibin.", "filter_hint": "Hejmar an sernavê beşê", "all_groups": "Hemû kom", + "season_n": "Demsala {}", + "specials": "Taybet", "filter_none": "Di vê blokê de tu beş li “{query}” nayê", "jump_to": "Biçe beşa {n}", "jump_loads": "{range} bar dike", @@ -1505,7 +1633,10 @@ "show_more": "Bêtir nîşan bide", "delete_confirm_title": "Vê şîroveyê jê bibî?", "delete_confirm_body": "Ev çalakî nayê vegerandin.", - "sign_in_prompt": "Ji bo şîroveyekê têkeve" + "sign_in_prompt": "Ji bo şîroveyekê têkeve", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · ji bo dîtinê bitikîne", + "unwatched_hidden": "Hîn nehatiye temaşekirin · ji bo dîtinê bitikîne" }, "downloads": { "view_titles": "Sernav", @@ -1955,7 +2086,8 @@ "title": "Lîsteyên min", "remove": "Rake", "empty_title": "Hîn tiştek nehatiye tomarkirin", - "empty_subtitle": "Sernavên ku tu tomar dikî li vir xuya dibin." + "empty_subtitle": "Sernavên ku tu tomar dikî li vir xuya dibin.", + "load_failed": "Ev lîste nehat barkirin." }, "torrent": { "title": "Torrent", @@ -2229,7 +2361,25 @@ "settings_title": "Mîhengên {}", "settings_reset": "Ji nû ve saz bike", "settings_failed": "Mîhengên vê çavkaniyê nehatin xwendin.", - "settings_none": "Mîhengên vê çavkaniyê tune ne." + "settings_none": "Mîhengên vê çavkaniyê tune ne.", + "loading_repo": "Depo tê barkirin…", + "add_sources": "Çavkaniyan zêde bike", + "choose_sources": "Çavkaniyan hilbijêre", + "select_all": "Hemûyan hilbijêre", + "select_none": "Hilbijartinê rake", + "install_selected": "Saz bike ({})", + "already_installed": "Sazkirî", + "remove": "Rake", + "remove_source": "Çavkaniyê rake", + "remove_source_confirm": "{} were rakirin? Mîheng û koda wê ya tomarkirî tên jêbirin. Tu dikarî wê ji depoya wê dîsa zêde bikî.", + "remove_repo_confirm": "Ev depo û hemû çavkaniyên wê werin rakirin?", + "enable_source": "Çalak bike", + "disable_source": "Neçalak bike", + "source_disabled": "Neçalak", + "update": "Nû bike", + "update_all": "Hemûyan nû bike", + "updates_available": "{} nûkirin hene", + "repo_check_failed": "{} nehat kontrolkirin: {}" }, "ux": { "quick_search": "Lêgerîna bilez ({})", @@ -2300,8 +2450,8 @@ "check_all": "Çavkaniyan kontrol bike", "check_bad_hint": "Veşartina çavkaniyên ku naxebitin wan ji van lîsteyan dûr dixe; dema dîsa bixebitin bi xwe vedigerin.", "check_done": "Kontrola çavkaniyan", - "check_hide_down": "Piraniya çavkaniyan qet nehatin gihîştin — ev ji girêdanê ye, ne ji çavkaniyan. Tiştek nehat nîşankirin; li ser toreke ku dixebite dîsa biceribîne.", - "check_network": "Çavkaniyên naxebitin veşêre", + "check_hide_down": "Çavkaniyên naxebitin veşêre", + "check_network": "Piraniya çavkaniyan qet nehatin gihîştin — ev ji girêdanê ye, ne ji çavkaniyan. Tiştek nehat nîşankirin; li ser toreke ku dixebite dîsa biceribîne.", "check_retrying": "Yên têkçûyî dîsa tên ceribandin…", "checking": "{} ji {} tê kontrolkirin…", "d_ago": "{} r berê", @@ -2315,6 +2465,8 @@ "v_blocked": "Astengkirî", "v_dead": "Naxebite", "v_empty": "Vala", + "v_empty_body": "This source answered, but its catalogue came back empty. That is often what a site looks like once it has shut the app out.", + "v_failing_body": "The last check of this source failed. One failure can be a bad minute — check again to find out.", "v_failing": "Bersiv nade", "v_outdated": "Nûkirin pêwîst e", "v_unknown": "Hîn nehatiye kontrolkirin", @@ -2527,7 +2679,18 @@ "card_single": "Ji bo kesên ku tu vî hesabî bi wan re parve dikî profîlan zêde bike", "load_failed": "Profîl nehatin barkirin", "offline": "Girêdana xwe kontrol bike û dîsa biceribîne.", - "error_generic": "Tiştek xelet çû. Ji kerema xwe dîsa biceribîne." + "error_generic": "Tiştek xelet çû. Ji kerema xwe dîsa biceribîne.", + "on_this_device": "Li ser vê cîhazê", + "ask_on_launch": "Di destpêkê de bipirse kî temaşe dike", + "ask_on_launch_desc": "Dema girtî be, Sozo profîla dawî ya hatî bikaranîn vedike. Profîlên bi PIN dîsa dipirsin.", + "pin_locked_until": "Gelek hewl. Piştî {} dîsa biceribîne.", + "pin_not_on_device": "Tu offline yî, û PIN-a vê profîlê hê li ser vê cîhazê nehatiye nivîsandin. Ji bo vekirinê carekê bi înternetê ve girê bide.", + "biometric_reason": "{} veke", + "biometric_offer_title": "Şopa tilîyê / Face ID bi kar bîne?", + "biometric_offer_body": "Ji bo {} li ser vê cîhazê şopa tilîyê / Face ID bi kar bîne. PIN jî dê bixebite.", + "biometric_offer_yes": "Bi kar bîne", + "biometric_offer_no": "Ne niha", + "biometric_toggle": "Şopa tilîyê / Face ID ji bo {}" }, "jellyfin": { "connect_server": "Serverekî medyayê girê bide (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Tu carî beşeke nû ji dest nede", "priming_body": "Sozo di kêliya ku tiştekî ku tu dişopînî beşeke an fesleke nû distîne de ji te re dibêje — û tiştekî ku te nexwestiye na.", "priming_body_title": "Di kêliya ku {} beşeke nû distîne de agahdar bibe — û tiştekî ku te nexwestiye na.", + "priming_download_title": "Bizane kengê daxistina te diqede", + "priming_download_body": "Sozo pêşketina daxistinê di agahdariyan de nîşan dide û dema ku ji bo temaşekirina bê înternet amade be ji te re dibêje. Ew dikare li ser beşên nû yên tiştên ku tu dişopînî jî te agahdar bike.", "priming_point_releases": "Beşên nû, bi posterê", "priming_point_quiet": "Saetên bêdeng", "priming_point_control": "Her sernavê bêdeng bike", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Tenê wêneyên jpg, png an webp.", "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", - "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" + "suggest": "Dubare dibe? Ji piştgiriyê re binivîse", + "error_offline": "Tu offline î an girêdan qut bû. Înternetê kontrol bike û dîsa biceribîne.", + "not_sent": "Nehat şandin: {}", + "diag_crash_log": "Tomara têkçûnan bibîne" + }, + "zangetsu": { + "title": "Çavkaniyên Zangetsu", + "row_subtitle": "Çavkaniyên JavaScript ji repoyên derveyî, li ser cîhaza te dixebitin", + "desc": "Repoyeke Zangetsu lê zêde bike da ku çavkaniyên anime û fîlman ên zêdetir saz bikî. Ew li ser cîhaza te dixebitin. Çavkaniyên ku Sozo jixwe heye wekî «Navxweyî» têne nîşankirin û guhertoya Sozo bi xwe tê bikaranîn.", + "loading_repo": "Repo tê barkirin…", + "repo_added": "Repo hat zêdekirin · {} çavkanî.", + "checking": "Nûvekirinên repoyan têne kontrolkirin…", + "updates_available": "{} nûvekirin hene.", + "installed_one": "{} hat sazkirin.", + "removed_one": "{} hat rakirin.", + "updated": "{} çavkanî hatin nûvekirin.", + "update_all": "Hemûyan nû bike ({})", + "update_to": "Nû bike bo v{}", + "built_in": "Navxweyî", + "search": "Li çavkaniyan bigere…", + "all_types": "Hemû", + "type_movies": "Fîlm", + "all_languages": "Hemû ziman", + "error_invalid_url": "Tenê girêdanên https li ser raw.githubusercontent.com an gist.githubusercontent.com dikarin werin zêdekirin.", + "error_unreachable": "Negihîşt vê repoyê. Girêdan û pêwendiya xwe kontrol bike.", + "error_invalid_index": "Ev girêdan ne index.json-eke derbasdar a Zangetsu ye." + }, + "star": { + "title": "Tu dixwazî alîkariya mezinbûna Sozo bikî?", + "body": "Li GitHubê stêrkekê bidê ⭐", + "action": "Li GitHubê stêrk bide", + "later": "Paşê", + "never": "Careke din nepirse", + "update_button": "Li GitHubê stêrkekê bide Sozo" + }, + "progress": { + "title": "Ast û XP", + "level": "Ast", + "level_short": "Ast {}", + "level_up": "Ast bilind bû", + "level_reached": "Tu gihîştî asta {}", + "unlocked_new": "Nû di dolaba te de", + "open_locker": "Dolabê veke", + "unavailable": "Ast hêj ne amade ne. Paşê dîsa binêre.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP heta asta {}", + "to_next_short": "Heta asta din {} XP", + "watch_today": "Temaşeya îro: {} ji {} XP", + "how_to_earn": "Meriv çawa XP qezenc dike", + "rule_watch": "Temaşe: her deqîqe 1 XP, rojê heta 300", + "rule_comment": "Şîrove: 5 XP, rojê heta 10 şîroveyan", + "rule_like": "Ecibandinek li şîroveya te: 1 XP, rojê heta 50", + "rule_streak": "Roja rêzê: 10 XP, di rojên girîng de bêtir", + "rule_achievement": "Serkeftin: li gorî astê ji 50 heta 400 XP", + "recent": "XP-ya dawî", + "reason_watch": "Temaşe", + "reason_comment": "Şîrove", + "reason_like": "Ecibandinên hatine", + "reason_streak": "Roja rêzê", + "reason_achievement": "Serkeftin", + "reason_other": "XP", + "locker": "Dolab", + "slot_frame": "Çarçoveyên avatarê", + "slot_title": "Nasnav", + "slot_color": "Rengên navî", + "equip": "Ji bo lixwekirinê bitikîne", + "equipped": "Li xwe kirî", + "unlock_level": "Ast {}", + "unlock_badge": "Nîşan: {}", + "frame_bronze": "Bronz", + "frame_silver": "Zîv", + "frame_gold": "Zêr", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Pelû", + "frame_flame": "Agir", + "frame_moon": "Heyv", + "title_newcomer": "Nûhatî", + "title_regular": "Domdar", + "title_veteran": "Kevnar", + "title_legend": "Efsane", + "title_binger": "Temaşevanê bêrawestan", + "title_cinephile": "Evîndarê sînemayê", + "title_otaku": "Otaku", + "title_bookworm": "Pirtûkxwîn", + "title_night_owl": "Kundê şevê", + "title_host": "Mazûvanê şahiyê", + "color_sky": "Asîmanî", + "color_mint": "Pûng", + "color_gold": "Zêrîn", + "color_rose": "Gulî", + "color_violet": "Mor", + "color_crimson": "Sorê tarî", + "color_aurora": "Ronahiya bakur", + "color_ember": "Pelû" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 20aed06f..bed20698 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -34,7 +34,9 @@ "remove": "Verwijderen", "show": "Tonen", "hide": "Verbergen", - "installed": "Geïnstalleerd" + "installed": "Geïnstalleerd", + "load_more_failed": "Kon niet meer laden", + "go_home": "Naar start" }, "auth": { "login": "Inloggen", @@ -306,7 +308,11 @@ "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." + "unknown": "Video kon niet worden geladen.", + "not_found": "Deze video staat niet meer op deze server. Probeer een andere server.", + "server_error": "De videoserver heeft problemen. Probeer het zo opnieuw of kies een andere server.", + "resolve_failed": "Kon geen videolink van deze bron krijgen.", + "details": "Details" }, "play": "Afspelen", "pause": "Pauze", @@ -529,6 +535,22 @@ "alt_guess": "Gok", "alt_guess_spoken": "Onzekere match — controleer de titel voordat je afspeelt", "alt_your_pick": "Jouw keuze", + "alt_auto": "Automatisch", + "alt_auto_hint": "Speelt af van de eerste bron die het heeft", + "alt_auto_trying": "{} proberen…", + "alt_auto_missed": "Geen passende bron kon het afspelen. Kies er hieronder een of doorzoek een bron.", + "alt_auto_checking": "{} controleren · {} van {}", + "alt_auto_waiting": "Wachten op meer bronnen…", + "alt_auto_stop": "Stoppen", + "alt_auto_stopped": "Gestopt.", + "alt_auto_capped": "{} bronnen gecontroleerd, geen enkele kon dit afspelen. Kies er hieronder een of probeer het opnieuw.", + "alt_auto_tried": "Gecontroleerd", + "alt_skip_no_match": "geen goede overeenkomst", + "alt_skip_no_episode": "aflevering niet gevonden", + "alt_skip_error": "kon niet laden", + "auto_switch_attempt": "Poging {} van {}", + "auto_switch_stop": "Stoppen", + "auto_switch_stopped": "Wisselen van server gestopt. Probeer het opnieuw, of kies een andere server of bron.", "alt_wrong_title": "Verkeerde titel?", "alt_wrong_title_hint": "Zoek zelf in {} en kies de juiste titel", "alt_corrected": "Gebruikt nu “{}”", @@ -554,10 +576,35 @@ "subtitle_n": "Ondertiteling {}", "subtitle_in_video": "In de video", "rewind": "Terugspoelen", + "rewind_by": "{} s terug", + "forward_by": "{} s vooruit", "mini_restore": "Terug naar het volledige venster", "pip_unavailable": "Beeld-in-beeld kon voor deze video niet starten.", "scrub_release_cancel": "Loslaten om te annuleren", - "auto_quality_desc_mpv": "Begint op de beste kwaliteit van de stream" + "auto_quality_desc_mpv": "Begint op de beste kwaliteit van de stream", + "reconnecting": "Verbinding verbroken — opnieuw verbinden…", + "all_servers_failed": "Geen van de servers van deze titel kon hem afspelen. Probeer het later of kies een andere bron.", + "shortcut_pip": "Minispeler (Shift: boven andere apps)", + "mini_popout_hint": "Minispeler · lang indrukken om boven andere apps te zweven", + "react": "Reageren", + "comments": "Reacties", + "episode_comments": "Reacties op aflevering {}", + "reaction_text": "Reactie typen", + "reaction_text_hint": "Iets korts…", + "reaction_wait": "Wacht een paar seconden voordat je opnieuw reageert", + "reaction_limit": "Je hebt het reactielimiet voor deze aflevering bereikt", + "reaction_failed": "Kon de reactie niet versturen", + "native_offer_title": "De nieuwe speler proberen?", + "native_offer_body": "De Liquid Glass-bediening en -menu’s van Apple. Je kunt dit altijd wijzigen via Instellingen → Speler.", + "native_offer_try": "Proberen", + "native_offer_not_now": "Niet nu", + "trying_again": "Opnieuw proberen (poging {})", + "try_mpv": "Proberen met de mpv-engine", + "ok_to_skip": "OK om over te slaan", + "quality_lowered": "Kwaliteit verlaagd", + "undo": "Ongedaan maken", + "crash_log": "Crashlogboek", + "crash_log_empty": "Geen crashes vastgelegd" }, "profile": { "title": "Profiel", @@ -649,6 +696,15 @@ "player_engine_section": "AFSPEELENGINE", "ask_engine_on_play": "Elke keer vragen", "ask_engine_on_play_desc": "Kies de engine telkens als een video start", + "controls_style": "STIJL VAN DE SPELERKNOPPEN", + "controls_style_desc": "Geldt vanaf de volgende keer dat je de speler opent.", + "controls_style_native": "Native (iOS 26)", + "controls_style_native_desc": "De eigen Liquid Glass-knoppen, -menu's en -panelen van de telefoon", + "controls_style_system": "Systeemglas", + "controls_style_system_desc": "De knoppen van de app op het Liquid Glass van de telefoon", + "controls_style_classic": "Klassiek glas", + "controls_style_classic_desc": "Het eigen glas van de app, zoals in eerdere versies", + "controls_style_needs_ios26": "Vereist iOS 26 of nieuwer", "playback_section": "AFSPELEN", "default_speed": "Standaardsnelheid", "default_speed_desc": "Snelheid waarmee elke video begint", @@ -671,6 +727,8 @@ "brightness_gesture_desc": "Veeg omhoog en omlaag aan de linkerkant", "volume_gesture": "Volume vegen", "volume_gesture_desc": "Veeg omhoog en omlaag aan de rechterkant", + "auto_pip": "Automatisch beeld-in-beeld", + "auto_pip_desc": "Als je tijdens een video op Home drukt, speelt hij verder in een klein venster", "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", @@ -743,7 +801,15 @@ "library_update": "Zoeken naar nieuwe afleveringen", "library_update_desc": "Controleert de titels die je volgt en meldt nieuwe afleveringen", "library_update_every": "Elke {} uur", - "library_update_daily": "Eén keer per dag" + "library_update_daily": "Eén keer per dag", + "contributors": "Bijdragers", + "contributors_error": "Bijdragers konden niet worden geladen", + "become_contributor": "Word bijdrager", + "become_contributor_body": "Sozo is open source. Code, vertalingen en bugmeldingen helpen allemaal.", + "reactions_overlay": "Reacties van kijkers", + "reactions_overlay_desc": "Reacties van andere kijkers tonen op het moment dat ze zijn verstuurd, en waar ze zich op de tijdbalk ophopen", + "hide_spoilers": "Spoilers verbergen voor afleveringen die ik niet heb gezien", + "hide_spoilers_desc": "Reacties over een aflevering die je nog niet hebt uitgekeken blijven wazig tot je erop tikt" }, "appearance": { "title": "Weergave", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Microfoon wordt klaargemaakt…", + "voice_no_speech": "Dat verstond ik niet", + "voice_no_speech_hint": "Spreek iets luider en dichter bij de microfoon en probeer het opnieuw.", + "voice_permission": "Microfoontoestemming staat uit", + "voice_permission_hint": "Geef Sozo in de systeeminstellingen toegang tot de microfoon om met je stem te zoeken.", + "voice_unavailable": "Spraakherkenning is niet beschikbaar op dit apparaat", + "voice_unavailable_hint": "Dit apparaat heeft geen spraakherkenningsdienst. Installeer of activeer er een in de systeeminstellingen, zoals die van Google.", + "voice_network": "Spraakdienst niet bereikbaar", + "voice_network_hint": "Controleer je internetverbinding en probeer het opnieuw.", + "voice_error": "Er ging iets mis tijdens het luisteren", + "voice_error_hint": "Misschien gebruikt een andere app de microfoon. Probeer het zo opnieuw.", + "voice_try_again": "Opnieuw proberen", + "voice_open_settings": "Instellingen openen", "try_torrents": "Torrents zoeken", "capped_note": "De {} betrouwbaarste van {} bronnen doorzocht — beperk de selectie om een specifieke te bereiken.", "rail_popular_on": "Populair op {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "Pincode vergeten?" }, "my_list": { + "sort": "Sorteren", + "sort_added": "Recent toegevoegd", + "sort_oldest": "Eerst toegevoegd", + "sort_title": "Titel A–Z", "saved_account": "Opgeslagen in account", "saved_local": "Op dit apparaat", "untitled": "Zonder titel", @@ -1160,7 +1242,33 @@ "reconnecting": "Opnieuw verbinden…", "connecting": "Verbinden…", "connect_failed_title": "Verbinden mislukt", - "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw." + "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw.", + "settings_title": "Kamerinstellingen", + "room_size": "Kamergrootte", + "room_size_less": "Minder mensen", + "room_size_more": "Meer mensen", + "only_host_setting": "Alleen de host bedient het afspelen", + "lock_room": "Kamer vergrendelen", + "lock_room_hint": "Niemand nieuw kan meedoen", + "wait_for_all": "Op iedereen wachten", + "wait_for_all_hint": "Kort pauzeren als iemand aan het bufferen is", + "invite_friends": "Vrienden uitnodigen", + "invite_sent": "Uitnodiging verstuurd", + "invite_failed": "Kon de uitnodiging niet versturen", + "invite_again_in": "Weer over {}", + "invite_no_friends": "Voeg vrienden toe om ze hier uit te nodigen", + "make_host": "Host maken", + "remove_member": "Uit de party verwijderen", + "remove_confirm": "{} uit deze party verwijderen? Opnieuw meedoen is dan niet mogelijk.", + "buffering": "Bufferen…", + "sys_joined": "{} doet mee", + "sys_left": "{} is vertrokken", + "sys_host": "{} is nu de host", + "sys_removed": "{} is verwijderd", + "error_banned": "Je bent uit deze party verwijderd", + "error_locked": "Deze party is vergrendeld", + "room_closed_kicked": "De host heeft je uit deze party verwijderd", + "waiting_for_others": "Wachten tot iedereen bij is…" }, "notifications": { "title": "Meldingen", @@ -1281,7 +1389,19 @@ "highlights": "Markeringen", "remove_highlight": "Markering verwijderen", "highlight_note": "Notitie toevoegen (optioneel)", - "no_highlights": "Nog geen markeringen. Selecteer tekst en kies Markeren." + "no_highlights": "Nog geen markeringen. Selecteer tekst en kies Markeren.", + "translate_page": "Pagina vertalen", + "translate_preview": "Vertaalde pagina", + "translate_apply": "Toepassen", + "translate_show_original": "Origineel tonen", + "translate_no_text": "Geen tekst gevonden op deze pagina.", + "translate_daily_limit": "Je hebt de vertalingen van vandaag opgebruikt. Probeer het morgen opnieuw.", + "translate_offline": "Je bent offline. Maak verbinding met internet om deze pagina te vertalen.", + "translate_sign_in": "Log in om pagina's te vertalen.", + "translate_failed": "Deze pagina kon niet worden vertaald. Probeer het opnieuw.", + "translate_language": "Pagina's vertalen naar", + "translate_auto": "Elke pagina vertalen tijdens het lezen", + "translate_auto_desc": "Elke nieuwe pagina gebruikt een van de vertalingen van vandaag." }, "cloudflare": { "title": "Bevestig dat je een mens bent", @@ -1363,6 +1483,10 @@ "watched": "Bekeken", "follow_series": "Serie volgen", "find_other_sources": "Andere bronnen zoeken", + "notice_title": "Deze bron kan deze titel niet afspelen", + "notice_moved_to_app": "De bron heeft deze titel naar zijn eigen app verplaatst, dus hier is niets meer om af te spelen. Een andere bron heeft hem misschien nog.", + "notice_no_player": "De bron vermeldt deze titel, maar heeft er geen speler voor. Een andere bron heeft hem misschien.", + "notice_is_series": "Op deze bron is dit een serie; die speelt vanuit de afleveringenlijst en niet als één film.", "anilist_track": "Volgen op AniList", "anilist_tracked": "Gevolgd op AniList", "following_on": "Wordt gevolgd", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "ZELFDE REEKS", + "relation_tmdb_recommended": "AANBEVOLEN", "relation_sequel": "VERVOLG", "relation_prequel": "VOORGESCHIEDENIS", "relation_parent": "HOOFDVERHAAL", @@ -1461,6 +1587,8 @@ "offline_desc": "Bewaarde lijst. Offline openen alleen gedownloade afleveringen.", "filter_hint": "Afleveringsnummer of titel", "all_groups": "Alle groepen", + "season_n": "Seizoen {}", + "specials": "Specials", "filter_none": "Geen aflevering die past bij “{query}” in dit blok", "jump_to": "Naar aflevering {n}", "jump_loads": "Laadt {range}", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Log in om een reactie te plaatsen", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · tik om te tonen", + "unwatched_hidden": "Nog niet gezien · tik om te tonen" }, "downloads": { "view_titles": "Titels", @@ -1955,7 +2086,8 @@ "title": "Mijn lijsten", "remove": "Verwijderen", "empty_title": "Nog niets opgeslagen", - "empty_subtitle": "Titels die je opslaat verschijnen hier." + "empty_subtitle": "Titels die je opslaat verschijnen hier.", + "load_failed": "Kon deze lijst niet laden." }, "torrent": { "title": "Torrents", @@ -2229,7 +2361,25 @@ "settings_title": "Instellingen van {}", "settings_reset": "Herstellen", "settings_failed": "De instellingen van deze bron konden niet worden gelezen.", - "settings_none": "Deze bron heeft geen instellingen." + "settings_none": "Deze bron heeft geen instellingen.", + "loading_repo": "Repository laden…", + "add_sources": "Bronnen toevoegen", + "choose_sources": "Kies bronnen", + "select_all": "Alles selecteren", + "select_none": "Niets selecteren", + "install_selected": "Installeren ({})", + "already_installed": "Geïnstalleerd", + "remove": "Verwijderen", + "remove_source": "Bron verwijderen", + "remove_source_confirm": "{} verwijderen? De instellingen en gecachte code worden gewist. Je kunt de bron opnieuw toevoegen via de repository.", + "remove_repo_confirm": "Deze repository en al zijn bronnen verwijderen?", + "enable_source": "Inschakelen", + "disable_source": "Uitschakelen", + "source_disabled": "Uitgeschakeld", + "update": "Bijwerken", + "update_all": "Alles bijwerken", + "updates_available": "{} update(s) beschikbaar", + "repo_check_failed": "Kon {} niet controleren: {}" }, "ux": { "quick_search": "Snel zoeken ({})", @@ -2300,8 +2450,8 @@ "check_all": "Bronnen controleren", "check_bad_hint": "Bronnen die plat liggen verbergen haalt ze uit deze lijsten; ze komen vanzelf terug zodra ze weer werken.", "check_done": "Broncontrole", - "check_hide_down": "De meeste bronnen waren helemaal niet bereikbaar — dat is de verbinding, niet de bronnen. Er is niets gemarkeerd; probeer het op een werkend netwerk.", - "check_network": "Verberg wat niet werkt", + "check_hide_down": "Verberg wat niet werkt", + "check_network": "De meeste bronnen waren helemaal niet bereikbaar — dat is de verbinding, niet de bronnen. Er is niets gemarkeerd; probeer het op een werkend netwerk.", "check_retrying": "De mislukte opnieuw proberen…", "checking": "{} van {} controleren…", "d_ago": "{} d geleden", @@ -2315,6 +2465,8 @@ "v_blocked": "Geblokkeerd", "v_dead": "Werkt niet", "v_empty": "Leeg", + "v_empty_body": "De bron antwoordde, maar de catalogus kwam leeg terug. Zo ziet een site er vaak uit die de app heeft buitengesloten.", + "v_failing_body": "De laatste controle van deze bron is mislukt. Eén mislukking kan een slechte minuut zijn — controleer het nog eens.", "v_failing": "Reageert niet", "v_outdated": "Update nodig", "v_unknown": "Nog niet gecontroleerd", @@ -2527,7 +2679,18 @@ "card_single": "Voeg profielen toe voor de mensen met wie je dit account deelt", "load_failed": "Profielen konden niet worden geladen", "offline": "Controleer je verbinding en probeer het opnieuw.", - "error_generic": "Er ging iets mis. Probeer het opnieuw." + "error_generic": "Er ging iets mis. Probeer het opnieuw.", + "on_this_device": "Op dit apparaat", + "ask_on_launch": "Bij het starten vragen wie er kijkt", + "ask_on_launch_desc": "Uit opent Sozo het laatst gebruikte profiel. Profielen met een pincode vragen er nog steeds om.", + "pin_locked_until": "Te veel pogingen. Probeer het opnieuw na {}.", + "pin_not_on_device": "Je bent offline en de pincode van dit profiel is op dit apparaat nog niet ingevoerd. Maak één keer verbinding met internet om het te ontgrendelen.", + "biometric_reason": "{} ontgrendelen", + "biometric_offer_title": "Vingerafdruk / Face ID gebruiken?", + "biometric_offer_body": "Gebruik vingerafdruk / Face ID voor {} op dit apparaat. De pincode blijft ook werken.", + "biometric_offer_yes": "Gebruiken", + "biometric_offer_no": "Niet nu", + "biometric_toggle": "Vingerafdruk / Face ID voor {}" }, "jellyfin": { "connect_server": "Mediaserver koppelen (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Mis nooit meer een nieuwe aflevering", "priming_body": "Sozo laat het je weten zodra iets wat je volgt een nieuwe aflevering of een nieuw hoofdstuk krijgt — en niets waar je niet om hebt gevraagd.", "priming_body_title": "Hoor het meteen wanneer ‘{}’ een nieuwe aflevering krijgt — en niets waar je niet om hebt gevraagd.", + "priming_download_title": "Weet wanneer je download klaar is", + "priming_download_body": "Sozo toont de downloadvoortgang in je meldingen en laat weten wanneer je offline kunt kijken. Ook kan Sozo je melden wanneer iets wat je volgt een nieuwe aflevering krijgt.", "priming_point_releases": "Nieuwe afleveringen, met poster", "priming_point_quiet": "Stille uren", "priming_point_control": "Elke titel dempen", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Alleen jpg-, png- of webp-afbeeldingen.", "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", - "suggest": "Blijft het gebeuren? Schrijf support" + "suggest": "Blijft het gebeuren? Schrijf support", + "error_offline": "Je bent offline of de verbinding is verbroken. Controleer je internet en probeer het opnieuw.", + "not_sent": "Niet verzonden: {}", + "diag_crash_log": "Crashlogboek bekijken" + }, + "zangetsu": { + "title": "Zangetsu-bronnen", + "row_subtitle": "JavaScript-bronnen uit externe repo's, draaien op je apparaat", + "desc": "Voeg een Zangetsu-repo toe om extra anime- en filmbronnen te installeren. Ze draaien op je apparaat. Bronnen die Sozo al meelevert, zijn gemarkeerd als ‘Ingebouwd’ en blijven Sozo's eigen versie gebruiken.", + "loading_repo": "Repo laden…", + "repo_added": "Repo toegevoegd · {} bronnen.", + "checking": "Repo's controleren op updates…", + "updates_available": "{} update(s) beschikbaar.", + "installed_one": "{} geïnstalleerd.", + "removed_one": "{} verwijderd.", + "updated": "{} bron(nen) bijgewerkt.", + "update_all": "Alles bijwerken ({})", + "update_to": "Bijwerken naar v{}", + "built_in": "Ingebouwd", + "search": "Bronnen zoeken…", + "all_types": "Alles", + "type_movies": "Films", + "all_languages": "Alle talen", + "error_invalid_url": "Alleen https-links op raw.githubusercontent.com of gist.githubusercontent.com kunnen worden toegevoegd.", + "error_unreachable": "Deze repo is niet bereikbaar. Controleer de link en je verbinding.", + "error_invalid_index": "Deze link verwijst niet naar een geldige Zangetsu-index.json." + }, + "star": { + "title": "Wil je Sozo helpen groeien?", + "body": "Geef het een ster op GitHub ⭐", + "action": "Ster geven op GitHub", + "later": "Later", + "never": "Niet meer vragen", + "update_button": "Geef Sozo een ster op GitHub" + }, + "progress": { + "title": "Level en XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Level omhoog", + "level_reached": "Je hebt level {} bereikt", + "unlocked_new": "Nieuw in je kluisje", + "open_locker": "Kluisje openen", + "unavailable": "Levels zijn nog niet beschikbaar. Kom binnenkort terug.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP tot level {}", + "to_next_short": "{} XP tot het volgende level", + "watch_today": "Vandaag gekeken: {} van {} XP", + "how_to_earn": "Zo verdien je XP", + "rule_watch": "Kijken: 1 XP per minuut, tot 300 per dag", + "rule_comment": "Een reactie: 5 XP, tot 10 reacties per dag", + "rule_like": "Een like op je reactie: 1 XP, tot 50 per dag", + "rule_streak": "Een streakdag: 10 XP, meer op mijlpaaldagen", + "rule_achievement": "Een prestatie: 50 tot 400 XP per niveau", + "recent": "Recente XP", + "reason_watch": "Kijken", + "reason_comment": "Reacties", + "reason_like": "Ontvangen likes", + "reason_streak": "Streakdag", + "reason_achievement": "Prestatie", + "reason_other": "XP", + "locker": "Kluisje", + "slot_frame": "Avatarranden", + "slot_title": "Titels", + "slot_color": "Naamkleuren", + "equip": "Tik om te dragen", + "equipped": "Gedragen", + "unlock_level": "Level {}", + "unlock_badge": "Badge: {}", + "frame_bronze": "Brons", + "frame_silver": "Zilver", + "frame_gold": "Goud", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Gloed", + "frame_flame": "Vlam", + "frame_moon": "Maan", + "title_newcomer": "Nieuwkomer", + "title_regular": "Vaste gast", + "title_veteran": "Veteraan", + "title_legend": "Legende", + "title_binger": "Bingewatcher", + "title_cinephile": "Filmliefhebber", + "title_otaku": "Otaku", + "title_bookworm": "Boekenwurm", + "title_night_owl": "Nachtuil", + "title_host": "Feestgastheer", + "color_sky": "Lucht", + "color_mint": "Munt", + "color_gold": "Goud", + "color_rose": "Roos", + "color_violet": "Violet", + "color_crimson": "Karmozijn", + "color_aurora": "Poollicht", + "color_ember": "Gloed" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 7e4d1f17..593ce761 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -34,7 +34,9 @@ "remove": "Remover", "show": "Mostrar", "hide": "Ocultar", - "installed": "Instalado" + "installed": "Instalado", + "load_more_failed": "Não foi possível carregar mais", + "go_home": "Ir para o início" }, "auth": { "login": "Entrar", @@ -306,7 +308,11 @@ "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." + "unknown": "Não foi possível carregar o vídeo.", + "not_found": "Este vídeo não está mais neste servidor. Tente outro servidor.", + "server_error": "O servidor de vídeo está com problemas. Tente novamente em instantes ou escolha outro servidor.", + "resolve_failed": "Não foi possível obter um link de vídeo desta fonte.", + "details": "Detalhes" }, "play": "Reproduzir", "pause": "Pausar", @@ -529,6 +535,22 @@ "alt_guess": "Suposição", "alt_guess_spoken": "Correspondência incerta — verifique o título antes de reproduzir", "alt_your_pick": "A sua escolha", + "alt_auto": "Automático", + "alt_auto_hint": "Reproduz da primeira fonte que o tiver", + "alt_auto_trying": "A tentar {}…", + "alt_auto_missed": "Nenhuma fonte correspondente conseguiu reproduzi-lo. Escolha uma abaixo ou pesquise numa fonte.", + "alt_auto_checking": "Verificando {} · {} de {}", + "alt_auto_waiting": "Aguardando mais fontes…", + "alt_auto_stop": "Parar", + "alt_auto_stopped": "Parado.", + "alt_auto_capped": "{} fontes verificadas e nenhuma conseguiu reproduzir isto. Escolha uma abaixo ou tente novamente.", + "alt_auto_tried": "Verificadas", + "alt_skip_no_match": "sem correspondência próxima", + "alt_skip_no_episode": "episódio não encontrado", + "alt_skip_error": "não foi possível carregar", + "auto_switch_attempt": "Tentativa {} de {}", + "auto_switch_stop": "Parar", + "auto_switch_stopped": "A troca de servidor foi interrompida. Tente novamente ou escolha outro servidor ou fonte.", "alt_wrong_title": "Título errado?", "alt_wrong_title_hint": "Procure você mesmo em {} e escolha o título certo", "alt_corrected": "Agora a usar «{}»", @@ -554,10 +576,35 @@ "subtitle_n": "Legenda {}", "subtitle_in_video": "No vídeo", "rewind": "Voltar", + "rewind_by": "Voltar {} s", + "forward_by": "Avançar {} s", "mini_restore": "Voltar à janela inteira", "pip_unavailable": "Não foi possível iniciar o picture-in-picture para este vídeo.", "scrub_release_cancel": "Solte para cancelar", - "auto_quality_desc_mpv": "Começa na melhor qualidade que a transmissão oferece" + "auto_quality_desc_mpv": "Começa na melhor qualidade que a transmissão oferece", + "reconnecting": "Conexão perdida — reconectando…", + "all_servers_failed": "Nenhum servidor deste título conseguiu reproduzi-lo. Tente mais tarde ou escolha outra fonte.", + "shortcut_pip": "Miniplayer (Shift: acima de outros apps)", + "mini_popout_hint": "Miniplayer · toque e segure para flutuar acima de outros apps", + "react": "Reagir", + "comments": "Comentários", + "episode_comments": "Comentários do episódio {}", + "reaction_text": "Escrever uma reação", + "reaction_text_hint": "Diga algo curto…", + "reaction_wait": "Espere alguns segundos antes de reagir de novo", + "reaction_limit": "Você atingiu o limite de reações deste episódio", + "reaction_failed": "Não foi possível enviar a reação", + "native_offer_title": "Experimentar o novo player?", + "native_offer_body": "Os controles e menus Liquid Glass da Apple. Você pode mudar isso a qualquer momento em Configurações → Player.", + "native_offer_try": "Experimentar", + "native_offer_not_now": "Agora não", + "trying_again": "Tentando novamente (tentativa {})", + "try_mpv": "Tentar com o motor mpv", + "ok_to_skip": "OK para pular", + "quality_lowered": "Qualidade reduzida", + "undo": "Desfazer", + "crash_log": "Registro de falhas", + "crash_log_empty": "Nenhuma falha registrada" }, "profile": { "title": "Perfil", @@ -649,6 +696,15 @@ "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", + "controls_style": "ESTILO DOS CONTROLES DO PLAYER", + "controls_style_desc": "Vale a partir da próxima vez que você abrir o player.", + "controls_style_native": "Nativo (iOS 26)", + "controls_style_native_desc": "Os botões, menus e painéis Liquid Glass do próprio telefone", + "controls_style_system": "Vidro do sistema", + "controls_style_system_desc": "Os botões do app sobre o Liquid Glass do telefone", + "controls_style_classic": "Vidro clássico", + "controls_style_classic_desc": "O vidro próprio do app, como nas versões anteriores", + "controls_style_needs_ios26": "Requer iOS 26 ou posterior", "playback_section": "REPRODUÇÃO", "default_speed": "Velocidade padrão", "default_speed_desc": "Velocidade com que todo vídeo começa", @@ -671,6 +727,8 @@ "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", + "auto_pip": "Picture-in-picture automático", + "auto_pip_desc": "Ao tocar em Início durante um vídeo, ele continua numa janela pequena", "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", @@ -743,7 +801,15 @@ "library_update": "Procurar novos episódios", "library_update_desc": "Verifica os títulos que você segue e avisa quando sai um novo", "library_update_every": "A cada {} horas", - "library_update_daily": "Uma vez por dia" + "library_update_daily": "Uma vez por dia", + "contributors": "Colaboradores", + "contributors_error": "Não foi possível carregar os colaboradores", + "become_contributor": "Torne-se colaborador", + "become_contributor_body": "O Sozo é de código aberto. Código, traduções e relatos de bugs, tudo ajuda.", + "reactions_overlay": "Reações dos espectadores", + "reactions_overlay_desc": "Mostrar as reações de outros espectadores no momento em que foram enviadas e onde se concentram na barra de progresso", + "hide_spoilers": "Ocultar spoilers de episódios que não assisti", + "hide_spoilers_desc": "Comentários sobre um episódio que você não terminou ficam desfocados até você tocar neles" }, "appearance": { "title": "Aparência", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Preparando o microfone…", + "voice_no_speech": "Não entendi", + "voice_no_speech_hint": "Fale um pouco mais alto e perto do microfone, e tente de novo.", + "voice_permission": "A permissão do microfone está desativada", + "voice_permission_hint": "Permita que o Sozo use o microfone nas configurações do sistema para buscar por voz.", + "voice_unavailable": "O reconhecimento de voz não está disponível neste dispositivo", + "voice_unavailable_hint": "Este dispositivo não tem um serviço de reconhecimento de voz. Instale ou ative um nas configurações do sistema, como o do Google.", + "voice_network": "Não foi possível acessar o serviço de voz", + "voice_network_hint": "Verifique sua conexão com a internet e tente de novo.", + "voice_error": "Algo deu errado ao ouvir", + "voice_error_hint": "Outro app pode estar usando o microfone. Tente de novo daqui a pouco.", + "voice_try_again": "Tentar de novo", + "voice_open_settings": "Abrir configurações", "try_torrents": "Buscar torrents", "capped_note": "Buscamos nas {} fontes mais confiáveis de {} — restrinja a seleção para chegar a uma específica.", "rail_popular_on": "Popular em {source}", @@ -1062,6 +1140,10 @@ "forgot_pin": "Esqueceu o PIN?" }, "my_list": { + "sort": "Ordenar", + "sort_added": "Adicionados recentemente", + "sort_oldest": "Adicionados primeiro", + "sort_title": "Título A–Z", "saved_account": "Salvo na conta", "saved_local": "Neste dispositivo", "untitled": "Sem título", @@ -1160,7 +1242,33 @@ "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." + "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo.", + "settings_title": "Configurações da sala", + "room_size": "Tamanho da sala", + "room_size_less": "Menos pessoas", + "room_size_more": "Mais pessoas", + "only_host_setting": "Só o anfitrião controla a reprodução", + "lock_room": "Trancar sala", + "lock_room_hint": "Ninguém novo pode entrar", + "wait_for_all": "Esperar todos", + "wait_for_all_hint": "Pausar um pouco quando alguém estiver carregando", + "invite_friends": "Convidar amigos", + "invite_sent": "Convite enviado", + "invite_failed": "Não foi possível enviar o convite", + "invite_again_in": "De novo em {}", + "invite_no_friends": "Adicione amigos para convidá-los aqui", + "make_host": "Tornar anfitrião", + "remove_member": "Remover da festa", + "remove_confirm": "Remover {} desta festa? A pessoa não poderá voltar.", + "buffering": "Carregando…", + "sys_joined": "{} entrou", + "sys_left": "{} saiu", + "sys_host": "{} agora é o anfitrião", + "sys_removed": "{} foi removido", + "error_banned": "Você foi removido desta festa", + "error_locked": "Esta festa está trancada", + "room_closed_kicked": "O anfitrião removeu você desta festa", + "waiting_for_others": "Esperando todos alcançarem…" }, "notifications": { "title": "Notificações", @@ -1281,7 +1389,19 @@ "highlights": "Destaques", "remove_highlight": "Remover destaque", "highlight_note": "Adicionar uma nota (opcional)", - "no_highlights": "Nenhum destaque ainda. Selecione um texto e escolha Destacar." + "no_highlights": "Nenhum destaque ainda. Selecione um texto e escolha Destacar.", + "translate_page": "Traduzir página", + "translate_preview": "Página traduzida", + "translate_apply": "Aplicar", + "translate_show_original": "Mostrar original", + "translate_no_text": "Nenhum texto encontrado nesta página.", + "translate_daily_limit": "Você usou as traduções de hoje. Tente novamente amanhã.", + "translate_offline": "Você está offline. Conecte-se à internet para traduzir esta página.", + "translate_sign_in": "Entre na sua conta para traduzir páginas.", + "translate_failed": "Não foi possível traduzir esta página. Tente novamente.", + "translate_language": "Traduzir páginas para", + "translate_auto": "Traduzir cada página enquanto leio", + "translate_auto_desc": "Cada página nova usa uma das traduções de hoje." }, "cloudflare": { "title": "Confirme que você é humano", @@ -1363,6 +1483,10 @@ "watched": "Assistido", "follow_series": "Seguir a série", "find_other_sources": "Procurar outras fontes", + "notice_title": "Esta fonte não consegue reproduzir este título", + "notice_moved_to_app": "A fonte passou este título para o próprio aplicativo, por isso não resta nada para reproduzir aqui. Outra fonte pode ainda tê-lo.", + "notice_no_player": "A fonte lista este título, mas não tem player para ele. Outra fonte pode tê-lo.", + "notice_is_series": "Nesta fonte isto é uma série, por isso é reproduzida pela lista de episódios e não como um filme.", "anilist_track": "Acompanhar no AniList", "anilist_tracked": "Acompanhado no AniList", "following_on": "Seguindo", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "MESMA SAGA", + "relation_tmdb_recommended": "RECOMENDADOS", "relation_sequel": "CONTINUAÇÃO", "relation_prequel": "PREQUEL", "relation_parent": "HISTÓRIA PRINCIPAL", @@ -1461,6 +1587,8 @@ "offline_desc": "Lista salva. Offline, só os episódios baixados abrem.", "filter_hint": "Número ou título do episódio", "all_groups": "Todos os grupos", + "season_n": "Temporada {}", + "specials": "Especiais", "filter_none": "Nenhum episódio corresponde a “{query}” neste bloco", "jump_to": "Ir para o episódio {n}", "jump_loads": "Carrega {range}", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Entre para deixar um comentário", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · toque para ver", + "unwatched_hidden": "Ainda não assistido · toque para ver" }, "downloads": { "view_titles": "Títulos", @@ -1955,7 +2086,8 @@ "title": "Minhas listas", "remove": "Remover", "empty_title": "Nada salvo ainda", - "empty_subtitle": "Os títulos que você salvar aparecem aqui." + "empty_subtitle": "Os títulos que você salvar aparecem aqui.", + "load_failed": "Não foi possível carregar esta lista." }, "torrent": { "title": "Torrents", @@ -2229,7 +2361,25 @@ "settings_title": "Configurações de {}", "settings_reset": "Redefinir", "settings_failed": "Não foi possível ler as configurações desta fonte.", - "settings_none": "Esta fonte não tem configurações." + "settings_none": "Esta fonte não tem configurações.", + "loading_repo": "Carregando repositório…", + "add_sources": "Adicionar fontes", + "choose_sources": "Escolha as fontes", + "select_all": "Selecionar todas", + "select_none": "Desmarcar todas", + "install_selected": "Instalar ({})", + "already_installed": "Instalada", + "remove": "Remover", + "remove_source": "Remover fonte", + "remove_source_confirm": "Remover {}? As configurações e o código em cache serão apagados. Você pode adicioná-la de novo pelo repositório.", + "remove_repo_confirm": "Remover este repositório e todas as suas fontes?", + "enable_source": "Ativar", + "disable_source": "Desativar", + "source_disabled": "Desativada", + "update": "Atualizar", + "update_all": "Atualizar todas", + "updates_available": "{} atualização(ões) disponível(is)", + "repo_check_failed": "Não foi possível verificar {}: {}" }, "ux": { "quick_search": "Busca rápida ({})", @@ -2300,8 +2450,8 @@ "check_all": "Verificar fontes", "check_bad_hint": "Ocultar as fontes fora do ar as tira destas listas; elas voltam sozinhas quando funcionarem de novo.", "check_done": "Verificação de fontes", - "check_hide_down": "A maioria das fontes não pôde ser alcançada — é a conexão, não as fontes. Nada foi marcado; tente numa rede que funcione.", - "check_network": "Ocultar as que não funcionam", + "check_hide_down": "Ocultar as que não funcionam", + "check_network": "A maioria das fontes não pôde ser alcançada — é a conexão, não as fontes. Nada foi marcado; tente numa rede que funcione.", "check_retrying": "Tentando de novo as que falharam…", "checking": "Verificando {} de {}…", "d_ago": "há {} d", @@ -2315,6 +2465,8 @@ "v_blocked": "Bloqueada", "v_dead": "Não funciona", "v_empty": "Vazia", + "v_empty_body": "A fonte respondeu, mas o catálogo veio vazio. É assim que costuma ficar um site que bloqueou o aplicativo.", + "v_failing_body": "A última verificação desta fonte falhou. Uma falha pode ser só um mau momento — verifique novamente.", "v_failing": "Não responde", "v_outdated": "Precisa de atualização", "v_unknown": "Ainda não verificada", @@ -2527,7 +2679,18 @@ "card_single": "Adicione perfis para as pessoas com quem você compartilha esta conta", "load_failed": "Não foi possível carregar os perfis", "offline": "Verifique sua conexão e tente novamente.", - "error_generic": "Algo deu errado. Tente novamente." + "error_generic": "Algo deu errado. Tente novamente.", + "on_this_device": "Neste dispositivo", + "ask_on_launch": "Perguntar quem está assistindo ao abrir", + "ask_on_launch_desc": "Desativado, o Sozo abre o último perfil usado. Perfis com PIN continuam pedindo.", + "pin_locked_until": "Muitas tentativas. Tente novamente depois das {}.", + "pin_not_on_device": "Você está offline e o PIN deste perfil ainda não foi digitado neste dispositivo. Conecte-se à internet uma vez para desbloqueá-lo.", + "biometric_reason": "Desbloquear {}", + "biometric_offer_title": "Usar digital / Face ID?", + "biometric_offer_body": "Use digital / Face ID para {} neste dispositivo. O PIN continua funcionando.", + "biometric_offer_yes": "Usar", + "biometric_offer_no": "Agora não", + "biometric_toggle": "Digital / Face ID para {}" }, "jellyfin": { "connect_server": "Conectar servidor de mídia (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Nunca perca um episódio novo", "priming_body": "O Sozo avisa assim que algo que você segue ganha um episódio ou capítulo novo — e nada que você não tenha pedido.", "priming_body_title": "Saiba na hora quando “{}” ganhar um episódio novo — e nada que você não tenha pedido.", + "priming_download_title": "Saiba quando seu download terminar", + "priming_download_body": "O Sozo mostra o progresso do download nas notificações e avisa quando estiver pronto para assistir offline. Também pode avisar sobre novos episódios do que você segue.", "priming_point_releases": "Episódios novos, com o pôster", "priming_point_quiet": "Horário silencioso", "priming_point_control": "Silencie qualquer título", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Só imagens jpg, png ou webp.", "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", "error_attachment_count": "No máximo 5 capturas por mensagem.", - "suggest": "Continua acontecendo? Fale com o suporte" + "suggest": "Continua acontecendo? Fale com o suporte", + "error_offline": "Você está offline ou a conexão caiu. Verifique a internet e tente novamente.", + "not_sent": "Não enviado: {}", + "diag_crash_log": "Ver registro de falhas" + }, + "zangetsu": { + "title": "Fontes Zangetsu", + "row_subtitle": "Fontes JavaScript de repositórios externos, executadas no seu dispositivo", + "desc": "Adicione um repositório Zangetsu para instalar mais fontes de anime e filmes. Elas rodam no seu dispositivo. As fontes que o Sozo já inclui aparecem como «Integrada» e continuam usando a versão do próprio Sozo.", + "loading_repo": "Carregando repositório…", + "repo_added": "Repositório adicionado · {} fontes.", + "checking": "Verificando atualizações dos repositórios…", + "updates_available": "{} atualização(ões) disponível(is).", + "installed_one": "{} instalada.", + "removed_one": "{} removida.", + "updated": "{} fonte(s) atualizada(s).", + "update_all": "Atualizar tudo ({})", + "update_to": "Atualizar para v{}", + "built_in": "Integrada", + "search": "Pesquisar fontes…", + "all_types": "Tudo", + "type_movies": "Filmes", + "all_languages": "Todos os idiomas", + "error_invalid_url": "Só é possível adicionar links https de raw.githubusercontent.com ou gist.githubusercontent.com.", + "error_unreachable": "Não foi possível acessar este repositório. Verifique o link e sua conexão.", + "error_invalid_index": "Este link não aponta para um index.json válido do Zangetsu." + }, + "star": { + "title": "Quer ajudar o Sozo a crescer?", + "body": "Dê uma estrela no GitHub ⭐", + "action": "Dar estrela no GitHub", + "later": "Mais tarde", + "never": "Não perguntar novamente", + "update_button": "Dê uma estrela ao Sozo no GitHub" + }, + "progress": { + "title": "Nível e XP", + "level": "Nível", + "level_short": "Nv. {}", + "level_up": "Subiu de nível", + "level_reached": "Você chegou ao nível {}", + "unlocked_new": "Novidades no seu armário", + "open_locker": "Abrir armário", + "unavailable": "Os níveis ainda não estão disponíveis. Volte em breve.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP até o nível {}", + "to_next_short": "{} XP até o próximo nível", + "watch_today": "Assistido hoje: {} de {} XP", + "how_to_earn": "Como ganhar XP", + "rule_watch": "Assistir: 1 XP por minuto, até 300 por dia", + "rule_comment": "Um comentário: 5 XP, até 10 comentários por dia", + "rule_like": "Uma curtida no seu comentário: 1 XP, até 50 por dia", + "rule_streak": "Um dia de sequência: 10 XP, mais em dias marcantes", + "rule_achievement": "Uma conquista: de 50 a 400 XP conforme o nível", + "recent": "XP recente", + "reason_watch": "Assistindo", + "reason_comment": "Comentários", + "reason_like": "Curtidas recebidas", + "reason_streak": "Dia de sequência", + "reason_achievement": "Conquista", + "reason_other": "XP", + "locker": "Armário", + "slot_frame": "Molduras de avatar", + "slot_title": "Títulos", + "slot_color": "Cores do nome", + "equip": "Toque para equipar", + "equipped": "Equipado", + "unlock_level": "Nível {}", + "unlock_badge": "Medalha: {}", + "frame_bronze": "Bronze", + "frame_silver": "Prata", + "frame_gold": "Ouro", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Brasa", + "frame_flame": "Chama", + "frame_moon": "Lua", + "title_newcomer": "Novato", + "title_regular": "Frequentador", + "title_veteran": "Veterano", + "title_legend": "Lenda", + "title_binger": "Maratonista", + "title_cinephile": "Cinéfilo", + "title_otaku": "Otaku", + "title_bookworm": "Rato de biblioteca", + "title_night_owl": "Coruja noturna", + "title_host": "Anfitrião", + "color_sky": "Céu", + "color_mint": "Menta", + "color_gold": "Ouro", + "color_rose": "Rosa", + "color_violet": "Violeta", + "color_crimson": "Carmesim", + "color_aurora": "Aurora", + "color_ember": "Brasa" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index efe8a882..2e068a8a 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -34,7 +34,9 @@ "remove": "Удалить", "show": "Показать", "hide": "Скрыть", - "installed": "Установлено" + "installed": "Установлено", + "load_more_failed": "Не удалось загрузить ещё", + "go_home": "На главную" }, "auth": { "login": "Войти", @@ -306,7 +308,11 @@ "no_source": "Для этой серии нет источника.", "empty_url": "Этот источник не вернул адрес видео.", "engine_unavailable": "Плеер не запустился. Полностью перезапустите приложение.", - "unknown": "Не удалось загрузить видео." + "unknown": "Не удалось загрузить видео.", + "not_found": "Этого видео больше нет на этом сервере. Выберите другой сервер.", + "server_error": "У видеосервера проблемы. Попробуйте чуть позже или выберите другой сервер.", + "resolve_failed": "Не удалось получить ссылку на видео из этого источника.", + "details": "Подробности" }, "play": "Воспроизвести", "pause": "Пауза", @@ -529,6 +535,22 @@ "alt_guess": "Предположение", "alt_guess_spoken": "Ненадёжное совпадение — проверьте название перед запуском", "alt_your_pick": "Ваш выбор", + "alt_auto": "Авто", + "alt_auto_hint": "Включит из первого источника, где это есть", + "alt_auto_trying": "Пробуем {}…", + "alt_auto_missed": "Ни один подходящий источник не смог это воспроизвести. Выберите ниже или поищите в источнике.", + "alt_auto_checking": "Проверяем {} · {} из {}", + "alt_auto_waiting": "Ждём другие источники…", + "alt_auto_stop": "Стоп", + "alt_auto_stopped": "Остановлено.", + "alt_auto_capped": "Проверено источников: {}, ни один не смог это воспроизвести. Выберите один ниже или попробуйте снова.", + "alt_auto_tried": "Проверено", + "alt_skip_no_match": "нет точного совпадения", + "alt_skip_no_episode": "серия не найдена", + "alt_skip_error": "не удалось загрузить", + "auto_switch_attempt": "Попытка {} из {}", + "auto_switch_stop": "Стоп", + "auto_switch_stopped": "Переключение серверов остановлено. Попробуйте снова или выберите другой сервер или источник.", "alt_wrong_title": "Не то название?", "alt_wrong_title_hint": "Найдите сами в {} и выберите нужное название", "alt_corrected": "Теперь используется «{}»", @@ -554,10 +576,35 @@ "subtitle_n": "Субтитры {}", "subtitle_in_video": "В самом видео", "rewind": "Назад", + "rewind_by": "{} сек назад", + "forward_by": "{} сек вперёд", "mini_restore": "Вернуть в большое окно", "pip_unavailable": "Не удалось включить «картинку в картинке» для этого видео.", "scrub_release_cancel": "Отпустите для отмены", - "auto_quality_desc_mpv": "Начинает с лучшего качества потока" + "auto_quality_desc_mpv": "Начинает с лучшего качества потока", + "reconnecting": "Соединение потеряно — переподключение…", + "all_servers_failed": "Ни один сервер этого тайтла не смог его воспроизвести. Попробуйте позже или выберите другой источник.", + "shortcut_pip": "Мини-плеер (Shift: поверх других приложений)", + "mini_popout_hint": "Мини-плеер · удерживайте, чтобы закрепить поверх других приложений", + "react": "Реакция", + "comments": "Комментарии", + "episode_comments": "Комментарии к серии {}", + "reaction_text": "Написать реакцию", + "reaction_text_hint": "Коротко…", + "reaction_wait": "Подождите несколько секунд перед следующей реакцией", + "reaction_limit": "Лимит реакций для этой серии исчерпан", + "reaction_failed": "Не удалось отправить реакцию", + "native_offer_title": "Попробовать новый плеер?", + "native_offer_body": "Кнопки и меню Liquid Glass от Apple. Это можно изменить в любой момент в Настройки → Плеер.", + "native_offer_try": "Попробовать", + "native_offer_not_now": "Не сейчас", + "trying_again": "Повторная попытка (попытка {})", + "try_mpv": "Попробовать с движком mpv", + "ok_to_skip": "OK — пропустить", + "quality_lowered": "Качество снижено", + "undo": "Отменить", + "crash_log": "Журнал сбоев", + "crash_log_empty": "Сбоев не зафиксировано" }, "profile": { "title": "Профиль", @@ -651,6 +698,15 @@ "player_engine_section": "ДВИЖОК ВОСПРОИЗВЕДЕНИЯ", "ask_engine_on_play": "Спрашивать каждый раз", "ask_engine_on_play_desc": "Выбирать плеер при запуске видео", + "controls_style": "СТИЛЬ КНОПОК ПЛЕЕРА", + "controls_style_desc": "Применится при следующем открытии плеера.", + "controls_style_native": "Системный (iOS 26)", + "controls_style_native_desc": "Кнопки, меню и окна Liquid Glass самого телефона", + "controls_style_system": "Системное стекло", + "controls_style_system_desc": "Кнопки приложения на Liquid Glass телефона", + "controls_style_classic": "Классическое стекло", + "controls_style_classic_desc": "Собственное стекло приложения, как в прежних версиях", + "controls_style_needs_ios26": "Нужна iOS 26 или новее", "playback_section": "ВОСПРОИЗВЕДЕНИЕ", "default_speed": "Скорость по умолчанию", "default_speed_desc": "С какой скоростью начинается видео", @@ -673,6 +729,8 @@ "brightness_gesture_desc": "Свайп вверх и вниз слева", "volume_gesture": "Свайп громкости", "volume_gesture_desc": "Свайп вверх и вниз справа", + "auto_pip": "Автоматическая «картинка в картинке»", + "auto_pip_desc": "Если нажать «Домой» во время просмотра, видео продолжится в маленьком окне", "seconds_short": "{} с", "subtitle_style_note": "Действуют для всех видео. Тайминг субтитров задаётся отдельно для каждой серии в плеере.", "searching_all_sources": "поиск по всем источникам", @@ -743,7 +801,15 @@ "library_update": "Проверять новые серии", "library_update_desc": "Проверяет отслеживаемые тайтлы и сообщает о новых сериях", "library_update_every": "Каждые {} ч", - "library_update_daily": "Раз в день" + "library_update_daily": "Раз в день", + "contributors": "Участники", + "contributors_error": "Не удалось загрузить участников", + "become_contributor": "Стать участником", + "become_contributor_body": "Sozo — проект с открытым исходным кодом. Помогает всё: код, переводы и сообщения об ошибках.", + "reactions_overlay": "Реакции зрителей", + "reactions_overlay_desc": "Показывать реакции других зрителей в момент, когда их отправили, и где они собираются на шкале перемотки", + "hide_spoilers": "Скрывать спойлеры к непросмотренным сериям", + "hide_spoilers_desc": "Комментарии к серии, которую вы не досмотрели, остаются размытыми, пока вы на них не нажмёте" }, "appearance": { "title": "Внешний вид", @@ -866,8 +932,20 @@ "all_sources_failed_one": "Источник не ответил. Это не пустой результат — попробуйте снова.", "voice": "Голосовой поиск", "voice_listening": "Слушаю…", - "voice_done": "Прослушивание остановлено", "voice_hint": "Назовите фильм или сериал", + "voice_starting": "Готовим микрофон…", + "voice_no_speech": "Не удалось расслышать", + "voice_no_speech_hint": "Говорите чуть громче и ближе к микрофону, затем попробуйте ещё раз.", + "voice_permission": "Нет доступа к микрофону", + "voice_permission_hint": "Разрешите Sozo доступ к микрофону в настройках системы, чтобы искать голосом.", + "voice_unavailable": "Распознавание речи недоступно на этом устройстве", + "voice_unavailable_hint": "На устройстве нет службы распознавания речи. Установите или включите её в настройках системы, например от Google.", + "voice_network": "Не удалось связаться со службой распознавания", + "voice_network_hint": "Проверьте подключение к интернету и попробуйте ещё раз.", + "voice_error": "Что-то пошло не так во время записи", + "voice_error_hint": "Возможно, микрофон занят другим приложением. Попробуйте ещё раз чуть позже.", + "voice_try_again": "Попробовать ещё раз", + "voice_open_settings": "Открыть настройки", "try_torrents": "Искать в торрентах", "capped_note": "Искали в {} самых надёжных из {} источников — сузьте область, чтобы дойти до конкретного.", "rail_popular_on": "Популярное на {source}", @@ -991,6 +1069,10 @@ "forgot_pin": "Забыли PIN?" }, "my_list": { + "sort": "Сортировка", + "sort_added": "Недавно добавленные", + "sort_oldest": "Давно добавленные", + "sort_title": "Название А–Я", "saved_account": "Сохранено в аккаунте", "saved_local": "Только на устройстве", "untitled": "Без названия", @@ -1160,7 +1242,33 @@ "reconnecting": "Переподключение…", "connecting": "Подключение…", "connect_failed_title": "Не удалось подключиться", - "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку." + "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку.", + "settings_title": "Настройки комнаты", + "room_size": "Размер комнаты", + "room_size_less": "Меньше участников", + "room_size_more": "Больше участников", + "only_host_setting": "Воспроизведением управляет только хост", + "lock_room": "Закрыть комнату", + "lock_room_hint": "Новые участники не смогут войти", + "wait_for_all": "Ждать всех", + "wait_for_all_hint": "Ненадолго ставить паузу, когда у кого-то буферизация", + "invite_friends": "Пригласить друзей", + "invite_sent": "Приглашение отправлено", + "invite_failed": "Не удалось отправить приглашение", + "invite_again_in": "Снова через {}", + "invite_no_friends": "Добавьте друзей, чтобы приглашать их сюда", + "make_host": "Сделать хостом", + "remove_member": "Удалить из комнаты", + "remove_confirm": "Удалить {} из комнаты? Вернуться он не сможет.", + "buffering": "Буферизация…", + "sys_joined": "{} присоединился", + "sys_left": "{} вышел", + "sys_host": "{} теперь хост", + "sys_removed": "{} удалён из комнаты", + "error_banned": "Вас удалили из этой комнаты", + "error_locked": "Эта комната закрыта", + "room_closed_kicked": "Хост удалил вас из комнаты", + "waiting_for_others": "Ждём, пока все догонят…" }, "notifications": { "title": "Уведомления", @@ -1281,7 +1389,19 @@ "highlights": "Выделения", "remove_highlight": "Убрать выделение", "highlight_note": "Добавить заметку (необязательно)", - "no_highlights": "Выделений пока нет. Выделите текст и выберите «Выделить»." + "no_highlights": "Выделений пока нет. Выделите текст и выберите «Выделить».", + "translate_page": "Перевести страницу", + "translate_preview": "Переведённая страница", + "translate_apply": "Применить", + "translate_show_original": "Показать оригинал", + "translate_no_text": "На этой странице не найден текст.", + "translate_daily_limit": "Переводы на сегодня закончились. Попробуйте завтра.", + "translate_offline": "Нет подключения к интернету. Подключитесь, чтобы перевести страницу.", + "translate_sign_in": "Войдите в аккаунт, чтобы переводить страницы.", + "translate_failed": "Не удалось перевести страницу. Попробуйте ещё раз.", + "translate_language": "Язык перевода страниц", + "translate_auto": "Переводить каждую страницу во время чтения", + "translate_auto_desc": "Каждая новая страница расходует один перевод из дневного лимита." }, "cloudflare": { "title": "Подтвердите, что вы человек", @@ -1363,6 +1483,10 @@ "watched": "Просмотрено", "follow_series": "Следить за сериалом", "find_other_sources": "Найти в других источниках", + "notice_title": "Этот источник не может воспроизвести этот тайтл", + "notice_moved_to_app": "Источник перенёс этот тайтл в своё приложение, так что здесь воспроизводить уже нечего. Возможно, он есть на другом источнике.", + "notice_no_player": "Источник показывает этот тайтл, но плеера для него нет. Возможно, он есть на другом источнике.", + "notice_is_series": "На этом источнике это сериал, поэтому он воспроизводится из списка эпизодов, а не как фильм.", "anilist_track": "Отслеживать в AniList", "anilist_tracked": "Отслеживается в AniList", "following_on": "Отслеживается", @@ -1393,6 +1517,8 @@ "download_batch_body": "В очередь встанут серии {range} — всего {count}. На мобильном интернете это может быть несколько гигабайт.", "relations": "Связанное", "no_relations": "Связанных тайтлов не найдено.", + "relation_tmdb_collection": "ТА ЖЕ СЕРИЯ", + "relation_tmdb_recommended": "РЕКОМЕНДУЕМ", "relation_sequel": "ПРОДОЛЖЕНИЕ", "relation_prequel": "ПРИКВЕЛ", "relation_parent": "ОСНОВНАЯ ИСТОРИЯ", @@ -1461,6 +1587,8 @@ "offline_desc": "Показан сохранённый список. Офлайн открываются только скачанные серии.", "filter_hint": "Номер или название серии", "all_groups": "Все группы", + "season_n": "Сезон {}", + "specials": "Спецвыпуски", "filter_none": "В этом блоке нет серии «{query}»", "jump_to": "Перейти к серии {n}", "jump_loads": "Загрузит {range}", @@ -1505,7 +1633,10 @@ "show_more": "Показать ещё", "delete_confirm_title": "Удалить этот комментарий?", "delete_confirm_body": "Это действие нельзя отменить.", - "sign_in_prompt": "Войдите, чтобы оставить комментарий" + "sign_in_prompt": "Войдите, чтобы оставить комментарий", + "spoiler": "Спойлер", + "spoiler_hidden": "Спойлер · нажмите, чтобы показать", + "unwatched_hidden": "Ещё не просмотрено · нажмите, чтобы показать" }, "downloads": { "view_titles": "Тайтлы", @@ -1956,7 +2087,8 @@ "title": "Мои списки", "remove": "Удалить", "empty_title": "Пока ничего нет", - "empty_subtitle": "Сохранённое появится здесь." + "empty_subtitle": "Сохранённое появится здесь.", + "load_failed": "Не удалось загрузить список." }, "torrent": { "title": "Торренты", @@ -2230,7 +2362,25 @@ "settings_title": "Настройки: {}", "settings_reset": "Сбросить", "settings_failed": "Не удалось прочитать настройки источника.", - "settings_none": "У этого источника нет настроек." + "settings_none": "У этого источника нет настроек.", + "loading_repo": "Загрузка репозитория…", + "add_sources": "Добавить источники", + "choose_sources": "Выберите источники", + "select_all": "Выбрать все", + "select_none": "Снять выбор", + "install_selected": "Установить ({})", + "already_installed": "Установлен", + "remove": "Удалить", + "remove_source": "Удалить источник", + "remove_source_confirm": "Удалить {}? Его настройки и сохранённый код будут удалены. Его можно снова добавить из репозитория.", + "remove_repo_confirm": "Удалить этот репозиторий и все его источники?", + "enable_source": "Включить", + "disable_source": "Отключить", + "source_disabled": "Отключён", + "update": "Обновить", + "update_all": "Обновить все", + "updates_available": "Доступно обновлений: {}", + "repo_check_failed": "Не удалось проверить {}: {}" }, "ux": { "quick_search": "Быстрый поиск ({})", @@ -2301,8 +2451,8 @@ "check_all": "Проверить источники", "check_bad_hint": "Скрытые неработающие источники пропадают из этих списков и возвращаются сами, когда снова заработают.", "check_done": "Проверка источников", - "check_hide_down": "Большинство источников вообще недоступны — дело в сети, а не в них. Ничего не отмечено; попробуйте в рабочей сети.", - "check_network": "Скрыть неработающие", + "check_hide_down": "Скрыть неработающие", + "check_network": "Большинство источников вообще недоступны — дело в сети, а не в них. Ничего не отмечено; попробуйте в рабочей сети.", "check_retrying": "Повторяем неудавшиеся…", "checking": "Проверка {} из {}…", "d_ago": "{} д назад", @@ -2316,6 +2466,8 @@ "v_blocked": "Заблокирован", "v_dead": "Не работает", "v_empty": "Пусто", + "v_empty_body": "Источник ответил, но каталог пришёл пустым. Так часто выглядит сайт, который закрыл доступ приложению.", + "v_failing_body": "Последняя проверка этого источника не удалась. Один сбой может быть случайным — проверьте ещё раз.", "v_failing": "Не отвечает", "v_outdated": "Нужно обновить", "v_unknown": "Ещё не проверен", @@ -2528,7 +2680,18 @@ "card_single": "Добавьте профили для тех, с кем вы делите аккаунт", "load_failed": "Не удалось загрузить профили", "offline": "Проверьте подключение и попробуйте снова.", - "error_generic": "Что-то пошло не так. Попробуйте ещё раз." + "error_generic": "Что-то пошло не так. Попробуйте ещё раз.", + "on_this_device": "На этом устройстве", + "ask_on_launch": "Спрашивать, кто смотрит, при запуске", + "ask_on_launch_desc": "Если выключено, Sozo откроет последний профиль. Профили с PIN-кодом всё равно спросят его.", + "pin_locked_until": "Слишком много попыток. Повторите после {}.", + "pin_not_on_device": "Нет подключения, а PIN-код этого профиля ещё не вводился на этом устройстве. Подключитесь к интернету один раз, чтобы его открыть.", + "biometric_reason": "Открыть профиль {}", + "biometric_offer_title": "Использовать отпечаток / Face ID?", + "biometric_offer_body": "Использовать отпечаток / Face ID для {} на этом устройстве. PIN-код тоже продолжит работать.", + "biometric_offer_yes": "Использовать", + "biometric_offer_no": "Не сейчас", + "biometric_toggle": "Отпечаток / Face ID для {}" }, "jellyfin": { "connect_server": "Подключить медиасервер (Jellyfin)", @@ -2779,6 +2942,8 @@ "priming_title": "Не пропускайте новые серии", "priming_body": "Sozo сообщит, как только у того, за чем вы следите, выйдет новая серия или глава, — и ничего сверх того, о чём вы просили.", "priming_body_title": "Узнайте первым, когда у «{}» выйдет новая серия, — и ничего лишнего.", + "priming_download_title": "Узнайте, когда загрузка завершится", + "priming_download_body": "Sozo показывает ход загрузки в уведомлениях и сообщает, когда можно смотреть офлайн. А ещё — о новых сериях того, за чем вы следите.", "priming_point_releases": "Новые серии с постером", "priming_point_quiet": "Тихие часы", "priming_point_control": "Отключение для любого тайтла", @@ -3005,6 +3170,101 @@ "error_attachment_type": "Только изображения jpg, png или webp.", "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", - "suggest": "Повторяется? Напишите в поддержку" + "suggest": "Повторяется? Напишите в поддержку", + "error_offline": "Нет интернета или соединение прервалось. Проверьте подключение и попробуйте снова.", + "not_sent": "Не отправлено: {}", + "diag_crash_log": "Открыть журнал сбоев" + }, + "zangetsu": { + "title": "Источники Zangetsu", + "row_subtitle": "JavaScript-источники из внешних репозиториев, работают на устройстве", + "desc": "Добавьте репозиторий Zangetsu, чтобы установить дополнительные источники аниме и фильмов. Они работают на вашем устройстве. Источники, которые уже есть в Sozo, помечены как «Встроенный» и используют версию Sozo.", + "loading_repo": "Загрузка репозитория…", + "repo_added": "Репозиторий добавлен · источников: {}.", + "checking": "Проверка обновлений репозиториев…", + "updates_available": "Доступно обновлений: {}.", + "installed_one": "{} установлен.", + "removed_one": "{} удалён.", + "updated": "Обновлено источников: {}.", + "update_all": "Обновить все ({})", + "update_to": "Обновить до v{}", + "built_in": "Встроенный", + "search": "Поиск источников…", + "all_types": "Все", + "type_movies": "Фильмы", + "all_languages": "Все языки", + "error_invalid_url": "Можно добавлять только https-ссылки на raw.githubusercontent.com или gist.githubusercontent.com.", + "error_unreachable": "Не удалось подключиться к репозиторию. Проверьте ссылку и подключение к интернету.", + "error_invalid_index": "Эта ссылка не ведёт на корректный index.json Zangetsu." + }, + "star": { + "title": "Хотите помочь Sozo расти?", + "body": "Поставьте звезду на GitHub ⭐", + "action": "Поставить звезду на GitHub", + "later": "Позже", + "never": "Больше не спрашивать", + "update_button": "Поставить Sozo звезду на GitHub" + }, + "progress": { + "title": "Уровень и XP", + "level": "Уровень", + "level_short": "Ур. {}", + "level_up": "Новый уровень", + "level_reached": "Вы достигли уровня {}", + "unlocked_new": "Новое в вашем шкафчике", + "open_locker": "Открыть шкафчик", + "unavailable": "Уровни пока недоступны. Загляните позже.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP до уровня {}", + "to_next_short": "{} XP до следующего уровня", + "watch_today": "Просмотр сегодня: {} из {} XP", + "how_to_earn": "Как получить XP", + "rule_watch": "Просмотр: 1 XP в минуту, до 300 в день", + "rule_comment": "Комментарий: 5 XP, до 10 комментариев в день", + "rule_like": "Лайк на ваш комментарий: 1 XP, до 50 в день", + "rule_streak": "День серии: 10 XP, больше в юбилейные дни", + "rule_achievement": "Достижение: от 50 до 400 XP по уровню", + "recent": "Недавние XP", + "reason_watch": "Просмотр", + "reason_comment": "Комментарии", + "reason_like": "Полученные лайки", + "reason_streak": "День серии", + "reason_achievement": "Достижение", + "reason_other": "XP", + "locker": "Шкафчик", + "slot_frame": "Рамки аватара", + "slot_title": "Титулы", + "slot_color": "Цвета имени", + "equip": "Нажмите, чтобы надеть", + "equipped": "Надето", + "unlock_level": "Уровень {}", + "unlock_badge": "Значок: {}", + "frame_bronze": "Бронза", + "frame_silver": "Серебро", + "frame_gold": "Золото", + "frame_neon": "Неон", + "frame_sakura": "Сакура", + "frame_ember": "Угли", + "frame_flame": "Пламя", + "frame_moon": "Луна", + "title_newcomer": "Новичок", + "title_regular": "Завсегдатай", + "title_veteran": "Ветеран", + "title_legend": "Легенда", + "title_binger": "Запойный зритель", + "title_cinephile": "Киноман", + "title_otaku": "Отаку", + "title_bookworm": "Книжный червь", + "title_night_owl": "Ночная сова", + "title_host": "Хозяин вечеринок", + "color_sky": "Небо", + "color_mint": "Мята", + "color_gold": "Золото", + "color_rose": "Роза", + "color_violet": "Фиалка", + "color_crimson": "Багрянец", + "color_aurora": "Сияние", + "color_ember": "Угли" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index e2324ce5..86029116 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -34,7 +34,9 @@ "remove": "Kaldır", "show": "Göster", "hide": "Gizle", - "installed": "Yüklendi" + "installed": "Yüklendi", + "load_more_failed": "Daha fazlası yüklenemedi", + "go_home": "Ana sayfaya dön" }, "auth": { "login": "Giriş yap", @@ -306,7 +308,11 @@ "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." + "unknown": "Video yüklenemedi.", + "not_found": "Bu video artık bu sunucuda yok. Başka bir sunucu deneyin.", + "server_error": "Video sunucusunda sorun var. Birazdan tekrar deneyin veya başka bir sunucu seçin.", + "resolve_failed": "Bu kaynaktan video bağlantısı alınamadı.", + "details": "Ayrıntılar" }, "play": "Oynat", "pause": "Duraklat", @@ -529,6 +535,22 @@ "alt_guess": "Tahmin", "alt_guess_spoken": "Belirsiz eşleşme — oynatmadan önce başlığı kontrol edin", "alt_your_pick": "Sizin seçiminiz", + "alt_auto": "Otomatik", + "alt_auto_hint": "Bunu bulunduran ilk kaynaktan oynatır", + "alt_auto_trying": "{} deneniyor…", + "alt_auto_missed": "Eşleşen kaynakların hiçbiri bunu oynatamadı. Aşağıdan birini seçin veya bir kaynakta arayın.", + "alt_auto_checking": "{} kontrol ediliyor · {} / {}", + "alt_auto_waiting": "Başka kaynaklar bekleniyor…", + "alt_auto_stop": "Durdur", + "alt_auto_stopped": "Durduruldu.", + "alt_auto_capped": "{} kaynak kontrol edildi, hiçbiri bunu oynatamadı. Aşağıdan birini seçin ya da tekrar deneyin.", + "alt_auto_tried": "Kontrol edilenler", + "alt_skip_no_match": "yakın eşleşme yok", + "alt_skip_no_episode": "bölüm bulunamadı", + "alt_skip_error": "yüklenemedi", + "auto_switch_attempt": "Deneme {} / {}", + "auto_switch_stop": "Durdur", + "auto_switch_stopped": "Sunucu değiştirme durduruldu. Tekrar deneyin ya da başka bir sunucu veya kaynak seçin.", "alt_wrong_title": "Yanlış başlık mı?", "alt_wrong_title_hint": "{} içinde kendiniz arayıp doğru başlığı seçin", "alt_corrected": "Artık “{}” kullanılıyor", @@ -554,10 +576,35 @@ "subtitle_n": "Altyazı {}", "subtitle_in_video": "Videonun içinde", "rewind": "Geri sar", + "rewind_by": "{} sn geri", + "forward_by": "{} sn ileri", "mini_restore": "Tam pencereye dön", "pip_unavailable": "Bu video için resim içinde resim başlatılamadı.", "scrub_release_cancel": "İptal için bırak", - "auto_quality_desc_mpv": "Yayının sunduğu en iyi kaliteyle başlar" + "auto_quality_desc_mpv": "Yayının sunduğu en iyi kaliteyle başlar", + "reconnecting": "Bağlantı koptu — yeniden bağlanılıyor…", + "all_servers_failed": "Bu içeriğin hiçbir sunucusu oynatamadı. Daha sonra tekrar dene veya başka bir kaynak seç.", + "shortcut_pip": "Mini oynatıcı (Shift: diğer uygulamaların üstünde)", + "mini_popout_hint": "Mini oynatıcı · diğer uygulamaların üstünde kalması için basılı tut", + "react": "Tepki ver", + "comments": "Yorumlar", + "episode_comments": "{}. bölüm yorumları", + "reaction_text": "Tepki yaz", + "reaction_text_hint": "Kısa bir şey yaz…", + "reaction_wait": "Tekrar tepki vermeden önce birkaç saniye bekle", + "reaction_limit": "Bu bölüm için tepki sınırına ulaştın", + "reaction_failed": "Tepki gönderilemedi", + "native_offer_title": "Yeni oynatıcıyı denemek ister misiniz?", + "native_offer_body": "Apple’ın Liquid Glass kontrolleri ve menüleri. Bunu istediğiniz zaman Ayarlar → Oynatıcı bölümünden değiştirebilirsiniz.", + "native_offer_try": "Dene", + "native_offer_not_now": "Şimdi değil", + "trying_again": "Yeniden deneniyor ({}. deneme)", + "try_mpv": "mpv motoruyla dene", + "ok_to_skip": "Atlamak için OK", + "quality_lowered": "Kalite düşürüldü", + "undo": "Geri al", + "crash_log": "Çökme kaydı", + "crash_log_empty": "Kayıtlı çökme yok" }, "profile": { "title": "Profil", @@ -649,6 +696,15 @@ "player_engine_section": "OYNATMA MOTORU", "ask_engine_on_play": "Her seferinde sor", "ask_engine_on_play_desc": "Her video başlarken motoru seç", + "controls_style": "OYNATICI KONTROL STİLİ", + "controls_style_desc": "Oynatıcıyı bir sonraki açışınızda geçerli olur.", + "controls_style_native": "Yerel (iOS 26)", + "controls_style_native_desc": "Telefonun kendi Liquid Glass düğmeleri, menüleri ve sayfaları", + "controls_style_system": "Sistem camı", + "controls_style_system_desc": "Uygulamanın düğmeleri telefonun kendi Liquid Glass'ı üzerinde", + "controls_style_classic": "Klasik cam", + "controls_style_classic_desc": "Uygulamanın kendi camı, önceki sürümlerdeki gibi", + "controls_style_needs_ios26": "iOS 26 veya üstü gerekir", "playback_section": "OYNATMA", "default_speed": "Varsayılan hız", "default_speed_desc": "Her videonun başlayacağı hız", @@ -671,6 +727,8 @@ "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", + "auto_pip": "Otomatik resim içinde resim", + "auto_pip_desc": "Video oynarken Ana ekran tuşuna basınca küçük bir pencerede oynamaya devam eder", "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", @@ -743,7 +801,15 @@ "library_update": "Yeni bölümleri denetle", "library_update_desc": "Takip ettiğin yapımları denetler, yenisi çıkınca haber verir", "library_update_every": "{} saatte bir", - "library_update_daily": "Günde bir" + "library_update_daily": "Günde bir", + "contributors": "Katkıda bulunanlar", + "contributors_error": "Katkıda bulunanlar yüklenemedi", + "become_contributor": "Katkıda bulun", + "become_contributor_body": "Sozo açık kaynaklıdır. Kod, çeviri ve hata bildirimleri; hepsi yardımcı olur.", + "reactions_overlay": "İzleyici tepkileri", + "reactions_overlay_desc": "Diğer izleyicilerin tepkilerini gönderildikleri anda ve ilerleme çubuğunda yoğunlaştıkları yerleri göster", + "hide_spoilers": "İzlemediğim bölümlerin spoilerlarını gizle", + "hide_spoilers_desc": "Bitirmediğin bir bölümle ilgili yorumlar sen dokunana kadar bulanık kalır" }, "appearance": { "title": "Görünüm", @@ -866,8 +932,20 @@ "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", + "voice_starting": "Mikrofon hazırlanıyor…", + "voice_no_speech": "Anlaşılamadı", + "voice_no_speech_hint": "Biraz daha yüksek sesle ve mikrofona yakın konuş, sonra tekrar dene.", + "voice_permission": "Mikrofon izni kapalı", + "voice_permission_hint": "Sesle aramak için sistem ayarlarından Sozo'nun mikrofonu kullanmasına izin ver.", + "voice_unavailable": "Bu cihazda konuşma tanıma kullanılamıyor", + "voice_unavailable_hint": "Bu cihazda konuşma tanıma hizmeti yok. Sistem ayarlarından bir tane yükle ya da aç, örneğin Google'ınkini.", + "voice_network": "Konuşma hizmetine ulaşılamadı", + "voice_network_hint": "İnternet bağlantını kontrol edip tekrar dene.", + "voice_error": "Dinlerken bir sorun oluştu", + "voice_error_hint": "Mikrofonu başka bir uygulama kullanıyor olabilir. Biraz sonra tekrar dene.", + "voice_try_again": "Tekrar dene", + "voice_open_settings": "Ayarları aç", "try_torrents": "Torrent ara", "capped_note": "{} kaynaktan en güvenilir {} tanesinde arandı — belirli birine ulaşmak için kapsamı daralt.", "rail_popular_on": "{source} içinde popüler", @@ -1062,6 +1140,10 @@ "forgot_pin": "PIN’ini mi unuttun?" }, "my_list": { + "sort": "Sırala", + "sort_added": "Son eklenenler", + "sort_oldest": "İlk eklenenler", + "sort_title": "Başlık A–Z", "saved_account": "Hesaba kaydedildi", "saved_local": "Bu cihazda", "untitled": "Başlıksız", @@ -1160,7 +1242,33 @@ "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." + "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene.", + "settings_title": "Oda ayarları", + "room_size": "Oda boyutu", + "room_size_less": "Daha az kişi", + "room_size_more": "Daha fazla kişi", + "only_host_setting": "Oynatmayı yalnızca host kontrol eder", + "lock_room": "Odayı kilitle", + "lock_room_hint": "Yeni kimse katılamaz", + "wait_for_all": "Herkesi bekle", + "wait_for_all_hint": "Biri arabelleğe alırken kısa süre duraklat", + "invite_friends": "Arkadaşlarını davet et", + "invite_sent": "Davet gönderildi", + "invite_failed": "Davet gönderilemedi", + "invite_again_in": "{} sonra tekrar", + "invite_no_friends": "Buradan davet etmek için arkadaş ekle", + "make_host": "Host yap", + "remove_member": "Partiden çıkar", + "remove_confirm": "{} bu partiden çıkarılsın mı? Tekrar katılamaz.", + "buffering": "Arabelleğe alınıyor…", + "sys_joined": "{} katıldı", + "sys_left": "{} ayrıldı", + "sys_host": "{} artık host", + "sys_removed": "{} çıkarıldı", + "error_banned": "Bu partiden çıkarıldın", + "error_locked": "Bu parti kilitli", + "room_closed_kicked": "Host seni bu partiden çıkardı", + "waiting_for_others": "Herkesin yetişmesi bekleniyor…" }, "notifications": { "title": "Bildirimler", @@ -1281,7 +1389,19 @@ "highlights": "Vurgular", "remove_highlight": "Vurguyu kaldır", "highlight_note": "Not ekle (isteğe bağlı)", - "no_highlights": "Henüz vurgu yok. Metin seçip Vurgula'yı seçin." + "no_highlights": "Henüz vurgu yok. Metin seçip Vurgula'yı seçin.", + "translate_page": "Sayfayı çevir", + "translate_preview": "Çevrilmiş sayfa", + "translate_apply": "Uygula", + "translate_show_original": "Orijinali göster", + "translate_no_text": "Bu sayfada metin bulunamadı.", + "translate_daily_limit": "Bugünkü çeviri hakkın doldu. Yarın tekrar dene.", + "translate_offline": "Çevrimdışısın. Bu sayfayı çevirmek için internete bağlan.", + "translate_sign_in": "Sayfaları çevirmek için giriş yap.", + "translate_failed": "Bu sayfa çevrilemedi. Tekrar dene.", + "translate_language": "Sayfaları şu dile çevir", + "translate_auto": "Okurken her sayfayı çevir", + "translate_auto_desc": "Her yeni sayfa bugünkü çeviri hakkından birini kullanır." }, "cloudflare": { "title": "İnsan olduğunu doğrula", @@ -1363,6 +1483,10 @@ "watched": "İzlendi", "follow_series": "Diziyi takip et", "find_other_sources": "Başka kaynak bul", + "notice_title": "Bu kaynak bu yapımı oynatamıyor", + "notice_moved_to_app": "Kaynak bu yapımı kendi uygulamasına taşımış, burada oynatılacak bir şey kalmamış. Başka bir kaynakta bulunabilir.", + "notice_no_player": "Kaynak bu yapımı listeliyor ama bir oynatıcısı yok. Başka bir kaynakta bulunabilir.", + "notice_is_series": "Bu kaynakta bu bir dizi; tek bir film olarak değil, bölüm listesinden oynatılır.", "anilist_track": "AniList’te takip et", "anilist_tracked": "AniList’te takip ediliyor", "following_on": "Takip ediliyor", @@ -1393,6 +1517,8 @@ "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_tmdb_collection": "AYNI SERİ", + "relation_tmdb_recommended": "ÖNERİLENLER", "relation_sequel": "DEVAM", "relation_prequel": "ÖNCESİ", "relation_parent": "ANA HİKÂYE", @@ -1461,6 +1587,8 @@ "offline_desc": "Kayıtlı liste gösteriliyor. Çevrimdışıyken yalnızca indirilen bölümler açılır.", "filter_hint": "Bölüm numarası veya adı", "all_groups": "Tüm gruplar", + "season_n": "{}. Sezon", + "specials": "Özel bölümler", "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", @@ -1505,7 +1633,10 @@ "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" + "sign_in_prompt": "Yorum bırakmak için giriş yap", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · görmek için dokun", + "unwatched_hidden": "Henüz izlemedin · görmek için dokun" }, "downloads": { "view_titles": "Başlıklar", @@ -1955,7 +2086,8 @@ "title": "Listelerim", "remove": "Kaldır", "empty_title": "Henüz bir şey kaydedilmedi", - "empty_subtitle": "Kaydettiğin yapımlar burada görünür." + "empty_subtitle": "Kaydettiğin yapımlar burada görünür.", + "load_failed": "Bu liste yüklenemedi." }, "torrent": { "title": "Torrentler", @@ -2229,7 +2361,25 @@ "settings_title": "{} ayarları", "settings_reset": "Sıfırla", "settings_failed": "Bu kaynağın ayarları okunamadı.", - "settings_none": "Bu kaynağın ayarı yok." + "settings_none": "Bu kaynağın ayarı yok.", + "loading_repo": "Depo yükleniyor…", + "add_sources": "Kaynak ekle", + "choose_sources": "Kaynakları seç", + "select_all": "Tümünü seç", + "select_none": "Hiçbirini seçme", + "install_selected": "Yükle ({})", + "already_installed": "Yüklü", + "remove": "Kaldır", + "remove_source": "Kaynağı kaldır", + "remove_source_confirm": "{} kaldırılsın mı? Ayarları ve önbellekteki kodu silinir. Deposundan yeniden ekleyebilirsin.", + "remove_repo_confirm": "Bu depo ve tüm kaynakları kaldırılsın mı?", + "enable_source": "Etkinleştir", + "disable_source": "Devre dışı bırak", + "source_disabled": "Devre dışı", + "update": "Güncelle", + "update_all": "Tümünü güncelle", + "updates_available": "{} güncelleme mevcut", + "repo_check_failed": "{} kontrol edilemedi: {}" }, "ux": { "quick_search": "Hızlı arama ({})", @@ -2300,8 +2450,8 @@ "check_all": "Kaynakları kontrol et", "check_bad_hint": "Çalışmayan kaynakları gizlemek onları bu listelerden çıkarır; yeniden çalıştıklarında kendiliğinden geri gelirler.", "check_done": "Kaynak kontrolü", - "check_hide_down": "Kaynakların çoğuna hiç ulaşılamadı — sorun kaynaklarda değil bağlantıda. Hiçbiri işaretlenmedi; çalışan bir ağda tekrar deneyin.", - "check_network": "Çalışmayanları gizle", + "check_hide_down": "Çalışmayanları gizle", + "check_network": "Kaynakların çoğuna hiç ulaşılamadı — sorun kaynaklarda değil bağlantıda. Hiçbiri işaretlenmedi; çalışan bir ağda tekrar deneyin.", "check_retrying": "Başarısız olanlar yeniden deneniyor…", "checking": "{} / {} kontrol ediliyor…", "d_ago": "{} gün önce", @@ -2315,6 +2465,8 @@ "v_blocked": "Engellendi", "v_dead": "Çalışmıyor", "v_empty": "Boş", + "v_empty_body": "Kaynak yanıt verdi ama katalog boş geldi. Uygulamayı dışarıda bırakmış bir site genelde böyle görünür.", + "v_failing_body": "Bu kaynağın son kontrolü başarısız oldu. Tek bir hata kötü bir dakika olabilir — tekrar kontrol edin.", "v_failing": "Yanıt vermiyor", "v_outdated": "Güncelleme gerekli", "v_unknown": "Henüz kontrol edilmedi", @@ -2527,7 +2679,18 @@ "card_single": "Bu hesabı paylaştığın kişiler için profil ekle", "load_failed": "Profiller yüklenemedi", "offline": "Bağlantını kontrol edip tekrar dene.", - "error_generic": "Bir sorun oluştu. Lütfen tekrar deneyin." + "error_generic": "Bir sorun oluştu. Lütfen tekrar deneyin.", + "on_this_device": "Bu cihazda", + "ask_on_launch": "Açılışta kimin izlediğini sor", + "ask_on_launch_desc": "Kapalıyken Sozo son kullanılan profili açar. PIN'li profiller yine de sorar.", + "pin_locked_until": "Çok fazla deneme. {} sonrasında tekrar dene.", + "pin_not_on_device": "Çevrimdışısın ve bu profilin PIN'i bu cihazda henüz girilmedi. Kilidi açmak için bir kez internete bağlan.", + "biometric_reason": "{} kilidini aç", + "biometric_offer_title": "Parmak izi / Face ID kullanılsın mı?", + "biometric_offer_body": "Bu cihazda {} için parmak izi / Face ID kullan. PIN de çalışmaya devam eder.", + "biometric_offer_yes": "Kullan", + "biometric_offer_no": "Şimdi değil", + "biometric_toggle": "{} için parmak izi / Face ID" }, "jellyfin": { "connect_server": "Medya sunucusu bağla (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Hiçbir yeni bölümü kaçırma", "priming_body": "Takip ettiğin bir şeye yeni bölüm ya da kısım gelir gelmez Sozo haber verir — istemediğin hiçbir şey gelmez.", "priming_body_title": "“{}” yeni bölüm alır almaz haberin olsun — istemediğin hiçbir şey gelmez.", + "priming_download_title": "İndirmenin bittiğini öğren", + "priming_download_body": "Sozo indirme ilerlemesini bildirimlerde gösterir ve çevrimdışı izlemeye hazır olduğunda haber verir. Takip ettiklerinin yeni bölümlerini de bildirebilir.", "priming_point_releases": "Afişiyle yeni bölümler", "priming_point_quiet": "Sessiz saatler", "priming_point_control": "İstediğin yapımı sessize al", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Yalnızca jpg, png veya webp görseller.", "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", - "suggest": "Tekrarlıyor mu? Desteğe yaz" + "suggest": "Tekrarlıyor mu? Desteğe yaz", + "error_offline": "Çevrimdışısın veya bağlantı koptu. İnternetini kontrol edip tekrar dene.", + "not_sent": "Gönderilmedi: {}", + "diag_crash_log": "Çökme kaydını görüntüle" + }, + "zangetsu": { + "title": "Zangetsu Kaynakları", + "row_subtitle": "Harici depolardan JavaScript kaynakları, cihazında çalışır", + "desc": "Ek anime ve film kaynakları yüklemek için bir Zangetsu deposu ekle. Bunlar cihazında çalışır. Sozo'da zaten olan kaynaklar «Yerleşik» olarak işaretlenir ve Sozo'nun kendi sürümü kullanılmaya devam eder.", + "loading_repo": "Depo yükleniyor…", + "repo_added": "Depo eklendi · {} kaynak.", + "checking": "Depolarda güncelleme aranıyor…", + "updates_available": "{} güncelleme mevcut.", + "installed_one": "{} yüklendi.", + "removed_one": "{} kaldırıldı.", + "updated": "{} kaynak güncellendi.", + "update_all": "Tümünü güncelle ({})", + "update_to": "v{} sürümüne güncelle", + "built_in": "Yerleşik", + "search": "Kaynak ara…", + "all_types": "Tümü", + "type_movies": "Filmler", + "all_languages": "Tüm diller", + "error_invalid_url": "Yalnızca raw.githubusercontent.com veya gist.githubusercontent.com üzerindeki https bağlantıları eklenebilir.", + "error_unreachable": "Bu depoya ulaşılamadı. Bağlantıyı ve internet bağlantını kontrol et.", + "error_invalid_index": "Bu bağlantı geçerli bir Zangetsu index.json dosyasına gitmiyor." + }, + "star": { + "title": "Sozo'nun büyümesine yardım etmek ister misin?", + "body": "GitHub'da yıldız ver ⭐", + "action": "GitHub'da yıldız ver", + "later": "Sonra", + "never": "Bir daha sorma", + "update_button": "Sozo'ya GitHub'da yıldız ver" + }, + "progress": { + "title": "Seviye ve XP", + "level": "Seviye", + "level_short": "Sv. {}", + "level_up": "Seviye atladın", + "level_reached": "{}. seviyeye ulaştın", + "unlocked_new": "Dolabında yeni", + "open_locker": "Dolabı aç", + "unavailable": "Seviyeler henüz kullanılamıyor. Yakında tekrar bak.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP sonra {}. seviye", + "to_next_short": "Sonraki seviyeye {} XP", + "watch_today": "Bugün izleme: {} / {} XP", + "how_to_earn": "XP nasıl kazanılır", + "rule_watch": "İzlemek: dakikada 1 XP, günde en fazla 300", + "rule_comment": "Yorum: 5 XP, günde en fazla 10 yorum", + "rule_like": "Yorumuna beğeni: 1 XP, günde en fazla 50", + "rule_streak": "Seri günü: 10 XP, dönüm noktası günlerinde daha fazla", + "rule_achievement": "Başarım: kademeye göre 50 ile 400 XP", + "recent": "Son XP", + "reason_watch": "İzleme", + "reason_comment": "Yorumlar", + "reason_like": "Alınan beğeniler", + "reason_streak": "Seri günü", + "reason_achievement": "Başarım", + "reason_other": "XP", + "locker": "Dolap", + "slot_frame": "Avatar çerçeveleri", + "slot_title": "Unvanlar", + "slot_color": "İsim renkleri", + "equip": "Kuşanmak için dokun", + "equipped": "Kuşanıldı", + "unlock_level": "Seviye {}", + "unlock_badge": "Rozet: {}", + "frame_bronze": "Bronz", + "frame_silver": "Gümüş", + "frame_gold": "Altın", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Kor", + "frame_flame": "Alev", + "frame_moon": "Ay", + "title_newcomer": "Yeni gelen", + "title_regular": "Müdavim", + "title_veteran": "Kıdemli", + "title_legend": "Efsane", + "title_binger": "Dizi kurdu", + "title_cinephile": "Sinemasever", + "title_otaku": "Otaku", + "title_bookworm": "Kitap kurdu", + "title_night_owl": "Gece kuşu", + "title_host": "Parti ev sahibi", + "color_sky": "Gök", + "color_mint": "Nane", + "color_gold": "Altın", + "color_rose": "Gül", + "color_violet": "Menekşe", + "color_crimson": "Kızıl", + "color_aurora": "Kutup ışığı", + "color_ember": "Kor" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 1716e906..7eeb8ab6 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -34,7 +34,9 @@ "remove": "Olib tashlash", "show": "Ko‘rsatish", "hide": "Yashirish", - "installed": "O‘rnatilgan" + "installed": "O‘rnatilgan", + "load_more_failed": "Ko‘proq yuklab bo‘lmadi", + "go_home": "Bosh sahifaga" }, "auth": { "login": "Kirish", @@ -306,7 +308,11 @@ "no_source": "Bu qism uchun manba topilmadi.", "empty_url": "Bu manba video manzilini qaytarmadi.", "engine_unavailable": "Pleyer ishga tushmadi. Ilovani butunlay qayta ishga tushiring.", - "unknown": "Videoni yuklab bo‘lmadi." + "unknown": "Videoni yuklab bo‘lmadi.", + "not_found": "Bu video endi ushbu serverda yo‘q. Boshqa serverni tanlang.", + "server_error": "Video serverida muammo bor. Birozdan so‘ng qayta urinib ko‘ring yoki boshqa serverni tanlang.", + "resolve_failed": "Bu manbadan video havolasini olib bo‘lmadi.", + "details": "Tafsilotlar" }, "play": "O'ynash", "pause": "To'xtatish", @@ -529,6 +535,22 @@ "alt_guess": "Taxmin", "alt_guess_spoken": "Ishonchsiz moslik — ijro etishdan oldin sarlavhani tekshiring", "alt_your_pick": "Sizning tanlovingiz", + "alt_auto": "Avtomatik", + "alt_auto_hint": "Bu bor birinchi manbadan qo‘yadi", + "alt_auto_trying": "{} sinab ko‘rilmoqda…", + "alt_auto_missed": "Mos manbalarning hech biri buni qo‘ya olmadi. Pastdan birini tanlang yoki manbadan qidiring.", + "alt_auto_checking": "{} tekshirilmoqda · {} / {}", + "alt_auto_waiting": "Boshqa manbalar kutilmoqda…", + "alt_auto_stop": "To‘xtatish", + "alt_auto_stopped": "To‘xtatildi.", + "alt_auto_capped": "{} ta manba tekshirildi, hech biri buni qo‘ya olmadi. Pastdan birini tanlang yoki qayta urinib ko‘ring.", + "alt_auto_tried": "Tekshirilganlar", + "alt_skip_no_match": "mos kelmadi", + "alt_skip_no_episode": "qism topilmadi", + "alt_skip_error": "yuklab bo‘lmadi", + "auto_switch_attempt": "Urinish {} / {}", + "auto_switch_stop": "To‘xtatish", + "auto_switch_stopped": "Server almashtirish to‘xtatildi. Qayta urinib ko‘ring yoki boshqa server yoki manba tanlang.", "alt_wrong_title": "Sarlavha noto‘g‘rimi?", "alt_wrong_title_hint": "{} ichidan o‘zingiz qidirib, to‘g‘ri sarlavhani tanlang", "alt_corrected": "Endi “{}” ishlatilyapti", @@ -554,10 +576,35 @@ "subtitle_n": "Subtitr {}", "subtitle_in_video": "Videoning ichida", "rewind": "Orqaga", + "rewind_by": "{}s orqaga", + "forward_by": "{}s oldinga", "mini_restore": "Katta oynaga qaytish", "pip_unavailable": "Bu video uchun “rasm ichida rasm” ishga tushmadi.", "scrub_release_cancel": "Qo‘yib yuborsangiz bekor bo‘ladi", - "auto_quality_desc_mpv": "Oqim beradigan eng yaxshi sifatdan boshlanadi" + "auto_quality_desc_mpv": "Oqim beradigan eng yaxshi sifatdan boshlanadi", + "reconnecting": "Aloqa uzildi — qayta ulanmoqda…", + "all_servers_failed": "Bu kontentning birorta serveri ochilmadi. Keyinroq urinib ko‘ring yoki boshqa manbani tanlang.", + "shortcut_pip": "Mini pleer (Shift: boshqa ilovalar ustida)", + "mini_popout_hint": "Mini pleer · boshqa ilovalar ustida suzishi uchun bosib turing", + "react": "Reaksiya", + "comments": "Izohlar", + "episode_comments": "{}-qism izohlari", + "reaction_text": "Reaksiya yozish", + "reaction_text_hint": "Qisqa yozing…", + "reaction_wait": "Qayta reaksiya bildirishdan oldin bir necha soniya kuting", + "reaction_limit": "Bu qism uchun reaksiyalar limiti tugadi", + "reaction_failed": "Reaksiyani yuborib bo'lmadi", + "native_offer_title": "Yangi pleyerni sinab ko‘rasizmi?", + "native_offer_body": "Apple’ning Liquid Glass tugmalari va menyulari. Buni istalgan vaqt Sozlamalar → Pleyer bo‘limida o‘zgartirishingiz mumkin.", + "native_offer_try": "Sinab ko‘rish", + "native_offer_not_now": "Hozir emas", + "trying_again": "Qayta urinilmoqda ({}-urinish)", + "try_mpv": "mpv dvigateli bilan sinash", + "ok_to_skip": "O‘tkazish uchun OK", + "quality_lowered": "Sifat pasaytirildi", + "undo": "Bekor qilish", + "crash_log": "Nosozliklar jurnali", + "crash_log_empty": "Nosozliklar qayd etilmagan" }, "profile": { "title": "Profil", @@ -651,6 +698,15 @@ "player_engine_section": "IJRO DVIGATELI", "ask_engine_on_play": "Har safar so‘ralsin", "ask_engine_on_play_desc": "Video boshlanganda pleyerni tanlash", + "controls_style": "PLEYER TUGMALARI USLUBI", + "controls_style_desc": "Pleyer keyingi safar ochilganda kuchga kiradi.", + "controls_style_native": "Tizimniki (iOS 26)", + "controls_style_native_desc": "Telefonning o'z Liquid Glass tugmalari, menyulari va oynalari", + "controls_style_system": "Tizim shishasi", + "controls_style_system_desc": "Ilova tugmalari telefonning o'z Liquid Glass shishasida", + "controls_style_classic": "Klassik shisha", + "controls_style_classic_desc": "Ilovaning o'z shishasi, avvalgi versiyalardagidek", + "controls_style_needs_ios26": "iOS 26 yoki yangisi kerak", "playback_section": "IJRO", "default_speed": "Standart tezlik", "default_speed_desc": "Har bir video shu tezlikda boshlanadi", @@ -673,6 +729,8 @@ "brightness_gesture_desc": "Chap tomonda yuqoriga-pastga surish", "volume_gesture": "Ovoz surish", "volume_gesture_desc": "O‘ng tomonda yuqoriga-pastga surish", + "auto_pip": "Avtomatik «rasm ichida rasm»", + "auto_pip_desc": "Video ijro etilayotganda Home tugmasi bosilsa, u kichik oynada davom etadi", "seconds_short": "{} s", "subtitle_style_note": "Bular barcha videolarga tegishli. Subtitr vaqti esa har bir qism uchun alohida va pleyer ichida sozlanadi.", "searching_all_sources": "barcha manbalar bo‘yicha qidirilmoqda", @@ -743,7 +801,15 @@ "library_update": "Yangi qismlarni tekshirish", "library_update_desc": "Kuzatayotgan titullaringizni tekshirib, yangi qism chiqsa xabar beradi", "library_update_every": "Har {} soatda", - "library_update_daily": "Kuniga bir marta" + "library_update_daily": "Kuniga bir marta", + "contributors": "Hissa qo'shganlar", + "contributors_error": "Hissa qo'shganlarni yuklab bo'lmadi", + "become_contributor": "Hissa qo'shing", + "become_contributor_body": "Sozo ochiq kodli loyiha. Kod, tarjima va xatolar haqida xabarlar — hammasi yordam beradi.", + "reactions_overlay": "Tomoshabinlar reaksiyalari", + "reactions_overlay_desc": "Boshqa tomoshabinlar reaksiyalarini yuborilgan lahzada va ular to'plangan joylarni vaqt chizig'ida ko'rsatish", + "hide_spoilers": "Ko'rmagan qismlarim uchun spoylerlarni yashirish", + "hide_spoilers_desc": "Tugatmagan qismingiz haqidagi izohlar siz bosmaguningizcha xira turadi" }, "appearance": { "title": "Ko'rinish", @@ -866,8 +932,20 @@ "all_sources_failed_one": "Manba javob bermadi. Bu bo‘sh natija emas — qayta urinib ko‘ring.", "voice": "Ovozli qidiruv", "voice_listening": "Tinglanmoqda…", - "voice_done": "Tinglash to‘xtadi", "voice_hint": "Film yoki serial nomini ayting", + "voice_starting": "Mikrofon tayyorlanmoqda…", + "voice_no_speech": "Eshitib bo‘lmadi", + "voice_no_speech_hint": "Mikrofonga yaqinroq, biroz balandroq gapiring va qayta urinib ko‘ring.", + "voice_permission": "Mikrofonga ruxsat berilmagan", + "voice_permission_hint": "Ovoz bilan qidirish uchun tizim sozlamalarida Sozo’ga mikrofondan foydalanishga ruxsat bering.", + "voice_unavailable": "Bu qurilmada nutqni aniqlash mavjud emas", + "voice_unavailable_hint": "Bu qurilmada nutqni aniqlash xizmati yo‘q. Uni tizim sozlamalarida o‘rnating yoki yoqing, masalan, Google xizmatini.", + "voice_network": "Nutq xizmatiga ulanib bo‘lmadi", + "voice_network_hint": "Internet aloqasini tekshirib, qayta urinib ko‘ring.", + "voice_error": "Tinglashda xatolik yuz berdi", + "voice_error_hint": "Mikrofonni boshqa ilova band qilgan bo‘lishi mumkin. Birozdan so‘ng qayta urinib ko‘ring.", + "voice_try_again": "Qayta urinish", + "voice_open_settings": "Sozlamalarni ochish", "try_torrents": "Torrentlarda qidirish", "capped_note": "{} ta manbadan eng ishonchli {} tasi qidirildi — aniq bittasiga yetish uchun qamrovni toraytiring.", "rail_popular_on": "{source} da mashhur", @@ -1062,6 +1140,10 @@ "forgot_pin": "PIN-kodni unutdingizmi?" }, "my_list": { + "sort": "Saralash", + "sort_added": "Yaqinda qo‘shilgan", + "sort_oldest": "Avval qo‘shilgan", + "sort_title": "Nomi A–Z", "saved_account": "Akkauntga saqlangan", "saved_local": "Faqat shu qurilmada", "untitled": "Nomsiz", @@ -1160,7 +1242,33 @@ "reconnecting": "Qayta ulanmoqda…", "connecting": "Ulanmoqda…", "connect_failed_title": "Ulanib bo'lmadi", - "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring." + "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring.", + "settings_title": "Xona sozlamalari", + "room_size": "Xona hajmi", + "room_size_less": "Kamroq odam", + "room_size_more": "Ko‘proq odam", + "only_host_setting": "Ijroni faqat host boshqaradi", + "lock_room": "Xonani qulflash", + "lock_room_hint": "Yangi hech kim qo'shila olmaydi", + "wait_for_all": "Hammani kutish", + "wait_for_all_hint": "Kimdir yuklanayotganda biroz pauza qilish", + "invite_friends": "Do'stlarni taklif qilish", + "invite_sent": "Taklif yuborildi", + "invite_failed": "Taklifni yuborib bo'lmadi", + "invite_again_in": "{} dan keyin yana", + "invite_no_friends": "Bu yerda taklif qilish uchun do'st qo'shing", + "make_host": "Host qilish", + "remove_member": "Xonadan chiqarish", + "remove_confirm": "{} xonadan chiqarilsinmi? U qayta qo'shila olmaydi.", + "buffering": "Yuklanmoqda…", + "sys_joined": "{} qo'shildi", + "sys_left": "{} chiqib ketdi", + "sys_host": "{} endi host", + "sys_removed": "{} chiqarildi", + "error_banned": "Siz bu xonadan chiqarildingiz", + "error_locked": "Bu xona qulflangan", + "room_closed_kicked": "Host sizni xonadan chiqardi", + "waiting_for_others": "Hamma yetib olishini kutyapmiz…" }, "notifications": { "title": "Bildirishnomalar", @@ -1281,7 +1389,19 @@ "highlights": "Belgilanganlar", "remove_highlight": "Belgini olib tashlash", "highlight_note": "Izoh qo'shing (ixtiyoriy)", - "no_highlights": "Hali belgilanganlar yo'q. Matnni tanlang va «Belgilash»ni bosing." + "no_highlights": "Hali belgilanganlar yo'q. Matnni tanlang va «Belgilash»ni bosing.", + "translate_page": "Sahifani tarjima qilish", + "translate_preview": "Tarjima qilingan sahifa", + "translate_apply": "Qo'llash", + "translate_show_original": "Asl matnni ko'rsatish", + "translate_no_text": "Bu sahifada matn topilmadi.", + "translate_daily_limit": "Bugungi tarjimalaringiz tugadi. Ertaga qayta urinib ko'ring.", + "translate_offline": "Internet yo'q. Sahifani tarjima qilish uchun internetga ulaning.", + "translate_sign_in": "Sahifalarni tarjima qilish uchun hisobingizga kiring.", + "translate_failed": "Sahifani tarjima qilib bo'lmadi. Qayta urinib ko'ring.", + "translate_language": "Sahifalarni qaysi tilga tarjima qilish", + "translate_auto": "O'qiyotganimda har bir sahifani tarjima qilish", + "translate_auto_desc": "Har bir yangi sahifa bugungi tarjimalardan bittasini sarflaydi." }, "cloudflare": { "title": "Siz odam ekanligingizni tasdiqlang", @@ -1363,6 +1483,10 @@ "watched": "Ko'rilgan", "follow_series": "Seriyani kuzatish", "find_other_sources": "Boshqa manbalardan izlash", + "notice_title": "Bu manba bu kinoni o'ynatolmaydi", + "notice_moved_to_app": "Manba bu kinoni o'zining ilovasiga ko'chirgan, shu yerda o'ynatish uchun hech nima qolmagan. Boshqa manbada bo'lishi mumkin.", + "notice_no_player": "Manba bu kinoni ro'yxatda ko'rsatadi, lekin unga pleyer yo'q. Boshqa manbada bo'lishi mumkin.", + "notice_is_series": "Bu manbada bu serial, shuning uchun u kino emas, qismlar ro'yxatidan o'ynaydi.", "anilist_track": "AniList'da kuzatish", "anilist_tracked": "AniList'da kuzatilmoqda", "following_on": "Kuzatilyapti", @@ -1393,6 +1517,8 @@ "download_batch_body": "{range} epizodlar navbatga qo‘yiladi — jami {count} ta. Mobil internetda bu bir necha gigabayt bo‘lishi mumkin.", "relations": "Bog‘liq", "no_relations": "Bunga bog‘liq asar topilmadi.", + "relation_tmdb_collection": "SHU TURKUMDAN", + "relation_tmdb_recommended": "TAVSIYA ETILADI", "relation_sequel": "DAVOMI", "relation_prequel": "OLDINGI QISM", "relation_parent": "ASOSIY HIKOYA", @@ -1461,6 +1587,8 @@ "offline_desc": "Saqlangan ro'yxat ko'rsatilmoqda. Oflayn faqat yuklab olingan qismlar ochiladi.", "filter_hint": "Epizod raqami yoki nomi", "all_groups": "Barcha guruhlar", + "season_n": "{}-mavsum", + "specials": "Maxsus qismlar", "filter_none": "Bu blokda “{query}” ga mos epizod yo‘q", "jump_to": "{n}-epizodga o‘tish", "jump_loads": "{range} yuklanadi", @@ -1505,7 +1633,10 @@ "show_more": "Ko'proq ko'rsatish", "delete_confirm_title": "Bu izoh o'chirilsinmi?", "delete_confirm_body": "Bu amalni bekor qilib bo'lmaydi.", - "sign_in_prompt": "Izoh qoldirish uchun tizimga kiring" + "sign_in_prompt": "Izoh qoldirish uchun tizimga kiring", + "spoiler": "Spoyler", + "spoiler_hidden": "Spoyler · ochish uchun bosing", + "unwatched_hidden": "Hali ko'rilmagan · ochish uchun bosing" }, "downloads": { "view_titles": "Nomlar", @@ -1955,7 +2086,8 @@ "title": "Ro‘yxatlarim", "remove": "O‘chirish", "empty_title": "Hozircha hech narsa yo‘q", - "empty_subtitle": "Saqlaganlaringiz shu yerda ko‘rinadi." + "empty_subtitle": "Saqlaganlaringiz shu yerda ko‘rinadi.", + "load_failed": "Bu ro‘yxatni yuklab bo‘lmadi." }, "torrent": { "title": "Torrentlar", @@ -2229,7 +2361,25 @@ "settings_title": "{} sozlamalari", "settings_reset": "Asliga qaytarish", "settings_failed": "Bu manbaning sozlamalarini o‘qib bo‘lmadi.", - "settings_none": "Bu manbada sozlamalar yo‘q." + "settings_none": "Bu manbada sozlamalar yo‘q.", + "loading_repo": "Repozitoriy yuklanmoqda…", + "add_sources": "Manbalar qo‘shish", + "choose_sources": "Manbalarni tanlang", + "select_all": "Hammasini tanlash", + "select_none": "Hech birini tanlamaslik", + "install_selected": "O‘rnatish ({})", + "already_installed": "O‘rnatilgan", + "remove": "O‘chirish", + "remove_source": "Manbani o‘chirish", + "remove_source_confirm": "{} o‘chirilsinmi? Uning sozlamalari va keshdagi kodi o‘chiriladi. Uni repozitoriyadan qayta qo‘shishingiz mumkin.", + "remove_repo_confirm": "Bu repozitoriy va uning barcha manbalari o‘chirilsinmi?", + "enable_source": "Yoqish", + "disable_source": "O‘chirib qo‘yish", + "source_disabled": "O‘chirilgan", + "update": "Yangilash", + "update_all": "Hammasini yangilash", + "updates_available": "{} ta yangilanish mavjud", + "repo_check_failed": "{} tekshirib bo‘lmadi: {}" }, "ux": { "quick_search": "Tezkor qidiruv ({})", @@ -2300,8 +2450,8 @@ "check_all": "Manbalarni tekshirish", "check_bad_hint": "Ishlamaydigan manbalarni yashirsangiz, ular bu ro‘yxatlardan chiqadi; qayta ishlay boshlasa, o‘zi qaytadi.", "check_done": "Manbalar tekshiruvi", - "check_hide_down": "Ko‘p manbaga umuman ulanib bo‘lmadi — bu manbalar emas, internet muammosi. Hech narsa belgilanmadi; ishlaydigan tarmoqda qayta urinib ko‘ring.", - "check_network": "Ishlamaydiganlarni yashirish", + "check_hide_down": "Ishlamaydiganlarni yashirish", + "check_network": "Ko‘p manbaga umuman ulanib bo‘lmadi — bu manbalar emas, internet muammosi. Hech narsa belgilanmadi; ishlaydigan tarmoqda qayta urinib ko‘ring.", "check_retrying": "Ishlamaganlar qayta tekshirilmoqda…", "checking": "{} / {} tekshirilmoqda…", "d_ago": "{} kun oldin", @@ -2315,6 +2465,8 @@ "v_blocked": "To‘silgan", "v_dead": "Ishlamayapti", "v_empty": "Bo‘sh", + "v_empty_body": "Manba javob berdi, lekin katalogi bo'sh keldi. Ilovani bloklagan sayt ko'pincha shunday ko'rinadi.", + "v_failing_body": "Bu manbaning oxirgi tekshiruvi muvaffaqiyatsiz bo'ldi. Bitta xato tasodif bo'lishi mumkin — yana tekshirib ko'ring.", "v_failing": "Javob bermayapti", "v_outdated": "Yangilash kerak", "v_unknown": "Hali tekshirilmagan", @@ -2527,7 +2679,18 @@ "card_single": "Hisobingizdan birga foydalanadiganlar uchun profil qo'shing", "load_failed": "Profillarni yuklab bo'lmadi", "offline": "Internet aloqasini tekshirib, qayta urinib ko'ring.", - "error_generic": "Xatolik yuz berdi. Qayta urinib ko'ring." + "error_generic": "Xatolik yuz berdi. Qayta urinib ko'ring.", + "on_this_device": "Shu qurilmada", + "ask_on_launch": "Ishga tushganda kim ko'rayotganini so'rash", + "ask_on_launch_desc": "O'chirilsa, Sozo oxirgi ishlatilgan profilni ochadi. PIN-kodli profillar baribir so'raladi.", + "pin_locked_until": "Urinishlar juda ko'p. {} dan keyin qayta urinib ko'ring.", + "pin_not_on_device": "Internet yo'q, bu profilning PIN-kodi esa shu qurilmada hali kiritilmagan. Ochish uchun bir marta internetga ulaning.", + "biometric_reason": "{} profilini ochish", + "biometric_offer_title": "Barmoq izi / Face ID ishlatilsinmi?", + "biometric_offer_body": "Shu qurilmada {} uchun barmoq izi / Face ID ishlatilsin. PIN-kod ham ishlashda davom etadi.", + "biometric_offer_yes": "Ishlatish", + "biometric_offer_no": "Hozir emas", + "biometric_toggle": "{} uchun barmoq izi / Face ID" }, "jellyfin": { "connect_server": "Media serverni ulash (Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "Yangi qismni o‘tkazib yubormang", "priming_body": "Kuzatayotgan asaringizda yangi qism yoki bob chiqishi bilan Sozo xabar beradi — so‘ramagan narsangiz bilan bezovta qilmaydi.", "priming_body_title": "«{}»ning yangi qismi chiqishi bilan xabar oling — ortiqcha hech narsa yo‘q.", + "priming_download_title": "Yuklab olish tugaganini biling", + "priming_download_body": "Sozo yuklab olish jarayonini bildirishnomalarda ko‘rsatadi va oflayn ko‘rishga tayyor bo‘lganda xabar beradi. Kuzatayotganlaringizning yangi qismlari haqida ham aytib turadi.", "priming_point_releases": "Yangi qismlar, posteri bilan", "priming_point_quiet": "Sokin soatlar", "priming_point_control": "Istalgan asarni ovozsiz qilish", @@ -3004,6 +3169,101 @@ "error_attachment_type": "Faqat jpg, png yoki webp rasm.", "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", - "suggest": "Takrorlanyaptimi? Support’ga yozing" + "suggest": "Takrorlanyaptimi? Support’ga yozing", + "error_offline": "Internet yo'q yoki aloqa uzildi. Internetni tekshirib, qayta urinib ko'ring.", + "not_sent": "Yuborilmadi: {}", + "diag_crash_log": "Nosozliklar jurnalini ko‘rish" + }, + "zangetsu": { + "title": "Zangetsu manbalari", + "row_subtitle": "Tashqi repolardagi JavaScript manbalar, qurilmangizda ishlaydi", + "desc": "Qo'shimcha anime va film manbalarini o'rnatish uchun Zangetsu reposini qo'shing. Ular qurilmangizda ishlaydi. Sozo'da allaqachon bor manbalar \"Ichki\" deb belgilanadi va Sozo'ning o'z versiyasi ishlatiladi.", + "loading_repo": "Repo yuklanmoqda…", + "repo_added": "Repo qo'shildi · {} ta manba.", + "checking": "Repolar yangilanishga tekshirilmoqda…", + "updates_available": "{} ta yangilanish mavjud.", + "installed_one": "{} o'rnatildi.", + "removed_one": "{} o'chirildi.", + "updated": "{} ta manba yangilandi.", + "update_all": "Hammasini yangilash ({})", + "update_to": "v{} ga yangilash", + "built_in": "Ichki", + "search": "Manbalarni qidirish…", + "all_types": "Hammasi", + "type_movies": "Filmlar", + "all_languages": "Barcha tillar", + "error_invalid_url": "Faqat raw.githubusercontent.com yoki gist.githubusercontent.com dagi https havolalarni qo'shish mumkin.", + "error_unreachable": "Repoga ulanib bo'lmadi. Havolani va internet aloqasini tekshiring.", + "error_invalid_index": "Bu havola to'g'ri Zangetsu index.json fayliga olib bormaydi." + }, + "star": { + "title": "Sozo rivojlanishiga yordam berishni xohlaysizmi?", + "body": "GitHub'da yulduzcha qo'ying ⭐", + "action": "GitHub'da yulduzcha qo'yish", + "later": "Keyinroq", + "never": "Boshqa so'ramang", + "update_button": "GitHub'da Sozo'ga yulduzcha qo'ying" + }, + "progress": { + "title": "Daraja va XP", + "level": "Daraja", + "level_short": "{}-dar.", + "level_up": "Yangi daraja", + "level_reached": "Siz {}-darajaga yetdingiz", + "unlocked_new": "Shkafingizda yangilik", + "open_locker": "Shkafni ochish", + "unavailable": "Darajalar hali mavjud emas. Keyinroq qarab ko‘ring.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP qoldi, keyin {}-daraja", + "to_next_short": "Keyingi darajagacha {} XP", + "watch_today": "Bugun tomosha: {} / {} XP", + "how_to_earn": "XP qanday olinadi", + "rule_watch": "Tomosha: daqiqasiga 1 XP, kuniga 300 tagacha", + "rule_comment": "Izoh: 5 XP, kuniga 10 ta izohgacha", + "rule_like": "Izohingizga layk: 1 XP, kuniga 50 tagacha", + "rule_streak": "Streak kuni: 10 XP, muhim kunlarda ko‘proq", + "rule_achievement": "Yutuq: darajasiga qarab 50 dan 400 XP gacha", + "recent": "So‘nggi XP", + "reason_watch": "Tomosha", + "reason_comment": "Izohlar", + "reason_like": "Olingan layklar", + "reason_streak": "Streak kuni", + "reason_achievement": "Yutuq", + "reason_other": "XP", + "locker": "Shkaf", + "slot_frame": "Avatar ramkalari", + "slot_title": "Unvonlar", + "slot_color": "Ism ranglari", + "equip": "Kiyish uchun bosing", + "equipped": "Kiyilgan", + "unlock_level": "{}-daraja", + "unlock_badge": "Nishon: {}", + "frame_bronze": "Bronza", + "frame_silver": "Kumush", + "frame_gold": "Oltin", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Cho‘g‘", + "frame_flame": "Alanga", + "frame_moon": "Oy", + "title_newcomer": "Yangi kelgan", + "title_regular": "Doimiy", + "title_veteran": "Faxriy", + "title_legend": "Afsona", + "title_binger": "Serial ishqibozi", + "title_cinephile": "Kinoshunos", + "title_otaku": "Otaku", + "title_bookworm": "Kitobxon", + "title_night_owl": "Tungi boyqush", + "title_host": "Bazm egasi", + "color_sky": "Osmon", + "color_mint": "Yalpiz", + "color_gold": "Oltin", + "color_rose": "Atirgul", + "color_violet": "Binafsha", + "color_crimson": "Qirmizi", + "color_aurora": "Qutb shafag‘i", + "color_ember": "Cho‘g‘" } } diff --git a/assets/translations/vi.json b/assets/translations/vi.json new file mode 100644 index 00000000..c9acbd4f --- /dev/null +++ b/assets/translations/vi.json @@ -0,0 +1,3269 @@ +{ + "app_name": "Sozo", + "general": { + "loading": "Đang tải...", + "error": "Đã xảy ra lỗi", + "retry": "Thử lại", + "cancel": "Hủy", + "confirm": "Xác nhận", + "ok": "OK", + "yes": "Có", + "no": "Không", + "back": "Quay lại", + "next": "Tiếp", + "done": "Xong", + "save": "Lưu", + "delete": "Xóa", + "edit": "Sửa", + "search": "Tìm kiếm", + "close": "Đóng", + "settings": "Cài đặt", + "logout": "Đăng xuất", + "no_internet": "Không có kết nối mạng", + "try_again": "Thử lại", + "see_all": "Xem tất cả", + "unknown": "Không rõ", + "off": "Tắt", + "on": "Bật", + "new_badge": "MỚI", + "reset": "Đặt lại", + "clear": "Xóa", + "copy": "Sao chép", + "share": "Chia sẻ", + "install": "Cài đặt", + "remove": "Gỡ", + "show": "Hiện", + "hide": "Ẩn", + "installed": "Đã cài", + "load_more_failed": "Không thể tải thêm", + "go_home": "Về trang chủ" + }, + "auth": { + "login": "Đăng nhập", + "register": "Đăng ký", + "email": "Email", + "password": "Mật khẩu", + "username": "Tên người dùng", + "email_hint": "Nhập email của bạn", + "password_hint": "Nhập mật khẩu của bạn", + "username_hint": "Nhập tên người dùng", + "forgot_password": "Quên mật khẩu?", + "dont_have_account": "Chưa có tài khoản?", + "already_have_account": "Đã có tài khoản?", + "sign_up": "Đăng ký", + "sign_in": "Đăng nhập", + "login_success": "Đăng nhập thành công", + "register_success": "Đăng ký thành công", + "logout_success": "Đã đăng xuất", + "invalid_email": "Địa chỉ email không hợp lệ", + "invalid_password": "Mật khẩu phải có ít nhất 6 ký tự", + "invalid_username": "Tên người dùng phải có ít nhất 3 ký tự", + "confirm_password": "Xác nhận mật khẩu", + "passwords_not_match": "Mật khẩu không khớp", + "terms_agree": "Tôi đồng ý với Điều khoản và Chính sách bảo mật", + "or_continue_with": "Hoặc tiếp tục với", + "welcome_back": "Chào mừng trở lại", + "create_account": "Tạo tài khoản", + "login_subtitle": "Đăng nhập để dùng hồ sơ và dữ liệu đã lưu của bạn.", + "register_subtitle": "Tạo tài khoản bằng email, tên người dùng và mật khẩu.", + "forgot_password_title": "Đặt lại mật khẩu", + "forgot_password_subtitle": "Nhập email tài khoản của bạn, chúng tôi sẽ gửi mã gồm 6 chữ số.", + "reset_password_subtitle": "Chúng tôi đã gửi mã đến {email}. Nhập mã cùng mật khẩu mới của bạn.", + "send_reset_code": "Gửi mã", + "reset_code_sent": "Nếu email này đã đăng ký, mã sẽ sớm được gửi đến", + "reset_code_hint": "Mã 6 chữ số", + "invalid_reset_code": "Nhập mã 6 chữ số", + "new_password_hint": "Mật khẩu mới", + "confirm_password_hint": "Nhập lại mật khẩu mới", + "reset_password": "Đặt lại mật khẩu", + "resend_code": "Gửi mã khác", + "resend_in": "Gửi lại sau {seconds} giây", + "identifier_hint": "Email hoặc tên người dùng", + "identifier_required": "Cần nhập email hoặc tên người dùng", + "continue_with_google": "Tiếp tục với Google", + "google_unavailable": "Không thể đăng nhập Google trên thiết bị này", + "google_failed": "Không đăng nhập được bằng Google. Vui lòng thử lại", + "weak": "Yếu", + "medium": "Trung bình", + "strong": "Mạnh", + "code_resent": "Đã gửi lại mã. Hãy kiểm tra email.", + "verify": "Xác minh", + "avatar_bad_format": "Định dạng ảnh không được hỗ trợ (jpg, png, webp)", + "verification_code": "Mã xác minh" + }, + "onboarding": { + "skip": "Bỏ qua", + "continue_with_email": "Tiếp tục với email", + "slide_1_title": "Hàng nghìn phim lẻ và phim bộ", + "slide_1_body": "Phim mới, điện ảnh thế giới và hoạt hình trong một ứng dụng.", + "slide_2_title": "Anime, cả một thư viện", + "slide_2_body": "Phim hot theo mùa, kinh điển và phụ đề. Liên kết AniList để danh sách luôn được cập nhật.", + "slide_3_title": "Cả trên màn hình lớn", + "slide_3_body": "Kênh trực tiếp, ghép nối điện thoại với TV và xem chung — Sozo cũng chạy trên TV.", + "get_started": "Bắt đầu", + "continue": "Tiếp tục", + "done": "Xong", + "not_now": "Để sau", + "retry": "Thử lại", + "progress_label": "Đã thiết lập {}%", + "kinds_title": "Bạn thích gì?", + "kinds_subtitle": "Chọn bao nhiêu tùy thích. Sozo sẽ mở ở mục bạn chọn đầu tiên.", + "kinds_pick_one": "Chọn ít nhất một mục", + "kinds_starts_here": "Bắt đầu ở đây", + "kind_anime": "Anime", + "kind_anime_hint": "Phim hot theo mùa và kinh điển", + "kind_movies": "Phim lẻ & bộ", + "kind_movies_hint": "Phim và chương trình TV khắp nơi", + "kind_manga": "Manga", + "kind_manga_hint": "Manga, manhwa và webtoon", + "kind_novels": "Tiểu thuyết", + "kind_novels_hint": "Light novel và web novel", + "genres_title": "Chọn thể loại", + "genres_subtitle": "Chọn từ ba thể loại trở lên, Trang chủ sẽ gợi ý thêm cho bạn.", + "genres_count": "{}/{}", + "genres_count_done": "Đã chọn {}", + "genres_pick_more": "Chọn thêm {}", + "genres_offline": "Bạn đang ngoại tuyến nên đây là các thể loại phổ biến.", + "account_title": "Mang theo bên mình", + "account_subtitle": "Tài khoản giúp đồng bộ lịch sử, danh sách và tiến độ trên mọi màn hình.", + "account_perk_sync": "Xem tiếp trên TV từ chỗ dừng trên điện thoại", + "account_perk_import": "Nhập danh sách từ AniList hoặc MyAnimeList", + "account_perk_profiles": "Tối đa bốn hồ sơ cho cả nhà", + "account_email": "Đăng ký bằng email", + "account_sign_in": "Tôi đã có tài khoản", + "account_guest": "Tiếp tục với tư cách khách", + "tv_pair_title": "Đăng nhập bằng điện thoại", + "tv_pair_body": "Quét mã bằng camera điện thoại, hoặc mở Sozo trên điện thoại, vào Hồ sơ → Liên kết TV rồi nhập mã.", + "tv_pair_waiting": "Đang chờ điện thoại của bạn…", + "tv_pair_expired": "Mã này đã hết hạn.", + "tv_pair_failed": "Không lấy được mã. Hãy kiểm tra kết nối.", + "tv_pair_new_code": "Lấy mã mới", + "tv_pair_qr_label": "Mã QR để đăng nhập TV này", + "import_title": "Mang danh sách của bạn sang", + "import_subtitle": "Phim đang xem sẽ được theo dõi để bạn biết khi có tập mới. Phim định xem sẽ vào Danh sách của tôi.", + "import_not_connected": "Chưa kết nối", + "import_connected": "Đã kết nối", + "import_connected_as": "Đã kết nối: {}", + "import_connect": "Kết nối", + "import_action": "Nhập", + "import_again": "Nhập lại", + "import_result": "{} đang theo dõi · {} trong Danh sách của tôi", + "import_following": "Đang theo dõi", + "import_my_list": "Đã thêm vào Danh sách của tôi", + "import_unmatched": "{} tựa không tìm thấy trên AniList nên đã bị bỏ qua.", + "import_already": "Mọi thứ đã có sẵn ở đây.", + "import_failed": "Nhập không thành công. Hãy kiểm tra kết nối và thử lại.", + "import_later": "Để sau", + "import_sheet_title": "Đang nhập danh sách của bạn", + "import_row_title": "Nhập danh sách của tôi", + "import_row_subtitle": "Theo dõi phim đang xem, thêm phim định xem vào Danh sách của tôi", + "notify_title": "Không bỏ lỡ tập nào", + "notify_body": "Nhận thông báo khi nội dung bạn theo dõi có tập hoặc chương mới.", + "notify_body_title": "Nhận thông báo khi {} có tập mới.", + "notify_allow": "Bật thông báo", + "notify_later": "Để sau", + "notify_mock_title": "Tập mới của {}", + "notify_mock_title_generic": "Đã có tập mới", + "notify_mock_body": "Đã sẵn sàng — nhấn để xem.", + "notify_now": "bây giờ", + "done_title": "Mọi thứ đã sẵn sàng", + "done_title_name": "Đã sẵn sàng, {}", + "done_subtitle": "Sozo đã được thiết lập theo sở thích của bạn. Bạn có thể đổi bất cứ lúc nào trong Cài đặt.", + "done_subtitle_profile": "Trang chủ của hồ sơ này sẽ ưu tiên những lựa chọn này.", + "done_start": "Bắt đầu xem", + "summary_kinds": "Bạn thích", + "summary_genres": "Thể loại", + "summary_import": "Đã nhập", + "summary_notifications": "Thông báo", + "summary_language": "Ngôn ngữ", + "summary_on": "Bật", + "summary_off": "Tắt", + "personalize_title": "Cá nhân hóa Sozo", + "personalize_subtitle": "Sở thích, thể loại, nhập danh sách và thông báo", + "profile_offer_title": "Thiết lập sở thích cho {}?", + "profile_offer_body": "Vài lần nhấn vào thứ họ thích là Trang chủ của họ sẽ đầy nội dung phù hợp.", + "profile_offer_yes": "Bắt đầu nào", + "genre_names": { + "action": "Hành động", + "adventure": "Phiêu lưu", + "animation": "Hoạt hình", + "comedy": "Hài", + "crime": "Tội phạm", + "documentary": "Tài liệu", + "drama": "Chính kịch", + "family": "Gia đình", + "fantasy": "Kỳ ảo", + "history": "Lịch sử", + "horror": "Kinh dị", + "mahou-shoujo": "Ma pháp thiếu nữ", + "mecha": "Mecha", + "music": "Âm nhạc", + "mystery": "Bí ẩn", + "psychological": "Tâm lý", + "romance": "Lãng mạn", + "sci-fi": "Viễn tưởng", + "slice-of-life": "Đời thường", + "sports": "Thể thao", + "supernatural": "Siêu nhiên", + "thriller": "Giật gân", + "war": "Chiến tranh", + "western": "Cao bồi" + }, + "badges_title": "Mỗi ngày đều có giá trị", + "badges_body": "Xem hoặc đọc một chút mỗi ngày để giữ chuỗi. Chuỗi và cột mốc sẽ mang lại huy hiệu để bạn khoe trên hồ sơ.", + "badges_week": "7 ngày liên tiếp sẽ nhận huy hiệu đầu tiên", + "widgets_title": "Sozo trên màn hình chính", + "widgets_body": "Xem tiếp từ chỗ đã dừng chỉ với một lần nhấn và theo dõi chuỗi mà không cần mở ứng dụng.", + "widgets_add_streak": "Thêm tiện ích chuỗi", + "widgets_add_continue": "Thêm tiện ích Xem tiếp", + "widgets_added": "Đã thêm — hãy xem màn hình chính", + "widgets_manual": "Nhấn giữ một chỗ trống trên màn hình chính, chọn Tiện ích rồi tìm Sozo.", + "widgets_later": "Để sau" + }, + "home": { + "explore_anime": "Khám phá anime", + "explore_manga": "Khám phá manga", + "explore_novel": "Khám phá tiểu thuyết", + "title": "Trang chủ", + "continue_watching": "Xem tiếp", + "continue_reading": "Đọc tiếp", + "trending": "Đang thịnh hành", + "new_releases": "Mới phát hành", + "top_rated": "Đánh giá cao", + "for_you": "Dành cho bạn", + "all_categories": "Tất cả danh mục", + "genres": "Thể loại", + "genres_no_match": "Không có thể loại phù hợp.", + "get_all": "Lấy tất cả", + "popular": "Phổ biến", + "watch_now": "Xem ngay", + "more_info": "Chi tiết", + "add_to_list": "Thêm vào danh sách", + "my_list": "Danh sách của tôi", + "featured": "Nổi bật", + "action": "Hành động", + "comedy": "Hài", + "drama": "Chính kịch", + "thriller": "Giật gân", + "horror": "Kinh dị", + "romance": "Lãng mạn", + "sci_fi": "Viễn tưởng", + "documentary": "Tài liệu", + "animation": "Hoạt hình", + "telegram_promo_title": "Tham gia Sozo trên Telegram", + "telegram_promo_subtitle": "Nhận tin cập nhật, tính năng mới và thông báo về nội dung.", + "telegram_join_channel": "Vào kênh", + "dont_show_again": "Không hiện lại", + "downloaded": "Đã tải xuống", + "view_all": "Xem tất cả", + "remove_from_continue": "Xóa khỏi mục Xem tiếp", + "ep_number": "Tập {}", + "untitled": "Không tiêu đề", + "empty_title": "Chưa có gì ở đây", + "empty_subtitle": "Hãy chọn nguồn khác hoặc kéo xuống để làm mới.", + "list_empty_title": "Danh sách này trống", + "list_empty_subtitle": "Hiện chưa có gì để hiển thị trong mục này.", + "suggest_services_title": "Thêm dịch vụ phát trực tuyến vào Trang chủ?", + "suggest_services_body": "Xem Netflix, Disney+ và các dịch vụ khác có gì ở khu vực của bạn. Bạn có thể gỡ bất cứ lúc nào.", + "suggest_add": "Thêm", + "suggest_no": "Không, cảm ơn", + "remove_from_home": "Gỡ khỏi Trang chủ", + "removed_undo_hint": "Đã gỡ. Thêm lại trong Giao diện → Bố cục Trang chủ.", + "airing_on_air": "ĐANG CHIẾU", + "airing_title": "Lịch chiếu", + "airing_next": "Tiếp theo: {} · Tập {}", + "airing_in_hm": "sau {} giờ {} phút", + "airing_in_m": "sau {} phút", + "airing_now": "đang chiếu", + "airing_today_count": "{} trong 24 giờ tới", + "airing_hint": "Xem lịch chiếu tuần này", + "airing_loading": "Đang kiểm tra lịch chiếu…" + }, + "player": { + "needs_media_kit": "Cần công cụ media_kit", + "auto_quality_desc": "Trình phát tự chọn chất lượng tốt nhất theo kết nối của bạn", + "on": "Bật", + "info_title": "Thông tin trình phát", + "info_resolution": "Độ phân giải", + "info_engine": "Công cụ", + "info_provider": "Nguồn", + "info_server": "Máy chủ", + "info_quality": "Chất lượng", + "info_codec": "Codec", + "info_hdr": "HDR", + "info_audio": "Âm thanh", + "info_container": "Định dạng", + "info_size": "Dung lượng", + "info_host": "Tên máy chủ", + "info_position": "Vị trí", + "info_buffer": "Đã đệm trước", + "info_speed": "Tốc độ", + "info_state": "Trạng thái", + "switching_to": "Đang chuyển sang {}…", + "fault": { + "decoder": "Thiết bị này không giải mã được video. Hãy thử chất lượng khác hoặc thử lại.", + "unsupported_format": "Thiết bị của bạn không hỗ trợ định dạng video này. Bạn có thể thử phát trong trình duyệt.", + "source_blocked": "Không mở được nguồn video — máy chủ có thể đã chặn. Hãy thử chất lượng khác.", + "network": "Lỗi mạng — hãy kiểm tra kết nối.", + "no_source": "Tập này không có nguồn.", + "empty_url": "Nguồn này không trả về địa chỉ video.", + "engine_unavailable": "Trình phát không khởi động được. Vui lòng khởi động lại hẳn ứng dụng.", + "unknown": "Không tải được video.", + "not_found": "Video này không còn trên máy chủ này. Hãy thử máy chủ khác.", + "server_error": "Máy chủ video đang gặp sự cố. Hãy thử lại sau giây lát hoặc chọn máy chủ khác.", + "resolve_failed": "Không lấy được liên kết video từ nguồn này.", + "details": "Chi tiết" + }, + "play": "Phát", + "pause": "Tạm dừng", + "resume": "Tiếp tục", + "forward": "Tua tới 10 giây", + "backward": "Tua lùi 10 giây", + "fullscreen": "Toàn màn hình", + "exit_fullscreen": "Thoát toàn màn hình", + "quality": "Chất lượng", + "subtitles": "Phụ đề", + "audio": "Âm thanh", + "speed": "Tốc độ phát", + "auto": "Tự động", + "off": "Tắt", + "episodes": "Các tập", + "next_episode": "Tập tiếp theo", + "season": "Phần {n}", + "fit_original": "Gốc", + "fit_fill": "Lấp đầy", + "fit_stretch": "Kéo giãn", + "play_in_browser": "Phát trong trình duyệt", + "view_logs": "Xem nhật ký", + "speed_2x": "Tốc độ 2x", + "tap_to_unlock": "Nhấn để mở khóa", + "previous": "Trước", + "live": "TRỰC TIẾP", + "go_live": "Về trực tiếp", + "lang_sub": "Phụ đề (SUB)", + "lang_dub": "Lồng tiếng (DUB)", + "lang_softsub": "Phụ đề rời", + "audio_language": "Ngôn ngữ âm thanh", + "speed_short": "Tốc độ", + "aspect": "Tỷ lệ", + "aspect_ratio": "Tỷ lệ khung hình", + "speed_now": "Tốc độ: {}", + "subtitle_style": "Kiểu phụ đề", + "audio_track": "Bản âm thanh", + "shortcuts_title": "Phím tắt", + "shortcut_play_pause": "Phát / tạm dừng", + "shortcut_seek": "Tua lùi / tới {} giây", + "shortcut_seek_long": "Nhảy lùi / tới {} giây", + "shortcut_frame": "Lùi / tới một khung hình", + "shortcut_decile": "Nhảy đến 0-90% video", + "shortcut_volume": "Tăng / giảm âm lượng", + "shortcut_mute": "Tắt tiếng", + "shortcut_speed": "Chậm hơn / nhanh hơn", + "shortcut_episode": "Tập trước / tập sau", + "shortcut_start_end": "Về đầu / đến cuối", + "shortcut_escape": "Đóng bảng, thoát toàn màn hình hoặc thoát", + "shortcut_help": "Hiện danh sách này", + "key_space": "Dấu cách", + "diagnostics_logs": "Chẩn đoán / Nhật ký", + "extracting_media": "Đang trích xuất nội dung…", + "loading_video": "Đang tải video…", + "fetching_link": "Đang lấy liên kết phát từ nguồn", + "preparing_stream": "Đang chuẩn bị luồng video", + "episode_n": "Tập {}", + "search_online": "Tìm trực tuyến", + "subtitle_sync": "Đồng bộ phụ đề", + "subtitle_sync_desc": "Chỉnh thời gian cho khớp với âm thanh", + "subtitle_sync_help": "Phụ đề hiện quá sớm? Hãy tăng (+). Quá muộn? Hãy giảm (−).", + "search_subtitles": "Tìm phụ đề", + "subtitle_search_hint": "Tên phim / tiêu đề", + "searching_subtitles": "Đang tìm phụ đề…", + "no_subtitles_found": "Không tìm thấy phụ đề", + "search_failed": "Tìm kiếm thất bại: {}", + "online_subtitles_count": "Phụ đề trực tuyến · {}", + "loading_subtitle": "Đang tải phụ đề…", + "subtitle_loaded": "Đã tải phụ đề", + "subtitle_failed_download": "Không tải được phụ đề này", + "subtitle_failed_empty": "Tệp phụ đề này không có nội dung", + "subtitle_failed_archive": "Không hỗ trợ phụ đề nén (.zip / .rar)", + "subtitle_failed_html": "Máy chủ trả về trang web, không phải phụ đề", + "subtitle_failed_format": "Định dạng phụ đề không được hỗ trợ", + "subtitle_rate": "Chuyển đổi tốc độ khung hình", + "subtitle_rate_help": "Phụ đề càng lúc càng lệch? Phụ đề này được căn theo tốc độ khung hình khác — hãy chọn mức chuyển đổi.", + "subtitle_rate_off": "Tắt", + "reset": "Đặt lại", + "font_size": "Cỡ chữ", + "size_small": "Nhỏ", + "size_medium": "Vừa", + "size_large": "Lớn", + "size_extra_large": "Rất lớn", + "text_color": "Màu chữ", + "background": "Nền", + "none": "Không", + "solid": "Đặc", + "edge": "Viền", + "shadow": "Bóng", + "outline": "Viền ngoài", + "position": "Vị trí", + "lower": "Thấp hơn", + "default": "Mặc định", + "higher": "Cao hơn", + "bold": "Đậm", + "audio_track_engine_only": "Chỉ media_kit", + "audio_track_single": "Chỉ có một bản", + "external_player": "Phát bằng trình phát ngoài", + "external_player_gated": "Nguồn này cần các header yêu cầu đặc biệt mà VLC và MX Player không gửi được. Rất có thể sẽ không tải được. Vẫn thử chứ?", + "external_player_try": "Vẫn thử", + "external_player_none": "Không tìm thấy trình phát video ngoài", + "audio_track_numbered": "Âm thanh {}", + "engine_sheet_title": "Phát bằng cách nào?", + "engine_sheet_subtitle": "Chọn công cụ phát cho video này. Bạn có thể đổi bất cứ lúc nào trong Cài đặt → Trình phát.", + "engine_sheet_dont_ask": "Không hỏi lại", + "engine_sheet_play": "Phát", + "subtitle_preview_text": "Con cáo nâu nhanh nhẹn nhảy qua chú chó lười", + "server": "Máy chủ", + "change_server": "Đổi máy chủ", + "stream_not_found": "Nguồn này giấu luồng phát sau một trình phát được bảo vệ. Hãy thử nguồn khác hoặc mở trong trình duyệt.", + "subtitle_translation": "Dịch phụ đề", + "auto_translate": "Tự động dịch", + "auto_translate_desc": "Khi nguồn không có phụ đề bằng ngôn ngữ của bạn, dịch bằng AI", + "translate_to": "Dịch sang", + "translate_note": "Bản dịch do máy tạo và có thể sai. Có giới hạn mỗi ngày.", + "translate_ai": "Dịch AI", + "translating_subtitle": "Đang dịch…", + "translate_failed": "Không dịch được", + "ai_translate_title": "Dịch sang {} bằng AI", + "ai_translate_hint": "Chọn một phụ đề trong danh sách rồi nhấn AI", + "ai_translate_remaining": "Còn {}/{} lượt dịch hôm nay", + "ai_translate_used_up": "Bạn đã dùng hết lượt dịch hôm nay", + "ai_translate_menu": "Dịch AI", + "ai_translate_menu_desc": "Dịch phụ đề sang {}", + "ready_translations": "Bản dịch có sẵn", + "lines": "dòng", + "sleep_timer": "Hẹn giờ ngủ", + "sleep_minutes": "{} phút", + "sleep_hours": "{} giờ", + "sleep_minutes_short": "{} phút", + "sleep_episode_end": "Hết tập", + "sleep_fired": "Hẹn giờ ngủ — đã tạm dừng phát", + "skip_opening": "Bỏ qua mở đầu", + "skip_ending": "Bỏ qua kết thúc", + "alt_sources": "Thử nguồn khác", + "alt_searching": "Đang tìm ở các nguồn khác…", + "alt_none": "Không có nguồn nào khác có phim này", + "alt_failed": "Không mở được nguồn này", + "alt_no_episode": "Nguồn này không có tập {}", + "alt_search_all": "Tìm mọi nguồn", + "guide": "Lịch phát", + "change_source": "Đổi nguồn", + "cast": "Truyền lên TV", + "cast_to": "Truyền đến", + "cast_searching": "Đang tìm thiết bị trên Wi-Fi này…", + "cast_none": "Không tìm thấy thiết bị. TV phải dùng chung Wi-Fi với điện thoại này.", + "cast_failed": "Không kết nối được với {}", + "cast_unsupported": "Không thể truyền luồng này", + "cast_active": "Đang phát trên {}", + "cast_stop": "Dừng truyền", + "color_profile": "Hình ảnh", + "color_natural": "Tự nhiên", + "color_cinema": "Rạp phim", + "color_night": "Ban đêm", + "color_anime": "Anime", + "color_restore": "Khôi phục", + "shaders": "Làm nét (Anime4K)", + "shaders_note": "Nâng cấp hình ảnh hoạt hình bằng GPU. Tệp shader chỉ tải một lần khi dùng lần đầu.", + "shader_off": "Tắt", + "shader_off_desc": "Không tăng cường", + "shader_sharpen": "Làm sắc nét", + "shader_sharpen_desc": "Khôi phục chi tiết — hợp với nguồn sạch", + "shader_deblur": "Khử mờ", + "shader_deblur_desc": "Khôi phục nhẹ hơn — cho nguồn bị mờ", + "shader_denoise": "Khử nhiễu", + "shader_denoise_desc": "Làm sạch hạt — cho nguồn bị nén", + "shader_tier": "MỨC DÙNG GPU", + "shader_tier_mid": "Cân bằng", + "shader_tier_mid_desc": "Mượt trên hầu hết điện thoại", + "shader_tier_high": "Tối đa", + "shader_tier_high_desc": "Nét nhất, cần GPU mạnh", + "font_family": "Phông chữ", + "font_system": "Mặc định", + "font_sans": "Sans", + "font_serif": "Serif", + "font_condensed": "Hẹp", + "font_mono": "Mono", + "second_track": "Bản thứ hai", + "second_track_on": "Đang hiện {} làm bản thứ hai", + "second_track_same": "Bản này đã là bản chính", + "second_track_hint": "Nhấn giữ một bản để hiện song song với bản đầu tiên", + "share_frame": "Chia sẻ khung hình", + "share_frame_failed": "Không chụp được khung hình", + "save_frame": "Lưu khung hình", + "save_frame_done": "Đã lưu vào thư viện ảnh", + "save_frame_failed": "Không lưu được khung hình", + "save_frame_denied": "Cần quyền truy cập thư viện ảnh để lưu khung hình", + "switching_server": "Đang đổi máy chủ…", + "recommended": "Đề xuất", + "episode_page_failed": "Không tải được phần tiếp theo của loạt phim", + "info_fields_desc": "Chọn thông tin hiện trên video. Thông tin hiện cùng các nút điều khiển.", + "layout_title": "Nút điều khiển", + "layout_desc": "Kéo để sắp xếp lại hoặc chuyển nút sang vị trí khác trên thanh.", + "layout_top_bar": "Thanh trên", + "layout_bottom_left": "Dưới trái", + "layout_bottom_right": "Dưới phải", + "layout_hidden": "Đã ẩn", + "layout_hidden_desc": "Vẫn truy cập được từ Cài đặt trong trình phát.", + "layout_pinned": "Cài đặt được giữ cố định — đó là lối quay về mọi thứ khác.", + "layout_top_bar_full": "Thanh trên đã đầy. Hãy chuyển bớt nút đi trước.", + "layout_unknown_control": "Nút này không còn khả dụng.", + "layout_move_to": "Chuyển", + "layout_empty": "Không có gì ở đây.", + "rotate": "Xoay", + "lock": "Khóa", + "enhance": "Làm nét", + "fit": "Vừa khung", + "pip": "Hình trong hình", + "layout_pinned_badge": "Đã ghim", + "info_fields_title": "Các dòng thông tin", + "logs_title": "Nhật ký trình phát ({})", + "logs_copied": "Đã sao chép nhật ký", + "logs_empty": "Chưa có nhật ký", + "alt_unavailable": "Không truy cập được danh sách nguồn. Hãy kiểm tra kết nối và thử lại.", + "alt_all_failed": "Không nguồn nào phản hồi. Thường là do kết nối, không phải do phim.", + "alt_some_failed": "Không tìm thấy kết quả khớp, dù có {} nguồn không phản hồi.", + "alt_no_answer": "Tìm kiếm kết thúc mà không có phản hồi. Hãy thử lại hoặc tự tìm trong một nguồn.", + "alt_none_asked": "Không nguồn nào khác của bạn hỗ trợ loại nội dung này nên không tìm được. Hãy thêm một nguồn phù hợp.", + "alt_no_match": "{} nguồn đã phản hồi nhưng không nguồn nào có phim này — kết quả gần nhất là phim khác. Nếu biết nguồn nào có, hãy tự tìm ở bên dưới.", + "alt_guess": "Đoán", + "alt_guess_spoken": "Chưa chắc khớp — hãy kiểm tra tên phim trước khi phát", + "alt_your_pick": "Bạn chọn", + "alt_auto": "Tự động", + "alt_auto_hint": "Phát từ nguồn đầu tiên có phim này", + "alt_auto_trying": "Đang thử {}…", + "alt_auto_missed": "Không kết quả nào phát được. Hãy chọn bên dưới hoặc tìm trong một nguồn.", + "alt_auto_checking": "Đang kiểm tra {} · {} / {}", + "alt_auto_waiting": "Đang chờ thêm nguồn…", + "alt_auto_stop": "Dừng", + "alt_auto_stopped": "Đã dừng.", + "alt_auto_capped": "Đã kiểm tra {} nguồn nhưng không nguồn nào phát được. Hãy chọn một nguồn bên dưới hoặc thử lại.", + "alt_auto_tried": "Đã kiểm tra", + "alt_skip_no_match": "không khớp đủ gần", + "alt_skip_no_episode": "không tìm thấy tập", + "alt_skip_error": "không tải được", + "auto_switch_attempt": "Lần thử {} / {}", + "auto_switch_stop": "Dừng", + "auto_switch_stopped": "Đã dừng chuyển máy chủ. Hãy thử lại, hoặc chọn máy chủ hay nguồn khác.", + "alt_wrong_title": "Sai phim?", + "alt_wrong_title_hint": "Tự tìm trong {} và chọn đúng phim", + "alt_corrected": "Đang dùng “{}”", + "alt_unmatched": "Đã tìm, không có kết quả khớp ({})", + "alt_unmatched_hint": "Không tìm thấy tên phim phù hợp ở các nguồn này. Hãy tự tìm nếu bạn biết phim có ở đó.", + "alt_search_by_hand": "Tự tìm", + "src_search_title": "Tìm trong {}", + "src_search_hint": "Tên phim cần tìm", + "src_search_go": "Tìm", + "src_searching": "Đang hỏi {}…", + "src_search_empty": "{} không có kết quả cho “{}”. Hãy thử cách viết khác — romaji hoặc tên dùng cho bản lồng tiếng.", + "src_search_slow": "{} không phản hồi kịp. Có thể nguồn chỉ chậm chứ không phải thiếu phim — hãy thử lại.", + "src_search_failed": "Không truy cập được {}. Hãy kiểm tra nguồn trong mục Nguồn rồi thử lại.", + "src_search_unavailable": "{} không còn được cài trên thiết bị này.", + "resolve_timeout": "Nguồn không phản hồi kịp. Hãy thử máy chủ hoặc nguồn khác.", + "layout_center": "Quanh nút phát", + "layout_center_full": "Quanh nút phát đã hết chỗ", + "open_subtitle_file": "Mở tệp phụ đề…", + "media_kit_emulator": "Không dùng trên máy ảo — đang dùng trình phát hệ thống", + "media_kit_fell_back": "media_kit không khởi động được ở đây — đang dùng trình phát hệ thống", + "segment_opening": "Mở đầu", + "segment_ending": "Kết thúc", + "subtitle_n": "Phụ đề {}", + "subtitle_in_video": "Trong video", + "rewind": "Tua lại", + "rewind_by": "Tua lại {} giây", + "forward_by": "Tua tới {} giây", + "mini_restore": "Trở về cửa sổ đầy đủ", + "pip_unavailable": "Không thể bật hình trong hình cho video này.", + "scrub_release_cancel": "Thả ra để hủy", + "auto_quality_desc_mpv": "Bắt đầu ở chất lượng tốt nhất mà luồng có", + "reconnecting": "Mất kết nối — đang kết nối lại…", + "all_servers_failed": "Không máy chủ nào của phim này phát được. Hãy thử lại sau hoặc chọn nguồn khác.", + "shortcut_pip": "Trình phát mini (Shift: nổi trên ứng dụng khác)", + "mini_popout_hint": "Trình phát mini · nhấn giữ để nổi trên ứng dụng khác", + "react": "Bày tỏ cảm xúc", + "comments": "Bình luận", + "episode_comments": "Bình luận tập {}", + "reaction_text": "Viết cảm xúc", + "reaction_text_hint": "Viết ngắn thôi…", + "reaction_wait": "Chờ vài giây trước khi bày tỏ tiếp", + "reaction_limit": "Bạn đã đạt giới hạn cảm xúc cho tập này", + "reaction_failed": "Không gửi được cảm xúc", + "native_offer_title": "Dùng thử trình phát mới?", + "native_offer_body": "Nút điều khiển và menu Liquid Glass của Apple. Bạn có thể thay đổi bất cứ lúc nào trong Cài đặt → Trình phát.", + "native_offer_try": "Dùng thử", + "native_offer_not_now": "Để sau", + "trying_again": "Đang thử lại (lần {})", + "try_mpv": "Thử với trình phát mpv", + "ok_to_skip": "Nhấn OK để bỏ qua", + "quality_lowered": "Đã giảm chất lượng", + "undo": "Hoàn tác", + "crash_log": "Nhật ký sự cố", + "crash_log_empty": "Chưa ghi nhận sự cố nào" + }, + "profile": { + "title": "Hồ sơ", + "my_account": "Tài khoản của tôi", + "notifications": "Thông báo", + "language": "Ngôn ngữ", + "theme": "Chủ đề", + "privacy": "Chính sách bảo mật", + "help": "Trợ giúp & Hỗ trợ", + "about": "Giới thiệu", + "version": "Phiên bản", + "subscription": "Gói đăng ký", + "downloads": "Tải xuống", + "watch_history": "Lịch sử xem", + "manage_profiles": "Quản lý hồ sơ", + "parental_controls": "Kiểm soát của phụ huynh", + "guest_title": "Đăng nhập vào hồ sơ", + "guest_subtitle": "Đăng nhập để đồng bộ mục yêu thích và lịch sử xem.", + "auth_provider": "Hình thức đăng nhập", + "joined": "Đã tham gia", + "providers": "Nguồn", + "providers_error": "Không tải được các nguồn.", + "offline_title": "Không kết nối được máy chủ", + "offline_local_available": "{} nguồn đã cài trên thiết bị này vẫn hoạt động. Nguồn đám mây sẽ không dùng được cho đến khi máy chủ hoạt động trở lại.", + "offline_no_local": "Máy chủ không phản hồi và thiết bị này chưa cài nguồn nào. Hãy cài plugin CloudStream để tiếp tục xem khi ngoại tuyến.", + "offline_cached_at": "Danh sách đám mây từ {}", + "offline_install_plugins": "Cài plugin", + "offline_badge": "NGOẠI TUYẾN", + "provider_needs_server": "Nguồn này cần máy chủ, mà máy chủ hiện không kết nối được. Hãy chọn nguồn đã cài trên thiết bị này.", + "logout_confirm": "Bạn có muốn đăng xuất khỏi tài khoản không?", + "connections": "Kết nối", + "coming_soon": "Sắp ra mắt", + "favorites": "Yêu thích", + "all_providers": "Tất cả nguồn", + "add_favorite": "Thêm vào yêu thích", + "cloudstream_sources": "Nguồn CloudStream", + "aniyomi_sources": "Nguồn Aniyomi", + "signin_account_title": "Đăng nhập tài khoản", + "signin_account_subtitle": "Đăng nhập để lưu mục yêu thích và lịch sử xem.", + "sign_in": "Đăng nhập", + "sign_out": "Đăng xuất", + "sign_out_confirm": "Bạn có chắc muốn đăng xuất không?", + "section_providers": "Nguồn", + "section_content": "Nội dung", + "sources_row": "Nguồn tiện ích mở rộng", + "sources_row_subtitle": "Duyệt mọi nguồn và cài thêm", + "provider": "Nguồn", + "choose_provider": "Đổi nguồn", + "search_providers_hint": "Tìm nguồn…", + "count_of_total_shown": "Hiện {} trên {}", + "no_providers_in_category": "Không có nguồn nào trong danh mục này", + "try_select_all": "Thử chọn \"Tất cả\" để xem mọi nguồn", + "section_activity": "HOẠT ĐỘNG", + "share_sources_desktop": "Chia sẻ nguồn sang máy tính", + "desktop_sources": "Nguồn trên máy tính", + "developer_role": "Lập trình viên di động", + "section_about": "GIỚI THIỆU", + "developer": "Nhà phát triển", + "website": "Trang web", + "server": "Máy chủ", + "expired": "Đã hết hạn", + "support_title": "Giúp Sozo tiếp tục hoạt động", + "server_expired": "Máy chủ đã hết hạn", + "days": "Ngày", + "hours": "Giờ", + "min": "Phút", + "sec": "Giây", + "support_body_expired": "Máy chủ đã hết hạn nên không thể tải nội dung nữa. Sự ủng hộ của bạn có thể giúp máy chủ hoạt động trở lại.", + "support_body": "Sozo phụ thuộc vào một máy chủ cần gia hạn định kỳ. Nếu bạn thích ứng dụng, sự ủng hộ của bạn giúp mọi thứ chạy ổn định.", + "support_developer": "Ủng hộ nhà phát triển", + "section_appearance": "GIAO DIỆN", + "native_window_bar": "Thanh cửa sổ hệ thống", + "native_window_bar_subtitle": "Dùng thanh tiêu đề chuẩn của Windows thay cho thanh riêng của ứng dụng.", + "liquid_glass_nav": "Thanh điều hướng kính lỏng", + "liquid_glass_nav_subtitle": "Thanh tab nổi kiểu kính mờ (iOS 26). Tắt để dùng thanh màu đặc đơn giản.", + "nav_compact_scroll": "Thu gọn thanh điều hướng khi cuộn", + "nav_compact_scroll_hint": "Giảm chiều cao khi cuộn. Áp dụng cho kiểu Kính và Đặc.", + "nav_style": "Thanh điều hướng", + "nav_style_glass": "Kính lỏng", + "nav_style_solid": "Đặc", + "nav_style_classic": "Cổ điển", + "section_player": "Trình phát", + "player_engine_native": "Trình phát hệ thống", + "player_engine_native_desc": "Trình phát của nền tảng. Tiết kiệm pin nhất và giải mã phần cứng. Không đổi được bản âm thanh.", + "player_engine_media_kit": "media_kit", + "player_engine_media_kit_desc": "Phát được nhiều định dạng hơn, cho phép đổi bản âm thanh và phụ đề. Tốn pin hơn một chút.", + "player_engine_external": "Trình phát ngoài", + "player_engine_external_desc": "Gửi video sang VLC hoặc MX Player thay vì phát tại đây.", + "player_engine_external_note": "Một số nguồn chỉ chạy trong Sozo vì cần header yêu cầu đặc biệt. Các nguồn đó sẽ không phát được trong ứng dụng ngoài.", + "player_engine_badge_default": "MẶC ĐỊNH", + "player_engine_section": "CÔNG CỤ PHÁT", + "ask_engine_on_play": "Luôn hỏi", + "ask_engine_on_play_desc": "Chọn công cụ phát mỗi khi bắt đầu video", + "controls_style": "KIỂU NÚT ĐIỀU KHIỂN TRÌNH PHÁT", + "controls_style_desc": "Áp dụng từ lần mở trình phát tiếp theo.", + "controls_style_native": "Gốc (iOS 26)", + "controls_style_native_desc": "Nút, menu và bảng Liquid Glass của chính điện thoại", + "controls_style_system": "Kính hệ thống", + "controls_style_system_desc": "Nút của ứng dụng trên Liquid Glass của điện thoại", + "controls_style_classic": "Kính cổ điển", + "controls_style_classic_desc": "Lớp kính riêng của ứng dụng, như các phiên bản trước", + "controls_style_needs_ios26": "Cần iOS 26 trở lên", + "playback_section": "PHÁT LẠI", + "default_speed": "Tốc độ mặc định", + "default_speed_desc": "Tốc độ khi bắt đầu mỗi video", + "default_fit": "Chế độ hiển thị mặc định", + "default_fit_desc": "Cách video vừa với màn hình", + "preferred_quality": "Chất lượng ưu tiên", + "preferred_quality_desc": "Mức bắt đầu phát khi luồng có nhiều chất lượng", + "quality_data_saver": "Tiết kiệm dữ liệu (thấp nhất)", + "auto_next_episode": "Tự động phát tập tiếp theo", + "auto_next_episode_desc": "Bắt đầu tập tiếp theo khi hết một tập", + "start_paused": "Mở và tạm dừng", + "keep_screen_on": "Giữ màn hình luôn sáng", + "keep_screen_on_desc": "Không để màn hình tắt khi đang xem", + "gestures_section": "CỬ CHỈ", + "seek_step": "Tua khi nhấn đúp", + "seek_step_desc": "Khoảng nhảy khi nhấn đúp", + "long_press_boost": "Tốc độ khi nhấn giữ", + "long_press_boost_desc": "Tốc độ khi giữ tay trên video", + "brightness_gesture": "Vuốt chỉnh độ sáng", + "brightness_gesture_desc": "Vuốt lên xuống ở bên trái", + "volume_gesture": "Vuốt chỉnh âm lượng", + "volume_gesture_desc": "Vuốt lên xuống ở bên phải", + "auto_pip": "Tự động hình trong hình", + "auto_pip_desc": "Nhấn Màn hình chính khi đang phát video sẽ tiếp tục phát trong cửa sổ nhỏ", + "seconds_short": "{} giây", + "subtitle_style_note": "Áp dụng cho mọi video. Thời gian phụ đề được đặt riêng cho từng tập ngay trong trình phát.", + "searching_all_sources": "đang tìm mọi nguồn", + "section_connections": "KẾT NỐI", + "section_extensions": "NGUỒN", + "section_settings": "CÀI ĐẶT", + "remove_favorite": "Xóa khỏi yêu thích", + "edit_profile": "Sửa hồ sơ", + "display_name_hint": "Tên của bạn", + "display_name_too_long": "Tên không được quá 60 ký tự", + "pick_from_gallery": "Chọn từ thư viện ảnh", + "take_photo": "Chụp ảnh", + "remove_photo": "Xóa ảnh", + "photo_pick_failed": "Không chọn được ảnh", + "incognito": "Ẩn danh", + "incognito_desc": "Xem mà không thêm vào lịch sử hay trình theo dõi", + "incognito_off": "Đã tắt ẩn danh", + "auto_skip_intro": "Tự động bỏ qua mở đầu", + "auto_skip_intro_desc": "Bỏ qua mở đầu và kết thúc anime mà không hỏi", + "incognito_active": "Đang bật ẩn danh. Những gì bạn xem sẽ không được lưu vào lịch sử hay gửi đến AniList và MAL.", + "incognito_turn_off": "Tắt", + "all_languages": "Tất cả ngôn ngữ", + "section_library": "THƯ VIỆN CỦA BẠN", + "section_watch": "XEM", + "hero_trailer": "Phát trailer ở đầu trang", + "hero_trailer_desc": "Áp phích nhường chỗ cho trailer không tiếng ngay sau khi mở phim", + "section_overview": "TỔNG QUAN", + "section_app": "ỨNG DỤNG", + "library": "Thư viện", + "library_subtitle": "Yêu thích, xem sau, danh sách riêng tư", + "sources_title": "Nguồn", + "section_active_source": "NGUỒN ĐANG DÙNG", + "active_source_footnote": "Nguồn đám mây mà Sozo dùng để tìm và phát. Tiện ích mở rộng bên dưới thêm các nguồn chạy trên thiết bị này.", + "settings_title": "Cài đặt", + "settings_subtitle": "Ngôn ngữ, giao diện, trình phát, bảo mật", + "section_general": "CHUNG", + "section_data": "DỮ LIỆU", + "section_lists": "DANH SÁCH", + "section_accounts": "TÀI KHOẢN", + "section_devices": "THIẾT BỊ", + "language_desc": "Ngôn ngữ giao diện", + "not_connected": "Chưa kết nối", + "joined_on": "Tham gia {}", + "watch_later": "Xem sau", + "watched": "Đã xem", + "stat_hours": "giờ đã xem", + "stat_completed": "đã hoàn thành", + "stat_streak": "ngày liên tiếp", + "guest_benefit_sync": "Đồng bộ yêu thích và lịch sử trên các thiết bị", + "guest_benefit_streak": "Chuỗi ngày, thống kê và hạng đố vui", + "guest_benefit_trackers": "AniList, MyAnimeList và Xem chung", + "activity": "Hoạt động", + "activity_subtitle": "Lịch sử, tải xuống, theo dõi, thống kê", + "connections_subtitle": "Trình theo dõi, Discord, TV, máy tính", + "trackers": "Trình theo dõi", + "stat_days": "ngày đã xem", + "mangayomi_sources": "Nguồn Mangayomi", + "view_only": "CHỈ XEM", + "start_paused_desc": "Mở trình phát mà không phát. Tự động chuyển tập vẫn phát.", + "date_format": "Định dạng ngày", + "date_format_desc": "Theo ngôn ngữ, trừ khi bạn chọn khác.", + "date_format_auto": "Theo ngôn ngữ", + "adult_content": "Nội dung 18+", + "adult_content_desc": "Hiện phim và nguồn người lớn ở mọi nơi — AniList, TMDB, CloudStream, Aniyomi, manga và torrent.", + "adult_confirm_title": "Bạn đã đủ 18 tuổi chưa?", + "adult_confirm_body": "Nội dung và nguồn người lớn sẽ xuất hiện trong toàn bộ ứng dụng. Bạn có thể tắt bất cứ lúc nào trong Cài đặt.", + "adult_confirm_yes": "Tôi đã đủ 18 tuổi", + "library_update": "Kiểm tra tập mới", + "library_update_desc": "Kiểm tra các phim bạn theo dõi và báo khi có tập mới", + "library_update_every": "Mỗi {} giờ", + "library_update_daily": "Mỗi ngày một lần", + "contributors": "Người đóng góp", + "contributors_error": "Không tải được danh sách người đóng góp", + "become_contributor": "Trở thành người đóng góp", + "become_contributor_body": "Sozo là mã nguồn mở. Mã nguồn, bản dịch và báo lỗi đều giúp ích.", + "reactions_overlay": "Cảm xúc người xem", + "reactions_overlay_desc": "Hiện cảm xúc của người xem khác đúng lúc họ gửi, và chỗ tập trung nhiều trên thanh tua", + "hide_spoilers": "Ẩn tiết lộ của các tập tôi chưa xem", + "hide_spoilers_desc": "Bình luận về tập bạn chưa xem hết sẽ bị làm mờ cho đến khi bạn chạm vào" + }, + "appearance": { + "title": "Giao diện", + "entry_subtitle": "Màu nhấn, đen tuyền", + "section_preview": "XEM TRƯỚC", + "section_accent": "MÀU NHẤN", + "accent_footnote": "Màu nhấn dùng cho nút, phần nổi bật, thanh tiến độ và tab đang chọn — trong toàn bộ ứng dụng.", + "accent_custom": "Tùy chỉnh", + "accent_system": "Material You", + "material_you": "Material You", + "material_you_desc": "Màu lấy từ hình nền của bạn", + "accent_red": "Đỏ Sozo", + "accent_ember": "Than hồng", + "accent_amber": "Hổ phách", + "accent_emerald": "Ngọc lục bảo", + "accent_teal": "Xanh mòng két", + "accent_sky": "Xanh trời", + "accent_ocean": "Đại dương", + "accent_indigo": "Chàm", + "accent_violet": "Tím", + "accent_magenta": "Đỏ tươi", + "accent_rose": "Hồng", + "accent_slate": "Xám đá", + "section_darkness": "NỀN", + "darkness_dark": "Tối", + "darkness_dark_desc": "Tông xám tối tiêu chuẩn", + "darkness_black": "Đen tuyền", + "darkness_black_desc": "Nền đen thật sự", + "amoled_footnote": "Trên màn hình OLED, điểm ảnh đen sẽ tắt hẳn nên đen tuyền cho độ tương phản sâu hơn và tiết kiệm pin hơn.", + "reset": "Đặt về mặc định", + "reset_tooltip": "Về giao diện mặc định", + "custom_title": "Màu tùy chỉnh", + "custom_hue": "SẮC ĐỘ", + "custom_saturation": "ĐỘ BÃO HÒA", + "custom_lightness": "ĐỘ SÁNG", + "custom_apply": "Dùng màu này", + "custom_adjusted": "Đã làm tối một chút để chữ trắng vẫn dễ đọc.", + "section_library": "TỪ THƯ VIỆN CỦA BẠN", + "library_footnote": "Màu lấy từ áp phích của những phim bạn đã xem.", + "section_extras": "THÊM", + "tint_nav": "Tô màu thanh tab", + "tint_nav_desc": "Tô tab đang chọn bằng màu nhấn thay vì màu trắng", + "shuffle": "Thử màu tiếp theo", + "shuffle_desc": "Lần lượt đổi qua từng màu trong bảng màu" + }, + "search": { + "title": "Tìm kiếm", + "hint": "Tìm phim lẻ, phim bộ...", + "no_results": "Không tìm thấy kết quả", + "no_results_for": "Không có kết quả cho \"{query}\"", + "recent": "Tìm kiếm gần đây", + "popular_searches": "Tìm kiếm phổ biến", + "categories": "Thể loại", + "categories_failed": "Không tải được danh mục", + "all": "Tất cả", + "movies": "Phim lẻ", + "series": "Phim bộ", + "filter": "Lọc", + "sort": "Sắp xếp", + "sort_newest": "Mới nhất", + "sort_oldest": "Cũ nhất", + "sort_rating": "Đánh giá", + "type": "Loại", + "apply": "Áp dụng", + "clear_filter": "Xóa", + "clear_genre": "Xóa bộ lọc thể loại", + "sources": "Nguồn", + "sources_n": "{} nguồn", + "all_sources": "Chọn tất cả", + "this_server": "Máy chủ này", + "local_group": "Trên máy", + "search_in": "Tìm trong", + "sources_hint": "Tìm nhiều nguồn cùng lúc. Để trống để dùng nguồn hiện tại.", + "searching": "Đang tìm…", + "all_source_search": "Tìm mọi nguồn", + "cross_hint": "Tìm trong các nguồn của bạn…", + "pick_sources": "Chọn các nguồn muốn tìm.", + "choose_sources": "Chọn nguồn", + "no_sources_selected": "Chưa chọn nguồn — nhấn để chọn", + "sources_selected": "Đã chọn {} — nhấn để đổi", + "type_to_search_n": "Nhập để tìm cùng lúc {} nguồn.", + "found_in": "Tìm thấy ở {} trên {} nguồn", + "results_n": "{} kết quả", + "results_so_far": "{} kết quả đến giờ", + "waiting_for": "Đang tìm {}…", + "no_results_any": "Không có kết quả ở nguồn nào đã chọn.", + "offline_note": "Ngoại tuyến — chỉ tìm được nguồn trên thiết bị.", + "group_by_source": "Nhóm theo nguồn", + "merge_titles": "Gộp phim trùng", + "open_from": "Mở từ", + "load_more": "Tải thêm", + "search_failed": "Tìm kiếm thất bại", + "source_failed": "Nguồn này hiện không khả dụng", + "try_all_sources": "Thử mọi nguồn", + "weak_results": "Không có gì giống “{query}” ở đây — nguồn này khớp theo thông tin khác. Các nguồn khác có thể có phim này.", + "did_you_mean": "Có phải bạn muốn tìm", + "search_sources": "Tìm nguồn", + "filter_providers": "Lọc nguồn…", + "selected_n": "Đã chọn {}", + "apply_n": "Áp dụng ({})", + "many_sources_warning": "Tìm {} nguồn có thể chậm. Ứng dụng vẫn phản hồi, nhưng ít nguồn sẽ nhanh hơn.", + "all_sources_n": "Tất cả nguồn ({})", + "more_sources": "Thêm…", + "select_all_sources": "Chọn tất cả", + "search_all_sources": "Tìm mọi nguồn", + "apply_all": "Tìm mọi nguồn", + "selected_n_of_m": "{} trên {} nguồn", + "loading_sources": "Đang tải các nguồn của bạn…", + "no_sources_available": "Không có nguồn nào. Hãy thêm hoặc bật một nguồn để tìm.", + "min_query_n": "Nhập ít nhất {} ký tự.", + "retry_failed": "Thử lại nguồn lỗi", + "status_searching": "Đang tìm…", + "status_searching_n": "{} đang tìm", + "status_with_results_n": "{} có kết quả", + "status_no_match_n": "{} không khớp", + "status_timed_out_n": "{} hết giờ", + "status_failed_n": "{} lỗi", + "all_sources_failed": "Cả {} nguồn đều không phản hồi. Đây không phải kết quả trống — hãy thử lại.", + "no_results_with_failures": "Không có kết quả khớp từ các nguồn đã phản hồi ({}). Không truy cập được {} nguồn.", + "all_sources_failed_one": "Nguồn bạn tìm không phản hồi. Đây không phải kết quả trống — hãy thử lại.", + "voice": "Tìm bằng giọng nói", + "voice_listening": "Đang nghe…", + "voice_hint": "Nói tên một phim lẻ hoặc phim bộ", + "voice_starting": "Đang chuẩn bị micrô…", + "voice_no_speech": "Không nghe rõ", + "voice_no_speech_hint": "Hãy nói to hơn một chút, gần micrô hơn, rồi thử lại.", + "voice_permission": "Quyền micrô đang tắt", + "voice_permission_hint": "Cho phép Sozo dùng micrô trong cài đặt hệ thống để tìm kiếm bằng giọng nói.", + "voice_unavailable": "Thiết bị này không hỗ trợ nhận dạng giọng nói", + "voice_unavailable_hint": "Thiết bị này không có dịch vụ nhận dạng giọng nói. Hãy cài đặt hoặc bật một dịch vụ trong cài đặt hệ thống, chẳng hạn của Google.", + "voice_network": "Không kết nối được dịch vụ giọng nói", + "voice_network_hint": "Kiểm tra kết nối internet rồi thử lại.", + "voice_error": "Đã xảy ra lỗi khi nghe", + "voice_error_hint": "Có thể một ứng dụng khác đang dùng micrô. Hãy thử lại sau giây lát.", + "voice_try_again": "Thử lại", + "voice_open_settings": "Mở cài đặt", + "try_torrents": "Tìm torrent", + "capped_note": "Đã tìm {} nguồn đáng tin cậy nhất trong {} nguồn — hãy thu hẹp phạm vi để tìm một nguồn cụ thể.", + "rail_popular_on": "Phổ biến trên {source}", + "rail_browse_on": "Duyệt {source}", + "rail_no_source": "Trước khi tìm kiếm", + "nothing_to_browse": "Không có gì để duyệt ở đây — hãy nhập tên phim để tìm.", + "filter_genres_hint": "Tìm danh mục", + "discovery": { + "title": "Khám phá", + "hint": "Duyệt kho phim bằng bộ lọc. Tìm theo tên phim là một tìm kiếm riêng.", + "type": "Loại nội dung", + "movie": "Phim lẻ", + "tv": "Phim bộ", + "sort": "Sắp xếp", + "popular": "Phổ biến", + "highest": "Điểm cao nhất", + "newest": "Mới nhất", + "trending": "Thịnh hành", + "rating": "Điểm tối thiểu · /10", + "any": "Bất kỳ", + "votes": "Lọc theo điểm yêu cầu ít nhất 100 lượt đánh giá.", + "year": "Năm phát hành", + "genre": "Thể loại", + "status": "Trạng thái", + "releasing": "Đang chiếu", + "finished": "Đã kết thúc", + "upcoming": "Sắp chiếu", + "hiatus": "Tạm ngưng", + "cancelled": "Đã hủy", + "format": "Định dạng", + "special": "Đặc biệt", + "music": "Âm nhạc", + "season": "Mùa", + "winter": "Đông", + "spring": "Xuân", + "summer": "Hè", + "fall": "Thu", + "show": "Xem kết quả", + "active": "Kho phim đã lọc", + "min_rating": "Điểm tối thiểu", + "other_year": "Khác…", + "any_rating": "Mọi mức điểm", + "r_low": "Chỉ loại phim tệ nhất", + "r5": "Xem được", + "r6": "Khá", + "r7": "Hay", + "r8": "Rất hay", + "r9": "Chỉ kiệt tác" + } + }, + "movie": { + "rating": "Đánh giá", + "year": "Năm", + "quality": "Chất lượng", + "duration": "Thời lượng", + "genre": "Thể loại", + "director": "Đạo diễn", + "cast": "Diễn viên", + "description": "Mô tả", + "similar": "Phim tương tự", + "trailer": "Trailer", + "share": "Chia sẻ", + "download": "Tải xuống", + "rate": "Đánh giá", + "episodes": "Các tập", + "seasons": "Các phần" + }, + "errors": { + "network": "Lỗi mạng. Hãy kiểm tra kết nối.", + "server": "Lỗi máy chủ. Vui lòng thử lại.", + "unauthorized": "Vui lòng đăng nhập để tiếp tục.", + "not_found": "Không tìm thấy nội dung.", + "unknown": "Đã xảy ra lỗi không xác định.", + "timeout": "Hết thời gian chờ. Vui lòng thử lại.", + "empty": "Không có nội dung.", + "source_http": "Máy chủ của nguồn trả về lỗi ({}).", + "no_browser": "Không có ứng dụng nào trên thiết bị này mở được liên kết đó." + }, + "navigation": { + "home": "Trang chủ", + "search": "Tìm kiếm", + "shorts": "Shorts", + "downloads": "Tải xuống", + "profile": "Hồ sơ", + "my_list": "Danh sách", + "history": "Lịch sử", + "following": "Theo dõi", + "premiere": "Công chiếu", + "buff": "Buff", + "anilist": "AniList", + "live_tv": "TV trực tiếp" + }, + "premiere": { + "tab_title": "Công chiếu", + "live_now": "Đang trực tiếp", + "upcoming": "Sắp diễn ra", + "live": "TRỰC TIẾP", + "catching_up": "Đang bắt kịp trực tiếp", + "starts_in": "Bắt đầu sau", + "starts_at": "Bắt đầu {when}", + "days": "NGÀY", + "hours": "GIỜ", + "minutes": "PHÚT", + "seconds": "GIÂY", + "waiting": "{count} đang chờ", + "watching": "{count} đang xem", + "join": "Tham gia", + "join_now": "Vào ngay", + "set_reminder": "Đặt nhắc nhở", + "reminder_set": "Đã đặt nhắc nhở", + "notify_me": "Báo cho tôi khi bắt đầu", + "guest_watch_hint": "Bạn có thể xem với tư cách khách.", + "login_to_chat_react": "Đăng nhập để trò chuyện và thả cảm xúc", + "login_to_chat": "Đăng nhập để trò chuyện", + "chat_hint": "Chào mọi người đi — chat đang mở", + "chat_empty": "Chào mọi người đi 👋 Chat bắt đầu cùng buổi công chiếu", + "chat_placeholder": "Tin nhắn…", + "slow_mode": "Chế độ chậm: {sec} giây", + "react": "Cảm xúc", + "paused_by_host": "Chủ phòng đã tạm dừng", + "buffering": "Đang đệm…", + "reconnecting": "Đang kết nối lại…", + "connecting": "Đang kết nối đến buổi công chiếu…", + "ended_title": "Buổi công chiếu đã kết thúc", + "ended_watch_vod": "Xem lại", + "canceled": "Buổi công chiếu này đã bị hủy", + "empty_title": "Chưa có buổi công chiếu", + "empty_subtitle": "Sự kiện trực tiếp sẽ hiện ở đây", + "error_generic": "Đã xảy ra lỗi", + "error_full": "Buổi công chiếu này đã đầy", + "error_not_found": "Không tìm thấy buổi công chiếu", + "retry": "Thử lại", + "share": "Chia sẻ", + "back": "Quay lại", + "resolve_failed": "Không bắt đầu phát được trên thiết bị này" + }, + "nav_customize": { + "entry_title": "Tùy chỉnh tab", + "entry_subtitle": "Chọn tab hiển thị và thứ tự của chúng", + "title": "Thanh dưới", + "shown": "Đang hiện", + "available": "Có sẵn", + "min_hint": "Giữ ít nhất {count} tab", + "max_hint": "Tối đa {count} tab", + "pick_range": "Chọn từ {min} đến {max} tab", + "reset": "Đặt lại", + "cancel": "Hủy", + "save": "Lưu" + }, + "app_lock": { + "section_label": "BẢO MẬT", + "app_lock": "Khóa ứng dụng", + "settings_title": "Khóa ứng dụng", + "setup_title": "Thiết lập mã PIN", + "enable": "Bật khóa ứng dụng", + "state_on": "Bật", + "state_off": "Tắt", + "choose_length_title": "Độ dài mã PIN", + "choose_length_subtitle": "Chọn số chữ số cho mã PIN của bạn.", + "length_n_digits": "Mã PIN {} chữ số", + "create_pin_title": "Tạo mã PIN mới", + "create_pin_subtitle": "Chọn mã mà bạn sẽ không chia sẻ với ai.", + "confirm_pin_title": "Xác nhận mã PIN", + "confirm_pin_subtitle": "Nhập lại mã PIN mới của bạn.", + "verify_title": "Ứng dụng đã khóa", + "verify_subtitle": "Nhập mã PIN để tiếp tục.", + "verify_current_pin": "Nhập mã PIN hiện tại", + "change_pin": "Đổi mã PIN", + "biometric": "Mở khóa bằng sinh trắc học", + "biometric_unavailable": "Thiết bị này không hỗ trợ sinh trắc học", + "biometric_enable_reason": "Xác thực để bật mở khóa bằng sinh trắc học", + "biometric_unlock_reason": "Xác thực để mở ứng dụng", + "pin_mismatch": "Mã PIN không khớp", + "pin_wrong": "Sai mã PIN", + "disable_title": "Tắt khóa ứng dụng?", + "disable_subtitle": "Mã PIN và tùy chọn sinh trắc học của bạn sẽ bị xóa.", + "enabled_hint": "Bạn sẽ được yêu cầu nhập mã PIN khi mở ứng dụng.", + "disabled_hint": "Khóa ứng dụng đang tắt. Hãy bật để bảo vệ nội dung bạn xem.", + "private_list": "Danh sách riêng tư", + "move_to_private": "Chuyển vào riêng tư", + "moved_to_private": "Đã chuyển vào danh sách riêng tư", + "removed_from_private": "Đã xóa khỏi riêng tư", + "move_to_my_list": "Chuyển về Danh sách của tôi", + "private_empty": "Chưa có gì riêng tư", + "private_setup_required": "Hãy thiết lập mã PIN trước khi dùng danh sách riêng tư", + "private_long_press_hint": "Nhấn giữ để chuyển vào Danh sách riêng tư — được ẩn sau mã PIN và không lưu lịch sử xem.", + "private_always_ask": "Luôn hỏi mã PIN cho danh sách riêng tư", + "private_always_ask_hint": "Nhập lại mã PIN mỗi lần, không chỉ một lần mỗi phiên", + "reset_title": "Đặt lại khóa ứng dụng?", + "reset_body": "Thao tác này xóa mã PIN và mở khóa Sozo trên máy tính này. Thư viện và lịch sử của bạn vẫn còn. Bạn có thể đặt mã PIN mới sau trong Hồ sơ → Khóa ứng dụng.", + "reset": "Đặt lại", + "forgot_pin": "Quên mã PIN?" + }, + "my_list": { + "sort": "Sắp xếp", + "sort_added": "Mới thêm", + "sort_oldest": "Thêm lâu nhất", + "sort_title": "Tên A–Z", + "saved_account": "Đã lưu vào tài khoản", + "saved_local": "Trên thiết bị này", + "untitled": "Không tiêu đề", + "empty_title": "Danh sách của bạn trống", + "empty_subtitle": "Nhấn nút + trên bất kỳ phim nào để lưu vào đây.", + "unauth_title": "Đăng nhập để xem danh sách", + "unauth_subtitle": "Các phim đã lưu sẽ hiện ở đây sau khi bạn đăng nhập.", + "error_title": "Không tải được danh sách" + }, + "deeplink": { + "opt_in_title": "Mở liên kết Sozo trong ứng dụng này?", + "opt_in_body_android": "Các liên kết sozo.azamov.me được chia sẻ sẽ mở ngay trong Sozo. Hãy bật \"Mở các liên kết được hỗ trợ\" trong cài đặt hệ thống.", + "opt_in_body_ios": "Liên kết Sozo tự động mở trong ứng dụng. Nếu liên kết mở trong Safari, hãy nhấn giữ và chọn \"Mở trong Sozo\".", + "opt_in_enable": "Bật", + "opt_in_later": "Để sau" + }, + "streak": { + "section_label": "CHUỖI NGÀY", + "empty_title": "Bắt đầu chuỗi ngày", + "empty_subtitle": "Xem một tập hôm nay để bắt đầu chuỗi ngày của bạn.", + "current_n_days": "{} ngày", + "locked_today": "Hôm nay đã xong. Ngày mai quay lại nhé!", + "keep_going": "Giữ chuỗi của bạn — đừng quên xem hôm nay.", + "risk_subtitle": "Chuỗi sẽ về 0 lúc nửa đêm. Hãy xem gì đó ngay để giữ chuỗi.", + "record_label": "Kỷ lục", + "next_milestone": "Mục tiêu tiếp", + "weekday_mon": "T2", + "weekday_tue": "T3", + "weekday_wed": "T4", + "weekday_thu": "T5", + "weekday_fri": "T6", + "weekday_sat": "T7", + "weekday_sun": "CN", + "milestone_title": "Chuỗi {} ngày!", + "milestone_subtitle": "Kiên trì tuyệt vời. Hãy tiếp tục và lập kỷ lục mới của riêng bạn.", + "share_text": "Đạt chuỗi {} ngày trên Sozo! 🔥", + "cta_continue": "Tiếp tục", + "cta_share": "Chia sẻ", + "freeze_label": "Đóng băng", + "freeze_count": "{} / {}", + "total_days_label": "Tổng số ngày", + "this_week_label": "Tuần này", + "calendar_label": "35 ngày qua", + "days_to_milestone": "Còn {} ngày đến mốc tiếp theo", + "freeze_saved_title": "Đã giữ được chuỗi!", + "freeze_saved_subtitle": "Một lượt đóng băng đã bù cho ngày bạn bỏ lỡ — chuỗi vẫn tiếp tục.", + "freeze_awarded_title": "Bạn nhận được một lượt đóng băng!", + "freeze_awarded_subtitle": "Lượt đóng băng bảo vệ chuỗi khi bạn bỏ lỡ một ngày.", + "freeze_explainer": "Lượt đóng băng tự động giữ chuỗi vào ngày bạn bỏ lỡ. Bạn nhận thêm khi xem đều đặn." + }, + "watch_party": { + "title": "Xem chung", + "entry_subtitle": "Xem đồng bộ với bạn bè — một người phát, mọi người cùng theo.", + "entry_create": "Tạo phòng", + "entry_create_hint": "Mở phòng và mời bạn bè bằng mã", + "entry_join": "Vào bằng mã", + "entry_join_hint": "Nhập mã bạn bè đã chia sẻ với bạn", + "not_available": "Phim này chưa hỗ trợ xem chung", + "create": "Tạo phòng", + "join": "Vào phòng", + "join_hint": "Nhập mã phòng", + "invalid_code": "Mã không hợp lệ", + "code_label": "Mã phòng", + "copy_code": "Sao chép mã", + "copied": "Đã sao chép", + "share_invite": "Chia sẻ lời mời", + "invite": "Mời", + "lobby_waiting_host": "Đang chờ chủ phòng bắt đầu…", + "host_label": "Chủ phòng", + "members": "Thành viên", + "member_count": "{} / {}", + "you": "Bạn", + "start_watching": "Bắt đầu xem", + "close_party": "Đóng phòng", + "leave_party": "Rời phòng", + "leave_confirm": "Rời phòng xem chung này?", + "only_host_controls": "Chỉ chủ phòng mới điều khiển được việc phát", + "host_changed": "Chủ phòng đã thay đổi", + "room_closed": "Phòng đã đóng", + "room_closed_empty": "Phòng đã đóng vì mọi người đều rời đi", + "content_none": "Chưa chọn phim", + "resolving": "Đang chuẩn bị video…", + "resolve_failed": "Không tải được phim này trên thiết bị của bạn", + "plugin_required_title": "Cần plugin", + "plugin_required_body": "Hãy cài plugin nguồn cần thiết để xem chung phim này.", + "install_plugin": "Cài plugin", + "chat_hint": "Nói gì đó…", + "chat_empty": "Chưa có tin nhắn", + "reactions": "Cảm xúc", + "error_not_found": "Không tìm thấy phòng hoặc phòng đã đóng", + "error_full": "Phòng này đã đầy", + "error_forbidden": "Bạn không có quyền vào phòng này", + "error_rate": "Chậm lại một chút nhé", + "error_unauthorized": "Vui lòng đăng nhập để vào phòng xem chung", + "error_generic": "Đã xảy ra lỗi", + "reconnecting": "Đang kết nối lại…", + "connecting": "Đang kết nối…", + "connect_failed_title": "Không kết nối được", + "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại.", + "settings_title": "Cài đặt phòng", + "room_size": "Kích thước phòng", + "room_size_less": "Ít người hơn", + "room_size_more": "Nhiều người hơn", + "only_host_setting": "Chỉ chủ phòng điều khiển phát", + "lock_room": "Khóa phòng", + "lock_room_hint": "Không ai mới có thể tham gia", + "wait_for_all": "Chờ mọi người", + "wait_for_all_hint": "Tạm dừng một chút khi có người đang tải", + "invite_friends": "Mời bạn bè", + "invite_sent": "Đã gửi lời mời", + "invite_failed": "Không gửi được lời mời", + "invite_again_in": "Lại sau {}", + "invite_no_friends": "Thêm bạn bè để mời họ vào đây", + "make_host": "Đặt làm chủ phòng", + "remove_member": "Xóa khỏi phòng", + "remove_confirm": "Xóa {} khỏi phòng này? Họ sẽ không thể vào lại.", + "buffering": "Đang tải…", + "sys_joined": "{} đã tham gia", + "sys_left": "{} đã rời đi", + "sys_host": "{} giờ là chủ phòng", + "sys_removed": "{} đã bị xóa", + "error_banned": "Bạn đã bị xóa khỏi phòng này", + "error_locked": "Phòng này đã bị khóa", + "room_closed_kicked": "Chủ phòng đã xóa bạn khỏi phòng này", + "waiting_for_others": "Đang chờ mọi người theo kịp…" + }, + "notifications": { + "title": "Thông báo", + "mark_all_read": "Đánh dấu đã đọc", + "empty_title": "Chưa có thông báo", + "empty_subtitle": "Khi có gì mới, bạn sẽ thấy ở đây.", + "time_now": "vừa xong", + "time_minutes": "{} phút", + "time_hours": "{} giờ", + "time_days": "{} ngày" + }, + "manga": { + "sources_title": "Nguồn manga", + "sources_desc": "Thêm kho tiện ích manga để cài thêm nguồn manga, manhwa và webtoon. Các nguồn chạy trên thiết bị của bạn (chỉ Android).", + "repo_url": "URL kho", + "add_source": "Thêm nguồn", + "installing": "Đang cài…", + "installing_long": "Đang cài tiện ích… kho lớn có thể mất một lúc.", + "installing_progress": "Đang cài {} / {} tiện ích…", + "no_extensions": "Không tìm thấy tiện ích ở URL đó.", + "added_sources": "Đã thêm {} kho · {} nguồn.", + "source_removed": "Đã xóa nguồn.", + "recommended": "ĐỀ XUẤT", + "installed_sources": "NGUỒN ĐÃ CÀI", + "source_settings": "CÀI ĐẶT NGUỒN", + "no_sources": "Chưa có nguồn", + "show_nsfw": "Nguồn người lớn", + "show_nsfw_hint": "Hiện các nguồn gắn nhãn 18+ trong danh sách này và khi chọn nguồn. Mặc định tắt.", + "show": "Hiện", + "hide": "Ẩn", + "installed": "Đã cài", + "android_only": "Tính năng này chỉ có trên Android", + "error_prefix": "Lỗi: {}", + "no_preferences": "Nguồn này không có cài đặt nào", + "not_selected": "Chưa chọn", + "n_selected": "Đã chọn {}", + "reading_mode": "Chế độ đọc", + "mode_continuous": "Liên tục", + "mode_paged": "Theo trang", + "direction": "Hướng đọc", + "dir_ltr": "Trái sang phải (LTR)", + "dir_rtl": "Phải sang trái (RTL)", + "background": "Nền", + "bg_black": "Đen", + "bg_gray": "Xám", + "bg_white": "Trắng", + "chapters": "Các chương", + "chapter_ended": "Hết chương", + "next_chapter": "Chương tiếp theo", + "first_chapter": "Chương đầu", + "last_chapter": "Chương cuối", + "no_pages": "Không tìm thấy trang nào", + "tap_to_reload": "Nhấn để tải lại", + "download_started": "Đã bắt đầu tải xuống", + "downloaded_pages": "{} / {} trang", + "check_updates": "Kiểm tra cập nhật", + "checking_updates": "Đang kiểm tra cập nhật tiện ích…", + "up_to_date": "Mọi tiện ích đều đã mới nhất.", + "updated_n": "Đã cập nhật {} tiện ích.", + "spread": "Hai trang", + "spread_desc": "Hai trang cạnh nhau khi xoay ngang, đúng như sách được vẽ", + "text_size": "Cỡ chữ", + "line_height": "Giãn dòng", + "typeface": "Kiểu chữ", + "typeface_default": "Mặc định", + "typeface_serif": "Serif", + "typeface_mono": "Mono", + "alignment": "Căn lề", + "align_left": "Căn trái", + "align_justify": "Căn đều", + "novel_download_unavailable": "Chưa hỗ trợ tải tiểu thuyết để đọc ngoại tuyến.", + "open_on_site": "Mở trên trang của nguồn", + "mark_read": "Đánh dấu đã đọc", + "mark_unread": "Đánh dấu chưa đọc", + "marked_read_n": "Đã đánh dấu {} chương là đã đọc", + "marked_unread_n": "Đã đánh dấu {} chương là chưa đọc", + "volume_n": "Quyển {}", + "other_chapters": "Khác", + "find_chapter": "Tìm chương…", + "no_chapter_match": "Không có chương phù hợp.", + "find_in_chapter": "Tìm trong chương", + "tts_listen": "Nghe", + "tts_read_aloud": "Đọc to", + "tts_play": "Phát", + "tts_pause": "Tạm dừng", + "tts_stop": "Dừng đọc to", + "tts_prev_sentence": "Câu trước", + "tts_next_sentence": "Câu sau", + "tts_speed": "Tốc độ", + "tts_pitch": "Cao độ", + "tts_voice": "Giọng đọc", + "tts_voice_default": "Mặc định hệ thống", + "tts_voice_network": "Giọng trực tuyến — cần kết nối mạng", + "tts_no_voices": "Thiết bị này không có giọng cho ngôn ngữ này. Hãy cài trong cài đặt chuyển văn bản thành giọng nói; trước đó giọng mặc định sẽ đọc.", + "tts_auto_next": "Tiếp tục sang chương sau", + "tts_auto_next_desc": "Tiếp tục đọc to khi hết chương", + "tts_sleep_timer": "Hẹn giờ ngủ", + "tts_sleep_off": "Tắt", + "tts_sleep_minutes": "{} phút", + "tts_sleep_end_of_chapter": "Hết chương", + "tts_sleep_stopped": "Hẹn giờ ngủ: đã dừng đọc to", + "tts_loading_next": "Đang tải chương tiếp theo…", + "tts_no_voice_for_lang": "Thiết bị không có giọng {} — đang đọc bằng giọng mặc định", + "tts_failed": "Đã dừng đọc to: công cụ giọng nói không phản hồi", + "layout": "Bố cục", + "mode_scroll": "Cuộn", + "mode_book": "Sách", + "reading_theme": "Chủ đề", + "theme_dark": "Tối", + "theme_black": "Đen", + "theme_sepia": "Nâu nhạt", + "theme_light": "Sáng", + "margins": "Lề", + "margin_narrow": "Hẹp", + "margin_normal": "Vừa", + "margin_wide": "Rộng", + "highlight": "Đánh dấu", + "highlights": "Đoạn đánh dấu", + "remove_highlight": "Bỏ đánh dấu", + "highlight_note": "Thêm ghi chú (tùy chọn)", + "no_highlights": "Chưa có đoạn đánh dấu. Hãy chọn đoạn văn rồi chọn Đánh dấu.", + "translate_page": "Dịch trang", + "translate_preview": "Trang đã dịch", + "translate_apply": "Áp dụng", + "translate_show_original": "Hiện bản gốc", + "translate_no_text": "Không tìm thấy chữ trên trang này.", + "translate_daily_limit": "Bạn đã dùng hết lượt dịch hôm nay. Hãy thử lại vào ngày mai.", + "translate_offline": "Bạn đang ngoại tuyến. Hãy kết nối internet để dịch trang này.", + "translate_sign_in": "Đăng nhập để dịch trang.", + "translate_failed": "Không dịch được trang này. Hãy thử lại.", + "translate_language": "Dịch trang sang", + "translate_auto": "Dịch từng trang khi đang đọc", + "translate_auto_desc": "Mỗi trang mới dùng một lượt dịch của hôm nay." + }, + "cloudflare": { + "title": "Xác minh bạn là người", + "hint": "Hoàn tất bước kiểm tra bên dưới — màn hình sẽ tự đóng khi xong.", + "solve": "Giải Cloudflare", + "done": "Xong" + }, + "desktop": { + "saved_refresh_on_pc": "Đã lưu — nhấn \"Làm mới nguồn\" trên máy tính để cập nhật.", + "sources_turned_off": "Đã tắt nguồn trên máy tính.", + "saved_loading_sources": "Đã lưu — đang tải nguồn từ điện thoại của bạn…", + "refreshing_sources": "Đang làm mới nguồn từ điện thoại của bạn…", + "share_title": "Chia sẻ nguồn sang máy tính", + "sources_title": "Nguồn trên máy tính", + "share_with_desktop": "Chia sẻ nguồn với máy tính", + "let_pc_use_sources": "Cho phép ứng dụng Sozo trên máy tính dùng nguồn của điện thoại này.", + "your_link": "Liên kết của bạn", + "link_copied": "Đã sao chép liên kết", + "no_wifi_address": "Đã bật nhưng không tìm thấy địa chỉ Wi-Fi. Hãy kết nối điện thoại với Wi-Fi (không phải dữ liệu di động) rồi mở lại màn hình này.", + "step_host_same_wifi": "Giữ điện thoại và máy tính dùng chung một mạng Wi-Fi.", + "step_host_turn_on": "Bật công tắc ở trên.", + "step_host_pick_sources": "Chọn các nguồn muốn chia sẻ (hoặc để bật “Chia sẻ tất cả”).", + "step_host_paste_on_pc": "Trên máy tính, mở Sozo → Hồ sơ → Nguồn trên máy tính rồi dán liên kết.", + "step_host_keep_open": "Giữ Sozo mở trên điện thoại khi bạn duyệt trên máy tính.", + "sources_to_share": "Nguồn chia sẻ", + "reload_from_phone": "Tải lại từ điện thoại", + "share_all_sources": "Chia sẻ tất cả nguồn", + "every_source_shared": "Mọi nguồn trên điện thoại này đều được chia sẻ", + "sharing_count": "Đang chia sẻ {} trên {}", + "save_send_to_desktop": "Lưu & gửi sang máy tính", + "n_selected": "Đã chọn {}", + "select_all": "Chọn tất cả", + "clear": "Xóa", + "no_sources_yet": "Điện thoại này chưa có nguồn. Hãy thêm trong Hồ sơ → Nguồn CloudStream / Aniyomi / Manga rồi tải lại tại đây.", + "phone_link": "Liên kết điện thoại", + "refresh_sources": "Làm mới nguồn", + "step_client_same_wifi": "Giữ máy tính này và điện thoại dùng chung một mạng Wi-Fi.", + "step_client_open_on_phone": "Trên điện thoại, mở Sozo → Hồ sơ → Chia sẻ nguồn sang máy tính rồi bật lên.", + "step_client_choose_sources": "Trên điện thoại, chọn các nguồn muốn chia sẻ (hoặc để bật “Chia sẻ tất cả”).", + "step_client_copy_link": "Sao chép liên kết hiển thị ở đó, dán vào ô trên rồi nhấn Lưu.", + "step_client_refresh": "Đã đổi nguồn chia sẻ trên điện thoại? Nhấn \"Làm mới nguồn\" tại đây.", + "step_client_keep_open": "Giữ Sozo mở trên điện thoại khi bạn duyệt ở đây.", + "step_client_emulator": "Đang dùng máy ảo Android? Không truy cập trực tiếp được IP của máy ảo — hãy chạy \"adb forward tcp:8765 tcp:8765\" trên máy tính này rồi dán http://127.0.0.1:8765 vào ô trên (vẫn bật công tắc trong máy ảo).", + "how_it_works": "Cách hoạt động", + "how_it_works_desc": "Dùng nguồn CloudStream, Aniyomi và manga trên ứng dụng Sozo cho máy tính. Điện thoại chạy nguồn; máy tính chỉ hiển thị.", + "internet_still_unavailable": "Vẫn chưa có Internet", + "offline_watch_downloads": "Bạn vẫn có thể xem video đã tải xuống khi mất kết nối.", + "refresh": "Làm mới" + }, + "detail": { + "offline_copy": "Bản ngoại tuyến", + "offline_copy_desc": "Không truy cập được nguồn nên đây là bản đã lưu cùng nội dung tải xuống của bạn.", + "download_will_resolve": "Đang xử lý…", + "download_needs_playback": "Nguồn này giấu luồng phát sau một trình phát được bảo vệ. Hãy phát tập này một lần rồi tải xuống từ trình phát.", + "download_choose_server": "Tải xuống · chọn máy chủ", + "download_playing_now": "Đang phát", + "similar": "Tương tự", + "comments": "Bình luận", + "screenshots": "Ảnh chụp", + "play": "Phát", + "no_episodes": "Không có tập nào", + "no_playable_source": "Không có nguồn phát được", + "added_to_my_list": "Đã thêm vào Danh sách của tôi", + "removed_from_my_list": "Đã xóa khỏi Danh sách của tôi", + "failed_resolve_pages": "Không tải được các trang", + "episodes_count": "{} tập", + "episodes_count_of": "{} trên {} tập", + "episode_n": "Tập {}", + "no_films": "Không tìm thấy phim", + "filmography": "Phim đã tham gia", + "actor": "Diễn viên", + "continue_watching": "Xem tiếp", + "continue_ep": "Xem tiếp · Tập {}", + "no_cast": "Không có thông tin diễn viên", + "no_recommendations": "Không có đề xuất", + "untitled": "Không tiêu đề", + "read": "Đọc", + "watch_later": "Xem sau", + "watched": "Đã xem", + "follow_series": "Theo dõi phim", + "find_other_sources": "Tìm nguồn khác", + "notice_title": "Nguồn này không phát được tiêu đề này", + "notice_moved_to_app": "Nguồn đã chuyển tiêu đề này sang ứng dụng riêng của họ, nên ở đây không còn gì để phát. Nguồn khác có thể vẫn còn.", + "notice_no_player": "Nguồn có liệt kê tiêu đề này nhưng không có trình phát cho nó. Nguồn khác có thể có.", + "notice_is_series": "Ở nguồn này đây là một bộ phim nhiều tập, nên nó phát từ danh sách tập chứ không phải như một phim lẻ.", + "anilist_track": "Theo dõi trên AniList", + "anilist_tracked": "Đã theo dõi trên AniList", + "following_on": "Đang theo dõi", + "following_off": "Chưa theo dõi", + "no_screenshots": "Không có ảnh chụp", + "show_more": "Xem thêm", + "show_less": "Thu gọn", + "time_left": "Còn {}", + "liked": "{}% thích", + "watched_pct": "Đã xem {}%", + "copy_link": "Sao chép liên kết", + "link_copied": "Đã sao chép liên kết", + "report_problem": "Báo lỗi", + "selected_n": "Đã chọn {}", + "select_all": "Chọn tất cả", + "select_none": "Xóa", + "download_n": "Tải xuống {}", + "download_queueing": "Đang xếp hàng…", + "download_action": "Tải xuống", + "download_already": "Đã tải xuống", + "download_in_progress": "Đang tải xuống", + "download_started": "Đã bắt đầu tải xuống", + "download_needs_permission": "Cần quyền thông báo để tải xuống", + "download_resolve_failed": "Không lấy được liên kết tải xuống cho tập này", + "download_queued_n": "Đã thêm {} vào tải xuống", + "download_none_queued": "Chưa thêm gì — có thể đã tải xuống rồi", + "download_batch_title": "Tải xuống nhiều tập?", + "download_batch_body": "Các tập {range} sẽ được xếp hàng — tổng cộng {count} tập. Trên dữ liệu di động có thể tốn vài gigabyte.", + "relations": "Liên quan", + "no_relations": "Không tìm thấy phim liên quan.", + "relation_tmdb_collection": "CÙNG LOẠT PHIM", + "relation_tmdb_recommended": "ĐỀ XUẤT", + "relation_sequel": "PHẦN TIẾP", + "relation_prequel": "TIỀN TRUYỆN", + "relation_parent": "TRUYỆN CHÍNH", + "relation_side_story": "NGOẠI TRUYỆN", + "relation_alternative": "PHIÊN BẢN KHÁC", + "relation_spin_off": "PHÁI SINH", + "relation_summary": "TÓM TẮT", + "relation_adaptation": "CHUYỂN THỂ", + "relation_source": "NGUYÊN TÁC", + "relation_character": "CHUNG NHÂN VẬT", + "relation_other": "KHÁC", + "trailer_action": "Trailer", + "more_options": "Tùy chọn khác", + "in_private_list": "Trong danh sách riêng tư", + "add_to_my_list_action": "Thêm vào Danh sách của tôi", + "remove_from_my_list_action": "Xóa khỏi Danh sách của tôi", + "trailer_mute": "Tắt tiếng trailer", + "trailer_unmute": "Bật tiếng trailer", + "my_list": "Danh sách của tôi", + "save_action": "Lưu", + "next_episode_in": "Tập {} sau {}", + "about": "Thông tin", + "about_studio": "Studio", + "about_source": "Nguyên tác", + "about_episodes": "Số tập", + "about_rank": "Hạng", + "about_status": "Trạng thái", + "about_format": "Định dạng", + "not_on_list": "Chưa có trong danh sách", + "add_to_list": "Thêm vào danh sách", + "about_network": "Kênh phát", + "about_seasons": "Số phần", + "about_budget": "Kinh phí", + "about_box_office": "Doanh thu", + "about_collection": "Bộ sưu tập", + "about_chapters": "Số chương", + "about_volumes": "Số quyển", + "tracking_score": "Điểm", + "tracking_progress": "Tiến độ", + "tracking_status": "Trạng thái", + "tracking_remove": "Xóa khỏi danh sách", + "tracking_removed": "Đã xóa khỏi {}", + "tracking_saved": "Đã lưu vào {}", + "tracking_not_scored": "Chưa chấm điểm", + "tracking_chapters_read": "Chương đã đọc", + "tracking_episodes_watched": "Tập đã xem", + "status_reading": "Đang đọc", + "status_rereading": "Đang đọc lại", + "about_author": "Tác giả", + "cd_days": "ngày", + "cd_hours": "giờ", + "cd_minutes": "phút", + "cd_seconds": "giây", + "next_episode_label": "Tập {} chiếu sau", + "airing_now": "Đang chiếu", + "tracking_pick_status": "Chọn trạng thái để thêm vào danh sách.", + "score_bad": "Tệ", + "score_meh": "Tàm tạm", + "score_fine": "Ổn", + "score_good": "Hay", + "score_great": "Rất hay", + "score_excellent": "Xuất sắc", + "score_masterpiece": "Kiệt tác" + }, + "episodes": { + "offline_desc": "Đang hiện danh sách đã lưu. Chỉ các tập đã tải xuống mới mở được khi ngoại tuyến.", + "filter_hint": "Số tập hoặc tên tập", + "all_groups": "Tất cả nhóm", + "season_n": "Phần {}", + "specials": "Tập đặc biệt", + "filter_none": "Không có tập nào khớp “{query}” trong khối này", + "jump_to": "Đến tập {n}", + "jump_loads": "Tải {range}", + "jump_missing": "Tập {n} không có trên trang này", + "range_of": "Tập {from}–{to} trên {total}" + }, + "shorts": { + "refresh": "Làm mới", + "opening": "Đang mở...", + "content_unavailable": "Nội dung không khả dụng", + "loading": "Đang tải Shorts...", + "empty_title": "Chưa có Shorts", + "empty_subtitle": "Các video ngắn sẽ hiện ở đây khi có.", + "error_title": "Không tải được Shorts", + "video_unavailable": "Video không khả dụng", + "speed_2x": "Tốc độ 2x", + "report_submitted": "Đã gửi báo cáo, cảm ơn bạn", + "watch_full_movie": "Xem cả phim", + "browse_movies": "Duyệt phim", + "sign_in_to_like": "Vui lòng đăng nhập để thích" + }, + "comments": { + "please_sign_in": "Vui lòng đăng nhập trước", + "report_submitted": "Đã gửi báo cáo, cảm ơn bạn", + "edited": "· đã sửa", + "like": "Thích", + "reply": "Trả lời", + "hide": "Ẩn", + "replies_one": "1 trả lời", + "replies_other": "{} trả lời", + "report": "Báo cáo", + "edit_hint": "Sửa bình luận của bạn...", + "reply_hint": "Viết trả lời...", + "comment_hint": "Viết bình luận...", + "editing": "Đang sửa", + "replying_to": "Đang trả lời {}", + "sign_in_to_comment": "Đăng nhập để bình luận", + "empty_title": "Chưa có bình luận", + "empty_subtitle": "Hãy là người đầu tiên chia sẻ suy nghĩ", + "count_one": "1 bình luận", + "count_other": "{} bình luận", + "show_more": "Xem thêm", + "delete_confirm_title": "Xóa bình luận này?", + "delete_confirm_body": "Không thể hoàn tác thao tác này.", + "sign_in_prompt": "Đăng nhập để bình luận", + "spoiler": "Tiết lộ", + "spoiler_hidden": "Tiết lộ nội dung · chạm để xem", + "unwatched_hidden": "Chưa xem · chạm để xem" + }, + "downloads": { + "view_titles": "Theo phim", + "view_files": "Tất cả", + "titles_empty_title": "Chưa có gì hoàn tất", + "titles_empty_subtitle": "Phim sẽ hiện ở đây khi có một bản tải xuống hoàn tất. Hàng chờ nằm trong mục Tất cả.", + "title_movie": "Phim lẻ", + "title_episodes_n": "Số tập: {}", + "title_chapters_n": "Số chương: {}", + "title_actions": "Thao tác khác", + "open_title": "Mở phim", + "relink": "Chuyển sang nguồn khác", + "relink_short": "Giữ tệp và tiến độ của bạn", + "relink_desc": "Chọn nguồn mới cho phim này. Bản tải xuống vẫn ở trên thiết bị và giữ nguyên tiến độ của bạn.", + "relink_searching": "Đang tìm {} trong các nguồn khác của bạn…", + "relink_none": "Không nguồn nào khác của bạn có phim này.", + "relink_guess": "Chưa chắc khớp — hãy kiểm tra tên phim trước", + "relink_failed": "Không tải được danh sách tập từ {}.", + "relink_no_match": "Không bản tải xuống nào khớp với tập trên {}.", + "relink_busy": "Một số mục vẫn đang tải. Hãy đợi xong hoặc tạm dừng rồi thử lại.", + "relink_confirm_title": "Chuyển sang {}?", + "relink_confirm_body": "{matched} trên {total} bản tải xuống khớp với các tập ở đó. Tệp và tiến độ của bạn được giữ nguyên.", + "relink_confirm_left": "Vẫn ở nguồn hiện tại: {}.", + "relink_action": "Chuyển", + "relink_done": "Đã chuyển bản tải xuống: {}.", + "relink_done_left": "Còn ở nguồn cũ: {}.", + "delete_downloads": "Xóa bản tải xuống", + "delete_downloads_confirm": "Xóa mọi bản tải xuống của {title} ({count})?", + "offline_not_downloaded": "Mục này chưa được tải xuống và không truy cập được nguồn.", + "delete_all_title": "Xóa tất cả bản tải xuống?", + "file_not_found": "Không tìm thấy tệp", + "clear_all": "Xóa tất cả", + "segments_progress": "{} / {} phân đoạn", + "downloaded_amount": "Đã tải {}", + "pages_count": "{} trang", + "downloaded": "Đã tải xuống", + "failed": "Thất bại", + "pending": "Đang chờ", + "remove": "Xóa", + "empty_title": "Chưa có bản tải xuống", + "empty_subtitle": "Video đã tải xuống sẽ hiện ở đây", + "paused": "Đã tạm dừng", + "wifi_only": "Chỉ tải xuống qua Wi-Fi", + "wifi_only_desc": "Các mục trong hàng chờ sẽ đợi đến khi có Wi-Fi", + "waiting_for_wifi": "Đang chờ Wi-Fi — sẽ tự động tải tiếp", + "export": "Lưu vào Tải xuống", + "export_hint": "Cho phép ứng dụng khác thấy tệp này", + "export_running": "Đang lưu…", + "export_progress": "Đang tạo EPUB… {} trên {} chương", + "export_done": "Đã lưu vào {}", + "export_failed": "Không lưu được vào Tải xuống", + "downloading": "Đang tải xuống", + "missing": "Thiếu tệp — nhấn để tải lại", + "redownload_started": "Đang tải lại…", + "file_missing_retry": "Tệp đó không còn. Đang tải lại.", + "download_again": "Tải lại", + "pause": "Tạm dừng", + "resume": "Tiếp tục", + "retry_all": "Thử lại mọi mục thất bại", + "sort_by": "SẮP XẾP THEO", + "sort_newest": "Mới nhất trước", + "sort_title": "Tên", + "sort_size": "Lớn nhất trước", + "filter_all": "Tất cả", + "filter_active": "Đang tải", + "filter_done": "Đã xong", + "filter_problems": "Cần xử lý", + "filter_empty_title": "Không có mục nào khớp bộ lọc", + "filter_empty_subtitle": "Các bản tải xuống vẫn còn — chỉ là chế độ xem đang được thu hẹp.", + "filter_clear": "Hiện tất cả bản tải xuống", + "storage_used": "Tải xuống đã dùng {}", + "storage_free": "Còn trống {}", + "orphans_found": "{} trong các tệp còn sót từ bản tải đã hủy", + "orphans_clean": "Dọn dẹp", + "group_summary": "Đã tải {} trên {}", + "group_problems": "Cần xử lý", + "error": { + "network": "Mất kết nối", + "refused": "Nguồn từ chối tải xuống — hãy phát một lần rồi thử lại", + "gone": "Tệp không còn trên nguồn", + "no_space": "Không đủ dung lượng trên thiết bị này", + "not_media": "Nguồn gửi một trang web thay vì video", + "incomplete": "Tải xuống kết thúc nhưng chưa đầy đủ", + "unknown": "Tải xuống thất bại" + }, + "location": "Nơi lưu bản tải xuống", + "location_hint": "Chỉ gồm những nơi ứng dụng ghi được mà không cần cấp quyền. Bản tải xuống sẽ chuyển theo cài đặt; chưa xóa gì cho đến khi mọi tệp đã chuyển xong.", + "location_internal": "Bộ nhớ máy (ứng dụng)", + "location_external": "Bộ nhớ máy", + "location_sdcard": "Thẻ SD", + "location_moved": "Đã chuyển bản tải xuống", + "location_unchanged": "Đã ở đó rồi", + "location_failed": "Không chuyển được bản tải xuống — chúng vẫn ở chỗ cũ", + "cooldown": "Nghỉ giữa các lượt tải", + "cooldown_desc": "Một số trang chặn ứng dụng tải liên tiếp nhiều tệp. Khoảng nghỉ giúp tránh điều đó, đổi lại hàng chờ chậm hơn.", + "cooldown_off": "Không nghỉ", + "cooldown_n": "{} giây" + }, + "history": { + "clear_title": "Xóa lịch sử", + "clear_confirm": "Bạn có chắc muốn xóa lịch sử xem không?", + "clear": "Xóa", + "clear_all": "Xóa tất cả", + "remove": "Xóa", + "empty_title": "Chưa có lịch sử xem", + "empty_subtitle": "Hãy bắt đầu xem để thấy lịch sử tại đây" + }, + "main": { + "shorts_refresh_title": "Làm mới Shorts", + "shorts_refresh_body": "Nhấn lại tab Shorts. Bảng tin sẽ làm mới và tải video mới.", + "double_tap": "Nhấn đúp", + "tap_again": "Nhấn lần nữa" + }, + "reports": { + "title": "Báo cáo", + "reason_spam": "Spam", + "reason_abuse": "Quấy rối", + "reason_sexual": "Nội dung tình dục", + "reason_violence": "Bạo lực", + "reason_copyright": "Bản quyền", + "reason_other": "Khác", + "note_hint": "Ghi chú thêm (tùy chọn)", + "submit": "Gửi" + }, + "ios": { + "sources_title": "Nguồn tiện ích mở rộng", + "how_it_works_desc": "Dùng nguồn CloudStream, Aniyomi và manga trong Sozo cho iOS. Điện thoại Android chạy nguồn; thiết bị này chỉ hiển thị.", + "step_client_same_wifi": "Giữ thiết bị này và điện thoại Android dùng chung một mạng Wi-Fi." + }, + "trivia": { + "fan_test_title": "Thử thách fan", + "fan_test_subtitle": "Chứng minh bạn là fan cứng nhất", + "hub_tagline": "Bạn thực sự hiểu điện ảnh đến đâu?", + "view_leaderboards": "Xem bảng xếp hạng", + "my_daily_rank": "Hạng hôm nay của tôi", + "rank_value": "#{}", + "unranked": "Chưa xếp hạng", + "points_value": "{} điểm", + "popular_now": "Đang phổ biến", + "results": "Kết quả", + "searching": "Đang tìm…", + "search_cast_hint": "Tìm diễn viên", + "no_results": "Không có kết quả", + "no_results_hint": "Hãy thử tên khác", + "something_wrong": "Đã xảy ra lỗi", + "filmography": "Phim đã tham gia", + "n_titles": "{count} phim", + "start_fan_test": "Bắt đầu thử thách", + "challenge_friend": "Thách đấu bạn bè", + "top_fans": "Fan hàng đầu", + "no_fans_yet": "Chưa có fan", + "be_first_fan": "Hãy là fan đầu tiên", + "fans_meta": "{rounds} vòng · trung bình {fandom}% độ fan", + "you": "Bạn", + "you_rank": "Bạn · #{rank}", + "best_score": "Cao nhất {score}", + "fandom": "Độ fan", + "challenge_share_text": "Bạn có fan {name} hơn tôi không? Làm Thử thách fan trên Sozo nhé!", + "scope_daily": "Hôm nay", + "scope_weekly": "Tuần", + "scope_all_time": "Mọi lúc", + "scope_friends": "Bạn bè", + "leaderboard": "Bảng xếp hạng", + "no_friends_board": "Chưa có bạn bè trên bảng", + "no_scores_yet": "Chưa có điểm", + "correct_of": "{count} câu đúng", + "forfeit_title": "Rời trò chơi?", + "forfeit_body": "Tiến độ của vòng này sẽ bị mất.", + "keep_playing": "Chơi tiếp", + "forfeit_confirm": "Rời đi", + "correct": "Đúng", + "the_answer": "Đáp án", + "watch_full": "Xem cả phim", + "building_round": "Đang tạo vòng chơi…", + "round_failed": "Không bắt đầu được vòng chơi", + "go_back": "Quay lại", + "fan_test_done": "Đã xong Thử thách fan", + "round_done": "Đã xong vòng chơi", + "score": "Điểm", + "rank": "Hạng", + "play_again": "Chơi lại", + "share_result": "Chia sẻ kết quả", + "share_text": "Tôi vừa chơi Buff trên Sozo!", + "challenge_text": "Nghĩ mình hiểu phim hơn tôi? Thách đấu tôi trên Buff trong Sozo nhé!", + "share_tagline": "Chơi Buff trên Sozo", + "challenge_from": "Thách đấu từ {name}", + "same_clips_head_to_head": "Cùng các đoạn phim, đấu trực tiếp", + "score_to_beat": "Điểm cần vượt", + "play_challenge": "Nhận thách đấu", + "challenge_not_found": "Không tìm thấy thách đấu", + "no_actors_yet": "Chưa có diễn viên", + "no_actors_yet_body": "Buff đang được bổ sung nội dung. Các đoạn phim được duyệt từng cái một — diễn viên sẽ hiện ở đây ngay khi đoạn phim được duyệt.", + "actor_not_ready": "Diễn viên này chưa sẵn sàng", + "actor_not_ready_body": "Chưa đủ đoạn phim đã duyệt cho một vòng chơi. Hãy thử diễn viên khác.", + "round_short_note": "Vòng này có {count} đoạn phim — tất cả đoạn đã duyệt của diễn viên này đến nay.", + "todays_top": "Top hôm nay", + "how_it_works": "Cách chơi Buff", + "how_step_1": "Chọn diễn viên", + "how_step_2": "Xem đoạn phim ngắn", + "how_step_3": "Đoán tên phim" + }, + "outage": { + "body_with_local": "Không kết nối được máy chủ Sozo. {} nguồn trên thiết bị của bạn vẫn hoạt động.", + "body_no_local": "Không kết nối được máy chủ Sozo. Hãy thêm nguồn CloudStream, Aniyomi hoặc Manga để tiếp tục duyệt mà không cần API của chúng tôi.", + "pick_source": "Chọn nguồn" + }, + "link_tv": { + "title": "Liên kết TV", + "tile_subtitle": "Quét mã QR trên TV hoặc nhập mã của TV", + "subtitle": "Quét mã QR trên TV hoặc nhập mã 8 ký tự hiển thị bên dưới mã QR.", + "scan_button": "Quét mã QR", + "scan_hint": "Hướng camera vào mã QR trên màn hình TV", + "manual_title": "Hoặc nhập mã", + "confirm": "Xác nhận", + "approved": "Đã liên kết TV", + "approved_named": "Đã liên kết “{}”", + "approved_hint": "TV sẽ đăng nhập vào tài khoản của bạn trong vài giây.", + "linked_devices": "Thiết bị đã liên kết", + "no_devices": "Chưa có thiết bị liên kết", + "unnamed_device": "Thiết bị chưa đặt tên", + "last_seen": "Hoạt động lần cuối: {}", + "unlink_title": "Hủy liên kết thiết bị", + "unlink_message": "Thiết bị này sẽ bị đăng xuất và cần mã QR mới để liên kết lại.", + "unlink_confirm": "Hủy liên kết", + "camera_denied": "Bị từ chối truy cập camera. Hãy cho phép trong Cài đặt hoặc nhập mã.", + "camera_error": "Không khởi động được camera.", + "error_bad_code": "Mã phải có 8 ký tự.", + "error_not_found": "Không tìm thấy mã đó. Hãy lấy mã mới trên TV.", + "error_expired": "Mã đã hết hạn. Hãy nhấn Làm mới trên TV.", + "error_already_used": "Mã đó đã được sử dụng.", + "error_unauthorized": "Vui lòng đăng nhập trước.", + "error_rate_limited": "Thử quá nhiều lần. Hãy thử lại sau giây lát.", + "error_network": "Không có kết nối mạng.", + "error_generic": "Đã xảy ra lỗi." + }, + "anilist": { + "connections_title": "Kết nối", + "connect": "Kết nối AniList", + "disconnect": "Ngắt kết nối", + "disconnect_confirm": "Tài khoản AniList của bạn sẽ bị ngắt kết nối và các phim đã liên kết sẽ bị quên. Danh sách của bạn vẫn còn trên AniList.", + "not_connected": "Chưa kết nối", + "connect_explainer": "Kết nối AniList để các tập bạn xem được đánh dấu tự động và danh sách của bạn hiện ở đây.", + "connected_explainer": "Các tập đã xem được ghi vào danh sách AniList của bạn. Kết nối được lưu trong tài khoản nên TV và các thiết bị khác sẽ không yêu cầu đăng nhập lại.", + "connect_prompt": "Kết nối tài khoản để xem thư viện AniList của bạn tại đây.", + "upcoming_connect_prompt": "Kết nối AniList để biết khi nào tập tiếp theo của phim bạn theo dõi được chiếu.", + "sign_in_first": "Hãy đăng nhập tài khoản Sozo trước", + "browser_failed": "Không mở được trình duyệt", + "open_library": "Thư viện AniList của tôi", + "linked_titles": "Phim đã liên kết", + "linked_titles_count": "Đã liên kết {} phim", + "linked_titles_none": "Chưa liên kết gì", + "unlink": "Hủy liên kết", + "unlinked": "Đã hủy liên kết {}", + "auto_matched": "Tự động", + "privacy_note": "Token AniList được lưu trong tài khoản Sozo của bạn. Ứng dụng chỉ ghi số tập bạn đã xem.", + "status_label": "TRẠNG THÁI", + "status_current": "Đang xem", + "status_planning": "Định xem", + "status_completed": "Đã xem xong", + "status_paused": "Tạm dừng", + "status_dropped": "Đã bỏ", + "status_repeating": "Đang xem lại", + "episodes_watched": "tập đã xem", + "chapters_read": "chương đã đọc", + "empty_status": "Danh sách \"{}\" của bạn trống", + "behind_n": "{} mới", + "mark_episode_watched": "Đánh dấu đã xem tập {}", + "mark_chapter_read": "Đánh dấu đã đọc chương {}", + "find_in_sources": "Tìm trong các nguồn", + "find_in_sources_hint": "Tìm phim này ở mọi nguồn", + "open_on_anilist": "Mở trên AniList", + "refresh": "Làm mới", + "retry": "Thử lại", + "upcoming_title": "Sắp chiếu", + "upcoming_empty": "Không phim nào bạn theo dõi có thông báo tập tiếp theo.", + "today": "Hôm nay", + "tomorrow": "Ngày mai", + "episode_n": "Tập {}", + "aired": "Đã chiếu", + "in_days": "sau {} ngày", + "in_hours": "sau {} giờ", + "in_minutes": "sau {} phút", + "link_title": "Liên kết với AniList", + "link_hint": "Chọn anime AniList tương ứng với phim này.", + "link_search_hint": "Tên anime", + "link_no_results": "Không tìm thấy gì", + "linked_to": "Đã liên kết với {}", + "tracking_on": "Đã theo dõi trên AniList", + "track_on_anilist": "Theo dõi trên AniList", + "n_episodes_short": "{} tập", + "n_chapters_short": "{} chương", + "open_anilist": "AniList", + "connect_tagline": "Tự động theo dõi những gì bạn xem", + "connect_short": "Kết nối", + "calendar_title": "Lịch chiếu", + "calendar_all": "Tất cả", + "calendar_mine": "Của tôi", + "calendar_episode": "Tập {episode}", + "calendar_empty": "Ngày này không có phim chiếu.", + "calendar_empty_mine": "Ngày này không có phim nào trong danh sách của bạn.", + "calendar_open": "Lịch chiếu", + "calendar_error": "Không tải được lịch chiếu.", + "calendar_rate_limited": "AniList đang bận. Hãy thử lại sau giây lát.", + "calendar_list_error": "Không tải được danh sách AniList của bạn.", + "calendar_now": "bây giờ", + "calendar_count": "{count} lượt chiếu", + "calendar_count_mine": "{mine} / {total} của bạn", + "calendar_episode_range": "Tập {from}-{to}", + "calendar_add_planning": "Thêm vào Định xem", + "calendar_added_planning": "Đã thêm vào danh sách Định xem", + "calendar_add_failed": "Không thêm được vào danh sách của bạn", + "calendar_already_on_list": "Đã có trong danh sách của bạn", + "browse_title": "Khám phá", + "browse_trending": "Thịnh hành", + "browse_season": "Mùa này", + "browse_popular": "Phổ biến mọi thời đại", + "browse_upcoming": "Sắp ra mắt", + "browse_empty": "Không có gì để hiển thị.", + "browse_failed": "Không truy cập được AniList.", + "episodes_count": "{count} tập", + "reminder_body": "Tập {episode} chiếu sau 10 phút", + "reminders_on": "Đã bật nhắc nhở — trước và khi tập phim lên sóng", + "reminders_off": "Đã tắt nhắc nhở tập phim", + "connect_first": "Hãy kết nối AniList trước", + "save_failed": "Không lưu được vào AniList", + "search_in_source": "TÌM TRONG MỘT NGUỒN", + "no_sources": "Chưa cài nguồn nào", + "released_body": "Đã có tập {episode}", + "remove_entry": "Xóa khỏi danh sách của tôi", + "remove_confirm": "Xóa \"{}\" khỏi AniList của bạn? Tiến độ của phim này cũng sẽ bị xóa.", + "removed": "Đã xóa khỏi AniList", + "retry_in": "Thử lại sau {} giây", + "kind_anime": "Anime", + "kind_manga": "Manga", + "kind_novel": "Tiểu thuyết", + "pending_updates": "{} cập nhật tiến độ đang chờ gửi", + "pending_updates_note": "Tự động gửi khi có kết nối lại. Nhấn để gửi ngay.", + "pending_sending": "Đang gửi…", + "pending_all_sent": "Đã gửi {}. Không còn gì đang chờ.", + "pending_some_left": "{} vẫn đang chờ — sẽ được gửi tự động." + }, + "mal": { + "connect": "Kết nối MyAnimeList", + "disconnect": "Ngắt kết nối MyAnimeList", + "disconnect_confirm": "Tài khoản MyAnimeList của bạn sẽ bị ngắt kết nối và các phim đã khớp sẽ bị quên. Danh sách của bạn vẫn còn trên MyAnimeList.", + "connect_explainer": "Kết nối MyAnimeList để các tập bạn xem được đánh dấu tự động.", + "connected_explainer": "Các tập đã xem được ghi vào MyAnimeList của bạn. Kết nối được lưu trong tài khoản nên TV và các thiết bị khác sẽ không yêu cầu đăng nhập lại.", + "not_configured": "MyAnimeList chưa được cấu hình trên máy chủ", + "matching_title": "Cách khớp phim", + "matching_note": "Khớp qua AniList, vốn có ID MyAnimeList của cùng mục đó. Liên kết phim một lần là cả hai trình theo dõi đều cập nhật.", + "privacy_note": "Token AniList và MyAnimeList được lưu trong tài khoản Sozo của bạn. Ứng dụng chỉ ghi số tập bạn đã xem.", + "tracking_on": "Đã theo dõi trên MyAnimeList", + "save_failed": "Không lưu được vào MyAnimeList", + "open_library": "MyAnimeList của tôi", + "linked_titles": "Phim đã liên kết", + "connect_prompt": "Kết nối tài khoản để xem MyAnimeList của bạn tại đây.", + "connect_first": "Hãy kết nối MyAnimeList trước", + "search_failed": "Không truy cập được MyAnimeList.", + "link_title": "Liên kết với MyAnimeList", + "link_hint": "Chọn anime MyAnimeList tương ứng với phim này.", + "linked_to": "Đã liên kết với {}", + "track": "Theo dõi trên MyAnimeList", + "tracked": "Đã theo dõi trên MyAnimeList", + "score_label": "ĐIỂM", + "remove_entry": "Xóa khỏi danh sách của tôi", + "remove_confirm": "Xóa \"{}\" khỏi MyAnimeList của bạn? Tiến độ của phim này cũng sẽ bị xóa.", + "removed": "Đã xóa khỏi MyAnimeList", + "rewatching_note": "Bạn đang xem lại phim này. Sozo giữ nguyên trạng thái để không kết thúc lượt xem lại." + }, + "tracker": { + "title": "Theo dõi", + "tab_following": "Đang theo dõi", + "tab_upcoming": "Sắp chiếu", + "following_empty": "Theo dõi một phim để nhận thông báo khi có tập mới.", + "new_episodes_found": "{} phim có tập mới", + "no_new_episodes": "Không có tập mới", + "n_episodes": "{} tập", + "not_checked": "Chưa kiểm tra", + "unfollow": "Bỏ theo dõi", + "unfollowed": "Đã bỏ theo dõi {}", + "undo": "Hoàn tác", + "new_episode_one": "Đã có tập {}", + "new_episodes_n": "{} tập mới — đến tập {}" + }, + "remote": { + "title": "Điều khiển TV", + "no_devices": "Chưa có TV liên kết. Hãy ghép nối từ ứng dụng trên TV trước.", + "load_failed": "Không tải được danh sách TV của bạn.", + "tv_offline": "TV đang ngoại tuyến", + "idle": "Không phát gì", + "command_failed": "Lệnh không đến được TV", + "search_on_tv": "TÌM TRÊN TV", + "search_hint": "Nhập ở đây, tìm kiếm chạy trên TV", + "back": "Quay lại", + "home": "Trang chủ", + "open_on_tv": "Phát trên TV", + "sent_to_tv": "Đã gửi đến {device}", + "connected_to": "Đã kết nối với {device}", + "not_connected_to": "{device} chưa kết nối", + "turn_tv_on": "Mở Sozo trên TV để điều khiển", + "retry": "Thử lại", + "navigation": "ĐIỀU HƯỚNG", + "playback": "PHÁT", + "pick_device": "TV nào?", + "device_online": "Trực tuyến", + "device_offline": "Ngoại tuyến — hãy mở Sozo trên TV" + }, + "live_tv": { + "channels": "Kênh", + "all_channels": "Tất cả kênh", + "title": "TV trực tiếp", + "favourites": "Yêu thích", + "recent": "Xem gần đây", + "categories": "Danh mục", + "countries": "Quốc gia", + "results": "Kết quả tìm kiếm", + "search_hint": "Tên kênh…", + "search_in": "Tìm trong {folder}…", + "no_match": "Không có kênh phù hợp.", + "empty": "Chưa có kênh nào.", + "load_failed": "Không tải được các kênh.", + "retry": "Thử lại", + "now": "Đang phát", + "next": "Tiếp theo", + "guide": "Lịch phát sóng", + "no_guide": "Kênh này không có lịch phát sóng.", + "watch": "Xem", + "favourite_add": "Thêm vào yêu thích", + "favourite_remove": "Xóa khỏi yêu thích", + "hint_long_press": "Nhấn giữ một kênh để xem lịch phát sóng và yêu thích.", + "channel_count": { + "one": "{} kênh", + "other": "{} kênh" + }, + "up_next": "TIẾP THEO", + "minutes_left": "Còn {} phút", + "last_opened": "Kênh mở gần nhất", + "recent_scopes": "Mở gần đây" + }, + "time": { + "now": "vừa xong", + "minutes": "{} phút trước", + "hours": "{} giờ trước", + "days": "{} ngày trước" + }, + "user_lists": { + "title": "Danh sách của tôi", + "remove": "Xóa", + "empty_title": "Chưa lưu gì", + "empty_subtitle": "Các phim bạn lưu sẽ hiện ở đây.", + "load_failed": "Không thể tải danh sách này." + }, + "torrent": { + "title": "Torrent", + "search_hint": "Tìm torrent…", + "empty_hint": "Tìm cùng lúc trên Nyaa, Tokyo Toshokan và nekoBT.", + "searching": "Đang tìm trên các tracker…", + "no_results": "Không tìm thấy gì trên các tracker đã bật", + "no_results_hint": "Hãy thử tên ngắn hơn hoặc nới lỏng bộ lọc.", + "results_n": "{} kết quả", + "seeders": "seeder", + "leechers": "leecher", + "unknown_swarm": "Không rõ quy mô swarm", + "dead": "Không có seeder", + "dead_warning": "Torrent này không có seeder nên không thể phát.", + "trusted": "Tin cậy", + "remake": "Đăng lại", + "filters": "Bộ lọc", + "category": "Danh mục", + "cat_all": "Tất cả", + "cat_anime": "Anime", + "cat_english": "Phụ đề Anh", + "cat_non_english": "Ngôn ngữ khác", + "cat_raw": "Raw", + "cat_amv": "AMV", + "cat_live_action": "Người đóng", + "sort": "Sắp xếp", + "sort_seeders": "Seeder", + "sort_date": "Mới nhất", + "sort_size": "Dung lượng", + "sort_downloads": "Lượt tải", + "min_seeders": "Seeder tối thiểu", + "quality": "Chất lượng", + "any_quality": "Bất kỳ", + "trusted_only": "Chỉ người đăng tin cậy", + "exclude_hardsubs": "Ẩn bản phụ đề cứng", + "exclude_mini": "Ẩn bản nén mini", + "exclude_remakes": "Ẩn bản đăng lại", + "require_dual_audio": "Chỉ âm thanh kép", + "exclude_batches": "Ẩn gói cả phần", + "trackers": "Tracker", + "reset_filters": "Đặt lại", + "consent_title": "Phát qua BitTorrent?", + "consent_body": "Torrent là mạng ngang hàng. Khi đang phát, những người khác trong swarm có thể thấy địa chỉ IP và nội dung bạn đang tải, đồng thời thiết bị của bạn cũng tải lên cho họ.\n\nHãy dùng VPN nếu điều đó quan trọng ở nơi bạn sống.", + "consent_accept": "Tôi hiểu", + "consent_remember": "Không hỏi lại", + "preparing": "Đang khởi động công cụ torrent…", + "fetching_metadata": "Đang lấy danh sách tệp từ swarm…", + "metadata_timeout": "Swarm không phản hồi. Torrent có thể không còn seeder.", + "engine_failed": "Không khởi động được công cụ torrent.", + "unsupported": "Phát torrent chỉ có trên Android.", + "pick_file": "Chọn tệp", + "pick_file_hint": "Torrent này có {} video.", + "play": "Phát", + "copy_magnet": "Sao chép liên kết magnet", + "magnet_copied": "Đã sao chép liên kết magnet", + "open_external": "Mở trong ứng dụng torrent", + "open_page": "Mở trên tracker", + "stats_peers": "{} peer", + "buffering": "Đang đệm {}%", + "still_searching": "{} tracker vẫn đang phản hồi", + "buffering_title": "Đang đệm…", + "no_video_in_torrent": "Torrent này không có video phát được.", + "tracker_failed": "Không tìm được trên {} — kết quả có thể chưa đầy đủ." + }, + "backup": { + "title": "Sao lưu & khôi phục", + "subtitle": "Lịch sử xem, danh sách, nguồn và cài đặt. Không bao gồm thông tin đăng nhập.", + "export": "Tạo bản sao lưu", + "export_desc": "Lưu một tệp để giữ lại hoặc gửi cho chính bạn", + "restore": "Khôi phục", + "restore_desc": "Đọc tệp sao lưu vào lại ứng dụng", + "restore_warning": "Các giá trị trong bản sao lưu sẽ ghi đè giá trị trên thiết bị này. Những gì không có trong tệp sẽ được giữ nguyên.", + "restored": "Đã khôi phục {} giá trị", + "not_a_backup": "Tệp đó không phải bản sao lưu Sozo", + "too_new": "Bản sao lưu đó được tạo bằng phiên bản ứng dụng mới hơn", + "export_failed": "Không tạo được bản sao lưu", + "restore_failed": "Không đọc được bản sao lưu đó", + "file_subject": "Bản sao lưu Sozo", + "last_export": "Sao lưu gần nhất: {}", + "never_exported": "Chưa có bản sao lưu nào trên thiết bị này.", + "reinstalling_sources": "Đang cài lại nguồn từ các kho…", + "repos_added": "Đã thêm kho: {}", + "plugins_installed": "Đã cài plugin: {}", + "prefs_restored": "Đã khôi phục cài đặt nguồn: {}", + "restore_done": "Đã khôi phục xong", + "restore_partial": "Đã khôi phục, còn thiếu một phần", + "missing_sources": "Các nguồn này không khôi phục được:", + "failed_repos": "Không truy cập được các kho này:" + }, + "provider_test": { + "title": "Kiểm tra nguồn", + "intro": "Chạy một lượt tìm kiếm thật, mở một phim và yêu cầu CDN gửi những byte đầu tiên. Kết quả chỉ đúng cho thiết bị và mạng này, vào lúc này.", + "summary": "{} trên {} phát được", + "run_all": "Kiểm tra hết", + "run_one": "Kiểm tra", + "stop": "Dừng", + "stage_catalogue": "Danh mục", + "stage_detail": "Phim", + "stage_stream": "Liên kết", + "stage_playback": "Phát", + "entry": "Kiểm tra nguồn" + }, + "catalog": { + "title": "Nguồn theo ngôn ngữ", + "subtitle": "Duyệt mọi nguồn tiện ích mở rộng trên cả bốn hệ sinh thái", + "type_all": "Tất cả", + "type_anime": "Anime", + "type_video": "Phim lẻ & bộ", + "type_manga": "Manga", + "type_novel": "Tiểu thuyết", + "add_repo": "Thêm kho", + "installed": "Đã thêm {} nguồn từ {}", + "install_empty": "Kho đó không có gì để cài.", + "install_failed": "Cài đặt thất bại: {}", + "needs_js": "Nguồn Dart — không chạy được ở đây", + "android_only": "Chỉ Android", + "empty": "Chưa có gì ở đây. Hãy thử ngôn ngữ khác hoặc xóa bộ lọc.", + "unreachable": "Không truy cập được danh mục nguồn." + }, + "mode": { + "video": "Xem", + "manga": "Manga", + "novel": "Tiểu thuyết", + "none_installed": "Chưa cài nguồn {} nào.", + "novels_hint": "Tiểu thuyết là chữ, không phải tranh — light novel và web novel, đọc bằng cùng trình đọc với manga. Nguồn tiểu thuyết đến từ các kho Mangayomi: thêm một kho là mọi nguồn tiểu thuyết trong đó sẽ hiện ở đây.", + "manga_hint": "Nguồn manga cần được cài, không có sẵn. Thêm một kho là mọi nguồn trong đó sẽ hiện ở đây." + }, + "home_rails": { + "title": "Bố cục Trang chủ", + "entry_title": "Tùy chỉnh Trang chủ", + "entry_subtitle": "Chọn hàng hiển thị và thứ tự của chúng", + "hint": "Kéo để sắp xếp lại. Nhấn biểu tượng mắt để ẩn một hàng — hàng vẫn giữ vị trí, nên khi bật lại sẽ về đúng chỗ cũ.", + "hero": "Banner nổi bật", + "resume": "Xem tiếp", + "new_releases": "Tập mới", + "genres": "Thể loại", + "picked_for_you": "Chọn cho bạn", + "live_tv": "TV trực tiếp", + "catalogue": "Các hàng danh mục", + "services": "Dịch vụ phát trực tuyến", + "watch_services": "Dịch vụ phát trực tuyến" + }, + "stats": { + "title": "Thống kê", + "entry_subtitle": "Thời gian xem, chuỗi ngày, theo nguồn", + "watched": "Đã xem", + "hours_short": "giờ", + "minutes_short": "phút", + "since": "Tính từ {}", + "completed": "Đã xong", + "streak": "Chuỗi ngày", + "last_days": "{} ngày qua", + "by_source": "Theo nguồn", + "empty_title": "Chưa có số liệu", + "empty_subtitle": "Hãy xem gì đó để bắt đầu tính. Nội dung xem ở chế độ ẩn danh không được tính." + }, + "discord": { + "sign_in": "Đăng nhập bằng Discord", + "sign_in_desc": "Đăng nhập trên trang của chính Discord. Token được lấy tự động và chỉ lưu trên thiết bị này.", + "or_paste": "hoặc tự dán token", + "web_login_title": "Kết nối Discord", + "web_login_paste": "Dán token", + "web_login_paste_title": "Dán token Discord", + "web_login_paste_hint": "Token người dùng Discord của bạn", + "title": "Discord", + "what_it_does": "Hiện nội dung bạn đang xem trên hồ sơ Discord — tên phim, tập và tiến độ. Bạn bè sẽ thấy trong danh sách bạn bè của họ.", + "enable": "Hiện trên Discord", + "enable_desktop_desc": "Cần ứng dụng Discord đang chạy trên máy tính này", + "enable_mobile_desc": "Cần token Discord của bạn — hãy đọc cảnh báo bên dưới trước", + "connected": "Đã kết nối", + "not_connected": "Chưa kết nối", + "risk_title": "Hãy đọc trước khi tiếp tục", + "risk_body": "Trên điện thoại không có ứng dụng Discord để kết nối, nên Sozo phải đăng nhập thay bạn bằng token của chính bạn. Discord không cho phép việc này: tài khoản của bạn có thể bị khóa. Token cho toàn quyền truy cập tài khoản Discord, và Sozo chỉ lưu nó trên thiết bị này — không bao giờ lưu trên máy chủ. Bạn có thể xóa bất cứ lúc nào bằng nút Quên.", + "token_label": "Token Discord", + "token_hint": "Dán token của bạn", + "token_saved": "Đã lưu token — dán token mới để thay thế", + "token_save": "Lưu", + "token_forget": "Quên", + "brand_title": "Rich Presence", + "brand_subtitle": "Bạn bè thấy bạn đang xem gì, trực tiếp trên hồ sơ của bạn", + "preview_heading": "ĐANG XEM", + "preview_title": "Tên phim hiện ở đây", + "preview_subtitle": "Phần và tập", + "preview_elapsed": "Đã xem {}", + "preview_elapsed_placeholder": "Thời gian đã xem", + "row_off": "Tắt", + "row_live": "Đang hiện trên hồ sơ", + "row_waiting": "Bật — đang chờ Discord" + }, + "stream_warning": { + "dolby_vision": "Dolby Vision — hình ảnh có thể ngả xanh lá hoặc tím trên thiết bị này", + "atmos": "Âm thanh Atmos — thiết bị này có thể không phát ra tiếng", + "indirect_host": "Bản sao này mở một trang web thay vì video", + "uhd": "4K — nặng hơn khi giải mã và tải xuống", + "large_file": "Tệp lớn — khởi động chậm trên dữ liệu di động", + "unreachable": "Liên kết này không phản hồi khi kiểm tra" + }, + "update": { + "available_title": "Đã có phiên bản mới (v{})", + "available_body": "Đã có phiên bản mới của ứng dụng.", + "later": "Để sau", + "now": "Cập nhật", + "open_store": "Mở trong App Store", + "required_title": "Cần cập nhật", + "required_body": "Vui lòng cài phiên bản mới (v{}) để tiếp tục dùng ứng dụng.", + "exit_app": "Thoát ứng dụng", + "downloading": "Đang tải bản cập nhật…" + }, + "ext": { + "cloudstream_title": "Nguồn CloudStream", + "aniyomi_title": "Nguồn Aniyomi", + "mangayomi_title": "Nguồn Mangayomi", + "android_only": "Tính năng này chỉ có trên Android", + "needs_webview": "Tiện ích JavaScript cần WebView, nhưng nền tảng này không có.", + "check_updates": "Kiểm tra cập nhật", + "check_extension_updates": "Kiểm tra cập nhật tiện ích", + "installed_sources": "NGUỒN ĐÃ CÀI", + "installed_repos": "KHO ĐÃ CÀI", + "sources_heading": "NGUỒN", + "recommended": "ĐỀ XUẤT", + "repo_url": "URL kho", + "index_url": "URL chỉ mục", + "browse_plugins": "Duyệt plugin", + "no_sources_yet": "Chưa có nguồn", + "no_repos_yet": "Chưa có kho", + "add_source": "Thêm nguồn", + "add_repo": "Thêm kho", + "installing": "Đang cài…", + "installing_extensions": "Đang cài tiện ích…", + "installing_plugins": "Đang cài plugin…", + "installing_progress": "Đang cài {} / {}", + "installing_plugins_progress": "Đang cài {} / {} plugin…", + "installing_extensions_progress": "Đang cài {} / {} tiện ích…", + "installing_long": "Đang cài tiện ích… kho lớn có thể mất một lúc.", + "checking_updates": "Đang kiểm tra cập nhật…", + "checking_extension_updates": "Đang kiểm tra cập nhật tiện ích…", + "all_up_to_date": "Mọi tiện ích đều đã mới nhất.", + "updated_count": "Đã cập nhật {} tiện ích.", + "updated_list": "Đã cập nhật {}: {}", + "update_check_failed": "Kiểm tra cập nhật thất bại: {}", + "source_removed": "Đã xóa nguồn.", + "repo_removed": "Đã xóa kho.", + "none_found": "Không tìm thấy tiện ích ở URL đó.", + "none_found_js": "Không tìm thấy tiện ích JavaScript ở URL đó.", + "added_sources": "Đã thêm {} nguồn.", + "added_sources_providers": "Đã thêm {} kho · {} nguồn.", + "installed_from_file": "Đã cài {} nguồn.", + "could_not_install": "Không cài được: {}", + "could_not_install_plugin": "Không cài được plugin", + "could_not_remove_plugin": "Không gỡ được plugin", + "error": "Lỗi: {}", + "cloudstream_desc": "Thêm một kho CloudStream rồi chỉ cài plugin bạn muốn — chỉ những plugin đó mới hiện thành nguồn. Chúng chạy trên thiết bị của bạn (chỉ Android).", + "aniyomi_desc": "Thêm kho tiện ích Aniyomi để cài thêm nguồn anime. Chúng chạy trên thiết bị của bạn (chỉ Android).", + "mangayomi_desc": "Tiện ích Mangayomi là JavaScript, không phải ứng dụng Android — nên khác với nguồn CloudStream, Aniyomi và Manga, chúng cũng chạy trên iOS, macOS và Windows.", + "plugins_summary": "{} plugin · đã cài {}", + "install_all": "Cài tất cả", + "repo_load_failed": "Không tải được kho này.", + "no_plugins": "Không tìm thấy plugin trong kho này", + "search_plugins": "Tìm plugin…", + "no_matches": "Không có kết quả", + "import_title": "Cài kho tiện ích?", + "import_pick_library": "Kho anime và manga dùng cùng định dạng tệp, nên hãy chọn thư viện cho kho này.", + "import_js_only": "Trên nền tảng này chỉ cài được tiện ích Mangayomi (JavaScript) — kho CloudStream, Aniyomi và Mihon chứa gói ứng dụng Android.", + "import_none_found": "Không tìm thấy tiện ích trong tệp này. Có thể tệp dành cho ứng dụng khác hoặc đã chọn sai loại.", + "mangayomi_url_only": "Kho Mangayomi được cài từ URL — hãy mở liên kết chỉ mục thay vì bản đã tải về.", + "skipped_dart": "Đã bỏ qua {} nguồn chỉ dùng Dart — các nguồn đó tích hợp sẵn trong Mangayomi và không chạy được ở đây.", + "type_manga": "Manga", + "type_anime": "Anime", + "type_novel": "Tiểu thuyết", + "settings": "Cài đặt nguồn", + "settings_title": "Cài đặt {}", + "settings_reset": "Đặt lại", + "settings_failed": "Không đọc được cài đặt của nguồn này.", + "settings_none": "Nguồn này không có cài đặt.", + "loading_repo": "Đang tải kho…", + "add_sources": "Thêm nguồn", + "choose_sources": "Chọn nguồn", + "select_all": "Chọn tất cả", + "select_none": "Bỏ chọn tất cả", + "install_selected": "Cài đặt ({})", + "already_installed": "Đã cài", + "remove": "Gỡ bỏ", + "remove_source": "Gỡ nguồn", + "remove_source_confirm": "Gỡ {}? Cài đặt và mã đã lưu của nguồn sẽ bị xóa. Bạn có thể thêm lại từ kho của nó.", + "remove_repo_confirm": "Gỡ kho này và tất cả nguồn của nó?", + "enable_source": "Bật", + "disable_source": "Tắt", + "source_disabled": "Đã tắt", + "update": "Cập nhật", + "update_all": "Cập nhật tất cả", + "updates_available": "Có {} bản cập nhật", + "repo_check_failed": "Không thể kiểm tra {}: {}" + }, + "ux": { + "quick_search": "Tìm nhanh ({})", + "quick_search_note": "Tìm nhanh bao gồm {} trên {} nguồn. Hãy chọn nguồn để tìm một nguồn cụ thể.", + "torrents": "Torrent", + "filters": "Bộ lọc", + "change_source": "Đổi nguồn", + "manage_sources": "Quản lý nguồn", + "no_source_match": "Không có nguồn nào khớp với tìm kiếm.", + "download_options": "Tùy chọn tải xuống", + "download_options_note": "Chọn nguồn và chất lượng có sẵn. Âm thanh theo nguồn đã chọn.", + "source_number": "Nguồn {}", + "checking_quality": "Đang kiểm tra chất lượng có sẵn…", + "quality_unavailable": "Không kiểm tra được chất lượng. Hãy thử lại hoặc tải luồng gốc mà không đảm bảo chất lượng.", + "quality_unknown": "Không rõ chất lượng", + "single_quality": "Chỉ có một chất lượng", + "size_unknown": "Nguồn không cung cấp dung lượng", + "interface_language_note": "Chỉ giao diện ứng dụng thay đổi. Tên phim, mô tả và nội dung khác từ nguồn vẫn giữ ngôn ngữ gốc.", + "continue": "Tiếp tục", + "use_source": "Dùng nguồn này", + "in_use": "Đang dùng", + "now_using": "Đang dùng {}", + "add_or_manage_sources": "Thêm hoặc quản lý nguồn" + }, + "sources": { + "fail_gone": "Trang của nguồn này không còn nữa", + "fail_blocked": "Trang này đang chặn ứng dụng", + "fail_rate_limited": "Trang này từ chối vì vượt giới hạn yêu cầu", + "fail_unreachable": "Không truy cập được trang", + "fail_incompatible": "Nguồn này cần phiên bản ứng dụng mới hơn", + "fail_outdated": "Nguồn này cần cập nhật — trang đã thay đổi và phiên bản đã cài không đọc được nữa.", + "fail_broken": "Nguồn này tự gặp lỗi", + "eco_sozo": "Sozo", + "eco_cloudstream": "CloudStream", + "eco_aniyomi": "Aniyomi", + "eco_manga": "Manga", + "eco_mangayomi": "Mangayomi", + "eco_jellyfin": "Jellyfin", + "eco_all": "Tất cả", + "language_not_stated": "Không ghi ngôn ngữ · {}", + "installed": "Đã cài", + "section": "Nguồn", + "filter_title": "Hiện nguồn từ", + "repo_any": "Mọi kho", + "repo_pick": "Chọn một kho", + "filter_type": "Loại nguồn", + "filter_language": "Ngôn ngữ", + "health_down": "Lỗi", + "health_cloudflare": "Cloudflare", + "health_down_title": "Nguồn này có vẻ đã ngừng hoạt động", + "health_cloudflare_title": "Có bước kiểm tra Cloudflare", + "health_down_body": "Sozo kiểm tra trang của các tiện ích mỗi đêm và trang này đã lỗi. Nguồn vẫn được tìm, nhưng cuối cùng và với thời gian chờ ngắn, để biết khi nào hoạt động lại. Nếu nguồn chạy được với bạn, nhãn sẽ biến mất.", + "health_cloudflare_body": "Trang đã trả lời kiểm tra của chúng tôi bằng thử thách Cloudflare. Điều này thường vẫn chạy được trên điện thoại. Nếu nguồn yêu cầu xác minh, hãy hoàn tất một lần là sẽ tải được.", + "health_reason": "Lý do", + "health_reason_parked": "Tên miền đang bị bỏ trống hoặc rao bán", + "health_reason_dns": "Tên miền không còn phân giải được", + "health_reason_tls": "Chứng chỉ bảo mật của trang bị lỗi", + "health_reason_timeout": "Trang không phản hồi kịp", + "health_reason_unreachable": "Không truy cập được trang", + "health_reason_http_5xx": "Trang đang trả về lỗi máy chủ", + "health_reason_blocked": "Thử thách Cloudflare đã chặn việc kiểm tra", + "health_reason_slow_response": "Trang phản hồi rất chậm", + "health_reason_maintainer_down": "Người duy trì đã đánh dấu plugin là ngừng hoạt động", + "health_reason_maintainer_slow": "Người duy trì đã đánh dấu plugin là chậm", + "health_reason_unknown": "Lần kiểm tra máy chủ gần nhất cho thấy nguồn bị lỗi", + "health_checked": "Kiểm tra lần cuối {}", + "hide_down": "Ẩn nguồn ngừng hoạt động · {}", + "check_all": "Kiểm tra nguồn", + "check_bad_hint": "Ẩn nguồn ngừng hoạt động sẽ loại chúng khỏi các danh sách này; chúng tự quay lại khi hoạt động trở lại.", + "check_done": "Kiểm tra nguồn", + "check_hide_down": "Ẩn nguồn ngừng hoạt động", + "check_network": "Hầu hết nguồn đều không truy cập được — đó là do kết nối, không phải do nguồn. Chưa đánh dấu gì; hãy thử lại trên mạng ổn định.", + "check_retrying": "Đang thử lại các nguồn lỗi…", + "checking": "Đang kiểm tra {} trên {}…", + "d_ago": "{} ngày trước", + "h_ago": "{} giờ trước", + "health_down_body_device": "Lần kiểm tra từ điện thoại này cho thấy nguồn không hoạt động. Nguồn vẫn được tìm, nhưng cuối cùng, để biết khi nào hoạt động lại — và phản hồi mới sẽ xóa nhãn.", + "health_reason_device_dead": "Nguồn đã lỗi trên điện thoại này hơn một lần", + "health_reason_device_outdated": "Mã của nguồn không còn hợp với trang — bản cập nhật từ kho sẽ khắc phục", + "just_now": "vừa xong", + "min_ago": "{} phút trước", + "v_alive": "Hoạt động", + "v_blocked": "Bị chặn", + "v_dead": "Không hoạt động", + "v_empty": "Trống", + "v_empty_body": "Nguồn có trả lời, nhưng danh mục trả về trống. Đó thường là dấu hiệu của một trang đã chặn ứng dụng.", + "v_failing_body": "Lần kiểm tra gần nhất của nguồn này đã thất bại. Một lần lỗi có thể chỉ là ngẫu nhiên — hãy kiểm tra lại.", + "v_failing": "Không phản hồi", + "v_outdated": "Cần cập nhật", + "v_unknown": "Chưa kiểm tra", + "auto_sweep": "Kiểm tra từng ít một khi có Wi-Fi", + "check_now": "Kiểm tra ngay", + "check_shown": "Mọi nguồn đang hiện · {}", + "check_stale": "Mới và lâu chưa kiểm tra · {}", + "check_troubled": "Ngừng hoạt động và lỗi · {}", + "checking_one": "Đang kiểm tra…", + "never_checked": "Chưa kiểm tra trên điện thoại này", + "problems_only": "Chỉ hiện nguồn có vấn đề" + }, + "settings": { + "dns_title": "DNS", + "dns_subtitle": "Phân giải địa chỉ nguồn qua nhà cung cấp khác. Hữu ích khi trang bị chặn bằng DNS. Không phải VPN — không ẩn lưu lượng hay đổi IP của bạn.", + "dns_system": "Mặc định hệ thống", + "dns_cloudflare": "Cloudflare (1.1.1.1)", + "dns_google": "Google (8.8.8.8)", + "dns_adguard": "AdGuard (không lọc)", + "dns_quad9": "Quad9 (9.9.9.9)", + "dns_mullvad": "Mullvad", + "dns_fallback": "Không truy cập được máy chủ DNS đó nên vẫn đang dùng DNS hệ thống." + }, + "source_manager": { + "profile_hint": "Chọn, thêm và quản lý nguồn", + "manage_repositories": "Quản lý kho", + "manage": "Quản lý", + "browse": "Duyệt", + "catalog_title": "Duyệt nguồn", + "add_short": "Thêm", + "use_short": "Dùng", + "selection_hint": "Nhấn vào nguồn để dùng. Duyệt để xem trước nội dung.", + "recommended_repositories": "Kho đề xuất", + "load_more": "Tải thêm", + "added": "Đã thêm" + }, + "catalogue": { + "section": "Danh mục", + "anilist": "AniList", + "tmdb": "TMDB", + "found_on": "Tìm thấy trên {}", + "change_source": "Đổi", + "no_source": "Không nguồn nào của bạn có phim này. Hãy thêm nguồn có phim hoặc tìm ở các nguồn khác.", + "no_resolver": "Không thể mở phim trong danh mục tại đây.", + "find_source": "Tìm nguồn", + "finding_source": "Đang tìm nguồn…", + "hint_anilist": "Danh mục anime", + "hint_tmdb": "Phim lẻ & bộ", + "hint_anilist_manga": "Danh mục manga", + "hint_anilist_novel": "Light novel", + "miss_no_sources_of_kind": "Chưa cài nguồn loại này nên chưa tìm được gì. Hãy thêm một nguồn đọc được loại nội dung này.", + "miss_unreachable": "Các nguồn của bạn không phản hồi kịp. Điều đó không có nghĩa là chúng không có phim này — hãy thử lại.", + "miss_nothing_to_search": "Không có thông tin nào ở đây để tìm nguồn.", + "approximate_source": "Đoán gần đúng", + "approximate_source_hint": "Chưa cài nguồn light novel nên đã dùng nguồn manga này. Đây có thể là bản chuyển thể chứ không phải tiểu thuyết." + }, + "watch": { + "services_title": "Dịch vụ phát trực tuyến", + "services_entry_hint": "Xem Netflix, Disney+ và các dịch vụ khác có gì ở khu vực của bạn", + "services_search_hint": "Tìm dịch vụ", + "top_services": "Dùng nhiều nhất ở đây", + "all_services": "Tất cả dịch vụ", + "no_match": "Không có dịch vụ phù hợp.", + "type_movies": "Phim lẻ", + "type_series": "Phim bộ", + "empty_region_title": "Không có dịch vụ nào ở đây", + "empty_region_subtitle": "TMDB chưa có danh sách dịch vụ cho khu vực này.", + "region_fell_back": "Chưa có danh sách dịch vụ cho quốc gia của bạn nên đây là các dịch vụ tại Hoa Kỳ.", + "load_failed": "Không tải được các dịch vụ phát trực tuyến." + }, + "trakt": { + "connect_title": "Kết nối Trakt", + "connect": "Kết nối Trakt", + "connect_explainer": "Phim bạn xem được thêm vào lịch sử Trakt, còn danh sách muốn xem và đánh giá của bạn sẽ hiện ở đây.", + "connected_explainer": "Nội dung bạn xem được ghi nhận lên Trakt ngay khi xem.", + "step_open": "Trên thiết bị bất kỳ, mở trakt.tv/activate", + "step_enter": "Nhập mã này", + "copy_code": "Nhấn để sao chép", + "copied": "Đã sao chép", + "open_activate": "Mở trakt.tv/activate", + "waiting": "Đang chờ bạn chấp thuận…", + "reconnecting": "Mất kết nối — vẫn đang thử…", + "expires_in": "Hết hạn sau {}", + "code_expired": "Mã này đã hết hạn.", + "denied": "Truy cập đã bị từ chối trên Trakt.", + "new_code": "Lấy mã mới", + "unavailable": "Hiện không truy cập được Trakt.", + "connected_as": "Đã kết nối: {}", + "disconnect": "Ngắt kết nối Trakt", + "disconnect_confirm": "Sozo sẽ ngừng gửi nội dung bạn xem đến Trakt. Lịch sử Trakt của bạn vẫn giữ nguyên.", + "action_mark_next": "Đánh dấu đã xem {}", + "action_mark_next_episode": "Đánh dấu đã xem tập tiếp theo", + "action_mark_watched": "Đánh dấu đã xem", + "action_not_interested": "Không quan tâm", + "action_open": "Mở trong Sozo", + "action_rate": "Đánh giá của bạn", + "action_remove_history": "Xóa khỏi lịch sử", + "action_remove_playback": "Xóa khỏi mục Xem tiếp", + "action_watchlist_add": "Thêm vào danh sách muốn xem", + "action_watchlist_remove": "Xóa khỏi danh sách muốn xem", + "added": "Đã thêm vào danh sách muốn xem trên Trakt", + "all_caught_up": "Bạn đã xem hết các phim của mình.", + "continue_watching": "Xem tiếp", + "empty_calendar": "Không có phim nào của bạn chiếu trong hai tuần tới.", + "empty_history": "Lịch sử Trakt của bạn chưa có gì.", + "empty_ratings": "Bạn chưa đánh giá gì trên Trakt.", + "empty_recs": "Chưa có gợi ý — hãy đánh giá vài phim rồi quay lại.", + "empty_trending": "Trakt hiện không có phim thịnh hành.", + "empty_watchlist": "Danh sách muốn xem trên Trakt của bạn trống.", + "episodes_progress": "{} trên {} tập", + "filter_all": "Tất cả", + "filter_movies": "Phim lẻ", + "filter_shows": "Phim bộ", + "hub_connect_prompt": "Kết nối Trakt để xem danh sách muốn xem, lịch sử, tập tiếp theo và lịch chiếu sắp tới — ngay tại đây.", + "link_action": "Liên kết", + "link_change": "Liên kết với phim khác", + "link_explainer": "Chọn phim trên Trakt cho “{}”. Những gì bạn xem ở đây sẽ được tính cho phim đó.", + "link_none": "Không tìm thấy gì trên Trakt.", + "link_season": "Phần", + "link_season_all": "Tất cả các phần", + "link_season_n": "Phần {}", + "link_title": "Liên kết với Trakt", + "marked": "Đã đánh dấu đã xem trên Trakt", + "movie": "Phim lẻ", + "next_up": "Tiếp theo", + "open_hub": "Mở Trakt của tôi", + "open_hub_desc": "Tập tiếp theo, muốn xem, lịch sử, lịch chiếu và gợi ý", + "rated": "Đã chấm {}/10 trên Trakt", + "rating_cleared": "Đã xóa đánh giá", + "removed": "Đã xóa", + "season_premiere": "Mở đầu phần mới", + "series_premiere": "Tập đầu tiên của phim", + "show": "Phim bộ", + "stat_episodes": "Tập", + "stat_hours": "Giờ", + "stat_movies": "Phim lẻ", + "stat_ratings": "Đánh giá", + "status_episodes": "Đã xem {} / {}", + "status_not_watched": "Chưa xem", + "status_watched": "Đã xem", + "status_watchlist": "Trong danh sách muốn xem", + "tab_calendar": "Lịch chiếu", + "tab_for_you": "Cho bạn", + "tab_history": "Lịch sử", + "tab_ratings": "Đánh giá", + "tab_trending": "Thịnh hành", + "tab_up_next": "Tiếp theo", + "tab_watchlist": "Muốn xem", + "today": "Hôm nay", + "tomorrow": "Ngày mai", + "yesterday": "Hôm qua" + }, + "webview": { + "back": "Quay lại", + "forward": "Tiến tới", + "reload": "Tải lại", + "open_outside": "Mở trong trình duyệt" + }, + "profiles": { + "title": "Hồ sơ", + "who_watching": "Ai đang xem?", + "manage": "Quản lý hồ sơ", + "manage_subtitle": "Mỗi người có lịch sử, Danh sách của tôi và Xem tiếp riêng trên tài khoản này.", + "add": "Thêm hồ sơ", + "edit": "Sửa hồ sơ", + "new_title": "Hồ sơ mới", + "name": "Tên", + "name_hint": "Tên hồ sơ", + "name_required": "Hãy nhập tên", + "name_taken": "Đã có hồ sơ khác dùng tên này.", + "avatar": "Ảnh đại diện", + "color": "Màu sắc", + "kids": "Hồ sơ trẻ em", + "kids_badge": "TRẺ EM", + "kids_desc": "Nội dung và nguồn 18+ được ẩn, và không thể quản lý hồ sơ từ đây.", + "kids_default_blocked": "Hồ sơ chính không thể là hồ sơ trẻ em.", + "kids_locked_title": "Hãy hỏi người lớn", + "kids_locked_body": "Không thể thay đổi hồ sơ từ hồ sơ trẻ em. Hãy chuyển sang hồ sơ khác để quản lý.", + "adult_kids_off": "Luôn tắt trong hồ sơ trẻ em.", + "pin": "Mã PIN hồ sơ", + "pin_desc": "Yêu cầu mã PIN trước khi mở hồ sơ này.", + "pin_on": "Bật", + "pin_off": "Tắt", + "pin_protected": "PIN", + "pin_change": "Đổi mã PIN", + "pin_remove": "Xóa mã PIN", + "pin_enter_title": "Nhập mã PIN", + "pin_enter_subtitle": "Nhập mã PIN của {}.", + "pin_create_title": "Chọn mã PIN", + "pin_create_subtitle": "4 chữ số.", + "pin_confirm_title": "Xác nhận mã PIN", + "pin_confirm_subtitle": "Nhập lại mã PIN đó.", + "pin_current_title": "Mã PIN hiện tại", + "pin_current_subtitle": "Nhập mã PIN hiện tại của hồ sơ này để tiếp tục.", + "pin_mismatch": "Mã PIN không khớp", + "pin_wrong": "Sai mã PIN", + "pin_too_many": "Thử quá nhiều lần. Hãy thử lại sau một phút.", + "continue": "Tiếp tục", + "save": "Lưu", + "saved": "Đã lưu hồ sơ", + "delete": "Xóa hồ sơ", + "delete_title": "Xóa {}?", + "delete_body": "Lịch sử, Danh sách của tôi và các danh sách của hồ sơ này sẽ bị xóa trên mọi thiết bị. Không thể hoàn tác.", + "main_badge": "Chính", + "current": "Đang xem", + "limit": "Mỗi tài khoản có tối đa {} hồ sơ.", + "switch": "Đổi hồ sơ", + "switch_short": "Đổi", + "count": "{} hồ sơ", + "card_single": "Thêm hồ sơ cho những người dùng chung tài khoản này", + "load_failed": "Không tải được hồ sơ", + "offline": "Hãy kiểm tra kết nối và thử lại.", + "error_generic": "Đã xảy ra lỗi. Vui lòng thử lại.", + "on_this_device": "Trên thiết bị này", + "ask_on_launch": "Hỏi ai đang xem khi mở ứng dụng", + "ask_on_launch_desc": "Khi tắt, Sozo mở hồ sơ dùng gần nhất. Hồ sơ có mã PIN vẫn sẽ hỏi.", + "pin_locked_until": "Thử quá nhiều lần. Hãy thử lại sau {}.", + "pin_not_on_device": "Bạn đang ngoại tuyến và mã PIN của hồ sơ này chưa từng được nhập trên thiết bị này. Hãy kết nối Internet một lần để mở khóa.", + "biometric_reason": "Mở khóa {}", + "biometric_offer_title": "Dùng vân tay / Face ID?", + "biometric_offer_body": "Dùng vân tay / Face ID cho {} trên thiết bị này. Mã PIN vẫn dùng được.", + "biometric_offer_yes": "Dùng", + "biometric_offer_no": "Để sau", + "biometric_toggle": "Vân tay / Face ID cho {}" + }, + "jellyfin": { + "connect_server": "Kết nối máy chủ media (Jellyfin)", + "row_title": "Máy chủ Jellyfin", + "row_subtitle": "Xem phim của riêng bạn từ máy chủ Jellyfin", + "title": "Máy chủ media", + "servers_label": "MÁY CHỦ", + "connect": "Kết nối máy chủ", + "connect_title": "Kết nối với Jellyfin", + "connect_intro": "Nhập địa chỉ máy chủ Jellyfin và tài khoản bạn dùng trên đó. Sozo kết nối trực tiếp với máy chủ; không có gì đi qua máy chủ của Sozo.", + "connect_footnote": "Địa chỉ http:// thường dùng được trong mạng gia đình. Từ bên ngoài, hãy dùng địa chỉ https mà máy chủ của bạn công khai.", + "url_hint": "Địa chỉ máy chủ, vd: 192.168.1.20:8096", + "username_hint": "Tên người dùng", + "password_hint": "Mật khẩu (có thể để trống)", + "test_connection": "Kiểm tra kết nối", + "testing": "Đang kiểm tra…", + "test_ok": "Đã kết nối · {ms} ms", + "found_server": "Đã tìm thấy {name} · Jellyfin {version} · {ms} ms", + "sign_in": "Đăng nhập", + "sign_in_again": "Đăng nhập lại", + "signed_in_to": "Đã đăng nhập {name} bằng {user}", + "libraries_title": "Thư viện trên Trang chủ", + "libraries_label": "THƯ VIỆN", + "libraries_intro": "Mỗi thư viện đang bật có một hàng riêng trên Trang chủ. Tìm kiếm áp dụng cho toàn bộ máy chủ.", + "libraries_empty": "Tài khoản này không có thư viện phim nào trên máy chủ.", + "saved": "Đã thêm {name} vào các nguồn của bạn", + "use_now": "Xem ngay", + "empty_title": "Chưa có máy chủ", + "empty_body": "Kết nối máy chủ Jellyfin để duyệt và phát phim của riêng bạn, với tiến độ luôn được đồng bộ.", + "servers_footnote": "Mật khẩu chỉ được gửi đến máy chủ. Sozo lưu khóa phiên ở dạng mã hóa trên thiết bị này.", + "remove": "Xóa máy chủ", + "remove_confirm": "Xóa {name}? Bạn sẽ bị đăng xuất khỏi máy chủ đó trên thiết bị này.", + "continue_watching": "Xem tiếp", + "next_up": "Tiếp theo", + "quality_original": "Gốc", + "quality_transcode": "Chuyển mã", + "quality_auto": "Tự động (HLS)", + "empty_server": "Máy chủ này chưa có gì để hiển thị. Hãy kiểm tra thư viện nào đang bật trong Máy chủ media.", + "error_unreachable": "Không kết nối được máy chủ. Hãy kiểm tra địa chỉ và đảm bảo thiết bị này cùng mạng.", + "error_not_jellyfin": "Địa chỉ đó có phản hồi nhưng không phải máy chủ Jellyfin.", + "error_login": "Sai tên người dùng hoặc mật khẩu.", + "error_session": "Máy chủ không còn chấp nhận phiên của thiết bị này. Hãy đăng nhập lại trong Máy chủ media.", + "error_server": "Máy chủ trả về lỗi", + "error_server_removed": "Máy chủ Jellyfin này đã bị xóa khỏi Sozo.", + "error_no_stream": "Máy chủ không cung cấp luồng phát được cho mục này.", + "error_url": "Hãy nhập địa chỉ máy chủ.", + "error_username": "Hãy nhập tên người dùng." + }, + "automation": { + "title": "Tự động hóa", + "entry_subtitle": "Tự động tải xuống, dọn dẹp và phát tiếp", + "section_downloads": "Tự động tải xuống", + "auto_download": "Tải xuống tập mới", + "auto_download_desc": "Cho các phim đang theo dõi mà bạn đã bật, khi cập nhật thư viện tìm thấy tập mới", + "wifi_only": "Chỉ Wi-Fi", + "wifi_only_desc": "Không dùng dữ liệu di động để tự động tải xuống", + "keep_last": "Giữ mới nhất", + "keep_last_value": "{} mỗi phim", + "storage_cap": "Giới hạn dung lượng", + "storage_cap_desc": "Dừng khi bản tải xuống chiếm đến mức này", + "no_limit": "Không giới hạn", + "gb": "{} GB", + "titles": "Phim", + "titles_value": "Bật {}", + "titles_desc": "Bật cho từng phim trong mục Đang theo dõi", + "status_never": "Chưa chạy lần nào", + "status_queued": "Lần chạy cuối {}: đã xếp hàng {}", + "status_nothing": "Lần chạy cuối {}: không có gì mới", + "hold_wifi": "Đang chờ Wi-Fi", + "hold_cap": "Đã đạt giới hạn dung lượng", + "hold_busy": "Đang chờ các bản tải khác bắt đầu", + "skipped_n": "{} tập cần được phát một lần trước khi tải xuống được", + "library_off": "Cập nhật thư viện đang tắt nên không có gì được kiểm tra. Hãy bật trong Cài đặt.", + "section_cleanup": "Dọn dẹp", + "delete_watched": "Xóa bản tải đã xem", + "delete_watched_desc": "Chỉ áp dụng cho bản tự động tải. Những gì bạn tự lưu vẫn được giữ.", + "section_playback": "Phát tiếp", + "prefetch_episode": "Chuẩn bị tập tiếp theo", + "prefetch_episode_desc": "Gần hết tập, tìm trước tập tiếp theo để bắt đầu nhanh hơn", + "up_next_prompt": "Nhắc tập tiếp theo", + "up_next_prompt_desc": "Đếm ngược trước tập tiếp theo, có nút Hủy", + "seconds": "{} giây", + "prefetch_chapter": "Tải trước chương sau", + "prefetch_chapter_desc": "Tải những trang đầu của chương sau trong lúc bạn đọc", + "up_next": "Tiếp theo", + "play_now": "Phát ngay", + "episode_n": "Tập {}", + "notify_title": "Đã tải xuống cho bạn", + "notify_body": "Đang tải xuống {} tập mới", + "follow_auto_on": "Tập mới của {} sẽ tự động tải xuống", + "follow_auto_off": "Đã tắt tự động tải xuống cho {}", + "follow_auto_tooltip": "Tự động tải xuống tập mới", + "global_off": "Tự động tải xuống đang tắt", + "open_settings": "Cài đặt", + "auto_chip": "Tự động tải" + }, + "social": { + "title": "Bạn bè", + "tab_feed": "Hoạt động", + "tab_friends": "Bạn bè", + "tab_requests": "Lời mời", + "hub_subtitle": "Xem bạn bè đang xem và đọc gì", + "hub_requests": "{} mới", + "privacy_title": "Bạn bè & quyền riêng tư", + "privacy_entry_subtitle": "Chia sẻ hoạt động, hiển thị hồ sơ, người đã chặn", + "search_title": "Tìm người", + "search_hint": "Tìm theo tên người dùng", + "search_intro_title": "Tìm bạn bè của bạn", + "search_intro_body": "Nhập ít nhất 2 chữ cái của tên người dùng. Hồ sơ riêng tư sẽ không hiện.", + "search_empty": "Không tìm thấy ai", + "search_empty_body": "Hãy kiểm tra chính tả hoặc hỏi họ tên người dùng chính xác.", + "action_add": "Kết bạn", + "action_requested": "Đã gửi", + "action_accept": "Chấp nhận", + "action_decline": "Từ chối", + "action_cancel_request": "Hủy lời mời", + "action_friends": "Bạn bè", + "action_remove": "Hủy kết bạn", + "action_block": "Chặn", + "action_unblock": "Bỏ chặn", + "action_closed": "Không nhận lời mời", + "remove_title": "Hủy kết bạn với {}?", + "remove_body": "Hai bạn sẽ không còn thấy hoạt động của nhau. Họ sẽ không nhận được thông báo.", + "block_title": "Chặn {}?", + "block_body": "Họ sẽ không thể tìm thấy bạn, xem hồ sơ hay gửi lời mời cho bạn, và mọi quan hệ bạn bè giữa hai người sẽ chấm dứt. Họ sẽ không nhận được thông báo.", + "cancel_request_title": "Hủy lời mời kết bạn?", + "cancel_request_body": "Lời mời của bạn gửi đến {} sẽ bị thu hồi.", + "sent_snack": "Đã gửi lời mời kết bạn", + "accepted_snack": "Bạn và {} đã là bạn bè", + "declined_snack": "Đã từ chối lời mời", + "removed_snack": "Đã hủy kết bạn", + "blocked_snack": "Đã chặn {}", + "unblocked_snack": "Đã bỏ chặn {}", + "requests_incoming": "Đã nhận", + "requests_outgoing": "Đã gửi", + "requests_empty_title": "Không có lời mời đang chờ", + "requests_empty_body": "Lời mời kết bạn bạn gửi hoặc nhận sẽ hiện ở đây.", + "friends_empty_title": "Chưa có bạn bè", + "friends_empty_body": "Tìm người theo tên người dùng và gửi lời mời cho họ.", + "find_friends": "Tìm bạn bè", + "friends_since": "Đã kết bạn {}", + "feed_empty_title": "Hiện chưa có gì", + "feed_empty_body": "Khi bạn bè chia sẻ những gì họ xem và đọc, nội dung sẽ hiện ở đây.", + "feed_no_friends_title": "Bảng tin của bạn trống", + "feed_no_friends_body": "Kết bạn để xem họ đang xem và đọc gì.", + "share_nudge": "Hoạt động của bạn đang riêng tư. Bạn bè không thấy bạn xem gì.", + "share_nudge_action": "Chia sẻ", + "verb_watched": "đã xem", + "verb_finished": "đã xem xong", + "verb_read": "đã đọc", + "verb_favorited": "đã thêm vào yêu thích", + "verb_planned": "định xem", + "verb_completed": "đã hoàn thành", + "verb_achieved": "đã nhận huy hiệu", + "episode_one": "Tập {}", + "episode_range": "Tập {}–{}", + "chapter_one": "Chương {}", + "chapter_range": "Chương {}–{}", + "activity_delete": "Xóa khỏi hoạt động", + "activity_delete_body": "Bạn bè sẽ không còn thấy mục này.", + "activity_hidden_title": "Hoạt động đang riêng tư", + "activity_hidden_body": "Chỉ những người họ cho phép mới thấy họ xem và đọc gì.", + "activity_hidden_friend": "Họ chưa bật chia sẻ hoạt động.", + "profile_activity": "Hoạt động gần đây", + "profile_activity_empty": "Không có hoạt động gần đây", + "profile_activity_empty_self": "Bật chia sẻ trong Bạn bè & quyền riêng tư, những gì bạn xem từ giờ sẽ hiện ở đây.", + "profile_self": "Đây là cách người khác thấy bạn", + "profile_unavailable": "Hồ sơ không khả dụng", + "profile_unavailable_body": "Tài khoản này không tồn tại hoặc bạn không thể xem.", + "section_activity": "Hoạt động", + "section_profile": "Hồ sơ", + "share_title": "Chia sẻ hoạt động của tôi", + "share_desc": "Cho bạn bè thấy bạn xem và đọc gì. Mặc định tắt.", + "share_private_note": "Hồ sơ của bạn đang đặt là Chỉ mình tôi nên không có gì được chia sẻ.", + "share_footnote": "Phiên ẩn danh và phim trong danh sách riêng tư không bao giờ được chia sẻ. Chỉ lượt xem trong 24 giờ qua được đăng, và các mục sẽ biến mất sau 90 ngày.", + "share_footnote_profile": "Chỉ hồ sơ chính của tài khoản mới chia sẻ hoạt động. Những gì bạn xem trên hồ sơ này không bao giờ được chia sẻ.", + "share_on_title": "Chia sẻ hoạt động của bạn?", + "share_on_body": "Từ giờ, bạn bè sẽ thấy các phim và truyện bạn xem, đọc, cùng những mục bạn thêm vào yêu thích, định xem hoặc đã hoàn thành. Bao gồm cả lượt xem trên TV đã ghép nối. Phiên ẩn danh và danh sách riêng tư không bao giờ được chia sẻ.", + "share_on_confirm": "Chia sẻ", + "share_off_title": "Ngừng chia sẻ?", + "share_off_body": "Bạn bè sẽ không còn thấy hoạt động của bạn, và mọi thứ đã chia sẻ sẽ bị xóa.", + "share_off_confirm": "Ngừng chia sẻ", + "my_activity": "Hoạt động đã chia sẻ", + "my_activity_desc": "Xem hoặc xóa những gì bạn bè có thể thấy", + "visibility_title": "Ai có thể xem hồ sơ của tôi", + "visibility_public": "Mọi người", + "visibility_friends": "Chỉ bạn bè", + "visibility_private": "Chỉ mình tôi", + "visibility_public_desc": "Bất kỳ ai cũng có thể tìm thấy bạn và xem hoạt động của bạn khi bật chia sẻ.", + "visibility_friends_desc": "Bất kỳ ai cũng có thể tìm thấy bạn, nhưng chỉ bạn bè thấy hoạt động của bạn.", + "visibility_private_desc": "Bạn bị ẩn khỏi tìm kiếm và không ai thấy hoạt động của bạn. Bạn bè hiện có vẫn giữ nguyên.", + "requests_title": "Ai có thể gửi lời mời cho tôi", + "requests_everyone": "Mọi người", + "requests_nobody": "Không ai", + "blocked_title": "Người đã chặn", + "blocked_empty": "Chưa chặn ai", + "blocked_empty_body": "Người bị bạn chặn không thể tìm thấy bạn hay gửi lời mời.", + "error_network": "Không kết nối được máy chủ. Hãy kiểm tra kết nối.", + "error_signin": "Đăng nhập để dùng Bạn bè.", + "error_not_found": "Không tìm thấy người dùng", + "error_requests_closed": "Người này không nhận lời mời kết bạn", + "error_already_friends": "Hai bạn đã là bạn bè", + "error_friend_limit": "Bạn đã đạt giới hạn số bạn bè", + "error_too_many_pending": "Quá nhiều lời mời đang chờ. Hãy hủy bớt trước.", + "error_rate_limited": "Thử quá nhiều lần. Hãy đợi một lát rồi thử lại.", + "error_generic": "Đã xảy ra lỗi. Hãy thử lại." + }, + "recap": { + "action": "Tóm tắt", + "action_hint": "Nắm lại mọi diễn biến trước tập {}", + "previously_on": "Diễn biến trước", + "up_to": "Đến {}", + "code_season_episode": "Phần {} · Tập {}", + "code_episode": "Tập {}", + "season_n": "Phần {}", + "loading": "Đang tóm tắt cho bạn…", + "spoiler_safe": "Không tiết lộ: không có gì từ {} trở đi.", + "covers": "Các tập đã tóm tắt", + "source": "Tóm tắt nội dung: {}", + "ai_note": "AI tóm tắt từ các nội dung này", + "unavailable_title": "Chưa có tóm tắt cho phim này", + "unavailable": "Không đủ tóm tắt từng tập để tổng hợp an toàn, nên chúng tôi sẽ không đoán.", + "error": "Không tải được tóm tắt" + }, + "release_notify": { + "channel_general": "Thông báo", + "channel_general_desc": "Tài khoản, bạn bè và tin chung", + "channel_releases": "Tập mới", + "channel_releases_desc": "Tập và chương mới của nội dung bạn theo dõi", + "channel_airing": "Nhắc lịch chiếu", + "channel_airing_desc": "Ngay trước khi tập bạn theo dõi được chiếu", + "channel_quiet": "Giờ yên tĩnh", + "channel_quiet_desc": "Gửi không tiếng trong giờ yên tĩnh của bạn", + "episode_one": "Đã có tập {}", + "episodes_many": "{} tập mới · đến tập {}", + "chapter_one": "Đã có chương {}", + "chapters_many": "{} chương mới · đến chương {}", + "action_watch": "Xem", + "action_read": "Đọc", + "action_seen": "Đánh dấu đã xem", + "summary_title": "Tập mới", + "summary_more": "{} phim có nội dung mới", + "badge_episode": "TẬP MỚI", + "badge_chapter": "CHƯƠNG MỚI", + "ep_short": "Tập", + "ch_short": "Chương", + "test_body": "Thông báo đang hoạt động. Chúng tôi sẽ báo khi có nội dung mới.", + "priming_title": "Không bỏ lỡ tập mới nào", + "priming_body": "Sozo báo ngay khi nội dung bạn theo dõi có tập hoặc chương mới — và không gửi gì bạn chưa yêu cầu.", + "priming_body_title": "Nhận thông báo ngay khi {} có tập mới — và không gửi gì bạn chưa yêu cầu.", + "priming_download_title": "Biết khi nào tải xuống hoàn tất", + "priming_download_body": "Sozo hiển thị tiến trình tải xuống trong thông báo và báo cho bạn khi có thể xem ngoại tuyến. Sozo cũng có thể báo tập mới của những gì bạn theo dõi.", + "priming_point_releases": "Tập mới, kèm áp phích", + "priming_point_quiet": "Giờ yên tĩnh", + "priming_point_control": "Tắt tiếng từng phim", + "priming_turn_on": "Bật thông báo", + "priming_not_now": "Để sau", + "priming_blocked_title": "Thông báo của Sozo đang tắt", + "priming_blocked_body": "Android sẽ không hỏi lại. Hãy bật trong cài đặt hệ thống rồi quay lại — chúng tôi sẽ nhận ra.", + "priming_open_settings": "Mở cài đặt", + "priming_enabled": "Mọi thứ đã sẵn sàng", + "priming_sample_show": "Phim yêu thích tiếp theo của bạn", + "follow": "Theo dõi", + "following": "Đang theo dõi", + "muted": "Đã tắt tiếng", + "follow_tooltip": "Theo dõi để nhận tập mới", + "notify_on": "Báo cho tôi khi có tập mới", + "notify_on_reading": "Báo cho tôi khi có chương mới", + "notify_on_desc": "Dù thế nào vẫn giữ theo dõi", + "unfollow": "Bỏ theo dõi", + "mute": "Tắt thông báo", + "unmute": "Bật lại thông báo", + "muted_snack": "Đã tắt thông báo cho {}", + "unmuted_snack": "Đã bật thông báo cho {}", + "callout_new_episode": "Tập mới", + "callout_new_episodes": "{} tập mới", + "callout_new_chapter": "Chương mới", + "callout_new_chapters": "{} chương mới", + "callout_watch": "Xem tập {}", + "callout_read": "Đọc chương {}", + "feed_title": "Mới phát hành", + "feed_today": "Hôm nay", + "feed_yesterday": "Hôm qua", + "feed_week": "Tuần này", + "feed_earlier": "Trước đó", + "feed_empty_title": "Chưa có gì mới", + "feed_empty_body": "Theo dõi một phim hoặc manga, tập và chương mới sẽ hiện ở đây ngay khi ra mắt.", + "feed_check": "Kiểm tra ngay", + "feed_mark_all": "Đánh dấu đã xem hết", + "feed_remove": "Xóa", + "feed_removed": "Đã xóa khỏi bảng tin", + "feed_none_found": "Hiện chưa có gì mới", + "feed_watch": "Xem", + "feed_read": "Đọc", + "feed_card_new": "{} có nội dung mới", + "feed_card_none": "Bạn đã xem hết rồi", + "ep_one": "Tập {}", + "ep_range": "Tập {}–{}", + "ch_one": "Chương {}", + "ch_range": "Chương {}–{}", + "mode_video": "Phim bộ", + "mode_manga": "Manga", + "mode_novel": "Tiểu thuyết", + "soon": "sắp có", + "in_hours": "sau {} giờ", + "in_days": "sau {} ngày", + "badge_new": "MỚI", + "home_rail": "Tập mới cho bạn", + "settings_title": "Thông báo", + "settings_entry_subtitle": "Tập mới, nhắc nhở, giờ yên tĩnh", + "master": "Cho phép thông báo", + "master_desc": "Bật hoặc tắt mọi thông báo của Sozo", + "section_types": "Báo cho tôi về", + "type_releases": "Tập & chương mới", + "type_releases_desc": "Từ nội dung bạn theo dõi", + "type_airing": "Nhắc lịch chiếu", + "type_airing_desc": "Trước và khi tập phim lên sóng, từ AniList", + "type_friends": "Bạn bè & trả lời", + "type_friends_desc": "Lời mời kết bạn, trả lời và lượt thích", + "type_streak": "Nhắc chuỗi ngày", + "type_streak_desc": "Trước khi chuỗi của bạn hết hạn", + "type_announcements": "Thông báo chung", + "type_announcements_desc": "Tin tức và cập nhật từ Sozo", + "section_quiet": "Giờ yên tĩnh", + "quiet": "Giờ yên tĩnh", + "quiet_desc": "Không rung ban đêm; thông báo đến không tiếng", + "quiet_from": "Từ", + "quiet_to": "Đến", + "section_checks": "Kiểm tra tập mới", + "check_background_note": "Trên Android, Sozo cũng kiểm tra ngầm vài giờ một lần khi bạn có mạng và pin không yếu. Nguồn tiện ích được kiểm tra khi ứng dụng đang mở.", + "permission": "Quyền hệ thống", + "permission_on": "Đã cho phép thông báo", + "permission_off": "Thông báo đang tắt", + "permission_blocked": "Bị chặn trong cài đặt hệ thống", + "permission_fix": "Bật", + "test": "Gửi thông báo thử", + "test_desc": "Xem thông báo tập mới trông thế nào", + "test_sent": "Đã gửi thông báo thử", + "airing_needs_anilist": "Kết nối AniList trong mục Kết nối để nhận nhắc lịch chiếu" + }, + "achievements": { + "title": "Thành tích", + "new_badge": "Huy hiệu mới", + "rare": "Hiếm", + "tier_n": "Hạng {}", + "tier_locked": "Đã khóa", + "tier_bronze": "Đồng", + "tier_silver": "Bạc", + "tier_gold": "Vàng", + "tier_ember": "Than hồng", + "share": "Chia sẻ", + "great": "Tuyệt!", + "see_all": "Xem mọi thành tích", + "also_earned": "Cũng đạt được", + "share_text": "Tôi vừa đạt “{}” trên Sozo", + "next_at": "Tiếp theo: {} ở mốc {}", + "next_tiers": "Hạng tiếp theo", + "singles": "Độc nhất", + "streak_days": "{} ngày liên tiếp", + "streak_best": "Kỷ lục: {} ngày", + "days_short": "{} ngày", + "streak_to_go": "Còn {} ngày nữa đến {}", + "streak_maxed": "Bạn đã có mọi huy hiệu ngọn lửa", + "unlocked_n": "Đã mở {} trên {}", + "showcase": "Trưng bày", + "showcase_edit": "Sửa", + "showcase_empty": "Huy hiệu bạn chọn sẽ hiện cạnh tên bạn — trên hồ sơ và với bạn bè.", + "showcase_hint": "Tối đa sáu, hiện cạnh tên bạn.", + "maxed": "Đã đạt mọi hạng", + "to_next": "{}: còn {}", + "signed_out": "Đăng nhập để nhận huy hiệu cho những gì bạn xem và đọc, và giữ chúng trên mọi thiết bị.", + "sign_in": "Đăng nhập", + "entry_hint": "Huy hiệu cho chuỗi ngày, số tập và số chương", + "earned_on": "Đạt được {}", + "not_yet": "Chưa đạt được", + "save": "Lưu", + "save_failed": "Không lưu được. Hãy thử lại.", + "streak_note": "Chuỗi dài nhất của bạn mới là thứ được tính: huy hiệu đã đạt sẽ được giữ dù chuỗi bị đứt. Lượt đóng băng giúp chuỗi tiếp tục.", + "name_streak": "Ngọn lửa", + "name_episodes": "Thợ săn tập phim", + "name_series_done": "Xem đến cùng", + "name_movies": "Mọt phim", + "name_chapters": "Mê lật trang", + "name_manga_done": "Bậc thầy manga", + "name_novel_chapters": "Mọt sách", + "name_active_days": "Trung thành", + "name_iron_will": "Ý chí thép", + "name_binge": "Cày phim", + "name_night_owl": "Cú đêm", + "name_party_host": "Chủ phòng", + "name_explorer": "Nhà thám hiểm", + "name_friendly": "Thân thiện", + "unit_streak": "{} ngày liên tiếp", + "unit_episodes": "{} tập", + "unit_series_done": "{} phim bộ đã xem xong", + "unit_movies": "{} phim lẻ", + "unit_chapters": "{} chương manga", + "unit_manga_done": "{} manga đã đọc xong", + "unit_novel_chapters": "{} chương tiểu thuyết", + "unit_active_days": "{} ngày hoạt động", + "rule_iron_will": "Chuỗi 100 ngày không cần lượt đóng băng nào", + "rule_binge": "10 tập của một phim trong một ngày", + "rule_night_owl": "Xem xong một tập từ nửa đêm đến 4 giờ sáng", + "rule_party_host": "Làm chủ phòng xem chung", + "rule_explorer": "Xem xong nội dung trên 10 nguồn khác nhau", + "rule_friendly": "Kết bạn với 5 người", + "viewer_hint": "Kéo hoặc nghiêng điện thoại · nhấn đúp để lật" + }, + "home_widget": { + "next": "Tập tiếp theo", + "out_now": "Đã ra mắt", + "locked": "Sozo đang khóa", + "locked_hint": "Nhấn để mở", + "at_risk": "Chuỗi {} ngày kết thúc tối nay", + "empty": "Bắt đầu xem và nội dung sẽ hiện ở đây", + "days": "{} ngày", + "episode": "Tập {}", + "chapter": "Chương {}", + "d": "{} ngày", + "h": "{} giờ", + "m": "{} phút", + "streak_days": "ngày liên tiếp", + "streak_start": "Bắt đầu chuỗi", + "streak_done": "Hôm nay đã xong", + "streak_tonight": "Hết hạn tối nay", + "streak_next": "Còn {left} ngày đến huy hiệu {target} ngày" + }, + "support": { + "entry_subtitle": "Báo lỗi hoặc đề xuất ý tưởng", + "intro_title": "Chúng tôi có thể giúp gì?", + "intro_body": "Hãy cho chúng tôi biết điều gì không ổn hoặc bạn muốn có gì. Chúng tôi đọc mọi tin nhắn và thường trả lời trong vòng một ngày.", + "new_request": "Yêu cầu mới", + "my_requests": "Yêu cầu của bạn", + "empty": "Bạn chưa gửi tin nhắn nào cho chúng tôi.", + "new_answer": "Có trả lời mới", + "team": "Hỗ trợ Sozo", + "category_title": "Về vấn đề gì?", + "cat_playback": "Phát video", + "cat_account": "Tài khoản & đăng nhập", + "cat_content": "Nguồn & nội dung", + "cat_bug": "Có gì đó bị lỗi", + "cat_idea": "Ý tưởng hoặc đề xuất", + "cat_other": "Vấn đề khác", + "message_label": "Tin nhắn của bạn", + "message_hint": "Chuyện gì đã xảy ra? Bạn đã làm gì, mong đợi gì và thấy gì thay vào đó?", + "contact_label": "Liên hệ (tùy chọn)", + "contact_hint": "Email hoặc tên người dùng Telegram", + "contact_note": "Bạn chưa đăng nhập — câu trả lời sẽ hiện ở đây, trong Trợ giúp & Hỗ trợ. Hãy để lại liên hệ nếu bạn cũng muốn nhận trả lời ở đó.", + "diag_title": "Đính kèm thông tin chẩn đoán", + "diag_body": "Phiên bản ứng dụng, hệ thống, loại mạng và nhật ký trình phát, đã loại bỏ mật khẩu, token và cookie. Điều này giúp chúng tôi tìm ra nguyên nhân nhanh hơn.", + "diag_show": "Xem nội dung sẽ gửi", + "diag_log_lines": "Nhật ký trình phát: {} dòng", + "send": "Gửi", + "sent": "Đã gửi — chúng tôi sẽ trả lời ở đây.", + "status_open": "Đang chờ trả lời", + "status_answered": "Đã trả lời", + "status_closed": "Đã đóng", + "reply_hint": "Viết tin nhắn…", + "close": "Đóng yêu cầu", + "close_confirm_title": "Đóng yêu cầu này?", + "close_confirm_body": "Bạn có thể viết lại bất cứ lúc nào — yêu cầu sẽ được mở lại.", + "waiting_note": "Chúng tôi đã nhận tin nhắn và sẽ trả lời ở đây. Nếu đã đăng nhập, bạn cũng sẽ nhận được thông báo.", + "closed_note": "Yêu cầu này đã đóng. Viết tiếp sẽ mở lại yêu cầu.", + "contact_support": "Viết cho bộ phận hỗ trợ", + "error_generic": "Đã xảy ra lỗi. Vui lòng thử lại.", + "error_too_many": "Hiện có quá nhiều yêu cầu — vui lòng đợi một chút.", + "error_short": "Vui lòng viết thêm vài từ.", + "error_thread_full": "Cuộc trò chuyện này quá dài — vui lòng tạo yêu cầu mới.", + "error_not_found": "Yêu cầu này không còn tồn tại.", + "screenshots_title": "Ảnh chụp màn hình (tùy chọn)", + "screenshots_hint": "Tối đa 5 ảnh jpg, png hoặc webp, mỗi ảnh 5 MB. Ảnh giúp chúng tôi thấy rõ vấn đề.", + "screenshots_skipped": "Một số ảnh đã bị bỏ qua: chỉ nhận jpg, png hoặc webp tối đa 5 MB.", + "uploading": "Đang tải ảnh lên {}/{}…", + "attach": "Đính kèm ảnh", + "error_attachment": "Không tải lên được một ảnh. Vui lòng thử lại.", + "error_attachment_type": "Chỉ nhận ảnh jpg, png hoặc webp.", + "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", + "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", + "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ", + "error_offline": "Bạn đang ngoại tuyến hoặc kết nối bị ngắt. Hãy kiểm tra internet rồi thử lại.", + "not_sent": "Chưa gửi được: {}", + "diag_crash_log": "Xem nhật ký sự cố" + }, + "zangetsu": { + "title": "Nguồn Zangetsu", + "row_subtitle": "Nguồn JavaScript từ kho bên ngoài, chạy trên thiết bị của bạn", + "desc": "Thêm một kho Zangetsu để cài thêm nguồn anime và phim. Chúng chạy trên thiết bị của bạn. Những nguồn Sozo đã có sẵn được đánh dấu «Tích hợp» và vẫn dùng phiên bản của chính Sozo.", + "loading_repo": "Đang tải kho…", + "repo_added": "Đã thêm kho · {} nguồn.", + "checking": "Đang kiểm tra cập nhật cho các kho…", + "updates_available": "Có {} bản cập nhật.", + "installed_one": "Đã cài {}.", + "removed_one": "Đã gỡ {}.", + "updated": "Đã cập nhật {} nguồn.", + "update_all": "Cập nhật tất cả ({})", + "update_to": "Cập nhật lên v{}", + "built_in": "Tích hợp", + "search": "Tìm nguồn…", + "all_types": "Tất cả", + "type_movies": "Phim", + "all_languages": "Mọi ngôn ngữ", + "error_invalid_url": "Chỉ có thể thêm liên kết https trên raw.githubusercontent.com hoặc gist.githubusercontent.com.", + "error_unreachable": "Không truy cập được kho này. Hãy kiểm tra liên kết và kết nối mạng.", + "error_invalid_index": "Liên kết này không trỏ tới một index.json Zangetsu hợp lệ." + }, + "star": { + "title": "Bạn muốn giúp Sozo phát triển?", + "body": "Gắn sao trên GitHub ⭐", + "action": "Gắn sao trên GitHub", + "later": "Để sau", + "never": "Không hỏi lại", + "update_button": "Gắn sao cho Sozo trên GitHub" + }, + "progress": { + "title": "Cấp độ & XP", + "level": "Cấp", + "level_short": "Cấp {}", + "level_up": "Lên cấp", + "level_reached": "Bạn đã đạt cấp {}", + "unlocked_new": "Mới trong tủ đồ của bạn", + "open_locker": "Mở tủ đồ", + "unavailable": "Cấp độ chưa khả dụng. Hãy quay lại sau.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "Còn {} XP lên cấp {}", + "to_next_short": "Còn {} XP lên cấp tiếp theo", + "watch_today": "Xem hôm nay: {} / {} XP", + "how_to_earn": "Cách nhận XP", + "rule_watch": "Xem: 1 XP mỗi phút, tối đa 300 mỗi ngày", + "rule_comment": "Bình luận: 5 XP, tối đa 10 bình luận mỗi ngày", + "rule_like": "Lượt thích bình luận của bạn: 1 XP, tối đa 50 mỗi ngày", + "rule_streak": "Ngày chuỗi: 10 XP, nhiều hơn vào ngày cột mốc", + "rule_achievement": "Thành tích: 50 đến 400 XP theo bậc", + "recent": "XP gần đây", + "reason_watch": "Xem phim", + "reason_comment": "Bình luận", + "reason_like": "Lượt thích nhận được", + "reason_streak": "Ngày chuỗi", + "reason_achievement": "Thành tích", + "reason_other": "XP", + "locker": "Tủ đồ", + "slot_frame": "Khung ảnh đại diện", + "slot_title": "Danh hiệu", + "slot_color": "Màu tên", + "equip": "Chạm để trang bị", + "equipped": "Đã trang bị", + "unlock_level": "Cấp {}", + "unlock_badge": "Huy hiệu: {}", + "frame_bronze": "Đồng", + "frame_silver": "Bạc", + "frame_gold": "Vàng", + "frame_neon": "Neon", + "frame_sakura": "Hoa anh đào", + "frame_ember": "Than hồng", + "frame_flame": "Ngọn lửa", + "frame_moon": "Mặt trăng", + "title_newcomer": "Người mới", + "title_regular": "Khách quen", + "title_veteran": "Kỳ cựu", + "title_legend": "Huyền thoại", + "title_binger": "Mọt phim bộ", + "title_cinephile": "Người mê điện ảnh", + "title_otaku": "Otaku", + "title_bookworm": "Mọt sách", + "title_night_owl": "Cú đêm", + "title_host": "Chủ tiệc", + "color_sky": "Bầu trời", + "color_mint": "Bạc hà", + "color_gold": "Vàng", + "color_rose": "Hồng", + "color_violet": "Tím", + "color_crimson": "Đỏ thẫm", + "color_aurora": "Cực quang", + "color_ember": "Than hồng" + } +} diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 6ce1e7a4..633e60af 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -34,7 +34,9 @@ "remove": "移除", "show": "顯示", "hide": "隱藏", - "installed": "已安裝" + "installed": "已安裝", + "load_more_failed": "載入唔到更多", + "go_home": "返去主頁" }, "auth": { "login": "登入", @@ -306,7 +308,11 @@ "no_source": "呢一集冇來源。", "empty_url": "呢個來源冇畀到影片地址。", "engine_unavailable": "播放器啟動唔到。請完全重開個 app。", - "unknown": "載入唔到影片。" + "unknown": "載入唔到影片。", + "not_found": "呢個伺服器已經冇咗呢段影片,試吓第二個伺服器。", + "server_error": "影片伺服器出咗問題,等陣再試或者揀第二個伺服器。", + "resolve_failed": "喺呢個來源攞唔到影片連結。", + "details": "詳情" }, "play": "播放", "pause": "暫停", @@ -529,6 +535,22 @@ "alt_guess": "估計", "alt_guess_spoken": "唔肯定係咪同一套 — 播之前睇清楚個標題", "alt_your_pick": "你揀嘅", + "alt_auto": "自動", + "alt_auto_hint": "由第一個有呢套嘅來源播放", + "alt_auto_trying": "試緊 {}…", + "alt_auto_missed": "冇一個相符嘅來源播到。喺下面揀一個或者喺來源度搜尋。", + "alt_auto_checking": "檢查緊 {} · 第 {} 個,共 {} 個", + "alt_auto_waiting": "等緊更多來源…", + "alt_auto_stop": "停止", + "alt_auto_stopped": "已停止。", + "alt_auto_capped": "檢查咗 {} 個來源,冇一個播到。喺下面揀一個或者再試。", + "alt_auto_tried": "已檢查", + "alt_skip_no_match": "唔夠相似", + "alt_skip_no_episode": "搵唔到呢集", + "alt_skip_error": "載入唔到", + "auto_switch_attempt": "第 {} 次嘗試,共 {} 次", + "auto_switch_stop": "停止", + "auto_switch_stopped": "已停止轉換伺服器。再試一次,或者揀第二個伺服器或來源。", "alt_wrong_title": "揀錯標題?", "alt_wrong_title_hint": "自己喺 {} 搵,再揀返啱嘅標題", "alt_corrected": "而家用緊「{}」", @@ -554,10 +576,35 @@ "subtitle_n": "字幕 {}", "subtitle_in_video": "影片內置", "rewind": "倒帶", + "rewind_by": "倒帶 {} 秒", + "forward_by": "快進 {} 秒", "mini_restore": "返去大視窗", "pip_unavailable": "呢條片開唔到子母畫面。", "scrub_release_cancel": "鬆手就取消", - "auto_quality_desc_mpv": "由串流最好嘅畫質開始" + "auto_quality_desc_mpv": "由串流最好嘅畫質開始", + "reconnecting": "連線中斷 — 重新連線緊…", + "all_servers_failed": "呢個節目冇一個伺服器播到。遲啲再試,或者揀第二個來源。", + "shortcut_pip": "迷你播放器(Shift:喺其他 App 上面)", + "mini_popout_hint": "迷你播放器 · 長按令佢浮喺其他 App 上面", + "react": "反應", + "comments": "留言", + "episode_comments": "第 {} 集留言", + "reaction_text": "寫個反應", + "reaction_text_hint": "講句簡短嘅…", + "reaction_wait": "等幾秒先再反應", + "reaction_limit": "呢集嘅反應已經到上限", + "reaction_failed": "送唔到個反應", + "native_offer_title": "試吓新播放器?", + "native_offer_body": "Apple 嘅 Liquid Glass 控制同選單。你隨時可以喺 設定 → 播放器 度改返。", + "native_offer_try": "試吓", + "native_offer_not_now": "遲啲先", + "trying_again": "再試緊(第 {} 次)", + "try_mpv": "用 mpv 引擎試吓", + "ok_to_skip": "撳 OK 跳過", + "quality_lowered": "已降低畫質", + "undo": "復原", + "crash_log": "當機紀錄", + "crash_log_empty": "冇當機紀錄" }, "profile": { "title": "個人檔案", @@ -649,6 +696,15 @@ "player_engine_section": "播放引擎", "ask_engine_on_play": "每次都問", "ask_engine_on_play_desc": "每次開片都揀一次引擎", + "controls_style": "播放器控制樣式", + "controls_style_desc": "下次打開播放器時生效。", + "controls_style_native": "原生(iOS 26)", + "controls_style_native_desc": "手機自己嘅 Liquid Glass 按鈕、選單同面板", + "controls_style_system": "系統玻璃", + "controls_style_system_desc": "App 嘅按鈕放喺手機自己嘅 Liquid Glass 上面", + "controls_style_classic": "經典玻璃", + "controls_style_classic_desc": "App 自己嘅玻璃,同舊版本一樣", + "controls_style_needs_ios26": "需要 iOS 26 或更新版本", "playback_section": "播放", "default_speed": "預設速度", "default_speed_desc": "每條片開始時嘅速度", @@ -671,6 +727,8 @@ "brightness_gesture_desc": "喺左邊上下掃", "volume_gesture": "音量滑動", "volume_gesture_desc": "喺右邊上下掃", + "auto_pip": "自動畫中畫", + "auto_pip_desc": "播放影片時撳主畫面,影片會喺細視窗繼續播", "seconds_short": "{}秒", "subtitle_style_note": "呢啲設定套用喺所有影片。字幕時間就每集獨立,要喺播放器入面調。", "searching_all_sources": "搜尋緊所有來源", @@ -743,7 +801,15 @@ "library_update": "檢查新集數", "library_update_desc": "檢查你追緊嘅作品,有新集就通知你", "library_update_every": "每 {} 小時", - "library_update_daily": "每日一次" + "library_update_daily": "每日一次", + "contributors": "貢獻者", + "contributors_error": "載入唔到貢獻者", + "become_contributor": "成為貢獻者", + "become_contributor_body": "Sozo 係開源項目。寫 code、翻譯、報 bug 都幫到手。", + "reactions_overlay": "觀眾反應", + "reactions_overlay_desc": "喺觀眾送出反應嗰刻顯示,同埋喺進度條標出反應集中嘅位置", + "hide_spoilers": "隱藏未睇集數嘅劇透", + "hide_spoilers_desc": "未睇完嗰集嘅留言會保持模糊,撳一下先顯示" }, "appearance": { "title": "外觀", @@ -866,8 +932,20 @@ "all_sources_failed_one": "你搜尋嘅來源冇回應。呢個唔係冇結果 — 請再試一次。", "voice": "語音搜尋", "voice_listening": "聽緊…", - "voice_done": "已停止聆聽", "voice_hint": "講出電影或者劇集嘅名", + "voice_starting": "準備緊咪高峰…", + "voice_no_speech": "聽唔清楚", + "voice_no_speech_hint": "講大聲少少、近啲咪高峰,再試一次。", + "voice_permission": "咪高峰權限已經熄咗", + "voice_permission_hint": "去系統設定俾 Sozo 用咪高峰,就可以用把聲搜尋。", + "voice_unavailable": "呢部機冇語音辨識", + "voice_unavailable_hint": "呢部機冇語音辨識服務。去系統設定安裝或者開一個,例如 Google 嘅。", + "voice_network": "連唔到語音服務", + "voice_network_hint": "檢查吓網絡連線,再試一次。", + "voice_error": "聽緊嘅時候出咗問題", + "voice_error_hint": "可能有其他 app 用緊咪高峰。等陣再試。", + "voice_try_again": "再試一次", + "voice_open_settings": "打開設定", "try_torrents": "搜尋 torrent", "capped_note": "已經搵咗 {} 個最穩定嘅來源(共 {} 個)— 想搵特定嗰個就縮窄範圍。", "rail_popular_on": "{source} 熱門", @@ -1062,6 +1140,10 @@ "forgot_pin": "唔記得 PIN?" }, "my_list": { + "sort": "排序", + "sort_added": "最近加入", + "sort_oldest": "最早加入", + "sort_title": "標題 A–Z", "saved_account": "已存入帳戶", "saved_local": "喺呢部裝置", "untitled": "冇標題", @@ -1160,7 +1242,33 @@ "reconnecting": "重新連線緊…", "connecting": "連線緊…", "connect_failed_title": "連線唔到", - "connect_failed_msg": "檢查下你嘅網絡連線再試一次。" + "connect_failed_msg": "檢查下你嘅網絡連線再試一次。", + "settings_title": "房間設定", + "room_size": "房間人數", + "room_size_less": "少啲人", + "room_size_more": "多啲人", + "only_host_setting": "淨係房主可以控制播放", + "lock_room": "鎖住房間", + "lock_room_hint": "新人唔可以加入", + "wait_for_all": "等埋大家", + "wait_for_all_hint": "有人緩衝嗰陣暫停一陣", + "invite_friends": "邀請朋友", + "invite_sent": "已經發送邀請", + "invite_failed": "發送唔到邀請", + "invite_again_in": "{} 後再試", + "invite_no_friends": "加咗朋友就可以喺度邀請佢哋", + "make_host": "設為房主", + "remove_member": "移出房間", + "remove_confirm": "將 {} 移出呢個房間?佢之後唔可以再加入。", + "buffering": "緩衝緊…", + "sys_joined": "{} 加入咗", + "sys_left": "{} 離開咗", + "sys_host": "{} 而家係房主", + "sys_removed": "{} 已被移出", + "error_banned": "你已經被移出呢個房間", + "error_locked": "呢個房間已經鎖咗", + "room_closed_kicked": "房主將你移出咗呢個房間", + "waiting_for_others": "等緊大家追上…" }, "notifications": { "title": "通知", @@ -1281,7 +1389,19 @@ "highlights": "標記清單", "remove_highlight": "移除標記", "highlight_note": "加個備註(可選)", - "no_highlights": "仲未有標記。揀選一段文字,再撳「標記」。" + "no_highlights": "仲未有標記。揀選一段文字,再撳「標記」。", + "translate_page": "翻譯呢版", + "translate_preview": "已翻譯嘅版", + "translate_apply": "套用", + "translate_show_original": "睇返原文", + "translate_no_text": "呢版搵唔到文字。", + "translate_daily_limit": "你今日嘅翻譯次數用晒喇,聽日再試啦。", + "translate_offline": "你而家離線。連上網先可以翻譯呢版。", + "translate_sign_in": "登入之後先可以翻譯漫畫版面。", + "translate_failed": "翻譯唔到呢版,請再試一次。", + "translate_language": "將版面翻譯成", + "translate_auto": "睇嘅時候自動翻譯每一版", + "translate_auto_desc": "每翻譯一版新嘅會用一次今日嘅翻譯次數。" }, "cloudflare": { "title": "驗證你係真人", @@ -1363,6 +1483,10 @@ "watched": "睇咗", "follow_series": "追呢套", "find_other_sources": "搵其他來源", + "notice_title": "呢個來源播唔到呢個片", + "notice_moved_to_app": "來源已經將呢個片搬去佢自己嘅應用程式,所以呢度冇嘢可以播。其他來源可能仲有。", + "notice_no_player": "來源有列出呢個片,但冇播放器。其他來源可能有。", + "notice_is_series": "喺呢個來源呢個係劇集,所以要由集數清單播,唔係當一部電影播。", "anilist_track": "喺 AniList 追蹤", "anilist_tracked": "已喺 AniList 追蹤", "following_on": "追緊", @@ -1393,6 +1517,8 @@ "download_batch_body": "第 {range} 集會排隊下載 — 一共 {count} 集。用流動數據可能會用幾個 GB。", "relations": "相關作品", "no_relations": "搵唔到同呢套相關嘅作品。", + "relation_tmdb_collection": "同系列", + "relation_tmdb_recommended": "推薦", "relation_sequel": "續篇", "relation_prequel": "前傳", "relation_parent": "本篇故事", @@ -1461,6 +1587,8 @@ "offline_desc": "顯示緊儲低咗嘅清單。離線時淨係開到已下載嘅集數。", "filter_hint": "集數或者標題", "all_groups": "所有字幕組", + "season_n": "第 {} 季", + "specials": "特別篇", "filter_none": "呢一段冇符合「{query}」嘅集數", "jump_to": "去第 {n} 集", "jump_loads": "載入 {range}", @@ -1505,7 +1633,10 @@ "show_more": "顯示更多", "delete_confirm_title": "刪除呢則留言?", "delete_confirm_body": "呢個動作冇得還原。", - "sign_in_prompt": "登入嚟留言" + "sign_in_prompt": "登入嚟留言", + "spoiler": "劇透", + "spoiler_hidden": "劇透 · 撳一下顯示", + "unwatched_hidden": "未睇 · 撳一下顯示" }, "downloads": { "view_titles": "作品", @@ -1955,7 +2086,8 @@ "title": "我嘅清單", "remove": "移除", "empty_title": "仲未儲存任何嘢", - "empty_subtitle": "你儲存咗嘅作品會喺呢度出現。" + "empty_subtitle": "你儲存咗嘅作品會喺呢度出現。", + "load_failed": "載入唔到呢個清單。" }, "torrent": { "title": "Torrent", @@ -2229,7 +2361,25 @@ "settings_title": "{} 設定", "settings_reset": "重設", "settings_failed": "讀唔到呢個來源嘅設定。", - "settings_none": "呢個來源冇設定。" + "settings_none": "呢個來源冇設定。", + "loading_repo": "載入緊儲存庫…", + "add_sources": "加入來源", + "choose_sources": "揀選來源", + "select_all": "全選", + "select_none": "全部唔揀", + "install_selected": "安裝 ({})", + "already_installed": "已安裝", + "remove": "移除", + "remove_source": "移除來源", + "remove_source_confirm": "移除 {}?佢嘅設定同快取程式碼會被刪除。你可以喺佢嘅儲存庫再加返。", + "remove_repo_confirm": "移除呢個儲存庫同佢所有來源?", + "enable_source": "啟用", + "disable_source": "停用", + "source_disabled": "已停用", + "update": "更新", + "update_all": "全部更新", + "updates_available": "有 {} 個更新", + "repo_check_failed": "檢查唔到 {}:{}" }, "ux": { "quick_search": "快速搜尋({})", @@ -2300,8 +2450,8 @@ "check_all": "檢查來源", "check_bad_hint": "收埋用唔到嘅來源會喺呢啲清單度消失;佢哋再用得嗰陣會自己返嚟。", "check_done": "來源檢查", - "check_hide_down": "大部分來源都連唔到——係網絡問題,唔係來源問題。冇標記任何嘢;喺正常網絡再試下。", - "check_network": "收埋用唔到嘅", + "check_hide_down": "收埋用唔到嘅", + "check_network": "大部分來源都連唔到——係網絡問題,唔係來源問題。冇標記任何嘢;喺正常網絡再試下。", "check_retrying": "重試失敗嗰啲…", "checking": "檢查緊 {} / {}…", "d_ago": "{} 日前", @@ -2315,6 +2465,8 @@ "v_blocked": "被封鎖", "v_dead": "用唔到", "v_empty": "空白", + "v_empty_body": "來源有回應,但目錄係空嘅。一個封鎖咗應用程式嘅網站通常就係咁。", + "v_failing_body": "呢個來源上次檢查失敗。一次失敗可能只係一時出事 — 再檢查一次睇下。", "v_failing": "冇回應", "v_outdated": "要更新", "v_unknown": "未檢查", @@ -2527,7 +2679,18 @@ "card_single": "幫同你共用呢個帳戶嘅人加個人檔案", "load_failed": "載入唔到個人檔案", "offline": "檢查吓網絡連線再試。", - "error_generic": "出咗問題,請再試。" + "error_generic": "出咗問題,請再試。", + "on_this_device": "喺呢部裝置", + "ask_on_launch": "開App時問邊個睇緊", + "ask_on_launch_desc": "關咗嘅話,Sozo會直接開返上次用嘅個人檔案。有PIN碼嘅個人檔案照樣會問。", + "pin_locked_until": "試得太多次。請喺{}之後再試。", + "pin_not_on_device": "你而家離線,而呢個個人檔案嘅PIN碼未喺呢部裝置輸入過。請連一次網嚟解鎖。", + "biometric_reason": "解鎖{}", + "biometric_offer_title": "用指紋 / Face ID?", + "biometric_offer_body": "喺呢部裝置用指紋 / Face ID開{}。PIN碼都照用得。", + "biometric_offer_yes": "用", + "biometric_offer_no": "遲啲先", + "biometric_toggle": "{}嘅指紋 / Face ID" }, "jellyfin": { "connect_server": "連接媒體伺服器(Jellyfin)", @@ -2778,6 +2941,8 @@ "priming_title": "唔好再錯過新一集", "priming_body": "你追緊嘅作品一出新一集或者新一話,Sozo 就即刻話你知 — 你冇要求嘅就唔會煩你。", "priming_body_title": "「{}」一出新一集就即刻知 — 你冇要求嘅就唔會煩你。", + "priming_download_title": "下載完成即刻知", + "priming_download_body": "Sozo 會喺通知度顯示下載進度,可以離線睇嘅時候話你知。亦都可以通知你追緊嘅劇有新一集。", "priming_point_releases": "新一集連海報", "priming_point_quiet": "靜音時段", "priming_point_control": "任何作品都可以靜音", @@ -3004,6 +3169,101 @@ "error_attachment_type": "只接受 jpg、png 或 webp 圖片。", "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", - "suggest": "成日都係咁?搵支援" + "suggest": "成日都係咁?搵支援", + "error_offline": "你而家離線,或者連線斷咗。請檢查網絡再試。", + "not_sent": "未送出:{}", + "diag_crash_log": "睇當機紀錄" + }, + "zangetsu": { + "title": "Zangetsu 來源", + "row_subtitle": "嚟自外部 repo 嘅 JavaScript 來源,喺你部機度運行", + "desc": "加一個 Zangetsu repo 嚟安裝更多動畫同電影來源。佢哋喺你部機度運行。Sozo 本身已經有嘅來源會標示為「內置」,繼續用 Sozo 自己嘅版本。", + "loading_repo": "載入緊 repo…", + "repo_added": "已加入 repo · {} 個來源。", + "checking": "檢查緊 repo 有冇更新…", + "updates_available": "有 {} 個更新。", + "installed_one": "已安裝 {}。", + "removed_one": "已移除 {}。", + "updated": "已更新 {} 個來源。", + "update_all": "全部更新({})", + "update_to": "更新到 v{}", + "built_in": "內置", + "search": "搜尋來源…", + "all_types": "全部", + "type_movies": "電影", + "all_languages": "所有語言", + "error_invalid_url": "只可以加 raw.githubusercontent.com 或者 gist.githubusercontent.com 上面嘅 https 連結。", + "error_unreachable": "連唔到呢個 repo。請檢查連結同網絡連線。", + "error_invalid_index": "呢個連結唔係指向有效嘅 Zangetsu index.json。" + }, + "star": { + "title": "想幫 Sozo 發展?", + "body": "喺 GitHub 畀粒星佢 ⭐", + "action": "喺 GitHub 畀星", + "later": "遲啲先", + "never": "唔使再問", + "update_button": "喺 GitHub 畀 Sozo 粒星" + }, + "progress": { + "title": "等級同 XP", + "level": "等級", + "level_short": "Lv {}", + "level_up": "升級", + "level_reached": "你升到第 {} 級", + "unlocked_new": "儲物櫃有新嘢", + "open_locker": "打開儲物櫃", + "unavailable": "等級暫時未有,遲啲再睇吓。", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "仲差 {} XP 到第 {} 級", + "to_next_short": "仲差 {} XP 到下一級", + "watch_today": "今日睇咗:{} / {} XP", + "how_to_earn": "點樣賺 XP", + "rule_watch": "睇片:每分鐘 1 XP,每日最多 300", + "rule_comment": "留言:5 XP,每日最多計 10 個留言", + "rule_like": "你嘅留言有人讚:1 XP,每日最多 50", + "rule_streak": "連續日:10 XP,里程碑嗰日仲多", + "rule_achievement": "成就:按等級 50 至 400 XP", + "recent": "最近 XP", + "reason_watch": "睇片", + "reason_comment": "留言", + "reason_like": "收到嘅讚", + "reason_streak": "連續日", + "reason_achievement": "成就", + "reason_other": "XP", + "locker": "儲物櫃", + "slot_frame": "頭像框", + "slot_title": "稱號", + "slot_color": "名字顏色", + "equip": "撳一下裝備", + "equipped": "已裝備", + "unlock_level": "第 {} 級", + "unlock_badge": "徽章:{}", + "frame_bronze": "銅", + "frame_silver": "銀", + "frame_gold": "金", + "frame_neon": "霓虹", + "frame_sakura": "櫻花", + "frame_ember": "餘燼", + "frame_flame": "火焰", + "frame_moon": "月亮", + "title_newcomer": "新人", + "title_regular": "熟客", + "title_veteran": "老鳥", + "title_legend": "傳奇", + "title_binger": "追劇狂", + "title_cinephile": "電影迷", + "title_otaku": "御宅族", + "title_bookworm": "書蟲", + "title_night_owl": "夜貓", + "title_host": "派對主人", + "color_sky": "天藍", + "color_mint": "薄荷", + "color_gold": "金色", + "color_rose": "玫瑰", + "color_violet": "紫羅蘭", + "color_crimson": "深紅", + "color_aurora": "極光", + "color_ember": "餘燼" } } diff --git a/deeplink/README.md b/deeplink/README.md index 7b2771fb..49cef83f 100644 --- a/deeplink/README.md +++ b/deeplink/README.md @@ -85,3 +85,21 @@ So'ng `sozo.azamov.me` DNS yozuvini backend'ning IP'siga yo'naltirish kerak `detail.html` ichida iOS App Store URL'i hozircha `id0000000000` — haqiqiy `id...` ni ilova chiqqach almashtiring. + +## `/play` links (for sozo-web) + +The app now reads these parameters on `/play` (and `/detail`): + +| Param | Meaning | +|---|---| +| `url` | content URL (required) | +| `provider` | source id; used for this navigation only, never saved as the current source | +| `ep` | episode number as shown to the viewer (1-based), `/play` only | +| `t` | start position in seconds (`95`, `1:35` and `1m35s` are all accepted), `/play` only | + +Example: `https://sozo.azamov.me/play?url=…&provider=hdrezka&ep=12&t=754` + +The web fallback page for `/play` (and `/detail`) must forward the **whole** +query string to `sozo://play…`, not only `url`. `detail.html` in this folder +does that now; the copy deployed by the sozo-web repo needs the same change. +`apple-app-site-association` already lists `/play` and `/play/*`. diff --git a/deeplink/apple-app-site-association b/deeplink/apple-app-site-association index f8136f94..4c45fa7e 100644 --- a/deeplink/apple-app-site-association +++ b/deeplink/apple-app-site-association @@ -8,7 +8,10 @@ "/detail", "/detail/*", "/play", - "/play/*" + "/play/*", + "/party/*", + "/link/*", + "/pair/*" ] } ] diff --git a/deeplink/detail.html b/deeplink/detail.html index 0c354880..69da7935 100644 --- a/deeplink/detail.html +++ b/deeplink/detail.html @@ -34,10 +34,9 @@

Sozo'da oching

'''; @@ -169,6 +173,7 @@ class JsRuntimeService { await _ensureExtractor( extractor.name, extractor.version, + extractor.url, ).timeout(kJsCallTimeout); // Refusals and challenges are numbered on DartFetch; everything after // this mark happened while THIS call was running. Other calls may be in @@ -180,6 +185,9 @@ class JsRuntimeService { // cross-search legs can be in flight at once and their network waits // overlap instead of queueing — which is what made searching several // sources feel frozen. + // + // The answer comes back as JSON text so iOS reads it as Android does: + // WebKit's own conversion hands over Map and doubles. var result = await _controller! .callAsyncJavaScript( functionBody: r''' @@ -193,7 +201,7 @@ class JsRuntimeService { throw new Error('Provider.' + fnName + ' is not implemented'); } const __r = await __fn.apply(__p, fnArgs); - return __r === undefined ? null : __r; + return __r === undefined ? null : JSON.stringify(__r); ''', arguments: { 'providerName': extractor.name, @@ -231,7 +239,7 @@ class JsRuntimeService { throw new Error('Provider.' + fnName + ' is not implemented'); } const __r = await __fn.apply(__p, fnArgs); - return __r === undefined ? null : __r; + return __r === undefined ? null : JSON.stringify(__r); ''', arguments: { 'providerName': extractor.name, @@ -260,7 +268,7 @@ class JsRuntimeService { ); throw Exception(blocked ?? error); } - final map = _coerceMap(result.value); + final map = jsObjectFrom(result.value); JsLog.res( tag, fn, @@ -274,19 +282,6 @@ class JsRuntimeService { } } - Map? _coerceMap(dynamic value) { - if (value == null) return null; - if (value is Map) return value.cast(); - if (value is String) { - if (value.isEmpty || value == 'null') return null; - try { - final decoded = jsonDecode(value); - if (decoded is Map) return decoded.cast(); - } catch (_) {} - } - return null; - } - String _summarizeArgs(List args) { if (args.isEmpty) return ''; return args @@ -403,15 +398,19 @@ class JsRuntimeService { /// Evaluates [name] into the page if it is not already there. /// /// Returns as soon as the extractor is registered; repeat calls are free. - Future _ensureExtractor(String name, int wantedVersion) { + Future _ensureExtractor(String name, int wantedVersion, String url) { final pending = _registering[name]; if (pending != null) return pending; - final run = _registerExtractor(name, wantedVersion); + final run = _registerExtractor(name, wantedVersion, url); _registering[name] = run; return run.whenComplete(() => _registering.remove(name)); } - Future _registerExtractor(String name, int wantedVersion) async { + Future _registerExtractor( + String name, + int wantedVersion, + String url, + ) async { final manifest = _manifest ??= await remote.fetchManifest(); final entry = manifest.byName(name); final version = entry?.version ?? wantedVersion; @@ -423,7 +422,12 @@ class JsRuntimeService { code = cache.readCode(name, version); } if (code == null || code.isEmpty) { - final fetched = await remote.fetchExtractor(name); + // External Zangetsu sources are not in the manifest; their ref carries + // the composed bundle's path. + final fetched = await remote.fetchExtractor( + name, + path: entry == null && name.startsWith('zx-') ? url : null, + ); code = fetched.code; final effective = version > 0 ? version : fetched.version; await cache.writeCode( diff --git a/lib/core/js/js_value.dart b/lib/core/js/js_value.dart new file mode 100644 index 00000000..f6cb56f1 --- /dev/null +++ b/lib/core/js/js_value.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +/// An extractor's answer as plain JSON types, whichever platform delivered it. +/// +/// Android hands `callAsyncJavaScript` results over as JSON text, which decodes +/// to `Map` all the way down. iOS and macOS pass WebKit's +/// objects through the platform channel instead, so every nested object arrives +/// as `Map`, and the models' `as Map` casts +/// throw on the first nested card. A shallow `cast` only fixes the top level. +/// +/// WebKit also hands every JavaScript number over as a double, where JSON text +/// gives `1` as an int. Whole numbers are turned back into ints so a page +/// number reads the same on both platforms. +Map? jsObjectFrom(Object? value) { + if (value == null) return null; + if (value is String) { + if (value.isEmpty || value == 'null') return null; + try { + value = jsonDecode(value); + } catch (_) { + return null; + } + } + return jsonMap(value); +} + +/// [value] as a `Map` with plain JSON types all the way down, +/// or null when it is not a map. For maps from a platform channel or a Hive +/// box, which arrive as `Map` or `Map`. +Map? jsonMap(Object? value) => + value is Map ? _object(value) : null; + +/// The maps in [value], each as [jsonMap] gives it. Anything else in the list +/// is skipped, and a value that is not a list gives an empty one. +List> jsonMaps(Object? value) => [ + if (value is List) + for (final e in value) + if (e is Map) _object(e), +]; + +Map _object(Map raw) => { + for (final e in raw.entries) + if (e.key != null) '${e.key}': _plain(e.value), +}; + +Object? _plain(Object? value) { + if (value is Map) return _object(value); + if (value is List) return value.map(_plain).toList(); + if (value is double && + value.isFinite && + value == value.truncateToDouble() && + value.abs() <= 9007199254740991) { + return value.toInt(); + } + return value; +} + +/// A whole number from a number or from numeric text, such as a count a source +/// sends as `"12"`. +int? jsonInt(Object? value) { + final n = value is String ? num.tryParse(value.trim()) : value; + return n is num && n.isFinite ? n.toInt() : null; +} + +/// Trimmed text from a string or a number, or null when there is none. +String? jsonText(Object? value) { + if (value is! String && value is! num) return null; + final s = '$value'.trim(); + return s.isEmpty ? null : s; +} diff --git a/lib/core/js/provider_registry.dart b/lib/core/js/provider_registry.dart index a5e681e2..ce1dc6b4 100644 --- a/lib/core/js/provider_registry.dart +++ b/lib/core/js/provider_registry.dart @@ -6,7 +6,10 @@ class ProviderRegistry { List? _cache; Future>? _loading; - ProviderRegistry({required this.source}); + ProviderRegistry({required this.source, this.external}); + + /// Providers installed on the device that the backend list does not carry. + final ProviderModel? Function(String id)? external; Future getById(String id) async { if (id.isEmpty) return null; @@ -14,7 +17,7 @@ class ProviderRegistry { for (final p in list) { if (p.id == id) return p; } - return null; + return external?.call(id); } Future preload() async { diff --git a/lib/core/localization/app_language.dart b/lib/core/localization/app_language.dart index db67d45a..0e34e22c 100644 --- a/lib/core/localization/app_language.dart +++ b/lib/core/localization/app_language.dart @@ -36,6 +36,8 @@ abstract final class AppLanguage { 'fr': 'Français', 'tr': 'Türkçe', 'id': 'Bahasa Indonesia', + 'vi': 'Tiếng Việt', + 'hi': 'हिन्दी', 'yue': '廣東話', 'ku': 'Kurdî (Kurmancî)', 'ckb': 'کوردی (سۆرانی)', @@ -55,6 +57,8 @@ abstract final class AppLanguage { 'fr': '🇫🇷', 'tr': '🇹🇷', 'id': '🇮🇩', + 'vi': '🇻🇳', + 'hi': '🇮🇳', 'yue': '🇭🇰', // No flag for either Kurdish: Unicode has no Kurdistan emoji, and the // flag of a state Kurds live in would be the wrong answer. They get the diff --git a/lib/core/localization/line_height.dart b/lib/core/localization/line_height.dart new file mode 100644 index 00000000..88daeaaa --- /dev/null +++ b/lib/core/localization/line_height.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +/// A tight [height] for one line of text, with room for marks where needed. +/// +/// A Vietnamese capital carries two marks, as in Ẳ or Ấ, and reaches about 1.03 em +/// above the baseline. Devanagari has marks on both sides of the line, as in कहानियाँ +/// and हूँ. Text cut off with an ellipsis is clipped to its line box, so at a height +/// of 1.08 a long title loses those marks, which makes it a different word. Other +/// languages keep the height the design chose. +double lineHeightFor(BuildContext context, double height) { + final code = Localizations.maybeLocaleOf(context)?.languageCode; + return _needsRoom.contains(code) && height < _roomy ? _roomy : height; +} + +const Set _needsRoom = {'vi', 'hi'}; +const double _roomy = 1.25; diff --git a/lib/core/native/native_bar_layout.dart b/lib/core/native/native_bar_layout.dart new file mode 100644 index 00000000..cf22a7cd --- /dev/null +++ b/lib/core/native/native_bar_layout.dart @@ -0,0 +1,204 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; + +/// One slot of a native bar, as the layout needs to know it. +class BarSlotSpec { + const BarSlotSpec({ + this.extent, + this.label, + this.fontSize = 12, + this.glyph = 20, + this.hasGlyph = true, + this.group = 0, + }); + + /// A square slot of this size; null is the bar's own slot size. + final double? extent; + + /// Words beside the glyph, measured; null is a glyph alone. + final String? label; + final double fontSize; + final double glyph; + + /// False for words alone (quality's "1080" on a compact row). + final bool hasGlyph; + + /// Which capsule the slot stands in. + final int group; +} + +/// How the slots share the room. +enum BarSpread { + /// Side by side, the bar as wide as they are. + packed, + + /// Spread evenly over all of it, as `MainAxisAlignment.spaceEvenly`. + even, +} + +class BarLayout { + const BarLayout({ + required this.size, + required this.slots, + required this.capsules, + }); + + final Size size; + final List slots; + + /// One per group, in group order; empty for a bar without capsules. + final List capsules; +} + +/// The room either side of a label, and between its glyph and its words, as +/// the Flutter bottom-row button draws them. +const double _labelInset = 8; +const double _labelGap = 4; + +/// Where every slot of a bar goes, or null when they do not fit in +/// [maxExtent] even tightened (slots 44→40, no gap between them). +/// +/// RTL mirrors a horizontal bar: the first group, and the first slot in it, +/// start at the right. A caller with physical order (transport) passes LTR. +BarLayout? layoutBar({ + required List slots, + required TextDirection dir, + Axis axis = Axis.horizontal, + required double maxExtent, + double slot = 44, + double gap = 4, + double padding = 6, + double groupGap = 16, + bool capsules = true, + BarSpread spread = BarSpread.packed, + TextScaler textScaler = TextScaler.noScaling, +}) { + if (slots.isEmpty) return null; + final labels = [ + for (final s in slots) + s.label == null ? 0.0 : _measure(s.label!, s.fontSize, dir, textScaler), + ]; + BarLayout? attempt(double slot, double gap) => _place( + slots: slots, + labels: labels, + dir: dir, + axis: axis, + maxExtent: maxExtent, + slot: slot, + gap: gap, + padding: capsules ? padding : 0, + groupGap: groupGap, + capsules: capsules, + spread: spread, + ); + return attempt(slot, gap) ?? attempt(math.min(slot, 40), 0); +} + +double _measure(String text, double size, TextDirection dir, TextScaler s) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle(fontSize: size, fontWeight: FontWeight.w600), + ), + textDirection: dir, + textScaler: s, + maxLines: 1, + )..layout(); + final width = painter.width.ceilToDouble(); + painter.dispose(); + return width; +} + +BarLayout? _place({ + required List slots, + required List labels, + required TextDirection dir, + required Axis axis, + required double maxExtent, + required double slot, + required double gap, + required double padding, + required double groupGap, + required bool capsules, + required BarSpread spread, +}) { + final mains = []; + for (var i = 0; i < slots.length; i++) { + final s = slots[i]; + if (s.label != null) { + final words = s.hasGlyph ? labels[i] : math.max(labels[i], 20.0); + final glyph = s.hasGlyph ? s.glyph + _labelGap : 0.0; + mains.add(math.max(slot, glyph + words + _labelInset * 2)); + } else { + mains.add(s.extent ?? slot); + } + } + final cross = [slot, for (final s in slots) ?s.extent].reduce(math.max); + + final rects = []; + final groupRects = []; + double total; + + Rect rectAt(double main, double length, double crossLength) { + final c = (cross - crossLength) / 2; + return axis == Axis.horizontal + ? Rect.fromLTWH(main, c, length, crossLength) + : Rect.fromLTWH(c, main, crossLength, length); + } + + if (spread == BarSpread.even) { + final used = mains.fold(0, (a, b) => a + b); + if (used > maxExtent) return null; + final space = (maxExtent - used) / (slots.length + 1); + var at = space; + for (var i = 0; i < slots.length; i++) { + rects.add(rectAt(at, mains[i], slots[i].extent ?? slot)); + at += mains[i] + space; + } + total = maxExtent; + } else { + var at = 0.0; + int? group; + double groupStart = 0; + for (var i = 0; i < slots.length; i++) { + final g = slots[i].group; + if (g != group) { + if (group != null) { + at += padding; + groupRects.add(rectAt(groupStart, at - groupStart, slot)); + at += groupGap; + } + group = g; + groupStart = at; + at += padding; + } else { + at += gap; + } + rects.add(rectAt(at, mains[i], slots[i].extent ?? slot)); + at += mains[i]; + } + at += padding; + groupRects.add(rectAt(groupStart, at - groupStart, slot)); + total = at; + if (total > maxExtent) return null; + } + + final size = axis == Axis.horizontal + ? Size(total, cross) + : Size(cross, total); + if (axis == Axis.horizontal && dir == TextDirection.rtl) { + Rect mirror(Rect r) => + Rect.fromLTWH(total - r.right, r.top, r.width, r.height); + return BarLayout( + size: size, + slots: [for (final r in rects) mirror(r)], + capsules: capsules ? [for (final r in groupRects) mirror(r)] : const [], + ); + } + return BarLayout( + size: size, + slots: rects, + capsules: capsules ? groupRects : const [], + ); +} diff --git a/lib/core/native/native_controls_gate.dart b/lib/core/native/native_controls_gate.dart new file mode 100644 index 00000000..a18330ed --- /dev/null +++ b/lib/core/native/native_controls_gate.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../glass/glass_sense.dart'; +import '../glass/native_glass.dart'; +import '../system/platform_utils.dart'; +import 'player_controls_style.dart'; + +/// Whether the player's controls are the phone's own glass buttons +/// (ios/Runner/NativePlayerBar.swift) rather than Flutter's. +/// +/// In the order [nativeGlassDraw] asks: an iPhone, no Reduce Transparency, +/// the clear system glass, and then the bar factory itself. TV and desktop +/// never host, whatever they report. +bool nativeControlsDraw({ + required bool isIOS, + required bool isTv, + required bool isDesktop, + required NativeGlassCapability cap, + required bool reduceTransparency, +}) { + if (!isIOS || isTv || isDesktop) return false; + if (reduceTransparency) return false; + if (!cap.systemGlass || !cap.styles.contains(NativeGlassStyle.clear.name)) { + return false; + } + return cap.glassControls; +} + +/// Overrides [isDesktopPlatform] for the gate: `flutter test` runs on a +/// desktop host, so without this no test could reach the native path. +@visibleForTesting +bool? debugNativeControlsDesktopOverride; + +abstract final class NativeControls { + static NativeGlassCapability? _known; + + /// Asks the phone once, before the first frame, so the player can decide + /// on its first build instead of swapping Flutter for UIKit under the + /// viewer. A phone slower than [timeout] keeps Flutter until it answers. + static Future warm({ + Duration timeout = const Duration(milliseconds: 300), + }) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + final asked = NativeGlass.capability().then((c) => _known = c); + try { + await asked.timeout(timeout); + } on TimeoutException { + // Answered later; [_known] is set then. + } + } + + @visibleForTesting + static void debugReset() => _known = null; + + /// The live decision for [context]: a [NativeControlsScope] above wins, + /// so the player can latch one answer for the whole page. + static bool of(BuildContext context) { + final latched = NativeControlsScope.maybeOf(context); + if (latched != null) return latched; + return decide(context); + } + + /// The decision from the capability and the switches, ignoring any scope; + /// only the native [chosen] style draws the native bars. + static bool decide( + BuildContext context, { + PlayerControlsStyle chosen = PlayerControlsStyle.native, + }) => + effectivePlayerControlsStyle( + chosen: chosen, + isIOS: defaultTargetPlatform == TargetPlatform.iOS, + isTv: isTvPlatform, + isDesktop: debugNativeControlsDesktopOverride ?? isDesktopPlatform, + cap: capabilityOf(context), + reduceTransparency: GlassSense.reduceTransparencyOf(context), + ) == + PlayerControlsStyle.native; + + /// Whether to offer the native controls once, on the same switches as + /// [decide]. + static bool shouldOffer( + BuildContext context, { + required PlayerControlsStyle? stored, + required bool alreadyOffered, + }) => shouldOfferNativeControls( + isIOS: defaultTargetPlatform == TargetPlatform.iOS, + isTv: isTvPlatform, + isDesktop: debugNativeControlsDesktopOverride ?? isDesktopPlatform, + cap: capabilityOf(context), + reduceTransparency: GlassSense.reduceTransparencyOf(context), + stored: stored, + alreadyOffered: alreadyOffered, + ); + + /// What the phone said, or [NativeGlassCapability.absent] before it has. + static NativeGlassCapability capabilityOf(BuildContext context) => + NativeGlassScope.of(context) ?? _known ?? NativeGlassCapability.absent; +} + +/// One decision for everything under it. +class NativeControlsScope extends InheritedWidget { + const NativeControlsScope({ + super.key, + required this.native, + required super.child, + }); + + final bool native; + + static bool? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.native; + + @override + bool updateShouldNotify(NativeControlsScope old) => old.native != native; +} diff --git a/lib/core/native/native_glass_bar.dart b/lib/core/native/native_glass_bar.dart new file mode 100644 index 00000000..efe728f1 --- /dev/null +++ b/lib/core/native/native_glass_bar.dart @@ -0,0 +1,518 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'native_bar_layout.dart'; +import 'native_controls_gate.dart'; +import 'native_link.dart'; + +/// How a bar's buttons stand on the glass. +enum NativeBarLook { + /// One clear glass capsule per group, plain buttons on it. + capsule, + + /// Each button its own glass circle. + cluster, +} + +/// One row of a button's menu. +@immutable +class NativeMenuRow { + const NativeMenuRow({ + required this.title, + this.subtitle, + this.symbol, + this.checked, + this.breakBefore = false, + this.section, + this.destructive = false, + this.disabled = false, + this.key, + }); + + final String title; + + /// A second line under the title, smaller. + final String? subtitle; + final String? symbol; + + /// Null draws no check column; false an empty one. VoiceOver reads a + /// checked row as selected. + final bool? checked; + final bool breakBefore; + + /// The heading over the run of rows this one starts (with [breakBefore], + /// or as the first row). + final String? section; + final bool destructive; + final bool disabled; + + /// What the row stands for, so a choice made while the rows changed under + /// the open menu still lands on the row the viewer saw. The title without. + final Object? key; + + bool _same(NativeMenuRow other) => + key != null ? key == other.key : title == other.title; + + Map toWire() => { + 'title': title, + 'subtitle': subtitle, + 'symbol': symbol, + 'checked': checked, + 'breakBefore': breakBefore, + 'section': section, + 'destructive': destructive, + 'disabled': disabled, + }; +} + +/// One button of a [NativeGlassBar]. +@immutable +class NativeBarItem { + const NativeBarItem({ + required this.id, + required this.symbol, + required this.semanticLabel, + this.title, + this.enabled = true, + this.active = false, + this.busy = false, + this.prominent = false, + this.hidden = false, + this.extent, + this.size, + this.glyph = 17, + this.fontSize = 12, + this.hasGlyph = true, + this.group = 0, + this.onPressed, + this.onLongPress, + this.menu, + this.menuPrimary = false, + this.onMenuSelected, + }); + + final String id; + + /// An SF Symbol name; empty for words alone. + final String symbol; + final String? title; + final String semanticLabel; + final bool enabled; + + /// Drawn in the accent, and said selected. + final bool active; + + /// A ring turning on the rim: buffering. + final bool busy; + + /// The tinted glass of the one action that matters most (play). + final bool prominent; + + /// Keeps its slot and draws nothing: the room an absent Previous leaves, + /// so play stays in the middle. + final bool hidden; + + /// The square slot the button stands in; null is the bar's own slot. + final double? extent; + + /// The drawn circle of a cluster button, centred in its slot; null fills it. + final double? size; + final double glyph; + final double fontSize; + final bool hasGlyph; + final int group; + final VoidCallback? onPressed; + + /// A hold on a button without a menu, and VoiceOver's long press on any: + /// the Flutter sheet behind a menu stays one gesture away. + final VoidCallback? onLongPress; + + /// Opened by a hold, as UIKit's own buttons open theirs. + final List? menu; + + /// The [menu] opens on a tap as well, as a pull-down button's does; the + /// button then says no `press`. VoiceOver's tap still runs [onPressed], + /// the Flutter sheet holding the same choices. + final bool menuPrimary; + final ValueChanged? onMenuSelected; + + BarSlotSpec get _slot => BarSlotSpec( + extent: extent, + label: title, + fontSize: fontSize, + glyph: glyph, + hasGlyph: hasGlyph, + group: group, + ); +} + +/// A group of player controls as the phone's own glass buttons, in one +/// hosted view (ios/Runner/NativePlayerBar.swift), or [fallback] wherever the +/// gate says no ([NativeControls]) or the slots do not fit. +/// +/// Dart lays it out ([layoutBar]) and sends the rects; UIKit only places the +/// buttons. Hits land only on a slot ([SlotHitBox]), so the gaps stay the +/// page's. It fades itself, in UIKit, on [shown], and leaves the frame once +/// faded so a hidden bar costs no composition. +class NativeGlassBar extends StatefulWidget { + const NativeGlassBar({ + super.key, + required this.items, + required this.look, + required this.fallback, + this.shown = true, + this.axis = Axis.horizontal, + this.direction, + this.spread = BarSpread.packed, + this.maxExtent = double.infinity, + this.accent = const Color(0xFFFFFFFF), + this.dim = const Color(0x00000000), + this.onMenuOpen, + this.onMenuClose, + }); + + static const String viewType = 'soplay/native_player_bar'; + + /// How long the bar takes to fade, as the controls' own fade. + static const Duration fade = Duration(milliseconds: 220); + + final List items; + final NativeBarLook look; + final WidgetBuilder fallback; + final bool shown; + final Axis axis; + + /// Null is the ambient direction; transport passes LTR. + final TextDirection? direction; + final BarSpread spread; + + /// A cap under the room the parent gives. + final double maxExtent; + final Color accent; + + /// The capsule glass's tint; words on it want a deeper one. + final Color dim; + final VoidCallback? onMenuOpen; + final VoidCallback? onMenuClose; + + @override + State createState() => _NativeGlassBarState(); +} + +class _NativeGlassBarState extends State + with NativeControlState { + /// One set for the view's life: a new one each build would replace the + /// recognizers mid-press. + static final _gestures = pressGestures(); + + BarLayout? _layout; + TextDirection _dir = TextDirection.ltr; + double _scale = 1; + bool _reduceMotion = false; + + /// Faded out and out of the frame. + late bool _gone = !widget.shown; + Timer? _fading; + + Animation? _route; + bool _routeMoving = false; + + /// The rows each menu was last sent, by item: a row is chosen by its index + /// in those. + final Map> _toldRows = {}; + + /// The rows of a menu as it was when it opened: UIKit keeps showing those + /// while it is up, whatever was sent since. + final Map> _openRows = {}; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + final route = ModalRoute.of(context)?.animation; + if (route != _route) { + _route?.removeStatusListener(_routeStatus); + _route = route; + route?.addStatusListener(_routeStatus); + _routeMoving = route != null && !route.isCompleted; + } + } + + void _routeStatus(AnimationStatus status) { + final moving = !status.isCompleted; + if (moving != _routeMoving && mounted) { + setState(() => _routeMoving = moving); + } + } + + @override + void didUpdateWidget(NativeGlassBar old) { + super.didUpdateWidget(old); + if (old.shown == widget.shown) return; + _fading?.cancel(); + if (widget.shown) { + _gone = false; + } else { + _fading = Timer(_reduceMotion ? Duration.zero : NativeGlassBar.fade, () { + if (mounted && !widget.shown) setState(() => _gone = true); + }); + } + } + + @override + void dispose() { + _fading?.cancel(); + _route?.removeStatusListener(_routeStatus); + super.dispose(); + } + + Map _shownWire() => { + 'shown': widget.shown, + 'ms': _reduceMotion ? 0 : NativeGlassBar.fade.inMilliseconds, + }; + + Map _itemWire(NativeBarItem item, Rect rect) => { + 'id': item.id, + 'symbol': item.hasGlyph ? item.symbol : '', + 'title': item.title, + 'enabled': item.enabled, + 'active': item.active, + 'busy': item.busy, + 'prominent': item.prominent, + 'hidden': item.hidden, + 'size': item.size ?? item.extent, + 'glyph': item.glyph, + 'fontSize': _scale * item.fontSize, + 'accent': widget.accent.toARGB32(), + 'rect': [rect.left, rect.top, rect.width, rect.height], + 'group': item.group, + 'menu': item.menu?.map((r) => r.toWire()).toList(), + 'menuPrimary': item.menuPrimary && item.menu != null, + 'longPress': item.onLongPress != null, + }; + + Map _payload(BarLayout layout) => { + 'items': [ + for (var i = 0; i < widget.items.length; i++) + _itemWire(widget.items[i], layout.slots[i]), + ], + 'look': widget.look.name, + 'dim': widget.dim.toARGB32(), + 'dir': _dir == TextDirection.rtl ? 'rtl' : 'ltr', + 'axis': widget.axis.name, + 'capsules': [ + for (final r in layout.capsules) [r.left, r.top, r.width, r.height], + ], + }; + + /// What a full `setItems` is needed for: anything that moves a button. + static const _placing = {'id', 'rect', 'group'}; + + static Object? _structure(Map payload) => { + for (final e in payload.entries) + if (e.key != 'items') e.key: e.value, + 'items': [ + for (final item in payload['items']! as List) + {for (final k in _placing) k: (item as Map)[k]}, + ], + }; + + @override + void sync() { + final layout = _layout; + if (layout == null || layout.slots.length != widget.items.length) return; + final payload = _payload(layout); + final told = toldOf('setItems') as Map?; + if (told == null || !nativeSame(_structure(told), _structure(payload))) { + say('setItems', payload); + } else { + final before = told['items']! as List; + final now = payload['items']! as List; + for (var i = 0; i < now.length; i++) { + final was = before[i] as Map; + final item = now[i] as Map; + final changed = { + for (final e in item.entries) + if (!_placing.contains(e.key) && !nativeSame(was[e.key], e.value)) + e.key: e.value, + }; + if (changed.isNotEmpty) say('setItem', {'id': item['id'], ...changed}); + } + } + remember('setItems', payload); + for (final item in widget.items) { + final rows = item.menu; + if (rows == null) { + _toldRows.remove(item.id); + } else { + _toldRows[item.id] = rows; + } + } + tell('setShown', _shownWire()); + } + + NativeBarItem? _item(Object? id) { + for (final item in widget.items) { + if (item.id == id) return item; + } + return null; + } + + @override + void heard(String method, Object? arguments) { + switch (method) { + case 'press': + final item = _item(arguments); + if (item != null && item.enabled) item.onPressed?.call(); + case 'longPress': + final item = _item(arguments); + if (item != null && item.enabled) item.onLongPress?.call(); + case 'select': + if (arguments is! Map) return; + final item = _item(arguments['id']); + final row = arguments['row']; + final told = _openRows[item?.id] ?? _toldRows[item?.id]; + final rows = item?.menu; + if (item == null || row is! int || told == null || rows == null) return; + if (row < 0 || row >= told.length) return; + final index = rows.indexWhere((r) => r._same(told[row])); + if (index >= 0) item.onMenuSelected?.call(index); + case 'menuOpen': + final rows = _toldRows[arguments]; + if (arguments is String && rows != null) _openRows[arguments] = rows; + widget.onMenuOpen?.call(); + case 'menuClose': + _openRows.remove(arguments); + widget.onMenuClose?.call(); + } + } + + @override + Widget build(BuildContext context) { + if (!NativeControls.of(context)) return widget.fallback(context); + final scaler = MediaQuery.textScalerOf(context); + _dir = widget.direction ?? Directionality.of(context); + _scale = scaler.scale(1); + return LayoutBuilder( + builder: (context, box) { + final room = widget.axis == Axis.horizontal + ? box.maxWidth + : box.maxHeight; + final max = room < widget.maxExtent ? room : widget.maxExtent; + final layout = layoutBar( + slots: [for (final item in widget.items) item._slot], + dir: _dir, + axis: widget.axis, + maxExtent: max, + capsules: widget.look == NativeBarLook.capsule, + spread: widget.spread, + textScaler: scaler, + ); + if (layout == null) return widget.fallback(context); + final first = _layout == null; + _layout = layout; + if (!first) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && linked) sync(); + }); + } + return _hostBar(layout); + }, + ); + } + + Widget _hostBar(BarLayout layout) { + final hits = widget.look == NativeBarLook.capsule + ? layout.capsules + : [ + for (var i = 0; i < widget.items.length; i++) + if (!widget.items[i].hidden) layout.slots[i], + ]; + final payload = _payload(layout); + final shown = _shownWire(); + final interactive = widget.shown && !_gone && !_routeMoving; + return Offstage( + offstage: _gone || _routeMoving, + child: SizedBox.fromSize( + size: layout.size, + child: Stack( + children: [ + Positioned.fill( + child: SlotHitBox( + rects: hits, + enabled: interactive, + child: ExcludeSemantics( + child: hosted( + type: NativeGlassBar.viewType, + params: {...payload, 'shown': widget.shown}, + gestures: _gestures, + made: {'setItems': payload, 'setShown': shown}, + ), + ), + ), + ), + for (var i = 0; i < widget.items.length; i++) + if (!widget.items[i].hidden) + Positioned.fromRect( + rect: layout.slots[i], + child: _slotSemantics(widget.items[i]), + ), + ], + ), + ), + ); + } + + Widget _slotSemantics(NativeBarItem item) => Semantics( + container: true, + button: true, + label: item.semanticLabel, + value: item.title == item.semanticLabel ? null : item.title, + enabled: item.enabled, + selected: item.active, + onTap: item.enabled ? item.onPressed : null, + onLongPress: item.enabled ? item.onLongPress : null, + child: const SizedBox.expand(), + ); +} + +/// Answers a hit only inside [rects]: the gaps between a bar's buttons are +/// the page's, so a tap there still toggles the controls and a double tap +/// still seeks. +class SlotHitBox extends SingleChildRenderObjectWidget { + const SlotHitBox({ + super.key, + required this.rects, + this.enabled = true, + super.child, + }); + + final List rects; + final bool enabled; + + @override + RenderSlotHitBox createRenderObject(BuildContext context) => + RenderSlotHitBox(rects, enabled: enabled); + + @override + void updateRenderObject(BuildContext context, RenderSlotHitBox renderObject) { + renderObject + ..rects = rects + ..enabled = enabled; + } +} + +class RenderSlotHitBox extends RenderProxyBox { + RenderSlotHitBox(this.rects, {this.enabled = true}); + + List rects; + bool enabled; + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!enabled || !rects.any((r) => r.contains(position))) return false; + return super.hitTest(result, position: position); + } +} diff --git a/lib/core/native/native_link.dart b/lib/core/native/native_link.dart new file mode 100644 index 00000000..046495e0 --- /dev/null +++ b/lib/core/native/native_link.dart @@ -0,0 +1,240 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../glass/glass_surface.dart' show PlatformViewMark; + +/// What a hosted view says back: the method and its arguments. +typedef NativeHeard = void Function(String method, Object? arguments); + +/// The way to one hosted view once it exists. The channel's in the app; a +/// test's fake records what is said and says things back. +abstract interface class NativeLink { + /// Tells UIKit [method], not waited on. Nothing once [dispose]d. + void say(String method, [Object? arguments]); + + /// Asks UIKit [method]; anything that goes wrong is null. + Future ask(String method, [Object? arguments]); + + void dispose(); +} + +/// A tap and a finger resting [hold]: a drag that starts on the view stays +/// the page's (swipe-seek), as it does over an `InkWell`. +Set> pressGestures({ + Duration hold = const Duration(milliseconds: 120), +}) => { + Factory(TapGestureRecognizer.new), + Factory( + () => LongPressGestureRecognizer(duration: hold), + ), +}; + +/// Where hosted views come from: `UiKitView`s and their channels in the app +/// ([ChannelNativeHost]), a fake in a test. +abstract interface class NativeHost { + Widget view({ + required String type, + required Map params, + required Set> gestures, + required void Function(NativeLink) onLink, + required NativeHeard onHeard, + }); +} + +/// Hosted views as the phone makes them: a `UiKitView` of the type and the +/// view's own channel, `/`. +final class ChannelNativeHost implements NativeHost { + const ChannelNativeHost(); + + @override + Widget view({ + required String type, + required Map params, + required Set> gestures, + required void Function(NativeLink) onLink, + required NativeHeard onHeard, + }) => PlatformViewMark( + child: UiKitView( + viewType: type, + creationParams: params, + creationParamsCodec: const StandardMessageCodec(), + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + gestureRecognizers: gestures, + onPlatformViewCreated: (id) => + onLink(ChannelNativeLink(MethodChannel('$type/$id'), onHeard)), + ), + ); +} + +final class ChannelNativeLink implements NativeLink { + ChannelNativeLink(this._channel, NativeHeard onHeard) { + _channel.setMethodCallHandler((call) async { + if (!_disposed) onHeard(call.method, call.arguments); + }); + } + + final MethodChannel _channel; + bool _disposed = false; + + @override + void say(String method, [Object? arguments]) { + if (_disposed) return; + _channel.invokeMethod(method, arguments).catchError((Object _) {}); + } + + @override + Future ask(String method, [Object? arguments]) async { + if (_disposed) return null; + try { + return await _channel.invokeMethod(method, arguments); + } on Object { + return null; + } + } + + @override + void dispose() { + _disposed = true; + _channel.setMethodCallHandler(null); + } +} + +/// Hands the hosted views under it a [NativeHost] in place of the channels: +/// a test's fake. +class NativeHostScope extends InheritedWidget { + const NativeHostScope({super.key, required this.host, required super.child}); + + final NativeHost host; + + static NativeHost of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.host ?? + const ChannelNativeHost(); + + @override + bool updateShouldNotify(NativeHostScope old) => old.host != host; +} + +/// The state of a hosted control: the link to its view and what the view was +/// last told, so it is told only what changed. +/// +/// A control writes [sync] — every [tell] of what it shows, run once the view +/// exists and on the frame after each new configuration — and [heard], what +/// UIKit says back. It builds its view with [hosted]. +mixin NativeControlState on State { + NativeLink? _link; + final Map _told = {}; + Map _made = const {}; + + @protected + void sync(); + + @protected + void heard(String method, Object? arguments); + + @protected + bool get linked => _link != null; + + /// Tells the view [method] with [value] unless that is what [method] last + /// told it, compared list by list and key by key. + @protected + void tell(String method, Object? value) { + final link = _link; + if (link == null) return; + if (_told.containsKey(method) && nativeSame(_told[method], value)) return; + _told[method] = value; + link.say(method, value); + } + + /// What [method] last told the view. + @protected + Object? toldOf(String method) => _told[method]; + + /// Counts [value] as told by [method], for a control that said it its own + /// way (a diff of it). + @protected + void remember(String method, Object? value) => _told[method] = value; + + /// Says [method] whatever was said before: a move, not a state. + @protected + void say(String method, [Object? arguments]) => _link?.say(method, arguments); + + /// The view. [made] is what it is created with, keyed by the method that + /// tells each part later, and counts as told. + @protected + Widget hosted({ + required String type, + required Map params, + required Set> gestures, + Map made = const {}, + }) { + for (final MapEntry(:key, :value) in made.entries) { + _told.putIfAbsent(key, () => value); + } + _made = made; + return NativeHostScope.of(context).view( + type: type, + params: params, + gestures: gestures, + onLink: _linked, + onHeard: _heard, + ); + } + + void _linked(NativeLink link) { + if (!mounted) { + link.dispose(); + return; + } + final again = _link != null; + _link?.dispose(); + _link = link; + // A view made again was made with what the last build handed it. + if (again) { + _told + ..clear() + ..addAll(_made); + } + sync(); + } + + void _heard(String method, Object? arguments) { + if (mounted) heard(method, arguments); + } + + @override + void didUpdateWidget(covariant W oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && linked) sync(); + }); + } + + @override + void dispose() { + _link?.dispose(); + _link = null; + super.dispose(); + } +} + +/// Two things told alike: lists item by item, maps key by key. +bool nativeSame(Object? a, Object? b) { + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!nativeSame(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || !nativeSame(a[key], b[key])) return false; + } + return true; + } + return a == b; +} diff --git a/lib/core/native/player_controls_style.dart b/lib/core/native/player_controls_style.dart new file mode 100644 index 00000000..5f0f8173 --- /dev/null +++ b/lib/core/native/player_controls_style.dart @@ -0,0 +1,111 @@ +import 'package:flutter/widgets.dart'; + +import '../glass/native_glass.dart'; +import 'native_controls_gate.dart'; + +/// How the player's controls are drawn on an iPhone, as chosen in +/// Settings → Player. +enum PlayerControlsStyle { + /// iOS 26's own Liquid Glass buttons, pull-down menus and iOS sheets. + native, + + /// Flutter's buttons, each on the phone's own clear glass. + systemGlass, + + /// Flutter's buttons on the Flutter shader glass, with the Flutter sheets. + classicGlass; + + /// The 3.2.1 look, until the viewer picks another. + static const PlayerControlsStyle fallback = PlayerControlsStyle.classicGlass; + + /// Stored by name; anything unknown reads as [fallback]. + static PlayerControlsStyle fromName(Object? name) => + values.firstWhere((s) => s.name == name, orElse: () => fallback); +} + +/// Whether the phone has the clear system glass (iOS 26+) that the system +/// glass style stands on. +bool systemGlassAvailable(NativeGlassCapability cap) => + cap.systemGlass && cap.styles.contains(NativeGlassStyle.clear.name); + +/// Whether [style] can be drawn on a phone answering [cap]. +bool playerControlsStyleAvailable( + PlayerControlsStyle style, + NativeGlassCapability cap, +) => switch (style) { + PlayerControlsStyle.native => systemGlassAvailable(cap) && cap.glassControls, + PlayerControlsStyle.systemGlass => systemGlassAvailable(cap), + PlayerControlsStyle.classicGlass => true, +}; + +/// Whether the player should offer the native controls once: an iPhone or +/// iPad that can draw them, a viewer who never chose a style, and an offer +/// not shown before. +bool shouldOfferNativeControls({ + required bool isIOS, + required bool isTv, + required bool isDesktop, + required NativeGlassCapability cap, + required bool reduceTransparency, + required PlayerControlsStyle? stored, + required bool alreadyOffered, +}) { + if (!isIOS || isTv || isDesktop || reduceTransparency) return false; + if (stored != null || alreadyOffered) return false; + return playerControlsStyleAvailable(PlayerControlsStyle.native, cap); +} + +/// What the offer's answer stores: "Try it" is [PlayerControlsStyle.native], +/// and "Not now" or any other dismissal keeps the classic glass for good. +PlayerControlsStyle nativeOfferOutcome(bool? accepted) => accepted == true + ? PlayerControlsStyle.native + : PlayerControlsStyle.classicGlass; + +/// The style the player actually draws for [chosen]: classic glass off an +/// iPhone, under Reduce Transparency, and below iOS 26. The native choice on +/// a phone with the glass but not the bar factory keeps the system glass +/// under Flutter's buttons, as it always did. +PlayerControlsStyle effectivePlayerControlsStyle({ + required PlayerControlsStyle chosen, + required bool isIOS, + required bool isTv, + required bool isDesktop, + required NativeGlassCapability cap, + required bool reduceTransparency, +}) { + if (!isIOS || isTv || isDesktop || reduceTransparency) { + return PlayerControlsStyle.classicGlass; + } + if (!systemGlassAvailable(cap)) return PlayerControlsStyle.classicGlass; + if (chosen == PlayerControlsStyle.native) { + return nativeControlsDraw( + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap, + reduceTransparency: reduceTransparency, + ) + ? PlayerControlsStyle.native + : PlayerControlsStyle.systemGlass; + } + return chosen; +} + +/// The chosen style for everything under it; the player's media glass layers +/// read it to keep to the shader on [PlayerControlsStyle.classicGlass]. +class PlayerControlsStyleScope extends InheritedWidget { + const PlayerControlsStyleScope({ + super.key, + required this.style, + required super.child, + }); + + final PlayerControlsStyle style; + + static PlayerControlsStyle? maybeOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.style; + + @override + bool updateShouldNotify(PlayerControlsStyleScope old) => old.style != style; +} diff --git a/lib/core/native/sf_symbols.dart b/lib/core/native/sf_symbols.dart new file mode 100644 index 00000000..2cf97b44 --- /dev/null +++ b/lib/core/native/sf_symbols.dart @@ -0,0 +1,67 @@ +/// The SF Symbol each player control wears on the native glass bars, keyed by +/// the control's id (`PlayerControlCatalogue`) and the player's own pieces. +/// +/// Apple's own where Apple's players have one (AVKit: `gobackward.10`, +/// `gauge.with.dots.needle.67percent`, `pip.enter`, `captions.bubble`), the +/// nearest standard symbol elsewhere. Every name is checked against the +/// phone's own list in test/core/native/sf_symbols_test.dart. +const Map playerSymbols = { + 'back': 'chevron.backward', + 'language': 'character.bubble', + 'subtitles': 'captions.bubble', + 'settings': 'gearshape', + 'orientation': 'rectangle.portrait.rotate', + 'lock': 'lock.fill', + 'unlock': 'lock.open.fill', + 'stats': 'info.circle', + 'speed': 'gauge.with.dots.needle.67percent', + 'server': 'server.rack', + 'quality': 'slider.horizontal.3', + 'episodes': 'rectangle.stack', + 'previous': 'backward.end.fill', + 'next': 'forward.end.fill', + 'shader': 'sparkles', + 'fit': 'aspectratio', + 'sleep': 'moon.zzz', + 'cast': 'tv.badge.wifi', + 'party': 'person.2', + 'pip': 'pip.enter', + 'download': 'arrow.down.circle', + 'rewind': 'gobackward', + 'forward': 'goforward', + 'play': 'play.fill', + 'pause': 'pause.fill', + 'replay': 'arrow.counterclockwise', + 'react': 'face.smiling', + 'comments': 'bubble.left.and.bubble.right', + 'skip': 'forward.fill', + 'more': 'ellipsis.circle', +}; + +/// The filled face of a control while it is on. +const Map activeSymbols = { + 'subtitles': 'captions.bubble.fill', + 'stats': 'info.circle.fill', + 'sleep': 'moon.zzz.fill', + 'download': 'arrow.down.circle.fill', + 'party': 'person.2.fill', + 'cast': 'tv.badge.wifi.fill', +}; + +/// [id]'s symbol, its filled face when [active] and it has one. +String playerSymbol(String id, {bool active = false}) => + (active ? activeSymbols[id] : null) ?? playerSymbols[id] ?? 'circle'; + +/// The rotate button names the turn it makes: the phone upright turns to +/// landscape, and back. +String orientationSymbol({required bool portrait}) => + portrait ? 'rectangle.portrait.rotate' : 'rectangle.landscape.rotate'; + +/// The step lengths SF Symbols draws a number into. +const Set _numberedSteps = {5, 10, 15, 30, 45, 60, 75, 90}; + +String rewindSymbol(int s) => + _numberedSteps.contains(s) ? 'gobackward.$s' : 'gobackward'; + +String forwardSymbol(int s) => + _numberedSteps.contains(s) ? 'goforward.$s' : 'goforward'; diff --git a/lib/core/network/media_request_headers.dart b/lib/core/network/media_request_headers.dart new file mode 100644 index 00000000..2b6c3f14 --- /dev/null +++ b/lib/core/network/media_request_headers.dart @@ -0,0 +1,48 @@ +import 'package:soplay/core/network/fetch_metadata.dart'; +import 'package:soplay/core/network/http_headers.dart'; +import 'package:soplay/core/network/user_agent.dart'; + +/// The Referer a provider's CDN wants when the source itself names none. +String? defaultMediaReferer(String? provider) { + switch (provider?.toLowerCase()) { + case 'asilmedia': + return 'https://asilmedia.org/'; + default: + return null; + } +} + +/// The headers a stream is fetched with: the app's defaults, then whatever +/// the source asked for on top, then browser fetch metadata. +/// +/// One rule for the player and the downloader. The downloader used to send +/// the source's headers and nothing else, and most sources name only a +/// Referer — so the file went out under `Dalvik/2.1.0` (the Android service's +/// HttpURLConnection) or `Dart/3.x (dart:io)`, while the player had asked for +/// the same url as the device's browser. Hosts that turn a bare HTTP client +/// away with a page rather than a status served that page, and the episode +/// that played failed in Downloads as "a web page instead of the video". +/// +/// Loopback urls (the app's own proxies) get the source's headers unchanged: +/// the proxy carries the upstream ones itself. +Map mediaRequestHeaders( + Uri uri, + Map sourceHeaders, { + String? provider, +}) { + if (uri.host == '127.0.0.1' || uri.host == 'localhost') { + return Map.of(sourceHeaders); + } + final referer = defaultMediaReferer(provider); + final merged = mergeHttpHeaders([ + { + 'User-Agent': kSozoUserAgent, + 'Accept': '*/*', + 'Accept-Language': 'uz,ru;q=0.9,en;q=0.8', + 'Referer': ?referer, + }, + sourceHeaders, + ]); + addFetchMetadata(merged, uri); + return merged; +} diff --git a/lib/core/network/web_page_sniff.dart b/lib/core/network/web_page_sniff.dart new file mode 100644 index 00000000..e9e3b028 --- /dev/null +++ b/lib/core/network/web_page_sniff.dart @@ -0,0 +1,46 @@ +/// How many leading bytes [looksLikeWebPage] needs to be sure: room for a BOM +/// and blank lines ahead of the markup. +const int webPageSniffBytes = 512; + +/// Whether the first bytes of a body are a web page rather than media. +/// +/// Not the Content-Type: disguised HLS CDNs label real MPEG-TS `text/html` +/// (vidapi's `page-N.html`, anikoto's `seg-N-….html`, checked 2026-10-25), and +/// the player, which never reads that header, plays them. Deciding by the +/// header failed every such episode as "the source sent a web page". +/// +/// Only markup counts: an AES-128 segment is random bytes, so "starts like TS" +/// cannot be the test. An SVG or XML prolog is not refused either, since a +/// comic page can be one. Same rule as `HlsDownloadPlaylist.looksLikeWebPage` +/// on Android, so both downloaders refuse the same answers. +bool looksLikeWebPage(List head) { + var i = 0; + if (head.length >= 3 && + head[0] == 0xEF && + head[1] == 0xBB && + head[2] == 0xBF) { + i = 3; + } + while (i < head.length && _isMarkupSpace(head[i])) { + i++; + } + final end = i + _prefixBytes > head.length ? head.length : i + _prefixBytes; + if (end <= i) return false; + final text = String.fromCharCodes(head.sublist(i, end)).toLowerCase(); + return _webPageStarts.any(text.startsWith); +} + +const int _prefixBytes = 16; + +const List _webPageStarts = [ + ' + b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C; diff --git a/lib/core/player/download_media_probe.dart b/lib/core/player/download_media_probe.dart index b7247205..12fce0b7 100644 --- a/lib/core/player/download_media_probe.dart +++ b/lib/core/player/download_media_probe.dart @@ -1,14 +1,48 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; +import 'package:soplay/core/network/media_request_headers.dart'; +import 'package:soplay/core/network/web_page_sniff.dart'; import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; -/// Checks mixed mirror candidates before enqueueing one. Reads only a prefix, -/// never the movie, and rejects an HTTP-success HTML error as well as 403s. +/// What the first 2 KB of a download candidate said. +enum MediaProbeVerdict { + /// The playlist or a container the downloader can save. + media, + + /// Markup where the media should be: a challenge, an error or a home page + /// served with a success status. + webPage, + + /// An answer that is no good for another reason: an error status, or bytes + /// that are neither the playlist nor a container this probe knows. + rejected, + + /// No answer in time, or no connection. That says nothing about the file, + /// so it must not be read as a no. + noAnswer, +} + +/// Checks download candidates before one is enqueued. Reads only a prefix, +/// never the movie. class DownloadMediaProbe { - DownloadMediaProbe(this.dio); + DownloadMediaProbe(this.dio, {this.timeout = const Duration(seconds: 8)}); final Dio dio; + final Duration timeout; - Future accepts(MediaResolveEntity media) async { + /// For mixed mirror results: only a candidate that proved itself is taken, + /// since the next mirror is right there to try instead. + Future accepts(MediaResolveEntity media) async => + await verdict(media) == MediaProbeVerdict.media; + + /// For a plain direct result, at tap time: true only for a definite web + /// page. A slow host, an odd container or an error status still queue as + /// they always did; only a page fails here instead of minutes later in the + /// list, under a message that says what it is. + Future servesWebPage(MediaResolveEntity media) async => + await verdict(media) == MediaProbeVerdict.webPage; + + Future verdict(MediaResolveEntity media) async { final cancel = CancelToken(); try { final response = await dio @@ -16,47 +50,88 @@ class DownloadMediaProbe { media.videoUrl, cancelToken: cancel, options: Options( - headers: {...media.headers, 'Range': 'bytes=0-2047'}, + // What the downloader will send, not what this client adds by + // default: a probe that passed under a browser's agent while the + // transfer went out under a bare one cleared a url that then + // failed in the list as a web page. + headers: { + ...mediaRequestHeaders( + Uri.parse(media.videoUrl), + media.headers, + ), + 'Range': 'bytes=0-2047', + }, responseType: ResponseType.stream, - sendTimeout: const Duration(seconds: 8), - receiveTimeout: const Duration(seconds: 8), + sendTimeout: timeout, + receiveTimeout: timeout, validateStatus: (_) => true, ), ) - .timeout(const Duration(seconds: 8)); + .timeout(timeout); if ((response.statusCode ?? 500) >= 400 || response.data == null) { - return false; + return MediaProbeVerdict.rejected; } final bytes = []; - await for (final chunk in response.data!.stream.timeout( - const Duration(seconds: 8), - )) { + await for (final chunk in response.data!.stream.timeout(timeout)) { bytes.addAll(chunk.take(2048 - bytes.length)); - if (bytes.length >= 512) break; - } - final text = utf8.decode(bytes, allowMalformed: true).trimLeft(); - if (media.type == 'hls' || media.videoUrl.contains('.m3u8')) { - return text.startsWith('#EXTM3U'); - } - if (media.type == 'dash' || media.videoUrl.contains('.mpd')) { - return RegExp(r')').hasMatch(text); + if (bytes.length >= webPageSniffBytes) break; } - if (text.toLowerCase().startsWith('= 12 && - (String.fromCharCodes(bytes.sublist(4, 8)) == 'ftyp' || - (bytes[0] == 0x1a && - bytes[1] == 0x45 && - bytes[2] == 0xdf && - bytes[3] == 0xa3) || - bytes[0] == 0x47 || - text.startsWith('#EXTM3U')); + return classifyPrefix(media, bytes); + } on TimeoutException { + return MediaProbeVerdict.noAnswer; + } on DioException catch (e) { + // An answer with a status was handled above; without one the request + // never got that far. + return e.response == null + ? MediaProbeVerdict.noAnswer + : MediaProbeVerdict.rejected; } catch (_) { - return false; + return MediaProbeVerdict.noAnswer; } finally { cancel.cancel('Media prefix checked'); } } + + /// The verdict on the first bytes of [media]'s body. + static MediaProbeVerdict classifyPrefix( + MediaResolveEntity media, + List bytes, + ) { + if (looksLikeWebPage(bytes)) return MediaProbeVerdict.webPage; + final text = utf8.decode(bytes, allowMalformed: true).trimLeft(); + if (media.type == 'hls' || media.videoUrl.contains('.m3u8')) { + return text.startsWith('#EXTM3U') + ? MediaProbeVerdict.media + : MediaProbeVerdict.rejected; + } + if (media.type == 'dash' || media.videoUrl.contains('.mpd')) { + return RegExp(r')').hasMatch(text) + ? MediaProbeVerdict.media + : MediaProbeVerdict.rejected; + } + return _isContainer(bytes) || text.startsWith('#EXTM3U') + ? MediaProbeVerdict.media + : MediaProbeVerdict.rejected; + } + + /// A file the downloader can keep, by its magic. MP4 and QuickTime were + /// only taken with `ftyp` first, and a QuickTime file may open with `wide`, + /// `mdat` or `moov`; AVI and FLV were not taken at all. + static bool _isContainer(List bytes) { + if (bytes.isEmpty) return false; + if (bytes[0] == 0x47) return true; // MPEG-TS sync byte. + if (bytes.length < 4) return false; + if (bytes[0] == 0x1a && + bytes[1] == 0x45 && + bytes[2] == 0xdf && + bytes[3] == 0xa3) { + return true; // EBML: Matroska, WebM. + } + final lead = String.fromCharCodes(bytes.sublist(0, 4)); + if (lead == 'RIFF') return true; // AVI. + if (lead.startsWith('FLV')) return true; + if (bytes.length < 8) return false; + const boxes = {'ftyp', 'moov', 'mdat', 'wide', 'free', 'skip', 'styp'}; + return boxes.contains(String.fromCharCodes(bytes.sublist(4, 8))); + } } diff --git a/lib/core/player/drm_controller.dart b/lib/core/player/drm_controller.dart index 7823107a..3a3beffb 100644 --- a/lib/core/player/drm_controller.dart +++ b/lib/core/player/drm_controller.dart @@ -170,6 +170,10 @@ class DrmController extends PlayerController { @override bool get letterboxesInternally => false; + /// DrmPlayerHost builds its ExoPlayer with handleAudioFocus on. + @override + bool get handlesAudioFocus => true; + /// Not exposed by this backend yet. /// /// ExoPlayer does have track selection, and a DRM stream is precisely the diff --git a/lib/core/player/hls_segment_filter.dart b/lib/core/player/hls_segment_filter.dart new file mode 100644 index 00000000..e57d4e2f --- /dev/null +++ b/lib/core/player/hls_segment_filter.dart @@ -0,0 +1,86 @@ +/// Takes the segments [drop] matches out of a media playlist, with their timing. +/// +/// Some catalogues splice a full-screen ad into every stream. It is ordinary +/// segments between two discontinuities, so the player cannot tell it from the +/// film; only the path gives it away, and the source knows that path. The two +/// discontinuities around it become one, because the film's timestamps still +/// jump there. +/// +/// A master playlist comes back as it is: its entries are not segments. So does a +/// playlist the rule would empty, since that rule is wrong rather than the film. +String dropHlsSegments(String playlist, RegExp drop) { + if (playlist.contains('#EXT-X-STREAM-INF')) return playlist; + final out = []; + // From an #EXTINF up to its uri, which decides whether they stay. + final pending = []; + var discontinuity = false; + var kept = 0; + var dropped = 0; + for (final line in playlist.split('\n')) { + final s = line.trim(); + if (s == '#EXT-X-DISCONTINUITY') { + discontinuity = true; + } else if (s.startsWith('#EXTINF') || + pending.isNotEmpty && s.startsWith('#')) { + pending.add(line); + } else if (s.isEmpty || s.startsWith('#')) { + out.add(line); + } else if (drop.hasMatch(s)) { + pending.clear(); + dropped++; + } else { + if (discontinuity) out.add('#EXT-X-DISCONTINUITY'); + discontinuity = false; + out + ..addAll(pending) + ..add(line); + pending.clear(); + kept++; + } + } + if (dropped == 0 || kept == 0) return playlist; + return out.join('\n'); +} + +const _pngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; +const _iend = [0x49, 0x45, 0x4E, 0x44]; +const _tsSync = 0x47; +const _tsPacket = 188; + +/// Whether these first bytes are a PNG's. +bool startsWithPng(List bytes) { + if (bytes.length < _pngSignature.length) return false; + for (var i = 0; i < _pngSignature.length; i++) { + if (bytes[i] != _pngSignature[i]) return false; + } + return true; +} + +/// Where the MPEG-TS starts in a segment wrapped in a PNG, or null if it cannot +/// be told yet from these bytes. +/// +/// Some hosts store video on image CDNs: a real picture, some padding, then the +/// stream, at an offset that differs per segment. Neither player reads past the +/// picture. One sync byte proves nothing, since padding may hold one, so the next +/// packet has to start where it should as well. +int? transportStreamStart(List bytes) { + var end = -1; + for (var i = _pngSignature.length; i + _iend.length <= bytes.length; i++) { + if (bytes[i] == _iend[0] && + bytes[i + 1] == _iend[1] && + bytes[i + 2] == _iend[2] && + bytes[i + 3] == _iend[3]) { + end = i + _iend.length + 4; // The chunk's CRC. + break; + } + } + if (end < 0) return null; + for (var i = end; i + _tsPacket * 2 < bytes.length; i++) { + if (bytes[i] == _tsSync && + bytes[i + _tsPacket] == _tsSync && + bytes[i + _tsPacket * 2] == _tsSync) { + return i; + } + } + return null; +} diff --git a/lib/core/player/local_hls_proxy.dart b/lib/core/player/local_hls_proxy.dart index 3661ebc1..d0f8fd96 100644 --- a/lib/core/player/local_hls_proxy.dart +++ b/lib/core/player/local_hls_proxy.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:soplay/core/player/hls_segment_filter.dart'; import 'package:soplay/core/player/dash_manifest.dart'; import 'package:soplay/core/player/hls_variants.dart'; import 'package:soplay/core/player/media_segment_relay.dart'; @@ -45,6 +47,8 @@ class LocalHlsProxy { // the menu was built may be picked long after, and an evicted session // answers 410 — the row looked fine and did nothing. bool longLived = false, + // Segments to leave out of the playlists, see [dropHlsSegments]. + String? dropSegments, }) async { await _ensureStarted(); final id = _randomId(); @@ -65,11 +69,23 @@ class LocalHlsProxy { hlsHeight: hlsHeight, keepOriginHeaders: keepOriginHeaders, longLived: longLived, + dropSegments: _ruleFrom(dropSegments), ); final query = parsed.hasQuery ? '?${parsed.query}' : ''; return 'http://127.0.0.1:$_port/hls/$id${parsed.path}$query'; } + // Comes from the server, so a rule that does not compile plays the stream as + // it is rather than failing it. + RegExp? _ruleFrom(String? pattern) { + if (pattern == null || pattern.isEmpty) return null; + try { + return RegExp(pattern); + } catch (_) { + return null; + } + } + Future dispose() async { _cleanupTimer?.cancel(); _cleanupTimer = null; @@ -152,8 +168,24 @@ class LocalHlsProxy { } upstreamHeaders[k] = v; }); - _forwardIncomingHeader(req, upstreamHeaders, 'range', 'Range'); - _forwardIncomingHeader(req, upstreamHeaders, 'if-range', 'If-Range'); + // A playlist is the one thing this proxy cannot pass along as it arrives: + // every segment address in it is rewritten to a loopback path, so it has to + // be read whole and is answered out of the rewritten bytes by _sendBuffer, + // range and all. Forwarding the player's Range would have the upstream hand + // back a few bytes of text instead, and a rewrite of those bytes is a + // playlist that claims to be the whole file. uzmovi's signed endpoint + // happens to ignore Range here, but a static .m3u8 on plain nginx does not. + // + // A segment hidden inside a picture is the same story for the same reason: + // where its transport stream begins differs per file and can only be found + // by reading from the top, so a host that honours the player's `bytes=0-1` + // probe would hand back two bytes of PNG signature with nowhere to look for + // the video. These are answered out of the unwrapped bytes further down, + // and a real picture at such a path is sliced here just the same. + if (!_mustBeReadWhole(cdnPath) && !_mustBeReadWhole(resolved)) { + _forwardIncomingHeader(req, upstreamHeaders, 'range', 'Range'); + _forwardIncomingHeader(req, upstreamHeaders, 'if-range', 'If-Range'); + } upstreamHeaders.putIfAbsent('Accept-Encoding', () => 'identity'); try { @@ -164,6 +196,21 @@ class LocalHlsProxy { ); final requestUrl = transformed?.url ?? upstreamUrl; final requestHeaders = transformed?.headers ?? upstreamHeaders; + + // AVPlayer — the engine every iOS playback runs on, see + // resolvePlayerEngine() — asks for a resource's size with a HEAD before + // it reads a byte of it, for the playlist and for each segment. A HEAD + // served by fetching the whole body would double the traffic the probe + // exists to avoid, so anything that is not a playlist is probed upstream + // with a HEAD as well and only its headers come back. A playlist has to + // be fetched and rewritten before its length is known, and a CDN that + // refuses HEAD outright falls through to the GET below with its body + // dropped. + final isHead = req.method == 'HEAD'; + if (isHead && !_looksLikeManifest(resolved)) { + if (await _answerHead(req, requestUrl, requestHeaders)) return; + } + final resp = await _dio.get( requestUrl, options: Options( @@ -201,9 +248,60 @@ class LocalHlsProxy { return; } + // A playlist at `/playlist` or `/master.txt` served as text says what it + // is only in its first bytes. Passed through unread, its absolute segment + // addresses went straight to the CDN with none of the session's headers. + var stream = body.stream; + var manifest = isManifest; + var unwrapped = false; + if (!isManifest && + !isDash && + !contentType.startsWith('video/') && + !contentType.startsWith('audio/')) { + final reader = StreamIterator(stream); + final first = await reader.moveNext() ? reader.current : Uint8List(0); + manifest = String.fromCharCodes(first.take(7)) == '#EXTM3U'; + stream = _resumed(first, reader); + if (!manifest) { + // Both players open a segment with `Range: bytes=0-`, so a 206 from + // the top is the whole file too. Further in, the wrapper is not there + // to find. + final fromTop = + status == 200 || + (status == 206 && + (resp.headers.value('content-range') ?? '').startsWith( + 'bytes 0-', + )); + unwrapped = fromTop && startsWithPng(first); + if (unwrapped) stream = _withoutImage(stream); + } + } + + // A playlist is answered out of bytes this proxy rewrote, so it has to + // hold the whole file before it rewrites anything. The name-based test + // above keeps the player's Range away from a `.m3u8` or `.mpd`, but a + // playlist served at `/playlist` or `/master.txt` names itself only in + // its first bytes — by which time the Range has gone upstream and a + // range-honouring host has answered with a slice of the text. Rewriting + // that slice would hand the player a manifest cut off mid-segment-list + // declaring itself whole, so the slice is dropped and the file asked for + // again entire. + final upstreamRange = resp.headers.value(HttpHeaders.contentRangeHeader); + if ((manifest || isDash) && + status == HttpStatus.partialContent && + !_coversWholeBody(upstreamRange)) { + await stream.drain(); + final whole = await _refetchWithoutRange(requestUrl, requestHeaders); + if (whole == null) { + await _reject(req, 502); + return; + } + stream = whole; + } + if (isDash) { final bytes = []; - await for (final chunk in body.stream) { + await for (final chunk in stream) { bytes.addAll(chunk); } if (origin == sess.origin) { @@ -225,14 +323,14 @@ class LocalHlsProxy { 'application', 'dash+xml', ); - req.response.add(utf8.encode(narrowed)); + _sendBuffer(req, utf8.encode(narrowed), isHead: isHead); await req.response.close(); return; } - if (isManifest) { + if (manifest) { final bytes = []; - await for (final chunk in body.stream) { + await for (final chunk in stream) { bytes.addAll(chunk); } if (origin == sess.origin) { @@ -241,7 +339,9 @@ class LocalHlsProxy { sess.basePath = resolved.substring(0, lastSlash); } } - final text = utf8.decode(bytes, allowMalformed: true); + final raw = utf8.decode(bytes, allowMalformed: true); + final rule = sess.dropSegments; + final text = rule == null ? raw : dropHlsSegments(raw, rule); String rewritten; try { rewritten = _rewriteM3u8( @@ -264,19 +364,125 @@ class LocalHlsProxy { 'application', 'vnd.apple.mpegurl', ); - req.response.add(utf8.encode(rewritten)); + _sendBuffer(req, utf8.encode(rewritten), isHead: isHead); + } else if (unwrapped) { + // The payload behind the wrapper is shorter than the upstream file by + // an amount known only once the picture has been read past, so neither + // the upstream's length nor its Content-Range says anything true about + // it. Answering a Range with a 200 carrying everything, and with + // byte ranges disclaimed, is the protocol error AVPlayer abandons the + // stream over — iOS plays every one of these sources, so the unwrapped + // bytes are gathered here and the range is cut from them, exactly as + // the rewritten playlists above are served. A segment is the one body + // small enough for that: the wrapper search already holds up to + // [_imageWrapperLimit] of it in memory before it gives up. + final payload = await _collect(stream); + req.response.headers.set(HttpHeaders.contentTypeHeader, 'video/mp2t'); + _sendBuffer(req, payload, isHead: isHead); } else { req.response.statusCode = status; if (contentType.isNotEmpty) { req.response.headers.set(HttpHeaders.contentTypeHeader, contentType); } - _copyHeader(resp, req, 'content-length'); - _copyHeader(resp, req, 'content-range'); - _copyHeader(resp, req, 'accept-ranges'); _copyHeader(resp, req, 'cache-control'); _copyHeader(resp, req, 'etag'); _copyHeader(resp, req, 'last-modified'); - await req.response.addStream(body.stream); + + // Some of the hosts behind this proxy ignore Range and answer the whole + // file with a 200 — uzmovi's own srv*.uzdown.space honours it, but the + // signed endpoint is one nginx config away from not doing so, and the + // extractor sources behind the generic path vary. libmpv and ExoPlayer + // shrug a full 200 off and read the body they were given; AVPlayer, the + // only engine iOS has, treats a 200 in reply to its own Range as a + // protocol error and abandons the stream. Since the upstream told us + // the total size, the range it refused can be served from here: slice + // the body and answer the 206 it asked for. + final wanted = _parseRange(req.headers.value('range')); + final total = _intHeader(resp, HttpHeaders.contentLengthHeader); + if (wanted != null && status == 200 && total != null) { + final start = wanted.start; + if (start >= total) { + await stream.drain(); + req.response.statusCode = HttpStatus.requestedRangeNotSatisfiable; + req.response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes */$total', + ); + req.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + await req.response.close(); + return; + } + final end = wanted.end == null || wanted.end! > total - 1 + ? total - 1 + : wanted.end!; + final length = end - start + 1; + // The slice is gathered before a byte of the response goes out, so + // the Content-Length committed here is one that can actually be + // delivered. Streaming it straight through meant trusting the + // upstream's declared total: a host that announces more bytes than it + // sends left the response promised a length it could never reach, + // dying mid-flight with the player already reading it instead of + // failing cleanly before the headers. Only a range larger than + // [_sliceBufferLimit] is still streamed, since nothing the players + // ask for here is that big and a whole film must not be held in + // memory on the off chance that it is. + void commit() { + req.response.statusCode = HttpStatus.partialContent; + req.response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes $start-$end/$total', + ); + req.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + req.response.headers.contentLength = length; + } + + if (length <= _sliceBufferLimit) { + final List slice; + try { + slice = await _collect(_slice(stream, start, end)); + } catch (e) { + debugPrint('[HLS_PROXY] slice failed: $e'); + await _reject(req, 502); + return; + } + if (slice.length != length) { + debugPrint( + '[HLS_PROXY] upstream declared $total bytes and sent fewer: ' + '$requestUrl', + ); + await _reject(req, 502); + return; + } + commit(); + if (!isHead) req.response.add(slice); + } else { + commit(); + final sliced = _slice(stream, start, end); + if (isHead) { + await sliced.drain(); + } else { + await req.response.addStream(sliced); + } + } + await req.response.close(); + return; + } + + _copyHeader(resp, req, 'content-length'); + _copyHeader(resp, req, 'content-range'); + // Claimed even when the upstream stayed quiet about it: with the total + // size in hand this proxy can serve a range itself, and a player that + // is not told ranges are available will not seek at all. + if (resp.headers.value(HttpHeaders.acceptRangesHeader) != null) { + _copyHeader(resp, req, 'accept-ranges'); + } else if (total != null) { + req.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + } + if (isHead) { + await stream.drain(); + } else { + await req.response.addStream(stream); + } } await req.response.close(); } catch (e) { @@ -285,6 +491,277 @@ class LocalHlsProxy { } } + Stream _resumed( + Uint8List first, + StreamIterator rest, + ) async* { + yield first; + while (await rest.moveNext()) { + yield rest.current; + } + } + + /// The stream behind a PNG wrapper, or everything as it came for a real picture. + Stream _withoutImage(Stream source) async* { + final held = BytesBuilder(copy: false); + var found = false; + await for (final chunk in source) { + if (found) { + yield chunk; + continue; + } + held.add(chunk); + final bytes = held.toBytes(); + final start = transportStreamStart(bytes); + if (start != null) { + found = true; + held.clear(); + yield Uint8List.sublistView(bytes, start); + } else if (bytes.length > _imageWrapperLimit) { + found = true; + held.clear(); + yield bytes; + } + } + if (!found) yield held.takeBytes(); + } + + /// Writes a manifest this proxy rewrote, which is the one case where the + /// exact size is known here. + /// + /// Stating Content-Length rather than letting the response go out chunked is + /// what AVPlayer sizes the resource from, and a HEAD answered without one + /// looks to it like an empty manifest. A range is honoured for the same + /// reason: AVPlayer probes a resource before it reads it, and a 200 carrying + /// the whole body in reply to its Range is a protocol error to it. The + /// rewritten manifest is a different length from the upstream one, so this + /// has to be answered from the bytes written here and not from the upstream + /// headers. + void _sendBuffer(HttpRequest req, List payload, {required bool isHead}) { + final wanted = _parseRange(req.headers.value('range')); + req.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + if (wanted == null) { + req.response.headers.contentLength = payload.length; + if (!isHead) req.response.add(payload); + return; + } + if (wanted.start >= payload.length) { + req.response.statusCode = HttpStatus.requestedRangeNotSatisfiable; + req.response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes */${payload.length}', + ); + return; + } + final end = wanted.end == null || wanted.end! > payload.length - 1 + ? payload.length - 1 + : wanted.end!; + req.response.statusCode = HttpStatus.partialContent; + req.response.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes ${wanted.start}-$end/${payload.length}', + ); + req.response.headers.contentLength = end - wanted.start + 1; + if (!isHead) req.response.add(payload.sublist(wanted.start, end + 1)); + } + + /// Whether [path] is a playlist by its name alone, which is all that is + /// known before a body has been read. + bool _looksLikeManifest(String path) { + final lower = path.toLowerCase(); + return lower.endsWith('.m3u8') || lower.endsWith('.mpd'); + } + + /// Whether a body at [path] is one this proxy has to see from its first byte: + /// a playlist, whose every address it rewrites, or a picture, which may be a + /// segment whose transport stream starts at an offset only the bytes reveal. + bool _mustBeReadWhole(String path) { + if (_looksLikeManifest(path)) return true; + final lower = path.toLowerCase(); + return lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.webp'); + } + + /// Answers a HEAD from the upstream's own HEAD, or reports that it could + /// not: a refused HEAD, or one for something that turns out to be a playlist + /// after all, has to be served by fetching the body instead. + Future _answerHead( + HttpRequest req, + String url, + Map headers, + ) async { + final Response resp; + try { + resp = await _dio.head( + url, + options: Options( + headers: headers, + followRedirects: true, + validateStatus: (_) => true, + ), + ); + } catch (e) { + debugPrint('[HLS_PROXY] head failed: $e'); + return false; + } + final status = resp.statusCode ?? 0; + if (status < 200 || status >= 300) { + debugPrint('[HLS_PROXY] upstream HEAD $status: $url'); + return false; + } + final contentType = (resp.headers.value('content-type') ?? '') + .toLowerCase(); + // Only a body this proxy passes through untouched can have its headers + // reported as they came. Anything that might be a playlist is rewritten + // here, which changes its length, so its size has to be measured. + if (!contentType.startsWith('video/') && !contentType.startsWith('audio/')) { + return false; + } + // Everything this answer needs is checked before the response is touched. + // Writing a status and a content type and then reporting failure handed the + // caller a response it had already begun, which the branches below only + // survive because each of them sets both again — a trap for the next edit. + final total = _intHeader(resp, HttpHeaders.contentLengthHeader); + if (total == null) { + debugPrint('[HLS_PROXY] upstream HEAD without a length: $url'); + return false; + } + req.response.statusCode = status; + req.response.headers.set(HttpHeaders.contentTypeHeader, contentType); + req.response.headers.contentLength = total; + _copyHeader(resp, req, 'content-range'); + _copyHeader(resp, req, 'cache-control'); + _copyHeader(resp, req, 'etag'); + _copyHeader(resp, req, 'last-modified'); + if (resp.headers.value(HttpHeaders.acceptRangesHeader) != null) { + _copyHeader(resp, req, 'accept-ranges'); + } else { + req.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + } + await req.response.close(); + return true; + } + + int? _intHeader(Response resp, String name) { + final raw = resp.headers.value(name); + if (raw == null) return null; + return int.tryParse(raw.trim()); + } + + /// The single byte range a player asked for, or null for no range and for + /// the multi-range and suffix forms, which neither engine sends and which + /// are better answered with the whole body than answered wrongly. + _ByteRange? _parseRange(String? raw) { + if (raw == null) return null; + final match = RegExp(r'^bytes=(\d+)-(\d*)$').firstMatch(raw.trim()); + if (match == null) return null; + final start = int.tryParse(match.group(1)!); + if (start == null) return null; + final endText = match.group(2)!; + final end = endText.isEmpty ? null : int.tryParse(endText); + if (end != null && end < start) return null; + return _ByteRange(start, end); + } + + /// Bytes [start]..[end] of [source], counted from the start of the stream. + /// + /// Fewer bytes come out than were asked for when the source ends early, which + /// is how an upstream that declares more than it sends is caught before any + /// of it has been promised to the player. + Stream _slice( + Stream source, + int start, + int end, + ) async* { + final reader = StreamIterator(source); + var offset = 0; + final wanted = end + 1; + try { + while (await reader.moveNext()) { + final chunk = reader.current; + final chunkStart = offset; + offset += chunk.length; + if (offset <= start) continue; + final from = chunkStart >= start ? 0 : start - chunkStart; + final to = offset <= wanted ? chunk.length : wanted - chunkStart; + if (to > from) yield Uint8List.sublistView(chunk, from, to); + if (offset >= wanted) return; + } + } finally { + // Leaving before the end is the usual case here — the upstream ignored + // the range and is still sending the rest of a file nobody wants — so the + // subscription is given up explicitly rather than left to whatever + // teardown the caller happens to perform on this generator. Measured + // against a slow mock, this stops the reading but not the sending: the + // connection stays up and the host delivers the rest into a client that + // discards it. Taking the connection down needs a handle this layer does + // not have, since dio's own CancelToken only aborts a request whose + // response has not arrived yet. + await reader.cancel(); + } + } + + /// Everything [source] carries, in memory. + Future _collect(Stream source) async { + final builder = BytesBuilder(copy: false); + await for (final chunk in source) { + builder.add(chunk); + } + return builder.takeBytes(); + } + + /// Whether a 206's Content-Range says the slice is the whole file after all, + /// which is what the `bytes=0-` both players open a resource with gets back. + bool _coversWholeBody(String? contentRange) { + if (contentRange == null) return false; + final match = RegExp( + r'^bytes\s+(\d+)-(\d+)/(\d+)$', + ).firstMatch(contentRange.trim()); + if (match == null) return false; + final start = int.parse(match.group(1)!); + final end = int.parse(match.group(2)!); + final total = int.parse(match.group(3)!); + return start == 0 && end == total - 1; + } + + /// [url] fetched again with no byte range, for the one body this proxy may + /// not serve in slices: a playlist it has to read and rewrite whole. + Future?> _refetchWithoutRange( + String url, + Map headers, + ) async { + final whole = {}; + headers.forEach((k, v) { + final lower = k.toLowerCase(); + if (lower == 'range' || lower == 'if-range') return; + whole[k] = v; + }); + try { + final resp = await _dio.get( + url, + options: Options( + headers: whole, + responseType: ResponseType.stream, + followRedirects: true, + validateStatus: (_) => true, + ), + ); + final status = resp.statusCode ?? 502; + final body = resp.data; + if (status >= 400 || body == null) { + await body?.stream.drain(); + debugPrint('[HLS_PROXY] playlist refetch $status: $url'); + return null; + } + return body.stream; + } catch (e) { + debugPrint('[HLS_PROXY] playlist refetch failed: $e'); + return null; + } + } + void _forwardIncomingHeader( HttpRequest req, Map headers, @@ -404,6 +881,15 @@ class LocalHlsProxy { } } +/// A single `Range: bytes=start-end` ask, with [end] left open when the player +/// wants everything from [start] on. +class _ByteRange { + const _ByteRange(this.start, this.end); + + final int start; + final int? end; +} + class _TransformedRequest { const _TransformedRequest({required this.url, required this.headers}); @@ -585,6 +1071,14 @@ class _RequestTransform { } } +/// More than any wrapper seen, and past it this is simply a picture. +const _imageWrapperLimit = 2 * 1024 * 1024; + +/// The largest range answered from memory rather than streamed. Comfortably +/// above a segment, which is what a player asks for a range of, and well below +/// a whole film, which it must never pull into memory even if it asks. +const _sliceBufferLimit = 24 * 1024 * 1024; + class _Session { _Session({ required this.origin, @@ -597,8 +1091,10 @@ class _Session { this.hlsHeight, this.keepOriginHeaders = false, this.longLived = false, + this.dropSegments, }); + final RegExp? dropSegments; final String? dashRepresentation; final int? hlsHeight; final bool keepOriginHeaders; diff --git a/lib/core/player/media_controller.dart b/lib/core/player/media_controller.dart index 5924ac01..9b5ae7c1 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -11,9 +11,14 @@ import 'package:soplay/core/player/player_video_track.dart'; import 'package:soplay/core/player/drm_controller.dart'; import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/core/player/playback_readiness.dart'; +import 'package:soplay/core/player/shader_presets.dart'; import 'package:soplay/core/system/platform_utils.dart'; import 'package:video_player/video_player.dart' as vp; +// Beside the controllers they bound: whoever builds one to play a network +// stream also decides how long it may take to start. +export 'package:soplay/core/player/playback_readiness.dart' + show startupBudget, withinStartupBudget; export 'package:video_player/video_player.dart' show VideoPlayerValue, @@ -32,6 +37,10 @@ abstract class PlayerController extends ValueNotifier { /// quality preference; [hlsBitrateFor] maps one to the other. static String mpvHlsBitrate = 'max'; + /// mpv's `hwdec` for the next stream, or null for the default. Set to 'no' + /// for a single retry after mpv failed mid-playback. + static String? mpvHwdec; + /// The `hls-bitrate` for a preferred height (see QualityPreference): the /// top rendition for Auto, the bottom for Data saver, and otherwise a /// ceiling a little above what that height usually streams at, so mpv opens @@ -57,6 +66,7 @@ abstract class PlayerController extends ValueNotifier { vp.VideoPlayerOptions? videoPlayerOptions, DrmConfig? drm, bool preferPlatform = false, + bool preferMediaKit = false, }) { // Encryption decides the backend before anything else does. libmpv cannot // decrypt CENC at all and `video_player` exposes no way to configure it, so @@ -97,7 +107,9 @@ abstract class PlayerController extends ValueNotifier { // reaches here — the player page hands off before building a controller — // but if it somehow does, falling through to the native backend is the // safe answer, not a crash. - if (!preferPlatform && _useMediaKit() && _mediaKitUsable()) { + if (!preferPlatform && + (preferMediaKit || _useMediaKit()) && + _mediaKitUsable()) { return _MediaKitController( _MediaKitSource.uri(url, httpHeaders, formatHint: formatHint), ); @@ -116,8 +128,11 @@ abstract class PlayerController extends ValueNotifier { File file, { vp.VideoPlayerOptions? videoPlayerOptions, bool preferPlatform = false, + bool preferMediaKit = false, }) { - if (!preferPlatform && _useMediaKit() && _mediaKitUsable()) { + if (!preferPlatform && + (preferMediaKit || _useMediaKit()) && + _mediaKitUsable()) { return _MediaKitController(_MediaKitSource.path(file.path)); } return _NativeController( @@ -136,8 +151,18 @@ abstract class PlayerController extends ValueNotifier { Future setVolume(double volume); Future setLooping(bool looping); + /// The loudest [setVolume] accepts: 1.0, or 1.5 where libmpv amplifies. + double get maxVolume => 1.0; + + /// Whether libmpv is the engine actually playing (not its platform fallback). + bool get isMpv => false; + bool get letterboxesInternally; + /// Whether the engine takes and gives up Android audio focus itself. + /// Requesting it on top of one that does steals focus from it. + bool get handlesAudioFocus => false; + /// Whether this backend can enumerate and switch audio tracks at all. /// /// False for `video_player`: ExoPlayer has track selection internally but the @@ -233,6 +258,14 @@ abstract class PlayerController extends ValueNotifier { Widget buildView({BoxFit fit = BoxFit.contain}); + /// Fires when [initialize], still running, has handed the stream to another + /// backend — libmpv giving way to the platform player. + /// + /// For a caller that bounds the start (see `withinStartupBudget`): the + /// engine that takes over has to open the stream from scratch, and timing it + /// from when the first one began would fail it for the first one's delay. + Stream get backendSwitches => const Stream.empty(); + @override Future dispose(); } @@ -426,6 +459,19 @@ class PlayerSubtitleTrack extends PlayerAudioTrack { bool _useMediaKit() => !isAndroidEmulator && resolvePlayerEngine() == PlayerEngine.mediaKit; +/// Linux decodes through `auto-copy`, not media_kit's default `auto`. +/// +/// `auto` hands decoded frames straight to the GPU through an OpenGL interop. +/// On an NVIDIA laptop that showed a solid blue picture with working audio +/// (issue #53). `auto-copy` still decodes on the GPU, then copies each frame to +/// system memory, so no interop is involved. The cost is some extra CPU. +@visibleForTesting +mkv.VideoControllerConfiguration mediaKitVideoConfiguration({ + required bool isLinux, +}) => isLinux + ? const mkv.VideoControllerConfiguration(hwdec: 'auto-copy') + : const mkv.VideoControllerConfiguration(); + /// media_kit needs its native side initialised exactly once before the first /// [mk.Player]. `main()` does this eagerly on desktop; on Android the engine is /// opt-in, so it is done by [warmUpPlayerEngine] at startup when the user has @@ -467,6 +513,20 @@ bool _mediaKitFailed = false; /// on a device that is about to show a black screen either way. const Duration _firstFrameTimeout = Duration(seconds: 5); +/// How long libmpv may hold a stream whose video track it has already found +/// without producing a picture, before the platform player is tried instead. +/// +/// The track list exists only once the container has been read, so this is +/// not a slow network: it is a decoder that has stalled. It used to be caught +/// only by the 30 s readiness wait, which a player page that moves to the next +/// server after 25 s (see `startupBudget`) never reaches. +const Duration _pictureTimeout = Duration(seconds: 12); + +/// How long a playing stream may stall on a held `tcp:` error before it is +/// reported. Above `network-timeout` (see [_MediaKitController.initialize]), +/// so ffmpeg's own reconnect gets its go first. +const Duration _networkGrace = Duration(seconds: 25); + /// Set as the error when libmpv plays but cannot display. /// /// A sentinel rather than prose: the player matches on it to rebuild with the @@ -537,6 +597,10 @@ class _NativeController extends PlayerController { @override bool get letterboxesInternally => false; + /// video_player's ExoPlayer is built with handleAudioFocus on. + @override + bool get handlesAudioFocus => true; + @override Widget buildView({BoxFit fit = BoxFit.contain}) => vp.VideoPlayer(_inner); @@ -608,7 +672,10 @@ class _MediaKitController extends PlayerController { // A black first frame is an annoyance. A crash is not, so this stays on the // library's default until the black frame has a fix that does not touch // native lifetimes. - _videoController = mkv.VideoController(_player); + _videoController = mkv.VideoController( + _player, + configuration: mediaKitVideoConfiguration(isLinux: Platform.isLinux), + ); } final _MediaKitSource _src; @@ -638,6 +705,22 @@ class _MediaKitController extends PlayerController { final _closed = Completer(); String? _error; + /// A `tcp:` error ffmpeg logged that has not (yet) been judged fatal. See + /// [_holdNetworkError]. + String? _heldNetworkError; + + /// The last `tcp:` error since this stream opened. Judging a stall + /// recovered forgets [_heldNetworkError], but the file can still end early + /// on it once the cache runs dry. + String? _lastNetworkError; + Duration _stalledAt = Duration.zero; + Timer? _networkGraceTimer; + final StreamController _backendSwitches = + StreamController.broadcast(sync: true); + + @override + Stream get backendSwitches => _backendSwitches.stream; + /// How often mpv's playhead reaches listeners. /// /// mpv reports `time-pos` on every displayed frame, 24 to 60 times a @@ -719,11 +802,55 @@ class _MediaKitController extends PlayerController { // here because nothing sets a --vf filter (colour profiles and shaders // act in the VO), the one case mpv's manual says framedrop can skip // the target frame. + // Up to 150%, for quiet sources. Only claimed once mpv accepted it. + try { + await platform.setProperty( + 'volume-max', + '${(_boostMax * 100).round()}', + ); + _boostReady = true; + } catch (e) { + debugPrint('[player] volume-max not applied: $e'); + } + final hwdec = PlayerController.mpvHwdec; + if (hwdec != null) { + try { + await platform.setProperty('hwdec', hwdec); + } catch (e) { + debugPrint('[player] hwdec not applied: $e'); + } + } try { await platform.setProperty('hr-seek-framedrop', 'yes'); } catch (e) { debugPrint('[player] hr-seek-framedrop not applied: $e'); } + // media_kit opens every Player with network-timeout=5 (media_kit 1.2.6, + // player/native/player/real.dart). On mobile data a five-second stall is + // routine: log_045e01 has mp4upload starts that died 5.3 s after + // "loading url", and log_2e4886 a vidapi episode killed a minute in by + // "tcp: ffurl_read returned 0xffffff92" — ETIMEDOUT, i.e. that timer. + // mpv's own default is 60; 15 survives a handover and still turns a + // dead host into an error well inside the page's startup budget. + // + // The reconnect flags are for progressive files (mp4upload, playmogo): + // a dropped connection resumes at its byte offset instead of ending the + // stream, and a failed connect is tried again a couple of times. + // reconnect_delay_max=5 caps that at about three attempts. HLS segments + // are retried by seg_max_retry=5, which media_kit puts in + // demuxer-lavf-o; that property is deliberately not set here, because + // setting it replaces media_kit's whole list, protocol_whitelist + // included. + try { + await platform.setProperty('network-timeout', '15'); + await platform.setProperty( + 'stream-lavf-o', + 'reconnect=1,reconnect_streamed=1,' + 'reconnect_on_network_error=1,reconnect_delay_max=5', + ); + } catch (e) { + debugPrint('[player] network options not applied: $e'); + } } final recovered = await initializeWithDecoderFallback( initialize: () async { @@ -762,6 +889,34 @@ class _MediaKitController extends PlayerController { return; } + // The surface exists; the texture showing it may not yet. media_kit_video + // 1.3.1 attaches mpv to the Android surface (--wid) only when the native + // side answers SetSurfaceSize with VideoOutput.Resize, and its listener for + // that ends in `player.seek(Duration.zero)` + // (android_video_controller/real.dart, widListener) — the library's own + // repaint. Returning before it ran left two gaps: the page's resume seek + // could land first and be undone by the library's seek to 0:00, and a + // texture that never arrived meant audio under a black rectangle with + // nothing reporting it. + // + // So: wait, bounded, for the texture; let the library's seek go first; + // then repaint where we are. Ordinary player commands only — nothing here + // moves when the render context is created or torn down, which is what + // androidAttachSurfaceAfterVideoParameters=false broke (see constructor). + if (!kIsWeb && Platform.isAndroid && w > 0 && h > 0) { + final attached = await _repaintOnceAttached(); + if (_disposed) return; + if (!attached) { + debugPrint( + '[player] libmpv texture never attached — trying platform playback', + ); + await _swapToPlatformBackend(); + return; + } + } + + // Whatever ffmpeg complained about on the way in, the stream started. + _heldNetworkError = null; value = value.copyWith( isInitialized: _error == null, duration: _player.state.duration, @@ -794,6 +949,7 @@ class _MediaKitController extends PlayerController { replacement.addListener(() { if (!_disposed) value = replacement.value; }); + if (!_backendSwitches.isClosed) _backendSwitches.add(null); await replacement.initialize(); if (_disposed) { await replacement.dispose(); @@ -815,6 +971,99 @@ class _MediaKitController extends PlayerController { } } + /// Waits for the video texture, then redraws the frame mpv is on. + /// False when no texture arrived: audio would play under a black rectangle. + Future _repaintOnceAttached() async { + final id = _videoController.id; + if (id.value == null) { + final attached = Completer(); + void onId() { + if (id.value != null && !attached.isCompleted) attached.complete(); + } + + id.addListener(onId); + try { + await Future.any([ + attached.future, + _closed.future, + ]).timeout(_firstFrameTimeout, onTimeout: () {}); + } finally { + id.removeListener(onId); + } + } + if (_disposed) return true; + if (id.value == null) return false; + // media_kit's surface listener runs under its own lock and ends in + // seek(0). It is a handful of property writes, so a short wait puts this + // seek after it rather than before, and the page's resume seek after both. + // The lock is not public (AndroidVideoController is not exported), so this + // is ordering by time, not by handshake. + await Future.delayed(const Duration(milliseconds: 250)); + if (_disposed) return true; + // Not on a live or unknown-length stream, where a seek means rejoining + // the edge rather than redrawing a frame. + if (_player.state.duration <= Duration.zero) return true; + try { + await _player.seek(_player.state.position); + } catch (e) { + debugPrint('[player] repaint seek failed: $e'); + } + return true; + } + + /// Holds a `tcp:` error rather than reporting it. + /// + /// media_kit turns every error-level line ffmpeg logs under `tcp:` into an + /// error event (media_kit 1.2.6 real.dart, the log-message handler). Most + /// are stalls the HLS demuxer re-requests and the http layer reconnects + /// past, and reported at once one of them ended the episode: log_2e4886, + /// 21:10:11, a minute into a vidapi stream that was playing. + /// + /// Before the first frame the start's own bounds judge it: a host that never + /// answers ends in mpv's "Failed to open", reported as before, or in the + /// readiness timeout, which then reports the held error (see + /// [_awaitReady]) rather than blaming a decoder. Once playing, it is + /// reported only if playback is still stuck after [_networkGrace]. + void _holdNetworkError(String e) { + _heldNetworkError = e; + _lastNetworkError = e; + if (!value.isInitialized) return; + if (_networkGraceTimer?.isActive ?? false) return; + _stalledAt = _player.state.position; + _networkGraceTimer = Timer(_networkGrace, _judgeNetworkStall); + } + + void _judgeNetworkStall() { + _networkGraceTimer?.cancel(); + _networkGraceTimer = null; + final held = _heldNetworkError; + if (_disposed || _fallback != null || held == null) return; + final state = _player.state; + switch (judgeNetworkStall( + stalledAt: _stalledAt, + position: state.position, + playing: state.playing, + completed: state.completed, + duration: state.duration, + )) { + case NetworkStall.recovered: + _heldNetworkError = null; + case NetworkStall.fatal: + _heldNetworkError = null; + _reportError(held); + case NetworkStall.undecided: + // Paused where it stalled: look again later rather than dropping it, + // or a viewer who paused through the stall would come back to a + // spinner nothing reports. + _networkGraceTimer = Timer(_networkGrace, _judgeNetworkStall); + } + } + + void _reportError(String e) { + _error = e; + _emit(value.copyWith(errorDescription: e)); + } + void _wire() { _subs ..add(_player.stream.position.listen(_onPosition)) @@ -834,9 +1083,16 @@ class _MediaKitController extends PlayerController { ), ) ..add( - _player.stream.completed.listen( - (c) => _emit(value.copyWith(isCompleted: c)), - ), + _player.stream.completed.listen((c) { + // An end of file right after a network error may be the connection + // ending the file early rather than the episode ending: judge it now + // instead of after the grace. + if (c && value.isInitialized) { + _heldNetworkError ??= _lastNetworkError; + if (_heldNetworkError != null) _judgeNetworkStall(); + } + _emit(value.copyWith(isCompleted: c)); + }), ) ..add(_player.stream.width.listen((_) => _emitSize())) ..add(_player.stream.height.listen((_) => _emitSize())) @@ -871,8 +1127,27 @@ class _MediaKitController extends PlayerController { ) ..add( _player.stream.error.listen((e) { - _error = e; - _emit(value.copyWith(errorDescription: e)); + // A live stream has no length and no cache to ride out a stall + // on: once it plays, a dropped connection is reported at once so + // the page reconnects instead of showing a frozen picture. + if (e.startsWith('tcp:') && + (!value.isInitialized || + _player.state.duration > Duration.zero)) { + _holdNetworkError(e); + return; + } + // One damaged audio or video packet once playing: mpv drops it and + // plays on (ad_lavc / vd_lavc), but media_kit forwards the line as + // an error, and reported it put a black card over a film that was + // still playing under it. Held like a network stall, it is only + // reported if playback really stops on it. Before the first frame + // it is reported at once, so a stream that cannot decode at all + // still moves on to another mirror. + if (value.isInitialized && isDecodeGlitch(e)) { + _holdNetworkError(e); + return; + } + _reportError(e); }), ); } @@ -1000,7 +1275,22 @@ class _MediaKitController extends PlayerController { value = v; } - Future _awaitReady() => awaitPlaybackReadiness( + Future _awaitReady() async { + try { + await _awaitReadiness(); + } on TimeoutException { + // A start that stalled on the network is the network's failure, not a + // decoder's. Reported as a timeout it would hand the stream to the + // platform player (initializeWithDecoderFallback) to fetch the same + // unreachable bytes; reported as what ffmpeg said, the page walks on to + // another server. + final held = _heldNetworkError; + if (held != null) throw StateError(held); + rethrow; + } + } + + Future _awaitReadiness() => awaitPlaybackReadiness( read: () => PlaybackReadiness( width: _player.state.width ?? 0, height: _player.state.height ?? 0, @@ -1021,11 +1311,15 @@ class _MediaKitController extends PlayerController { _player.stream.error, ], canceled: _closed.future, + pictureTimeout: _pictureTimeout, ); @override Future play() => _fallback?.play() ?? _player.play(); + @override + bool get handlesAudioFocus => _fallback?.handlesAudioFocus ?? false; + @override Future pause() => _fallback?.pause() ?? _player.pause(); @@ -1054,10 +1348,21 @@ class _MediaKitController extends PlayerController { Future setPlaybackSpeed(double speed) => _fallback?.setPlaybackSpeed(speed) ?? _player.setRate(speed); + static const double _boostMax = 1.5; + bool _boostReady = false; + + @override + double get maxVolume => _fallback != null + ? _fallback!.maxVolume + : (_boostReady ? _boostMax : 1.0); + + @override + bool get isMpv => _fallback == null; + @override Future setVolume(double volume) => _fallback != null ? _fallback!.setVolume(volume) - : _player.setVolume((volume * 100).clamp(0.0, 100.0)); + : _player.setVolume((volume * 100).clamp(0.0, maxVolume * 100)); @override Future setLooping(bool looping) => _fallback != null @@ -1109,10 +1414,11 @@ class _MediaKitController extends PlayerController { await platform.setProperty('glsl-shaders', ''); return; } - // Separated by ':', which is what mpv's list properties take on every - // platform this runs on. A path containing one would break the list, and - // these are filenames this app chose, so none can. - await platform.setProperty('glsl-shaders', paths.join(':')); + // Separator per platform — see [shaderChainProperty]. + await platform.setProperty( + 'glsl-shaders', + shaderChainProperty(paths, windows: !kIsWeb && Platform.isWindows), + ); } catch (e) { // An enhancement is never worth losing the episode over. A shader that // will not compile on this GPU should cost the sharpening, not playback. @@ -1251,6 +1557,7 @@ class _MediaKitController extends PlayerController { Future _disposeOnce() async { if (!_closed.isCompleted) _closed.complete(); await _teardownMpv(); + await _backendSwitches.close(); await _fallback?.dispose(); } @@ -1266,6 +1573,9 @@ class _MediaKitController extends PlayerController { // the fallback. _positionFlush?.cancel(); _positionFlush = null; + // Nor a held network error judged against a player that is gone. + _networkGraceTimer?.cancel(); + _networkGraceTimer = null; for (final s in _subs) { await s.cancel(); } diff --git a/lib/core/player/mpv_auto_quality.dart b/lib/core/player/mpv_auto_quality.dart new file mode 100644 index 00000000..db559642 --- /dev/null +++ b/lib/core/player/mpv_auto_quality.dart @@ -0,0 +1,97 @@ +/// libmpv's "Auto" quality, which mpv itself does not have. +/// +/// mpv opens one HLS rendition (`hls-bitrate`) and never switches. Auto used to +/// mean "the top one" everywhere, which on mobile data rebuffered every few +/// seconds. Now the start follows the connection, and repeated stalls step +/// down one rung. +library; + +import 'package:soplay/core/player/media_controller.dart'; + +enum NetworkKind { unmetered, cellular, unknown } + +abstract final class MpvAutoQuality { + /// Auto's ceiling on mobile data. + static const int cellularCap = 720; + + static const List rungs = [2160, 1440, 1080, 720, 480, 360]; + + /// The `hls-bitrate` to open with. Only Auto (0) depends on the network. + static String startBitrate({ + required int preference, + required NetworkKind network, + }) { + if (preference != 0) return PlayerController.hlsBitrateFor(preference); + return network == NetworkKind.cellular + ? PlayerController.hlsBitrateFor(cellularCap) + : 'max'; + } + + /// One rung below [currentHeight], or null at the bottom already. + static String? stepDown({int? currentHeight, required String current}) { + final h = currentHeight; + if (h != null && h > 0) { + for (final r in rungs) { + if (r < h - h * 0.08) return PlayerController.hlsBitrateFor(r); + } + return current == 'min' ? null : 'min'; + } + if (current == 'min') return null; + if (current == 'max') return PlayerController.hlsBitrateFor(1080); + final bps = int.tryParse(current); + if (bps == null) return null; + for (final r in rungs) { + final b = PlayerController.hlsBitrateFor(r); + if (int.parse(b) < bps) return b; + } + return 'min'; + } +} + +/// Counts buffering stalls during playback. [stalls] of them inside [window] +/// trips it; a stall right after a seek is the seek, not the network. +class StallWatchdog { + StallWatchdog({ + this.stalls = 3, + this.window = const Duration(seconds: 60), + this.afterSeek = const Duration(seconds: 3), + }); + + final int stalls; + final Duration window; + final Duration afterSeek; + + final List _at = []; + Duration? _seekAt; + bool _disabled = false; + + bool get disabled => _disabled; + + /// After an undo the viewer has said what they want; no more stepping. + void disable() { + _disabled = true; + _at.clear(); + } + + void noteSeek(Duration now) => _seekAt = now; + + /// A stall began at [now]. True when it is the one that trips the watchdog, + /// which then starts counting afresh. + bool recordStall(Duration now) { + if (_disabled) return false; + final seek = _seekAt; + if (seek != null && now - seek <= afterSeek) return false; + _at.removeWhere((t) => now - t > window); + _at.add(now); + if (_at.length >= stalls) { + _at.clear(); + return true; + } + return false; + } + + void reset() { + _at.clear(); + _seekAt = null; + } +} diff --git a/lib/core/player/playback_readiness.dart b/lib/core/player/playback_readiness.dart index 79e71a6a..cd0b474d 100644 --- a/lib/core/player/playback_readiness.dart +++ b/lib/core/player/playback_readiness.dart @@ -44,13 +44,24 @@ class PlaybackReadiness { (!hasVideo && hasAudio && duration > Duration.zero); } +/// Waits until [read] reports a playable stream. +/// +/// [pictureTimeout], when given, is how long a stream whose video track is +/// already known may go without the decoder producing a picture. That is a +/// decoder that has stalled, not a slow network — the track list exists only +/// once the container has been read — and it is the case the platform player +/// can rescue (see [initializeWithDecoderFallback]). Measured from the first +/// snapshot that lists a video track, so a slow host's long open does not +/// count against it; [timeout] still bounds the whole wait. Future awaitPlaybackReadiness({ required PlaybackReadiness Function() read, required Iterable> signals, required Future canceled, Duration timeout = const Duration(seconds: 30), + Duration? pictureTimeout, }) async { final ready = Completer(); + Timer? pictureTimer; void inspect() { if (ready.isCompleted) return; final snapshot = read(); @@ -58,6 +69,16 @@ Future awaitPlaybackReadiness({ ready.completeError(StateError(snapshot.error!)); } else if (snapshot.ready) { ready.complete(); + } else if (pictureTimeout != null && + snapshot.hasVideo && + pictureTimer == null) { + pictureTimer = Timer(pictureTimeout, () { + if (!ready.isCompleted) { + ready.completeError( + TimeoutException('No picture from the decoder', pictureTimeout), + ); + } + }); } } @@ -77,8 +98,147 @@ Future awaitPlaybackReadiness({ try { await ready.future.timeout(timeout); } finally { + pictureTimer?.cancel(); for (final subscription in subscriptions) { await subscription.cancel(); } } } + +/// How long a stream may take to reach its first frame before the player +/// gives up on that server and moves to the next. Null: no limit. +/// +/// Neither engine bounds a start on its own. libmpv's readiness wait is 30 s +/// and on Android then hands the stream to ExoPlayer, whose initialize() +/// waits for its first READY with no limit at all, so a host that accepts the +/// connection and then trickles left "Video loading…" up for good (ticket +/// 6aba98fa). The resolve before this has had a deadline for a long time; +/// this is the same line for the second half. +/// +/// The slowest healthy starts in the support logs are 17–19 s (vidapi HLS on +/// mobile data, log_2e4886), so 25 s leaves room without making a dead host +/// cost much more than it did. A stream sniffed out of an embed page gets +/// longer: those CDNs are the slow ones (a playmogo file took 43 s in +/// log_045e01). A torrent has to find peers and fetch the first pieces before +/// there is anything to decode. A live channel that will not open is +/// reconnected rather than abandoned, so a few seconds more costs nothing. +/// A file on disk has nothing remote to wait for. +Duration? startupBudget({ + required bool local, + bool torrent = false, + bool sniffed = false, + bool live = false, +}) { + if (local) return null; + if (torrent) return const Duration(seconds: 90); + if (sniffed) return const Duration(seconds: 35); + if (live) return const Duration(seconds: 30); + return const Duration(seconds: 25); +} + +/// Waits for [start], failing with a [TimeoutException] once [budget] has +/// passed without it completing. +/// +/// [restarts] gives the start a fresh [budget] each time it fires, at most +/// [maxRestarts] times: a controller that hands the stream to another engine +/// mid-start (libmpv to ExoPlayer) has spent part of the allowance on an +/// engine that could not play it, and the one that can deserves its own. +/// +/// [start] is left running on a timeout, and anything it throws afterwards is +/// swallowed here; the caller disposes the controller behind it. +Future withinStartupBudget( + Future start, { + required Duration budget, + Stream? restarts, + int maxRestarts = 1, +}) async { + final done = Completer(); + Timer? timer; + void arm() { + timer?.cancel(); + timer = Timer(budget, () { + if (!done.isCompleted) { + done.completeError(TimeoutException('Playback did not start', budget)); + } + }); + } + + var restartsLeft = maxRestarts; + arm(); + final subscription = restarts?.listen((_) { + if (done.isCompleted || restartsLeft <= 0) return; + restartsLeft--; + arm(); + }); + start.then( + (_) { + if (!done.isCompleted) done.complete(); + }, + onError: (Object error, StackTrace stack) { + if (!done.isCompleted) done.completeError(error, stack); + }, + ); + try { + await done.future; + } finally { + timer?.cancel(); + // Not awaited: the answer is already decided, and a broadcast stream's + // cancel hands back a future that belongs to no zone in particular. + final cancel = subscription?.cancel(); + if (cancel != null) unawaited(cancel); + } +} + +/// What a network error reported during playback turned out to be, judged +/// after a grace period. +enum NetworkStall { + /// The playhead moved on: the engine's own retry got through. + recovered, + + /// Playback stopped on it: still not moving while meant to be playing, or + /// ended well short of the end. Report it, so the player can reconnect. + fatal, + + /// Paused with the playhead where it stalled. Nothing to judge yet. + undecided, +} + +/// Judges a network error held since the playhead was at [stalledAt]. +/// +/// libmpv reports every error-level `tcp:` line ffmpeg logs as an error, and +/// most of them are stalls the HLS demuxer (seg_max_retry) or the http layer +/// (reconnect) gets past on its own. Only one that stopped playback is worth +/// tearing the stream down for. [completed] short of [duration] is the other +/// way playback stops on one: a progressive file whose connection could not +/// be resumed reads as an early end of file. +NetworkStall judgeNetworkStall({ + required Duration stalledAt, + required Duration position, + required bool playing, + required bool completed, + required Duration duration, +}) { + // The end of file first: the cache keeps the playhead moving well past the + // stall, so a file the connection cut short has always "moved" by the time + // it ends. Without a length (a live stream) any end is the connection's. + if (completed) { + final early = + duration <= Duration.zero || + position < duration - const Duration(seconds: 5); + return early ? NetworkStall.fatal : NetworkStall.recovered; + } + // Either way: a viewer who seeked back past the stall and is watching again + // has a stream that works, even though the playhead is behind where it was. + if ((position - stalledAt).abs() > const Duration(seconds: 1)) { + return NetworkStall.recovered; + } + return playing ? NetworkStall.fatal : NetworkStall.undecided; +} + +/// A decoder log line about one packet: "Error decoding audio." from mpv's +/// ad_lavc, "Error while decoding frame" from ffmpeg. The decoder drops that +/// packet and carries on, so the line alone says nothing about playback. +bool isDecodeGlitch(String error) { + final l = error.toLowerCase(); + return l.startsWith('error decoding') || l.contains('error while decoding'); +} diff --git a/lib/core/player/player_dock.dart b/lib/core/player/player_dock.dart new file mode 100644 index 00000000..650f44f9 --- /dev/null +++ b/lib/core/player/player_dock.dart @@ -0,0 +1,312 @@ +import 'dart:math' as math; + +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; + +enum DockCorner { topLeft, topRight, bottomLeft, bottomRight } + +/// Where the corner player sits, as plain arithmetic so it can be tested +/// without a window. +class DockGeometry { + DockGeometry._(); + + /// Gap between the player and the edge of the app's content area. + static const double margin = 16; + static const double minWidth = 240; + static const double defaultWidth = 400; + + /// Past this share of the window the "mini" player covers the very page it + /// exists to leave usable. + static const double maxShare = 0.6; + + /// How far ahead a release is projected along its fling, in seconds: a + /// flick toward a corner lands there even when let go of mid-window. + static const double flingLookahead = 0.12; + + static double clampAspect(double a) => + a.isFinite && a > 0 ? a.clamp(0.5, 2.6).toDouble() : 16 / 9; + + /// The player's size for a wanted [width] at [aspect], kept between + /// [minWidth] and [maxShare] of [area] and never taller than the area. + static Size sizeFor(double width, double aspect, Size area) { + final a = clampAspect(aspect); + final maxW = math.max( + minWidth, + math.min(area.width * maxShare, (area.height - 2 * margin) * a), + ); + final w = (width.isFinite ? width : defaultWidth) + .clamp(minWidth, maxW) + .toDouble(); + return Size(w, (w / a).roundToDouble()); + } + + static Rect rectFor(DockCorner c, Size size, Size area) { + final left = c == DockCorner.topLeft || c == DockCorner.bottomLeft; + final top = c == DockCorner.topLeft || c == DockCorner.topRight; + return Rect.fromLTWH( + left ? margin : area.width - size.width - margin, + top ? margin : area.height - size.height - margin, + size.width, + size.height, + ); + } + + /// [rect] moved by [offset] but kept wholly inside [area], so a drag can not + /// lose the player past the edge of the window. + static Rect dragged(Rect rect, Offset offset, Size area) { + final r = rect.shift(offset); + final maxLeft = math.max(0.0, area.width - r.width); + final maxTop = math.max(0.0, area.height - r.height); + return Rect.fromLTWH( + r.left.clamp(0.0, maxLeft), + r.top.clamp(0.0, maxTop), + r.width, + r.height, + ); + } + + /// The corner a release snaps to: whichever quarter of the window the + /// player's centre would be in a moment later at the release [velocity]. + static DockCorner nearest(Rect rect, Offset velocity, Size area) { + final c = rect.center + velocity * flingLookahead; + final left = c.dx < area.width / 2; + final top = c.dy < area.height / 2; + if (top) return left ? DockCorner.topLeft : DockCorner.topRight; + return left ? DockCorner.bottomLeft : DockCorner.bottomRight; + } + + /// The grips sit on the edges facing the middle of the window: dragging + /// one away from the docked corner grows the player, toward it shrinks it. + /// The box keeps the picture's shape, so a vertical pull is converted to + /// width through [aspect], and of the two axes the larger pull wins. + static double resized( + double width, + Offset delta, + DockCorner c, + double aspect, + ) { + final left = c == DockCorner.topLeft || c == DockCorner.bottomLeft; + final top = c == DockCorner.topLeft || c == DockCorner.topRight; + final growX = left ? delta.dx : -delta.dx; + final growY = (top ? delta.dy : -delta.dy) * clampAspect(aspect); + return width + (growX.abs() >= growY.abs() ? growX : growY); + } +} + +enum _DockMode { none, docked, handingBack } + +/// The desktop corner player, Telegram-style. +/// +/// Owns no playback. It holds the [GlobalKey] of the PlayerPage that is in +/// the corner and where the corner window is. The page's State moves between +/// the /player route and PlayerDockHost under that key, so every timer, +/// subscription and the controller itself keep running across the move — +/// history ticks, auto-advance, subtitles, party sync and retries included. +/// +/// Two channels, because they change at very different rates: this notifier +/// says *which* page is docked (rare, and it rebuilds the host), [geometry] +/// says where it is (every frame of a drag, and only relays the box out). +class PlayerDock extends ChangeNotifier { + PlayerDock._(); + static final PlayerDock instance = PlayerDock._(); + + /// Desktop only. Phones already float over other apps through the system + /// picture-in-picture, and a television has no pointer to drag one with. + static bool get supported => isDesktopPlatform && !isTvPlatform; + + static const _cornerKey = 'player_dock_corner'; + static const _widthKey = 'player_dock_width'; + + _DockMode _mode = _DockMode.none; + GlobalKey? _key; + PlayerArgs? _args; + bool _prefsRead = false; + + final _Pulse _geometry = _Pulse(); + + /// Fires when the corner, size, aspect or drag offset changes. + Listenable get geometry => _geometry; + + /// Opens /player. Set by the host, so this file does not import the router. + void Function(PlayerArgs args)? openRoute; + + DockCorner corner = DockCorner.bottomRight; + double width = DockGeometry.defaultWidth; + double aspect = 16 / 9; + Offset drag = Offset.zero; + bool dragging = false; + + /// The host's last laid-out content area and player rect. Written during + /// layout, without notifying: a drag ends in screen space and needs to know + /// what it was dragged across. + Size area = Size.zero; + Rect? lastRect; + + bool get isDocked => _mode == _DockMode.docked; + GlobalKey? get dockedKey => isDocked ? _key : null; + PlayerArgs? get dockedArgs => isDocked ? _args : null; + + /// Where the player belongs in [area] right now, drag included. + Rect rectIn(Size area) { + final base = DockGeometry.rectFor( + corner, + DockGeometry.sizeFor(width, aspect, area), + area, + ); + return drag == Offset.zero ? base : DockGeometry.dragged(base, drag, area); + } + + void dock({ + required GlobalKey key, + required PlayerArgs args, + required Size videoSize, + }) { + _readPrefs(); + _key = key; + _args = args; + _mode = _DockMode.docked; + _setAspect(videoSize); + drag = Offset.zero; + dragging = false; + notifyListeners(); + } + + /// Back to the full player. Flip first, push second: the host stops + /// building the page in the same frame the new route starts building it, + /// which is what lets the GlobalKey carry the State across. A key that is + /// built nowhere at the end of a frame is disposed. + /// + /// False, and nothing changes, when there is no host to hand back from. + bool expand() { + final args = _args; + final open = openRoute; + if (!isDocked || args == null || open == null) return false; + _mode = _DockMode.handingBack; + notifyListeners(); + open(args); + return true; + } + + /// Called by PlayerRoute.initState: the key of the page being handed back, + /// or null for an ordinary open. No notify: the host already stopped + /// showing it when [expand] flipped the mode. + GlobalKey? takeHandBack() { + if (_mode != _DockMode.handingBack) return null; + final key = _key; + _clear(); + return key; + } + + /// Closes the corner player. Completes after the frame in which the page + /// was disposed, so its history is saved and its stream stopped by then. + Future close() async { + if (_mode == _DockMode.none) return; + _clear(); + notifyListeners(); + await SchedulerBinding.instance.endOfFrame; + } + + /// From PlayerPage.dispose. Silent: it runs while the tree is finalised. + void forget(GlobalKey key) { + if (_key == key) _clear(); + } + + /// A new episode or rendition can change the shape of the picture. + void reportVideoSize(Size size) { + if (!isDocked) return; + final before = aspect; + _setAspect(size); + if ((aspect - before).abs() > 0.01) _geometry.ping(); + } + + void dragStart() { + dragging = true; + drag = Offset.zero; + _geometry.ping(); + } + + void dragBy(Offset delta) { + drag += delta; + _geometry.ping(); + } + + void dragEnd(Offset velocity) { + if (!area.isEmpty) { + corner = DockGeometry.nearest(rectIn(area), velocity, area); + } + drag = Offset.zero; + dragging = false; + _savePrefs(); + _geometry.ping(); + } + + void resizeBy(Offset delta) { + if (area.isEmpty) return; + width = DockGeometry.sizeFor( + DockGeometry.resized(width, delta, corner, aspect), + aspect, + area, + ).width; + _geometry.ping(); + } + + void resizeEnd() => _savePrefs(); + + void _clear() { + _mode = _DockMode.none; + _key = null; + _args = null; + drag = Offset.zero; + dragging = false; + } + + void _setAspect(Size s) { + if (s.width > 0 && s.height > 0) { + aspect = DockGeometry.clampAspect(s.width / s.height); + } + } + + // The same box and the same direct access as DesktopWindow's geometry. + void _readPrefs() { + if (_prefsRead) return; + _prefsRead = true; + try { + final box = Hive.box(AppConstants.settingsBox); + final c = box.get(_cornerKey); + if (c is int && c >= 0 && c < DockCorner.values.length) { + corner = DockCorner.values[c]; + } + final w = box.get(_widthKey); + if (w is num && w > 0) width = w.toDouble(); + } catch (_) {} + } + + void _savePrefs() { + try { + final box = Hive.box(AppConstants.settingsBox); + box.put(_cornerKey, corner.index); + box.put(_widthKey, width); + } catch (_) {} + } + + /// Back to a fresh, empty dock between widget tests. + @visibleForTesting + void debugReset() { + _clear(); + openRoute = null; + corner = DockCorner.bottomRight; + width = DockGeometry.defaultWidth; + aspect = 16 / 9; + area = Size.zero; + lastRect = null; + _prefsRead = true; + } +} + +class _Pulse extends ChangeNotifier { + void ping() => notifyListeners(); +} diff --git a/lib/core/player/quality_preference.dart b/lib/core/player/quality_preference.dart index 94e95386..2e33734c 100644 --- a/lib/core/player/quality_preference.dart +++ b/lib/core/player/quality_preference.dart @@ -31,6 +31,28 @@ abstract final class QualityPreference { return atOrBelow.isNotEmpty ? atOrBelow.last : heights.first; } + /// The height to pin a parsed master to, or [auto] to let the engine choose. + /// + /// The viewer's [preference] first — a pick, a remembered height or the + /// standing setting. Failing that, on an engine that adapts on its own + /// ([adaptiveEngine]: ExoPlayer, AVPlayer) the height the source's own label + /// names. Such an engine starts a master from a bandwidth estimate, which + /// on mobile data is the bottom rung, so a server labelled "1080p" played + /// at 320p under a button that said 1080p. Pinning the named height makes + /// the label true. libmpv is left alone: its Auto opens the top rendition + /// (PlayerController.hlsBitrateFor) and never adapts afterwards. + static int pinFor({ + required int preference, + required int? labelledHeight, + required bool adaptiveEngine, + }) { + if (preference != auto) return preference; + if (adaptiveEngine && labelledHeight != null && labelledHeight > 0) { + return labelledHeight; + } + return auto; + } + /// Whether the source playing is a lone adaptive stream whose master is /// worth reading for renditions. /// diff --git a/lib/core/player/shader_presets.dart b/lib/core/player/shader_presets.dart index 4a4ed406..f9a93c99 100644 --- a/lib/core/player/shader_presets.dart +++ b/lib/core/player/shader_presets.dart @@ -133,6 +133,21 @@ class ShaderPreset { }; } +/// The chain as mpv's `glsl-shaders` property wants it. +/// +/// mpv's path lists are separated by ':' on Unix and by ';' on Windows, where +/// a ':' is the drive letter's own. Joined with ':' there, a chain of three +/// files arrives as one impossible filename — +/// `Cannot open file 'C:\…\Anime4K_Clamp_Highlights.glsl:C:\…_x2_VL.glsl': +/// Invalid argument` — and mpv brings video output down with it: the episode +/// is a black screen behind a playback error, on Windows only, and only for +/// the people who turned Anime4K on (ticket 6ae742f9). +/// +/// Nothing escapes: these are filenames this app chose, flattened by +/// [ShaderStore.pathFor], and none contains either separator. +String shaderChainProperty(List paths, {required bool windows}) => + paths.join(windows ? ';' : ':'); + /// How much GPU the chain is allowed to cost. enum ShaderTier { /// The M networks. Smooth on most phones. diff --git a/lib/core/preview/frame_preview_service.dart b/lib/core/preview/frame_preview_service.dart index 6f08f42d..c52275d2 100644 --- a/lib/core/preview/frame_preview_service.dart +++ b/lib/core/preview/frame_preview_service.dart @@ -26,9 +26,28 @@ class FramePreviewService { static const MethodChannel _ch = MethodChannel('soplay/preview'); static bool get isSupported => Platform.isAndroid || Platform.isIOS; + static const Duration openAllowance = Duration(seconds: 15); + + /// The first frame of a stream is a playlist, a whole segment and a decode. + /// Lulustream's 12 s segments are 2.3 MB, 9 seconds on a 2 Mbit/s connection, + /// and with 6 seconds allowed that stream never showed a picture: each try + /// was given up on and the next one paid for a segment again. Must stay above + /// the native wait in HlsFramePreview. + static const Duration frameAllowance = Duration(seconds: 20); + + /// A progressive file reads its index before the first frame, and a slow + /// host's can take longer than 5 seconds. A timeout discards the retriever + /// and what it had read, so a short one never got a frame at all. static final _native = FramePreviewSession( supported: isSupported, - invoke: (method, args) => _ch.invokeMethod(method, args), + invoke: Platform.isAndroid + ? FallbackDecoder( + primary: (method, args) => _ch.invokeMethod(method, args), + secondary: _invokeExtractor, + ).call + : (method, args) => _ch.invokeMethod(method, args), + openTimeout: openAllowance, + frameTimeout: frameAllowance, ); /// HLS frames: a playlist and a segment before the first one, so longer @@ -36,8 +55,8 @@ class FramePreviewService { static final _hls = FramePreviewSession( supported: Platform.isAndroid, invoke: _invokeHls, - openTimeout: const Duration(seconds: 15), - frameTimeout: const Duration(seconds: 6), + openTimeout: openAllowance, + frameTimeout: frameAllowance, ); static const int bucketMs = FramePreviewSession.bucketMs; @@ -135,7 +154,7 @@ class FramePreviewService { return out; } - static _Warmer? _warmer; + static PreviewGridWarmer? _warmer; static bool _scrubbing = false; /// A drag on the seek bar started or ended. The warmer yields to it: the @@ -166,8 +185,9 @@ class FramePreviewService { // How far "near" reaches follows how sparse the grid is. Fixed at 30 s, // a far jump on the seek bar found nothing on a 16-frame metered grid. _sessionFor(hls).durationMs = durationMs; - _warmer = _Warmer( + _warmer = PreviewGridWarmer( session: _sessionFor(hls), + isScrubbing: () => _scrubbing, url: url, headers: headers, durationMs: durationMs, @@ -209,6 +229,48 @@ class FramePreviewService { return _ch.invokeMethod(method, a); } + /// A stream the retriever could not read, on Media3's extractor. Asked what + /// it is rather than told: this path is for what the source mislabelled too. + static Future _invokeExtractor( + String method, + Map? args, + ) async { + final a = {...?args, 'hls': true}; + if (method == 'open') { + final url = a['url'] as String? ?? ''; + final headers = + (a['headers'] as Map?)?.cast() ?? const {}; + final playlist = await _isPlaylist(url, headers); + a['progressive'] = !playlist; + a['url'] = await _proxied(url, headers, playlist: playlist); + } + return _ch.invokeMethod(method, a); + } + + /// Whether [url] answers with an HLS playlist, from its first bytes. + static Future _isPlaylist( + String url, + Map headers, + ) async { + final uri = Uri.tryParse(url); + if (uri == null || !uri.scheme.startsWith('http')) { + return url.toLowerCase().contains('.m3u8'); + } + final client = HttpClient()..connectionTimeout = const Duration(seconds: 3); + try { + final req = await client.getUrl(uri); + headers.forEach(req.headers.set); + req.headers.set(HttpHeaders.rangeHeader, 'bytes=0-15'); + final res = await req.close().timeout(const Duration(seconds: 4)); + final first = await res.first.timeout(const Duration(seconds: 2)); + return String.fromCharCodes(first.take(7)) == '#EXTM3U'; + } catch (_) { + return false; + } finally { + client.close(force: true); + } + } + /// One proxied address per stream, so the native side sees the same URL on /// every drag and keeps its extractor instead of rebuilding it. /// @@ -219,40 +281,68 @@ class FramePreviewService { static final Map, DateTime)> _proxiedCache = {}; static const _proxiedTtl = Duration(minutes: 20); - static Future _proxied(String url, Map headers) { + static Future _proxied( + String url, + Map headers, { + bool playlist = true, + }) { final keys = headers.keys.toList()..sort(); final key = '$url\u0000${keys.map((k) => '$k=${headers[k]}').join('&')}'; if (_proxiedCache.length > 8) _proxiedCache.clear(); final now = DateTime.now(); final held = _proxiedCache[key]; if (held != null && now.difference(held.$2) < _proxiedTtl) return held.$1; - final fresh = _proxy(url, headers); - _proxiedCache[key] = (fresh, now); - return fresh; + final fresh = _proxy(url, headers, playlist: playlist); + final address = fresh.then((r) => r.$1); + _proxiedCache[key] = (address, now); + // A failed attempt is not an answer. Held for its 20 minutes, one refused + // request left the stream on an address missing its headers, and every + // frame of it failed for as long. + unawaited( + fresh.then((r) { + if (!r.$2) _proxiedCache.remove(key); + }), + ); + return address; } - /// The lightest variant, behind the local proxy so its headers go along. - static Future _proxy(String url, Map headers) async { + /// The lightest variant, behind the local proxy so its headers go along, and + /// whether that all worked. A progressive file has no variants to look for. + static Future<(String, bool)> _proxy( + String url, + Map headers, { + required bool playlist, + }) async { final uri = Uri.tryParse(url); final local = uri != null && uri.host == '127.0.0.1'; // The lightest rendition whichever way the stream is reached: a stream // already on the local proxy used to open its whole master, and the // decoder picked a 720p segment for a 360px thumbnail. Its variants are // proxied paths of the same session, so they keep its headers. - final target = await _lightestVariant(url, headers) ?? url; - if (local || headers.isEmpty) return target; + var settled = true; + var target = url; + if (playlist) { + try { + target = await _lightestVariant(url, headers) ?? url; + } catch (_) { + settled = false; + } + } + if (local || headers.isEmpty) return (target, settled); try { - return await getIt().register( + final address = await getIt().register( upstreamUrl: target, headers: headers, // The player's own headers, Referer included: the CDN checks it. keepOriginHeaders: true, ); + return (address, settled); } catch (_) { - return target; + return (target, false); } } + /// Null when [url] has no variants; throws when it could not be read. static Future _lightestVariant( String url, Map headers, @@ -264,14 +354,14 @@ class FramePreviewService { final req = await client.getUrl(uri); headers.forEach(req.headers.set); final res = await req.close().timeout(const Duration(seconds: 8)); - if (res.statusCode != 200) return null; + if (res.statusCode != 200) { + throw HttpException('playlist answered ${res.statusCode}'); + } final body = await res .transform(utf8.decoder) .join() .timeout(const Duration(seconds: 8)); return lightestVariantOf(body, uri); - } catch (_) { - return null; } finally { client.close(force: true); } @@ -299,8 +389,7 @@ class FramePreviewService { !RegExp(r'avc|hvc|hev|av01|vp0?9|dvh|mp4v').hasMatch(codecs)) { continue; } - if (!line.contains('RESOLUTION=') && - playlist.contains('RESOLUTION=')) { + if (!line.contains('RESOLUTION=') && playlist.contains('RESOLUTION=')) { continue; } final rate = @@ -392,8 +481,13 @@ class FramePreviewSession { /// either, the source cannot be decoded here and the card should say so by /// showing no picture rather than a skeleton that never fills. int _consecutiveNulls = 0; - bool get failing => - _consecutiveNulls >= 3 && _cache.isEmpty && _grid.isEmpty; + bool get failing => _consecutiveNulls >= 3 && _cache.isEmpty && _grid.isEmpty; + + @visibleForTesting + int get debugCacheBytes => _cacheBytes; + + /// Attempts in a row that produced no frame, for whatever reason. + int get consecutiveFailures => _consecutiveNulls; final _misses = {}; DateTime? _retryOpenAfter; @@ -409,7 +503,7 @@ class FramePreviewSession { final identity = '$url\u0000${jsonEncode({for (final key in keys) key: headers[key]})}'; if (_identity == identity) return; - if (_identity != null || _running != null) unawaited(endScrub()); + if (_identity != null || _running != null) unawaited(_release()); _generation++; _cache.clear(); _grid.clear(); @@ -471,9 +565,9 @@ class FramePreviewSession { if (busy) return false; final bucket = _bucketOf(positionMs); if (_grid.containsKey(bucket)) return true; - final bytes = _cache.remove(bucket) ?? await frame(bucket); + final bytes = _uncache(bucket) ?? await frame(bucket); if (bytes != null && _grid.length < maxGridFrames) { - _cache.remove(bucket); + _uncache(bucket); _grid[bucket] = bytes; FramePreviewService.frames.value++; final key = _diskKey; @@ -483,6 +577,12 @@ class FramePreviewSession { return true; } + Uint8List? _uncache(int bucket) { + final bytes = _cache.remove(bucket); + if (bytes != null) _cacheBytes -= bytes.length; + return bytes; + } + bool _sameHeaders(Map first, Map second) => first.length == second.length && first.entries.every((entry) => second[entry.key] == entry.value); @@ -537,6 +637,7 @@ class FramePreviewSession { }).timeout(openTimeout); if (request.generation != _generation) return; if (opened == false) { + _consecutiveNulls++; _retryOpenAfter = DateTime.now().add(const Duration(seconds: 2)); return; } @@ -564,9 +665,15 @@ class FramePreviewSession { } } } on TimeoutException { - if (request.generation == _generation) await endScrub(); + if (request.generation == _generation) { + // A dead host times out rather than answering, and these uncounted + // left the card on a skeleton that never filled. + _consecutiveNulls++; + await _release(); + } } catch (_) { if (request.generation == _generation) { + _consecutiveNulls++; _openedGeneration = null; _retryOpenAfter = DateTime.now().add(const Duration(seconds: 2)); } @@ -598,9 +705,21 @@ class FramePreviewSession { } } - /// Drop native resources and pending work as soon as the finger lifts. Cache - /// remains scoped to the configured source for inexpensive repeated scrubs. + /// The finger lifted. What is waiting is dropped, but a frame already being + /// fetched is left to land: it has been paid for, and on a slow host a drag is + /// shorter than one frame. Thrown away, the next drag paid for the same + /// segment and no drag ever showed a picture. Its end schedules the release. Future endScrub() async { + final queued = _queued; + _queued = null; + if (queued != null) _finish(queued, null); + if (_running != null) return; + await _release(); + } + + /// Drop native resources and pending work. Cache remains scoped to the + /// configured source for inexpensive repeated scrubs. + Future _release() async { _idle?.cancel(); _idle = null; final closedGeneration = ++_generation; @@ -630,14 +749,101 @@ class FramePreviewSession { _consecutiveNulls = 0; durationMs = 0; _retryOpenAfter = null; - await endScrub(); + await _release(); + } +} + +/// The platform's retriever first, and Media3's extractor for what it cannot +/// read: an index it gives up on, a host that ignores its range requests, HLS a +/// source called something else. Every one of those used to mean no picture for +/// the whole episode, though the player itself was showing the video. +class FallbackDecoder { + FallbackDecoder({ + required this.primary, + required this.secondary, + this.openLimit = const Duration(seconds: 10), + this.nullsBeforeSwitch = 2, + }); + + final PreviewInvoke primary; + final PreviewInvoke secondary; + + /// Below the session's open allowance, so the extractor gets a turn in it. + final Duration openLimit; + final int nullsBeforeSwitch; + + String? _url; + Map? _openArgs; + bool _switched = false; + int _nulls = 0; + + Future call(String method, Map? args) async { + switch (method) { + case 'open': + return _open(args); + case 'frame': + return _frame(args); + default: + final result = await primary(method, args); + if (_switched) await secondary(method, args); + return result; + } + } + + Future _open(Map? args) async { + final url = args?['url'] as String?; + if (url != _url) { + _url = url; + _switched = false; + _nulls = 0; + } + _openArgs = args; + if (_switched) return secondary('open', args); + Object? opened = false; + try { + opened = await primary( + 'open', + args, + ).timeout(openLimit, onTimeout: () => false); + } catch (_) {} + if (opened != false) return opened; + _switched = true; + return secondary('open', args); + } + + Future _frame(Map? args) async { + if (_switched) return secondary('frame', args); + final bytes = await primary('frame', args); + if (bytes != null) { + _nulls = 0; + return bytes; + } + if (++_nulls < nullsBeforeSwitch) return null; + _switched = true; + final opened = await secondary('open', { + ...?_openArgs, + 'generation': args?['generation'], + }); + return opened == false ? null : secondary('frame', args); } } /// Fills a session's grid in the background. See [FramePreviewService.warm]. -class _Warmer { - _Warmer({ +class PreviewGridWarmer { + /// Failures in a row that mean the host is not answering. Each one is a + /// request to it, and two full passes over a dead host are 240 of them. + static const int restAfter = 6; + + /// How long to leave it, and how many times. A dropped connection or a host + /// restarting is back in a minute, and stopping for good left the rest of the + /// episode with no grid. + static const Duration rest = Duration(seconds: 45); + static const int maxRests = 5; + int _rests = 0; + + PreviewGridWarmer({ required this.session, + required this.isScrubbing, required this.url, required this.headers, required this.durationMs, @@ -648,6 +854,7 @@ class _Warmer { }); final FramePreviewSession session; + final bool Function() isScrubbing; final String url; final Map headers; final int durationMs; @@ -682,11 +889,15 @@ class _Warmer { if (session.hasGridFrame(at)) continue; while (true) { if (_stopped || !session.serves(url)) return; - if (!FramePreviewService._scrubbing && await session.gridFrame(at)) { + if (!isScrubbing() && await session.gridFrame(at)) { break; } await Future.delayed(const Duration(milliseconds: 300)); } + if (session.consecutiveFailures >= restAfter) { + if (++_rests > maxRests) return; + await Future.delayed(rest); + } await Future.delayed(pace); } } @@ -741,6 +952,7 @@ class PreviewDiskCache { } static final Set _pruned = {}; + static Future _pruning = Future.value(); static Future save(String key, int bucket, Uint8List bytes) async { try { @@ -749,7 +961,12 @@ class PreviewDiskCache { if (!await dir.exists()) { await dir.create(recursive: true); await _touch(dir); - if (_pruned.add(key)) unawaited(_prune(root)); + if (_pruned.add(key)) { + // One prune at a time. Run side by side, two of them picked the + // same oldest folders; the second delete threw, and the catch + // around the loop gave up on the rest, leaving one too many. + _pruning = _pruning.then((_) => _prune(root)); + } } await File('${dir.path}/$bucket.jpg').writeAsBytes(bytes, flush: false); } catch (_) {} @@ -779,7 +996,11 @@ class PreviewDiskCache { for (final d in dirs) (d, await _usedAt(d)), ]..sort((a, b) => a.$2.compareTo(b.$2)); for (final (d, _) in dated.take(dirs.length - maxEpisodes)) { - await d.delete(recursive: true); + // Each on its own: a folder already gone is no reason to keep the + // others. + try { + await d.delete(recursive: true); + } catch (_) {} } } catch (_) {} } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index cf566077..b160bfa2 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -40,7 +40,7 @@ import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/presentation/pages/actor_page.dart'; import 'package:soplay/features/detail/presentation/pages/detail_page.dart'; import 'package:soplay/features/detail/presentation/pages/episodes_page.dart'; -import 'package:soplay/features/detail/presentation/pages/player_page.dart'; +import 'package:soplay/features/detail/presentation/pages/player_route.dart'; import 'package:soplay/features/download/presentation/pages/downloads_page.dart'; import 'package:soplay/features/history/presentation/pages/history_page.dart'; import 'package:soplay/features/home/domain/entities/view_all.dart'; @@ -110,6 +110,7 @@ import '../../features/home/presentation/pages/home_view_all_page.dart'; import '../../features/home/presentation/pages/genres_page.dart'; import '../../features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/achievements/presentation/pages/achievements_page.dart'; +import 'package:soplay/features/progress/presentation/pages/progress_page.dart'; /// The arrival every "opening a thing" route shares. /// @@ -165,6 +166,11 @@ Page _bloomPage(GoRouterState state, Widget child) { ); } +/// Null when the route got the `extra` it needs, else `/main`: a route that +/// only makes sense with args (a restored stack, a stray link) goes Home +/// instead of throwing on the cast. +String? extraOr(GoRouterState state) => state.extra is T ? null : '/main'; + class AppRouter { AppRouter._(); @@ -236,6 +242,7 @@ class AppRouter { ), GoRoute( path: '/episodes', + redirect: (context, state) => extraOr(state), pageBuilder: (context, state) { final args = state.extra as EpisodesArgs; return _bloomPage(state, EpisodesPage(args: args)); @@ -252,6 +259,7 @@ class AppRouter { ), GoRoute( path: '/actor', + redirect: (context, state) => extraOr(state), pageBuilder: (context, state) { final args = state.extra as ActorArgs; return _bloomPage(state, ActorPage(args: args)); @@ -259,13 +267,17 @@ class AppRouter { ), GoRoute( path: '/player', + redirect: (context, state) => extraOr(state), builder: (context, state) { final args = state.extra as PlayerArgs; - return PlayerPage(args: args); + // Through PlayerRoute, which keys the page: on the desktop it may be + // the one already playing in the corner, handed back with its state. + return PlayerRoute(args: args); }, ), GoRoute( path: '/reader', + redirect: (context, state) => extraOr(state), builder: (context, state) { final args = state.extra as ReaderArgs; return ReaderPage(args: args); @@ -441,6 +453,10 @@ class AppRouter { builder: (context, state) => const NotificationsPage(), ), GoRoute(path: '/streak', builder: (context, state) => const StreakPage()), + GoRoute( + path: '/progress', + builder: (context, state) => const ProgressPage(), + ), GoRoute( path: '/achievements', builder: (context, state) => const AchievementsPage(), @@ -480,7 +496,10 @@ class AppRouter { }, ), // Help & Support. `new` before `:id`, which would otherwise take it. - GoRoute(path: '/support', builder: (context, state) => const SupportPage()), + GoRoute( + path: '/support', + builder: (context, state) => const SupportPage(), + ), GoRoute( path: '/support/new', builder: (context, state) => SupportNewPage( diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 443c85cd..352c649c 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../constants/app_constants.dart'; +import '../native/player_controls_style.dart'; import '../player/quality_preference.dart'; import 'profile_scope.dart'; import '../subtitles/subtitle_languages.dart'; @@ -616,6 +617,26 @@ class HiveService { await _settingsBox.put(AppConstants.defaultPlayerFitKey, fit); } + /// How the player's controls are drawn on an iPhone; stored by name. + PlayerControlsStyle get playerControlsStyle => + storedPlayerControlsStyle ?? PlayerControlsStyle.fallback; + + /// The style the viewer chose, or null when they never have. + PlayerControlsStyle? get storedPlayerControlsStyle { + final raw = _settingsBox.get(AppConstants.playerControlsStyleKey); + return raw == null ? null : PlayerControlsStyle.fromName(raw); + } + + /// Whether the player has offered the native controls already. + bool get nativeControlsOffered => + _settingsBox.get(AppConstants.nativeControlsOfferedKey) == true; + + Future markNativeControlsOffered() => + _settingsBox.put(AppConstants.nativeControlsOfferedKey, true); + + Future setPlayerControlsStyle(PlayerControlsStyle style) => + _settingsBox.put(AppConstants.playerControlsStyleKey, style.name); + /// A [QualityPreference] value; anything unknown reads as Auto. int get preferredQuality => QualityPreference.normalize( _settingsBox.get(AppConstants.preferredQualityKey), @@ -736,6 +757,17 @@ class HiveService { await _settingsBox.put(AppConstants.longPressBoostKey, rate); } + /// The desktop player's own volume (1.0 = 100%, up to 1.5 on mpv) and mute. + ({Object? level, Object? muted}) getDesktopVolume() => ( + level: _settingsBox.get(AppConstants.desktopVolumeKey), + muted: _settingsBox.get(AppConstants.desktopMutedKey), + ); + + Future saveDesktopVolume(double level, bool muted) async { + await _settingsBox.put(AppConstants.desktopVolumeKey, level); + await _settingsBox.put(AppConstants.desktopMutedKey, muted); + } + /// Whether to remind before an episode on the AniList list airs. bool get airingRemindersEnabled => _settingsBox.get(AppConstants.airingRemindersKey, defaultValue: false) == @@ -911,6 +943,13 @@ class HiveService { await _settingsBox.put(AppConstants.volumeGestureKey, value); } + /// Android: Home while a video plays shrinks it into picture-in-picture. + bool get autoPipEnabled => + _settingsBox.get(AppConstants.autoPipKey, defaultValue: true) == true; + + Future setAutoPipEnabled(bool value) => + _settingsBox.put(AppConstants.autoPipKey, value); + /// Explicit on/off answers for the player info overlay, or null when the /// viewer has never opened the picker. /// @@ -1446,6 +1485,31 @@ class HiveService { await _settingsBox.put(AppConstants.subtitleTranslateLangKey, lang.trim()); } + /// Target language for manga page translation. Follows the subtitle one + /// until chosen. + String getMangaTranslateLang() { + final saved = _settingsBox.get(AppConstants.mangaTranslateLangKey); + if (saved is String && saved.isNotEmpty) return saved; + return getSubtitleTranslateLang(); + } + + Future setMangaTranslateLang(String lang) async { + await _settingsBox.put(AppConstants.mangaTranslateLangKey, lang.trim()); + } + + /// Translate each manga page as it comes on screen. Off by default: every + /// new page spends one of the day's translations. + bool get mangaAutoTranslate => + _settingsBox.get( + AppConstants.mangaAutoTranslateKey, + defaultValue: false, + ) == + true; + + Future setMangaAutoTranslate(bool value) async { + await _settingsBox.put(AppConstants.mangaAutoTranslateKey, value); + } + SubtitleStyle getSubtitleStyle() { final raw = _settingsBox.get(AppConstants.subtitleStyleKey); if (raw is String && raw.isNotEmpty) { @@ -1488,4 +1552,11 @@ class HiveService { } await _settingsBox.put('sub_rate::$key', rate); } + + /// Whether the first download has offered notification permission. + bool get downloadPrimingShown => + _settingsBox.get('download_priming_shown', defaultValue: false) == true; + + Future markDownloadPrimingShown() => + _settingsBox.put('download_priming_shown', true); } diff --git a/lib/core/storage/profile_scope.dart b/lib/core/storage/profile_scope.dart index 386b2a68..dc68c2b4 100644 --- a/lib/core/storage/profile_scope.dart +++ b/lib/core/storage/profile_scope.dart @@ -66,6 +66,9 @@ class ProfileScope { 'follow_sync_outbox', 'follow_sync_tombstones', 'follow_sync_at', + 'progress_view', + 'progress_seen_level', + 'progress_watch_sent', }; static String box(String base) => boxFor(base, _namespace); diff --git a/lib/core/subtitles/online_subtitles_service.dart b/lib/core/subtitles/online_subtitles_service.dart index fc3b6cc3..4ce63f14 100644 --- a/lib/core/subtitles/online_subtitles_service.dart +++ b/lib/core/subtitles/online_subtitles_service.dart @@ -63,6 +63,8 @@ class OnlineSubtitlesService { bool isSerial = false, int? season, int? episode, + // The language the viewer reads: the server looks further for this one. + String? want, }) async { final imdb = await resolveImdbId(title: title, series: isSerial); if (imdb == null) return const []; @@ -76,6 +78,7 @@ class OnlineSubtitlesService { // Empty language asks the backend for everything it can find, rather // than only English. 'language': '', + if (want != null && want.isNotEmpty) 'want': want, if (isSerial && season != null) 'season': season, if (isSerial && episode != null) 'episode': episode, }, diff --git a/lib/core/subtitles/subtitle_auto_translate.dart b/lib/core/subtitles/subtitle_auto_translate.dart index 6dd61278..0cd011dd 100644 --- a/lib/core/subtitles/subtitle_auto_translate.dart +++ b/lib/core/subtitles/subtitle_auto_translate.dart @@ -76,8 +76,14 @@ abstract final class SubtitleAutoTranslate { if (needle.isEmpty) return false; final hay = label.toLowerCase(); + final first = _wordsOf(hay).firstOrNull; for (final word in _wordsOf(hay)) { - if (word == needle) return true; + if (word == needle) { + // After a language, `HI` means hearing impaired: `English (HI)` is + // English, and matching it as Hindi would stop the real translation. + if (needle != 'hi' || word == first) return true; + continue; + } // `en-US`, `pt_BR`: the region is a variant of the same language. final dash = word.indexOf(RegExp('[-_]')); if (dash > 0 && word.substring(0, dash) == needle) return true; @@ -137,6 +143,8 @@ abstract final class SubtitleAutoTranslate { 'dut': 'nl', 'pol': 'pl', 'ind': 'id', + 'vie': 'vi', + 'hin': 'hi', 'uzb': 'uz', }; @@ -158,6 +166,8 @@ abstract final class SubtitleAutoTranslate { 'pl': ['polski', 'polish'], 'uk': ['українськ', 'ukrainian'], 'id': ['indonesia', 'indonesian'], + 'vi': ['tiếng việt', 'tieng viet', 'vietnamese', 'vietsub'], + 'hi': ['हिन्दी', 'हिंदी', 'hindi'], 'ja': ['日本語', 'japanese'], 'ko': ['한국어', 'korean'], 'zh': ['中文', 'chinese'], diff --git a/lib/core/subtitles/subtitle_cipher.dart b/lib/core/subtitles/subtitle_cipher.dart new file mode 100644 index 00000000..e3339e85 --- /dev/null +++ b/lib/core/subtitles/subtitle_cipher.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:cryptography/dart.dart'; + +import 'subtitle_parser.dart'; + +class SubtitleCipherException implements Exception { + const SubtitleCipherException(this.message); + final String message; + @override + String toString() => 'SubtitleCipherException: $message'; +} + +/// The key a source sends with a subtitle whose cue text lines are encrypted. +/// +/// KissKH's `.srt.txt1` files are ordinary SRT with every text line AES-128-CBC +/// encrypted and base64 encoded. See the backend's `docs/SUBTITLE_CIPHER.md`. +class SubtitleCipher { + const SubtitleCipher({ + required this.alg, + required this.key, + required this.iv, + this.scope = _scope, + this.encoding = _encoding, + }); + + static const _alg = 'aes-128-cbc'; + static const _scope = 'cue-text'; + static const _encoding = 'base64'; + + final String alg; + final String key; + final String iv; + final String scope; + final String encoding; + + /// Null unless [raw] is a map with a non-empty `alg`, `key` and `iv`. + static SubtitleCipher? fromJson(Object? raw) { + if (raw is! Map) return null; + String? text(String field) { + final v = raw[field]; + return v is String && v.trim().isNotEmpty ? v.trim() : null; + } + + final alg = text('alg'); + final key = text('key'); + final iv = text('iv'); + if (alg == null || key == null || iv == null) return null; + return SubtitleCipher( + alg: alg, + key: key, + iv: iv, + scope: text('scope') ?? _scope, + encoding: text('encoding') ?? _encoding, + ); + } + + bool get isSupported => + alg.toLowerCase() == _alg && + scope.toLowerCase() == _scope && + encoding.toLowerCase() == _encoding; + + /// Decrypts a downloaded file and returns it as UTF-8 bytes. + Future> decryptBytes(List bytes) async => + utf8.encode(await decrypt(decodeSubtitleBytes(bytes))); + + /// Decrypts every cue text line of [body]; cue numbers, timing lines and + /// blank lines pass through, and a line that does not open is kept as is. + /// + /// Throws when the cipher is unsupported or fewer than half the text lines + /// open: a wrong key still yields valid padding now and then. + Future decrypt(String body) async { + if (!isSupported) { + throw SubtitleCipherException('unsupported $alg/$scope/$encoding'); + } + final keyBytes = utf8.encode(key); + final ivBytes = utf8.encode(iv); + if (keyBytes.length != 16 || ivBytes.length != 16) { + throw const SubtitleCipherException('key and iv must be 16 bytes'); + } + final aes = DartAesCbc.with128bits(macAlgorithm: MacAlgorithm.empty); + final secret = SecretKeyData(keyBytes); + + var encrypted = 0; + var opened = 0; + final out = []; + for (final line in body.split('\n')) { + final cr = line.endsWith('\r'); + final text = (cr ? line.substring(0, line.length - 1) : line).trim(); + if (!_isCueText(text)) { + out.add(line); + continue; + } + encrypted++; + try { + final clear = await aes.decrypt( + SecretBox(base64.decode(text), nonce: ivBytes, mac: Mac.empty), + secretKey: secret, + ); + out.add(cr ? '${utf8.decode(clear)}\r' : utf8.decode(clear)); + opened++; + } catch (_) { + out.add(line); + } + } + if (encrypted > 0 && opened * 2 < encrypted) { + throw SubtitleCipherException('opened $opened of $encrypted lines'); + } + return out.join('\n'); + } + + static bool _isCueText(String text) => + text.isNotEmpty && + !text.contains('-->') && + text != 'WEBVTT' && + !RegExp(r'^\d+$').hasMatch(text); +} diff --git a/lib/core/subtitles/subtitle_languages.dart b/lib/core/subtitles/subtitle_languages.dart index aa6240c8..e29e8c8b 100644 --- a/lib/core/subtitles/subtitle_languages.dart +++ b/lib/core/subtitles/subtitle_languages.dart @@ -32,6 +32,8 @@ const List<(String, String)> kSubtitleTranslateLanguages = [ ('pl', 'Polski'), ('uk', 'Українська'), ('id', 'Bahasa Indonesia'), + ('vi', 'Tiếng Việt'), + ('hi', 'हिन्दी'), ('ja', '日本語'), ('ko', '한국어'), ('zh', '中文'), diff --git a/lib/core/subtitles/subtitle_translation_service.dart b/lib/core/subtitles/subtitle_translation_service.dart index ffeae1bc..ab87b8f5 100644 --- a/lib/core/subtitles/subtitle_translation_service.dart +++ b/lib/core/subtitles/subtitle_translation_service.dart @@ -55,6 +55,16 @@ class SubtitleDailyLimitReached implements Exception { String toString() => message; } +/// Thrown when the translation request never reached the server. +class TranslationOffline implements Exception { + const TranslationOffline(); +} + +/// Thrown when translating needs an account and there is none signed in. +class TranslationNeedsSignIn implements Exception { + const TranslationNeedsSignIn(); +} + /// Asks the backend to translate a subtitle the source has none of in the /// target language. /// @@ -171,7 +181,12 @@ class SubtitleTranslationService { } return out; } on DioException catch (e) { + if (e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.connectionTimeout) { + throw const TranslationOffline(); + } final status = e.response?.statusCode; + if (status == 401) throw const TranslationNeedsSignIn(); final message = _messageFrom(e); if (status == 429) throw SubtitleDailyLimitReached(message); throw Exception(message); diff --git a/lib/core/system/app_orientation.dart b/lib/core/system/app_orientation.dart index 96400377..5d9c9a24 100644 --- a/lib/core/system/app_orientation.dart +++ b/lib/core/system/app_orientation.dart @@ -1,6 +1,9 @@ import 'dart:io' show Platform; +import 'dart:ui' show PlatformDispatcher, Size; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show WidgetsBinding, WidgetsBindingObserver; import 'package:soplay/core/system/platform_utils.dart'; class AppOrientation { @@ -8,12 +11,64 @@ class AppOrientation { static const _channel = MethodChannel('app/orientation'); + static const List appDefault = [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]; + + /// What was last asked for, so a fold or unfold can re-apply it. + static List? _requested; + static _MetricsWatch? _watch; + + /// A tablet or an unfolded foldable: 600dp is where Material stops treating + /// a window as a phone. + @visibleForTesting + static bool isLargeScreen(Size logicalSize) => + !logicalSize.isEmpty && logicalSize.shortestSide >= 600; + + /// A portrait-only request is the phone layout's lock; a large screen is + /// left free to rotate instead. Landscape requests (the player) are kept. + @visibleForTesting + static List resolve( + List requested, { + required bool largeScreen, + }) { + final portraitOnly = + requested.isNotEmpty && + requested.every( + (o) => + o == DeviceOrientation.portraitUp || + o == DeviceOrientation.portraitDown, + ); + if (largeScreen && portraitOnly) return DeviceOrientation.values; + return requested; + } + + static Size _screenSize() { + final views = PlatformDispatcher.instance.views; + if (views.isEmpty) return Size.zero; + final view = views.first; + try { + final display = view.display; + if (!display.size.isEmpty) return display.size / display.devicePixelRatio; + } catch (_) {} + return view.physicalSize / view.devicePixelRatio; + } + static Future set(List orientations) async { if (!Platform.isAndroid && !Platform.isIOS) return; // A television is fixed landscape and cannot be rotated. Applying the app's // portrait lock there letterboxes the whole UI into a phone-shaped column // in the middle of the screen, so TV keeps the system orientation. if (isTvPlatform) return; + _requested = orientations; + _watch ??= _MetricsWatch()..attach(); + await _apply( + resolve(orientations, largeScreen: isLargeScreen(_screenSize())), + ); + } + + static Future _apply(List orientations) async { if (Platform.isIOS) { try { await _channel.invokeMethod('set', { @@ -34,3 +89,26 @@ class AppOrientation { DeviceOrientation.landscapeRight => 'landscapeRight', }; } + +/// Re-applies the last request when a foldable opens or closes. +class _MetricsWatch with WidgetsBindingObserver { + bool? _large; + + void attach() { + try { + WidgetsBinding.instance.addObserver(this); + } catch (_) {} + } + + @override + void didChangeMetrics() { + final large = AppOrientation.isLargeScreen(AppOrientation._screenSize()); + if (large == _large) return; + _large = large; + final requested = AppOrientation._requested; + if (requested == null) return; + AppOrientation._apply( + AppOrientation.resolve(requested, largeScreen: large), + ).catchError((Object _) {}); + } +} diff --git a/lib/core/system/app_settings.dart b/lib/core/system/app_settings.dart new file mode 100644 index 00000000..e09321ad --- /dev/null +++ b/lib/core/system/app_settings.dart @@ -0,0 +1,23 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens this app's page in the system settings, where permissions live. +/// Returns false where there is no such page or it could not be opened. +Future openAppSettings() async { + if (kIsWeb) return false; + try { + if (Platform.isAndroid) { + return await const MethodChannel( + 'soplay/platform', + ).invokeMethod('openAppSettings') ?? + false; + } + if (Platform.isIOS) { + return await launchUrl(Uri.parse('app-settings:')); + } + } catch (_) {} + return false; +} diff --git a/lib/core/system/desktop_window.dart b/lib/core/system/desktop_window.dart index e9a41906..1da9d5f8 100644 --- a/lib/core/system/desktop_window.dart +++ b/lib/core/system/desktop_window.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hive_flutter/hive_flutter.dart'; // Where the displays actually SIT. `dart:ui` enumerates displays but reports // only their sizes, and a saved window position can only be judged against @@ -80,6 +81,11 @@ class DesktopWindow { static const double miniWidth = 426; + /// The macOS side of floating (macos/Runner/MainFlutterWindow.swift): on + /// every Space and beside another app's full-screen window, which + /// window_manager's setAlwaysOnTop alone does not do. + static const MethodChannel _pipWindow = MethodChannel('sozo/pip_window'); + /// Shrinks the window to a [miniWidth]-wide player at [aspect], above /// every other window, in the bottom-right corner of the display it is on. static Future enterMini({double aspect = 16 / 9}) async { @@ -100,6 +106,13 @@ class DesktopWindow { _beforeMini = before; mini.value = true; final size = Size(miniWidth, (miniWidth / a).roundToDouble()); + // Only the video: no caption, no traffic lights, whatever the title-bar + // setting is (the native one drew "Sozo" across the top of the mini). + await windowManager.setTitleBarStyle( + TitleBarStyle.hidden, + windowButtonVisibility: false, + ); + await windowManager.setHasShadow(true); await windowManager.setMinimumSize(Size(240, (240 / a).roundToDouble())); await windowManager.setAlwaysOnTop(true); await windowManager.setSize(size); @@ -116,6 +129,12 @@ class DesktopWindow { Offset(area.right - size.width - 24, area.bottom - size.height - 24), ); } + // Last: setAlwaysOnTop above sets its own level, which this replaces. + if (Platform.isMacOS) { + try { + await _pipWindow.invokeMethod('enterFloat'); + } catch (_) {} + } } catch (_) {} } @@ -125,10 +144,23 @@ class DesktopWindow { if (!mini.value) return; mini.value = false; try { + // First: the window takes full screen again only once it is a + // full-screen-primary window again. + if (Platform.isMacOS) { + try { + await _pipWindow.invokeMethod('exitFloat'); + } catch (_) {} + } try { await windowManager.setAspectRatio(0); } catch (_) {} await windowManager.setAlwaysOnTop(false); + // The caption as main() set it, not setNativeTitleBar(): that one hides + // the macOS traffic lights, which the app always shows. + await windowManager.setTitleBarStyle( + nativeTitleBar.value ? TitleBarStyle.normal : TitleBarStyle.hidden, + windowButtonVisibility: Platform.isMacOS || nativeTitleBar.value, + ); await windowManager.setMinimumSize(minimumSize); final before = _beforeMini; if (before != null) await windowManager.setBounds(before); @@ -591,10 +623,12 @@ class WindowTitleBar extends StatelessWidget { DesktopWindow.fullscreen, DesktopWindow.immersive, DesktopWindow.nativeTitleBar, + DesktopWindow.mini, ]), builder: (context, _) { if (DesktopWindow.fullscreen.value || DesktopWindow.immersive.value || + DesktopWindow.mini.value || DesktopWindow.nativeTitleBar.value) { return const SizedBox.shrink(); } diff --git a/lib/core/system/foreground_poller.dart b/lib/core/system/foreground_poller.dart new file mode 100644 index 00000000..fc00047b --- /dev/null +++ b/lib/core/system/foreground_poller.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// A periodic task that runs only while the app is in the foreground. +/// +/// Feed it lifecycle changes: it stops on `paused`/`hidden`/`detached`, and on +/// `resumed` ticks once and restarts the period. +class ForegroundPoller { + ForegroundPoller({required this.period, required this.onTick}); + + final Duration period; + final VoidCallback onTick; + Timer? _timer; + + bool get isRunning => _timer?.isActive ?? false; + + void start() { + _timer?.cancel(); + _timer = Timer.periodic(period, (_) => onTick()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + void handleLifecycle(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + if (isRunning) return; + onTick(); + start(); + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + stop(); + case AppLifecycleState.inactive: + break; + } + } + + void dispose() => stop(); +} diff --git a/lib/core/system/star_prompt.dart b/lib/core/system/star_prompt.dart new file mode 100644 index 00000000..42966450 --- /dev/null +++ b/lib/core/system/star_prompt.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; + +/// Where the GitHub star prompt stands: episodes finished, the count at which +/// it may show next, and whether the viewer has answered for good. +@immutable +class StarPromptState { + const StarPromptState({ + this.completed = 0, + this.nextAt = StarPromptRules.firstAt, + this.done = false, + }); + + final int completed; + final int nextAt; + final bool done; + + factory StarPromptState.fromMap(Object? raw) { + if (raw is! Map) return const StarPromptState(); + int asInt(Object? v, int fallback) => v is num ? v.toInt() : fallback; + return StarPromptState( + completed: asInt(raw['completed'], 0), + nextAt: asInt(raw['nextAt'], StarPromptRules.firstAt), + done: raw['done'] == true, + ); + } + + Map toMap() => { + 'completed': completed, + 'nextAt': nextAt, + 'done': done, + }; + + StarPromptState copyWith({int? completed, int? nextAt, bool? done}) => + StarPromptState( + completed: completed ?? this.completed, + nextAt: nextAt ?? this.nextAt, + done: done ?? this.done, + ); + + @override + bool operator ==(Object other) => + other is StarPromptState && + other.completed == completed && + other.nextAt == nextAt && + other.done == done; + + @override + int get hashCode => Object.hash(completed, nextAt, done); +} + +/// The decisions, with no storage and no clock. +abstract final class StarPromptRules { + static const int firstAt = 7; + static const int snoozeEpisodes = 20; + + static bool shouldShow(StarPromptState s) => + !s.done && s.completed >= s.nextAt; + + static StarPromptState episodeCompleted(StarPromptState s) => + s.copyWith(completed: s.completed + 1); + + /// Showing it counts as "Later" until the viewer says otherwise, so a + /// banner scrolled past or left behind does not come back on every screen. + static StarPromptState shown(StarPromptState s) => + s.copyWith(nextAt: s.completed + snoozeEpisodes); + + static StarPromptState later(StarPromptState s) => shown(s); + + /// Starred, or "Don't ask again": either way, never again. + static StarPromptState answered(StarPromptState s) => s.copyWith(done: true); +} + +/// [StarPromptRules] over the settings box. App-wide, not per profile: it is +/// a question about the app, asked of whoever holds the device. +abstract final class StarPrompt { + static final ValueNotifier revision = ValueNotifier(0); + + static Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + static StarPromptState get state => + StarPromptState.fromMap(_box?.get(AppConstants.starPromptKey)); + + static bool get shouldShow => StarPromptRules.shouldShow(state); + + static Future _apply( + StarPromptState Function(StarPromptState) change, + ) async { + final box = _box; + if (box == null) return; + final before = state; + final after = change(before); + if (after == before) return; + try { + await box.put(AppConstants.starPromptKey, after.toMap()); + } catch (_) {} + revision.value++; + } + + static Future episodeCompleted() => + _apply(StarPromptRules.episodeCompleted); + + static Future markShown() => _apply(StarPromptRules.shown); + + static Future later() => _apply(StarPromptRules.later); + + static Future answered() => _apply(StarPromptRules.answered); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 22f50d86..73de5554 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder; import 'package:flutter/material.dart'; import '../system/platform_utils.dart'; import 'app_colors.dart'; @@ -27,6 +28,15 @@ class AppTheme { static ThemeData get dark => ThemeData( brightness: Brightness.dark, useMaterial3: true, + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + TargetPlatform.linux: ZoomPageTransitionsBuilder(), + }, + ), scaffoldBackgroundColor: AppColors.background, colorScheme: ColorScheme( brightness: Brightness.dark, diff --git a/lib/core/tv/tv_keys.dart b/lib/core/tv/tv_keys.dart index 8afb4efe..1ae64dca 100644 --- a/lib/core/tv/tv_keys.dart +++ b/lib/core/tv/tv_keys.dart @@ -28,6 +28,24 @@ class TvKeys { LogicalKeyboardKey.arrowRight, }; + /// The remote's search and microphone buttons. KEYCODE_SEARCH arrives as + /// [LogicalKeyboardKey.browserSearch] and KEYCODE_ASSIST as + /// [LogicalKeyboardKey.launchAssistant]; KEYCODE_VOICE_ASSIST (231) has no + /// Flutter name and arrives in the Android plane. Many TVs keep the + /// assistant keys for the system, so the app sees them only on some remotes. + static final Set voiceSearch = { + LogicalKeyboardKey.browserSearch, + LogicalKeyboardKey.launchAssistant, + LogicalKeyboardKey.speechInputToggle, + const LogicalKeyboardKey( + LogicalKeyboardKey.androidPlane | androidKeycodeVoiceAssist, + ), + }; + + static const int androidKeycodeVoiceAssist = 231; + + static bool isVoiceSearch(LogicalKeyboardKey k) => voiceSearch.contains(k); + static bool isActivate(LogicalKeyboardKey k) => activate.contains(k); static bool isDirectional(LogicalKeyboardKey k) => directional.contains(k); diff --git a/lib/core/widgets/bloom_page_transition.dart b/lib/core/widgets/bloom_page_transition.dart index fb578485..6d475259 100644 --- a/lib/core/widgets/bloom_page_transition.dart +++ b/lib/core/widgets/bloom_page_transition.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show PredictiveBackEvent; import 'package:flutter/material.dart'; /// How a title's page arrives: it blooms. @@ -34,7 +36,14 @@ class BloomPageTransition extends StatefulWidget { State createState() => _BloomPageTransitionState(); } -class _BloomPageTransitionState extends State { +/// On Android the back gesture drives the bloom backwards, as +/// [PredictiveBackPageTransitionsBuilder] does for a plain page: a +/// [CustomTransitionPage] never consults the theme, so without this the page +/// only popped on release. +class _BloomPageTransitionState extends State + with WidgetsBindingObserver { + ModalRoute? _route; + bool _tracking = false; late CurvedAnimation _settle; late CurvedAnimation _fade; late CurvedAnimation _behind; @@ -43,6 +52,50 @@ class _BloomPageTransitionState extends State { void initState() { super.initState(); _attach(); + if (defaultTargetPlatform == TargetPlatform.android) { + WidgetsBinding.instance.addObserver(this); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _route = ModalRoute.of(context); + } + + @override + bool handleStartBackGesture(PredictiveBackEvent backEvent) { + final route = _route; + if (backEvent.isButtonEvent || + route == null || + !route.isCurrent || + !route.popGestureEnabled) { + return false; + } + _tracking = true; + route.handleStartBackGesture(progress: 1 - backEvent.progress); + return true; + } + + @override + void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) { + if (_tracking) { + _route?.handleUpdateBackGestureProgress(progress: 1 - backEvent.progress); + } + } + + @override + void handleCancelBackGesture() { + if (!_tracking) return; + _tracking = false; + _route?.handleCancelBackGesture(); + } + + @override + void handleCommitBackGesture() { + if (!_tracking) return; + _tracking = false; + _route?.handleCommitBackGesture(); } @override @@ -80,6 +133,7 @@ class _BloomPageTransitionState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _detach(); super.dispose(); } diff --git a/lib/core/widgets/load_more_failed_footer.dart b/lib/core/widgets/load_more_failed_footer.dart new file mode 100644 index 00000000..d6af9556 --- /dev/null +++ b/lib/core/widgets/load_more_failed_footer.dart @@ -0,0 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// "Couldn't load more · Retry", where a paged grid's bottom spinner was. +class LoadMoreFailedFooter extends StatelessWidget { + const LoadMoreFailedFooter({super.key, required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + 'general.load_more_failed'.tr(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 13), + ), + ), + const Text( + ' · ', + style: TextStyle(color: AppColors.textHint, fontSize: 13), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 8), + minimumSize: const Size(48, 40), + ), + child: Text('general.retry'.tr()), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/star_prompt_banner.dart b/lib/core/widgets/star_prompt_banner.dart new file mode 100644 index 00000000..a2d4e835 --- /dev/null +++ b/lib/core/widgets/star_prompt_banner.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:soplay/core/constants/app_constants.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/player/player_dock.dart'; +import 'package:soplay/core/system/star_prompt.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// The "star Sozo on GitHub" card. Nothing at all until [StarPrompt] says it +/// is due, and only on the route on top: the player is a route of its own, so +/// a banner on Home or Detail never appears over playback. +class StarPromptBanner extends StatefulWidget { + const StarPromptBanner({ + super.key, + this.margin = const EdgeInsets.fromLTRB(12, 8, 12, 0), + this.safeBottom = false, + this.tab, + }); + + final EdgeInsetsGeometry margin; + final bool safeBottom; + + /// For a banner inside a main tab: tabs stay mounted behind the visible + /// one, so the route alone does not say it can be seen. + final TabId? tab; + + @override + State createState() => _StarPromptBannerState(); +} + +class _StarPromptBannerState extends State { + bool _visible = false; + NavController? _nav; + ModalRoute? _route; + + @override + void initState() { + super.initState(); + StarPrompt.revision.addListener(_schedule); + PlayerDock.instance.addListener(_schedule); + if (widget.tab != null && getIt.isRegistered()) { + _nav = getIt()..index.addListener(_schedule); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Subscribes to isCurrent, so returning from the player re-checks. + _route = ModalRoute.of(context); + _schedule(); + } + + @override + void dispose() { + StarPrompt.revision.removeListener(_schedule); + PlayerDock.instance.removeListener(_schedule); + _nav?.index.removeListener(_schedule); + super.dispose(); + } + + void _schedule() { + WidgetsBinding.instance + ..addPostFrameCallback((_) => _check()) + ..ensureVisualUpdate(); + } + + void _check() { + if (!mounted || _visible) return; + final route = _route; + if (route != null && !route.isCurrent) return; + if (PlayerDock.instance.isDocked) return; + final tab = widget.tab; + if (tab != null && !(_nav?.isActive(tab) ?? false)) return; + if (!StarPrompt.shouldShow) return; + unawaited(StarPrompt.markShown()); + setState(() => _visible = true); + } + + void _hide() { + if (mounted) setState(() => _visible = false); + } + + Future _star() async { + _hide(); + await StarPrompt.answered(); + try { + await launchUrl( + Uri.parse(AppConstants.sozoRepoUrl), + mode: LaunchMode.externalApplication, + ); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _visible ? _card(context) : const SizedBox(width: double.infinity), + ); + } + + Widget _card(BuildContext context) { + final card = Padding( + padding: widget.margin, + child: Material( + color: AppColors.surface, + elevation: 6, + shadowColor: Colors.black54, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsetsDirectional.fromSTEB(14, 12, 8, 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.star_rounded, + color: AppColors.rating, + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'star.title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'star.body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2, + children: [ + TextButton( + onPressed: () { + _hide(); + unawaited(StarPrompt.answered()); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.textHint, + ), + child: Text('star.never'.tr()), + ), + TextButton( + onPressed: () { + _hide(); + unawaited(StarPrompt.later()); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + ), + child: Text('star.later'.tr()), + ), + TextButton.icon( + onPressed: _star, + icon: const Icon(Icons.star_rounded, size: 18), + label: Text('star.action'.tr()), + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + ), + ); + return SizedBox( + width: double.infinity, + child: widget.safeBottom ? SafeArea(top: false, child: card) : card, + ); + } +} diff --git a/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart b/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart index 6be659a7..c86d0587 100644 --- a/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart +++ b/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart @@ -6,6 +6,8 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/router/app_router.dart'; import 'package:soplay/features/achievements/data/achievements_service.dart'; import 'package:soplay/features/achievements/presentation/dialogs/achievement_unlocked_dialog.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/presentation/dialogs/level_up_dialog.dart'; /// Shows badges as they are earned — but never over something being watched /// or read. @@ -37,12 +39,17 @@ class AchievementCelebrationHost extends StatefulWidget { class _AchievementCelebrationHostState extends State { AchievementsService? _service; + ProgressService? _progress; bool _showing = false; Timer? _settle; @override void initState() { super.initState(); + if (getIt.isRegistered()) { + _progress = getIt() + ..pendingLevelUp.addListener(_schedule); + } if (!getIt.isRegistered()) return; _service = getIt()..pending.addListener(_schedule); AppRouter.router.routerDelegate.addListener(_schedule); @@ -52,6 +59,7 @@ class _AchievementCelebrationHostState void dispose() { _settle?.cancel(); _service?.pending.removeListener(_schedule); + _progress?.pendingLevelUp.removeListener(_schedule); AppRouter.router.routerDelegate.removeListener(_schedule); super.dispose(); } @@ -66,7 +74,8 @@ class _AchievementCelebrationHostState Future _maybeShow() async { final service = _service; if (service == null || _showing || !mounted) return; - if (service.pending.value.isEmpty) return; + final levelUp = _progress?.pendingLevelUp.value; + if (service.pending.value.isEmpty && levelUp == null) return; final path = AppRouter.router.routerDelegate.currentConfiguration.uri.path; if (AchievementCelebrationHost.quietPaths.contains(path)) return; final context = AppRouter.router.routerDelegate.navigatorKey.currentContext; @@ -82,17 +91,31 @@ class _AchievementCelebrationHostState ); if (!context.mounted) return; final unlocks = service.takePending(); - if (unlocks.isEmpty) return; - await AchievementUnlockedDialog.show( - context, - unlocks, - view: service.state.value, - onSeeAll: () => AppRouter.router.push('/achievements'), - ); + if (unlocks.isNotEmpty) { + await AchievementUnlockedDialog.show( + context, + unlocks, + view: service.state.value, + onSeeAll: () => AppRouter.router.push('/achievements'), + ); + } + // A badge often brings the level with it; one after the other. + final up = _progress?.takeLevelUp(); + if (up != null && context.mounted) { + await LevelUpDialog.show( + context, + up, + onOpen: () => AppRouter.router.push('/progress'), + ); + } } finally { _showing = false; } - if (mounted && service.pending.value.isNotEmpty) _schedule(); + if (mounted && + (service.pending.value.isNotEmpty || + _progress?.pendingLevelUp.value != null)) { + _schedule(); + } } @override diff --git a/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart b/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart index 32867ea4..b79ab911 100644 --- a/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart +++ b/lib/features/aniyomi/presentation/pages/aniyomi_sources_page.dart @@ -228,6 +228,7 @@ class _AniyomiSourcesPageState extends State { width: size, height: size, fit: BoxFit.cover, + cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)).round(), errorBuilder: (_, _, _) => Container( width: size, height: size, diff --git a/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart b/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart index 1a908937..ec342d03 100644 --- a/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart +++ b/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart @@ -1,13 +1,11 @@ import 'dart:convert'; -import 'dart:isolate'; -import 'dart:math'; -import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:local_auth/local_auth.dart'; import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; class AppLockLocalDataSource { @@ -15,6 +13,7 @@ class AppLockLocalDataSource { required HiveService hiveService, FlutterSecureStorage? secureStorage, LocalAuthentication? localAuth, + DeviceBiometrics? biometrics, DateTime Function()? clock, }) : _hive = hiveService, _secure = @@ -25,29 +24,14 @@ class AppLockLocalDataSource { accessibility: KeychainAccessibility.first_unlock, ), ), - _localAuth = localAuth ?? LocalAuthentication(), + _biometrics = biometrics ?? DeviceBiometrics(localAuth), _now = clock ?? DateTime.now; final HiveService _hive; final FlutterSecureStorage _secure; - final LocalAuthentication _localAuth; + final DeviceBiometrics _biometrics; final DateTime Function() _now; - /// PBKDF2 rounds for a new hash. A PIN has at most a million values, so the - /// hash is only ever a speed bump for someone holding a copy of the stored - /// secret; the real protection is that the secret sits in the platform - /// keystore and that guessing on the device is rate-limited below. The - /// count is stored with the hash, so it can be raised later without - /// invalidating anyone's PIN. - static const int pbkdf2Iterations = 60000; - - /// Wrong guesses allowed before a wait is imposed. - static const int freeAttempts = 5; - - /// The first wait; it doubles with each further wrong guess, up to [maxWait]. - static const Duration firstWait = Duration(seconds: 30); - static const Duration maxWait = Duration(hours: 1); - static const String _failuresKey = 'app_lock_failures'; static const String _lockedUntilKey = 'app_lock_locked_until'; @@ -56,8 +40,8 @@ class AppLockLocalDataSource { bool get isBiometricPreferred => _hive.isAppLockBiometricEnabled; Future setPin(String pin) async { - final salt = _generateSalt(); - final hash = await _hashPbkdf2(pin, salt, pbkdf2Iterations); + final salt = PinHash.newSalt(); + final hash = await PinHash.hash(pin, salt); await _secure.write(key: AppConstants.appLockPinSaltSecureKey, value: salt); await _secure.write(key: AppConstants.appLockPinHashSecureKey, value: hash); await _clearFailures(); @@ -90,14 +74,14 @@ class AppLockLocalDataSource { } final bool ok; - if (stored.startsWith('pbkdf2\$')) { - ok = await _verifyPbkdf2(pin, salt, stored); + if (PinHash.isPbkdf2(stored)) { + ok = await PinHash.verify(pin, salt, stored); } else { - ok = _constantTimeEquals(_legacyHash(pin, salt), stored); + ok = PinHash.constantTimeEquals(_legacyHash(pin, salt), stored); // Upgrade in place, now that the PIN is in hand to re-hash. if (ok) { try { - final upgraded = await _hashPbkdf2(pin, salt, pbkdf2Iterations); + final upgraded = await PinHash.hash(pin, salt); await _secure.write( key: AppConstants.appLockPinHashSecureKey, value: upgraded, @@ -161,33 +145,12 @@ class AppLockLocalDataSource { Future setBiometricPreferred(bool value) => _hive.setAppLockBiometricEnabled(value); - Future isBiometricAvailable() async { - try { - final supported = await _localAuth.isDeviceSupported(); - if (!supported) return false; - final canCheck = await _localAuth.canCheckBiometrics; - if (!canCheck) return false; - final available = await _localAuth.getAvailableBiometrics(); - return available.isNotEmpty; - } catch (_) { - return false; - } - } + Future isBiometricAvailable() => _biometrics.isAvailable(); Future authenticateWithBiometrics(String reason) async { - try { - final ok = await _localAuth.authenticate( - localizedReason: reason, - options: const AuthenticationOptions( - stickyAuth: true, - biometricOnly: true, - ), - ); - if (ok) await _clearFailures(); - return ok; - } catch (_) { - return false; - } + final ok = await _biometrics.authenticate(reason); + if (ok) await _clearFailures(); + return ok; } Future _recordFailure() async { @@ -199,27 +162,16 @@ class AppLockLocalDataSource { try { await _secure.write(key: _failuresKey, value: '$failures'); } catch (_) {} - final wait = waitAfter(failures); - if (wait == null) { - return PinCheckResult.wrong(freeAttempts - failures); - } - final until = _now().add(wait); + final result = PinBackoff.afterFailure(failures, _now()); + final until = result.retryAt; + if (until == null) return result; try { await _secure.write( key: _lockedUntilKey, value: '${until.millisecondsSinceEpoch}', ); } catch (_) {} - return PinCheckResult.lockedOut(until); - } - - /// The wait imposed after the [failures]-th wrong guess in a row, or null - /// while guesses are still free. - static Duration? waitAfter(int failures) { - if (failures < freeAttempts) return null; - final doublings = min(failures - freeAttempts, 20); - final wait = firstWait * pow(2, doublings).toInt(); - return wait > maxWait ? maxWait : wait; + return result; } Future _clearFailures() async { @@ -229,12 +181,6 @@ class AppLockLocalDataSource { } catch (_) {} } - String _generateSalt() { - final rnd = Random.secure(); - final bytes = List.generate(16, (_) => rnd.nextInt(256)); - return base64Url.encode(bytes); - } - /// The original single-round hash. Kept only to recognise PINs set before /// PBKDF2, which [checkPin] upgrades on their next unlock. The `soplay` /// suffix is part of those stored hashes — changing it would lock every @@ -243,47 +189,4 @@ class AppLockLocalDataSource { final bytes = utf8.encode('$salt::$pin::soplay'); return sha256.convert(bytes).toString(); } - - Future _verifyPbkdf2(String pin, String salt, String stored) async { - // pbkdf2$sha256$$ - final parts = stored.split('\$'); - if (parts.length != 4 || parts[1] != 'sha256') return false; - final iterations = int.tryParse(parts[2]); - if (iterations == null || iterations <= 0) return false; - final candidate = await _hashPbkdf2(pin, salt, iterations); - return _constantTimeEquals(candidate, stored); - } - - /// Runs off the UI isolate: tens of thousands of HMAC rounds would drop - /// frames on the keypad animation. - static Future _hashPbkdf2(String pin, String salt, int iterations) { - return Isolate.run(() => hashPbkdf2Sync(pin, salt, iterations)); - } - - /// PBKDF2-HMAC-SHA256 with one 32-byte block, encoded with its parameters. - static String hashPbkdf2Sync(String pin, String salt, int iterations) { - final hmac = Hmac(sha256, utf8.encode(pin)); - final saltBytes = utf8.encode(salt); - final block = Uint8List(saltBytes.length + 4) - ..setAll(0, saltBytes) - ..setAll(saltBytes.length, const [0, 0, 0, 1]); - var u = hmac.convert(block).bytes; - final out = Uint8List.fromList(u); - for (var i = 1; i < iterations; i++) { - u = hmac.convert(u).bytes; - for (var j = 0; j < out.length; j++) { - out[j] ^= u[j]; - } - } - return 'pbkdf2\$sha256\$$iterations\$${base64.encode(out)}'; - } - - static bool _constantTimeEquals(String a, String b) { - if (a.length != b.length) return false; - var diff = 0; - for (var i = 0; i < a.length; i++) { - diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); - } - return diff == 0; - } } diff --git a/lib/features/app_lock/data/pin_secrets.dart b/lib/features/app_lock/data/pin_secrets.dart new file mode 100644 index 00000000..f9235e92 --- /dev/null +++ b/lib/features/app_lock/data/pin_secrets.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; + +/// Salted PBKDF2 for a PIN kept on the device: the app lock's and the offline +/// check of a profile PIN. +abstract final class PinHash { + /// PBKDF2 rounds for a new hash. A PIN has at most a million values, so the + /// hash is only ever a speed bump for someone holding a copy of the stored + /// secret; the real protection is that the secret sits in the platform + /// keystore and that guessing on the device is rate-limited. The count is + /// stored with the hash, so it can be raised later without invalidating + /// anyone's PIN. + static const int iterations = 60000; + + static String newSalt() { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + return base64Url.encode(bytes); + } + + /// Runs off the UI isolate: tens of thousands of HMAC rounds would drop + /// frames on the keypad animation. + static Future hash( + String pin, + String salt, [ + int rounds = iterations, + ]) => Isolate.run(() => hashSync(pin, salt, rounds)); + + /// PBKDF2-HMAC-SHA256 with one 32-byte block, encoded with its parameters. + static String hashSync(String pin, String salt, int rounds) { + final hmac = Hmac(sha256, utf8.encode(pin)); + final saltBytes = utf8.encode(salt); + final block = Uint8List(saltBytes.length + 4) + ..setAll(0, saltBytes) + ..setAll(saltBytes.length, const [0, 0, 0, 1]); + var u = hmac.convert(block).bytes; + final out = Uint8List.fromList(u); + for (var i = 1; i < rounds; i++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < out.length; j++) { + out[j] ^= u[j]; + } + } + return 'pbkdf2\$sha256\$$rounds\$${base64.encode(out)}'; + } + + static bool isPbkdf2(String stored) => stored.startsWith('pbkdf2\$'); + + /// Whether [pin] matches a [stored] value made by [hash]. + static Future verify(String pin, String salt, String stored) async { + // pbkdf2$sha256$$ + final parts = stored.split('\$'); + if (parts.length != 4 || parts[1] != 'sha256') return false; + final rounds = int.tryParse(parts[2]); + if (rounds == null || rounds <= 0) return false; + final candidate = await hash(pin, salt, rounds); + return constantTimeEquals(candidate, stored); + } + + static bool constantTimeEquals(String a, String b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diff == 0; + } +} + +/// How many wrong guesses in a row are free, and the growing wait after them. +abstract final class PinBackoff { + static const int freeAttempts = 5; + + /// The first wait; it doubles with each further wrong guess, up to [maxWait]. + static const Duration firstWait = Duration(seconds: 30); + static const Duration maxWait = Duration(hours: 1); + + /// The wait imposed after the [failures]-th wrong guess in a row, or null + /// while guesses are still free. + static Duration? waitAfter(int failures) { + if (failures < freeAttempts) return null; + final doublings = min(failures - freeAttempts, 20); + final wait = firstWait * pow(2, doublings).toInt(); + return wait > maxWait ? maxWait : wait; + } + + /// What the [failures]-th wrong guess in a row, made at [now], comes to. + static PinCheckResult afterFailure(int failures, DateTime now) { + final wait = waitAfter(failures); + if (wait == null) return PinCheckResult.wrong(freeAttempts - failures); + return PinCheckResult.lockedOut(now.add(wait)); + } +} + +/// Fingerprint or face, through `local_auth`. Never throws. +class DeviceBiometrics { + DeviceBiometrics([LocalAuthentication? localAuth]) + : _localAuth = localAuth ?? LocalAuthentication(); + + final LocalAuthentication _localAuth; + + Future isAvailable() async { + try { + final supported = await _localAuth.isDeviceSupported(); + if (!supported) return false; + final canCheck = await _localAuth.canCheckBiometrics; + if (!canCheck) return false; + final available = await _localAuth.getAvailableBiometrics(); + return available.isNotEmpty; + } catch (_) { + return false; + } + } + + Future authenticate(String reason) async { + try { + return await _localAuth.authenticate( + localizedReason: reason, + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: true, + ), + ); + } catch (_) { + return false; + } + } +} diff --git a/lib/features/app_updater/presentation/widgets/update_dialog.dart b/lib/features/app_updater/presentation/widgets/update_dialog.dart index ebc5e3a0..f6642720 100644 --- a/lib/features/app_updater/presentation/widgets/update_dialog.dart +++ b/lib/features/app_updater/presentation/widgets/update_dialog.dart @@ -3,9 +3,12 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/system/star_prompt.dart'; import 'package:soplay/core/theme/app_colors.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'; +import 'package:url_launcher/url_launcher.dart'; const Color _androidAccent = Color(0xFF10B981); const Color _iosAccent = Color(0xFF0F172A); @@ -45,15 +48,23 @@ class _MaterialUpdateDialog extends StatelessWidget { ), ), content: SingleChildScrollView( - child: (check.releaseNotes ?? '').trim().isEmpty - ? Text( - 'update.available_body'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - height: 1.4, - ), - ) - : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + (check.releaseNotes ?? '').trim().isEmpty + ? Text( + 'update.available_body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + height: 1.4, + ), + ) + : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + const SizedBox(height: 8), + const _StarOnGitHubButton(), + ], + ), ), actions: [ TextButton( @@ -84,9 +95,16 @@ class _CupertinoUpdateDialog extends StatelessWidget { title: Text('update.available_title'.tr(args: ['${check.version}'])), content: Padding( padding: const EdgeInsets.only(top: 8), - child: (check.releaseNotes ?? '').trim().isEmpty - ? Text('update.available_body'.tr()) - : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + (check.releaseNotes ?? '').trim().isEmpty + ? Text('update.available_body'.tr()) + : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + const SizedBox(height: 6), + const _StarOnGitHubButton(), + ], + ), ), actions: [ if (hasStore) @@ -111,3 +129,35 @@ class _CupertinoUpdateDialog extends StatelessWidget { ); } } + +class _StarOnGitHubButton extends StatelessWidget { + const _StarOnGitHubButton(); + + Future _star() async { + await StarPrompt.answered(); + try { + await launchUrl( + Uri.parse(AppConstants.sozoRepoUrl), + mode: LaunchMode.externalApplication, + ); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: TextButton.icon( + onPressed: _star, + icon: const Icon(Icons.star_rounded, size: 16), + label: Text('star.update_button'.tr()), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 12.5), + ), + ), + ); + } +} diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 605b396d..ce4b5a52 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -5,8 +5,11 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:soplay/features/trakt/data/trakt_service.dart'; import 'package:soplay/core/error/result.dart'; +import 'package:soplay/core/player/player_dock.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/my_list/data/datasources/my_list_local_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/stats/data/watch_stats_store.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/auth/data/models/user_model.dart'; @@ -275,6 +278,10 @@ class AuthRepositoryImpl implements AuthRepository { /// writes to a real third-party list, so a leftover one would file the next /// person's viewing under a stranger's AniList or MyAnimeList profile. Future _clearAccountScopedData() async { + // Before anything is wiped: the corner player saves its history as it + // closes, and once the rows below are gone that save would write the + // account's title back as nobody's, for the next account to adopt. + await PlayerDock.instance.close(); await _hiveService.clearAuth(); // First, so everything below clears the default profile's boxes, which // are the ones that stay on the device. @@ -320,6 +327,12 @@ class AuthRepositoryImpl implements AuthRepository { if (getIt.isRegistered()) { getIt().reset(); } + if (getIt.isRegistered()) { + await getIt().reset(); + } + if (getIt.isRegistered()) { + await getIt().reset(); + } // Not server-backed, and cleared for exactly that reason: nothing would // ever remove it otherwise, and a watch-time total is as personal as the // history it was counted from. diff --git a/lib/features/comments/data/datasources/comments_data_source.dart b/lib/features/comments/data/datasources/comments_data_source.dart index d60bf809..5b4c3cb2 100644 --- a/lib/features/comments/data/datasources/comments_data_source.dart +++ b/lib/features/comments/data/datasources/comments_data_source.dart @@ -73,6 +73,84 @@ class CommentsDataSource { return (body['cascadeDeleted'] as num?)?.toInt() ?? 0; } + Future getScopedComments({ + required String provider, + required String contentUrl, + String? episodeKey, + int page = 1, + int limit = 20, + }) async { + final res = await dio.get( + '/comments', + queryParameters: { + 'provider': provider, + 'contentUrl': contentUrl, + 'episodeKey': ?episodeKey, + 'page': page, + 'limit': limit, + }, + ); + return CommentListModel.fromJson(res.data as Map); + } + + Future createScoped({ + required String provider, + required String contentUrl, + required String text, + String? parentId, + String? episodeKey, + bool spoiler = false, + int? atSec, + }) async { + final res = await dio.post( + '/comments', + data: { + 'provider': provider, + 'contentUrl': contentUrl, + 'text': text, + 'parentId': ?parentId, + 'episodeKey': ?episodeKey, + if (spoiler) 'spoiler': true, + 'atSec': ?atSec, + }, + ); + final body = res.data as Map; + return CommentModel.fromJson( + (body['item'] as Map).cast(), + ); + } + + Future editScoped({ + required String id, + required String text, + bool? spoiler, + }) async { + final res = await dio.put( + '/comments/$id', + data: {'text': text, 'spoiler': ?spoiler}, + ); + final body = res.data as Map; + return CommentModel.fromJson( + (body['item'] as Map).cast(), + ); + } + + Future> episodeCounts({ + required String provider, + required String contentUrl, + }) async { + final res = await dio.get( + '/comments/episode-counts', + queryParameters: {'provider': provider, 'contentUrl': contentUrl}, + ); + final counts = (res.data as Map?)?['counts']; + if (counts is! Map) return const {}; + return { + for (final e in counts.entries) + if (e.value is num) e.key.toString(): (e.value as num).toInt(), + }; + } + Future toggleLike(String id) async { final res = await dio.post('/comments/$id/like'); final body = res.data as Map; diff --git a/lib/features/comments/data/datasources/timed_reactions_data_source.dart b/lib/features/comments/data/datasources/timed_reactions_data_source.dart new file mode 100644 index 00000000..aa2264d0 --- /dev/null +++ b/lib/features/comments/data/datasources/timed_reactions_data_source.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +class TimedReactionsDataSource { + const TimedReactionsDataSource({required this.dio}); + + final Dio dio; + + Future fetch({ + required String provider, + required String contentUrl, + required String episodeKey, + }) async { + final res = await dio.get( + '/reactions', + queryParameters: { + 'provider': provider, + 'contentUrl': contentUrl, + 'episodeKey': episodeKey, + }, + ); + final data = res.data; + if (data is! Map) return ReactionTimeline.empty; + return ReactionTimeline.fromJson(data.cast()); + } + + Future post({ + required String provider, + required String contentUrl, + required String episodeKey, + required int atSec, + required String kind, + required String value, + }) async { + await dio.post( + '/reactions', + data: { + 'provider': provider, + 'contentUrl': contentUrl, + 'episodeKey': episodeKey, + 'atSec': atSec, + 'kind': kind, + 'value': value, + }, + ); + } +} diff --git a/lib/features/comments/data/models/comment_author_model.dart b/lib/features/comments/data/models/comment_author_model.dart index 513ecae5..101bfbba 100644 --- a/lib/features/comments/data/models/comment_author_model.dart +++ b/lib/features/comments/data/models/comment_author_model.dart @@ -1,4 +1,5 @@ import 'package:soplay/features/comments/domain/entities/comment_author.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; class CommentAuthorModel extends CommentAuthor { const CommentAuthorModel({ @@ -6,6 +7,7 @@ class CommentAuthorModel extends CommentAuthor { required super.username, super.displayName, super.photoURL, + super.decoration, }); factory CommentAuthorModel.fromJson(Map json) => @@ -14,5 +16,6 @@ class CommentAuthorModel extends CommentAuthor { username: json['username'] as String? ?? '', displayName: json['displayName'] as String?, photoURL: json['photoURL'] as String?, + decoration: UserDecoration.fromJson(json), ); } diff --git a/lib/features/comments/data/models/comment_model.dart b/lib/features/comments/data/models/comment_model.dart index 5bd05dab..069c07ea 100644 --- a/lib/features/comments/data/models/comment_model.dart +++ b/lib/features/comments/data/models/comment_model.dart @@ -15,6 +15,9 @@ class CommentModel extends CommentEntity { required super.replyCount, required super.createdAt, required super.updatedAt, + super.episodeKey, + super.spoiler, + super.atSec, }); factory CommentModel.fromJson(Map json) { @@ -40,6 +43,9 @@ class CommentModel extends CommentEntity { replyCount: (json['replyCount'] as num?)?.toInt() ?? 0, createdAt: parse(json['createdAt']), updatedAt: parse(json['updatedAt'] ?? json['createdAt']), + episodeKey: json['episodeKey'] as String?, + spoiler: json['spoiler'] as bool? ?? false, + atSec: (json['atSec'] as num?)?.toInt(), ); } } diff --git a/lib/features/comments/data/repositories/comments_repository_impl.dart b/lib/features/comments/data/repositories/comments_repository_impl.dart index 7a87bd3a..b788aef8 100644 --- a/lib/features/comments/data/repositories/comments_repository_impl.dart +++ b/lib/features/comments/data/repositories/comments_repository_impl.dart @@ -5,8 +5,10 @@ import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; import 'package:soplay/features/comments/domain/entities/comment_list.dart'; import 'package:soplay/features/comments/domain/entities/like_result.dart'; import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; +import 'package:soplay/features/comments/domain/repositories/episode_comments_repository.dart'; -class CommentsRepositoryImpl implements CommentsRepository { +class CommentsRepositoryImpl + implements CommentsRepository, EpisodeCommentsRepository { final CommentsDataSource dataSource; const CommentsRepositoryImpl(this.dataSource); @@ -60,6 +62,60 @@ class CommentsRepositoryImpl implements CommentsRepository { @override Future> delete(String id) => _wrap(() => dataSource.delete(id)); + @override + Future> getScopedComments({ + required String provider, + required String contentUrl, + String? episodeKey, + int page = 1, + int limit = 20, + }) => + _wrap(() => dataSource.getScopedComments( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + page: page, + limit: limit, + )); + + @override + Future> createScoped({ + required String provider, + required String contentUrl, + required String text, + String? parentId, + String? episodeKey, + bool spoiler = false, + int? atSec, + }) => + _wrap(() => dataSource.createScoped( + provider: provider, + contentUrl: contentUrl, + text: text, + parentId: parentId, + episodeKey: episodeKey, + spoiler: spoiler, + atSec: atSec, + )); + + @override + Future> editScoped({ + required String id, + required String text, + bool? spoiler, + }) => + _wrap(() => dataSource.editScoped(id: id, text: text, spoiler: spoiler)); + + @override + Future>> episodeCounts({ + required String provider, + required String contentUrl, + }) => + _wrap(() => dataSource.episodeCounts( + provider: provider, + contentUrl: contentUrl, + )); + @override Future> toggleLike(String id) => _wrap(() => dataSource.toggleLike(id)); diff --git a/lib/features/comments/data/social_playback_prefs.dart b/lib/features/comments/data/social_playback_prefs.dart new file mode 100644 index 00000000..55b20563 --- /dev/null +++ b/lib/features/comments/data/social_playback_prefs.dart @@ -0,0 +1,37 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; + +/// Spoiler and timed-reaction preferences. +/// +/// Best-effort like the other small stores: with no box (widget tests, the +/// moments before Hive opens) every getter answers its default. +class SocialPlaybackPrefs { + const SocialPlaybackPrefs(); + + static const String _hideSpoilersKey = 'hide_unwatched_spoilers'; + static const String _reactionsOverlayKey = 'timed_reactions_overlay'; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + /// Hide comments about episodes the viewer has not finished. On by default. + bool get hideUnwatchedSpoilers => + _box?.get(_hideSpoilersKey, defaultValue: true) != false; + + Future setHideUnwatchedSpoilers(bool value) async { + await _box?.put(_hideSpoilersKey, value); + } + + /// Other viewers' reactions over the video. On by default. + bool get reactionsOverlay => + _box?.get(_reactionsOverlayKey, defaultValue: true) != false; + + Future setReactionsOverlay(bool value) async { + await _box?.put(_reactionsOverlayKey, value); + } +} diff --git a/lib/features/comments/domain/entities/comment_author.dart b/lib/features/comments/domain/entities/comment_author.dart index 94f2c633..5ecae7ca 100644 --- a/lib/features/comments/domain/entities/comment_author.dart +++ b/lib/features/comments/domain/entities/comment_author.dart @@ -1,14 +1,18 @@ +import 'package:soplay/features/progress/domain/progress.dart'; + class CommentAuthor { final String id; final String username; final String? displayName; final String? photoURL; + final UserDecoration? decoration; const CommentAuthor({ required this.id, required this.username, this.displayName, this.photoURL, + this.decoration, }); String get nameOrUsername => diff --git a/lib/features/comments/domain/entities/comment_entity.dart b/lib/features/comments/domain/entities/comment_entity.dart index e20346f8..cd63c5e2 100644 --- a/lib/features/comments/domain/entities/comment_entity.dart +++ b/lib/features/comments/domain/entities/comment_entity.dart @@ -14,6 +14,14 @@ class CommentEntity { final DateTime createdAt; final DateTime updatedAt; + /// Null for a title-level comment. + final String? episodeKey; + final bool spoiler; + + /// The second of the episode the comment was written at, when it was + /// written from the player. + final int? atSec; + const CommentEntity({ required this.id, required this.provider, @@ -27,6 +35,9 @@ class CommentEntity { required this.replyCount, required this.createdAt, required this.updatedAt, + this.episodeKey, + this.spoiler = false, + this.atSec, }); bool get isTopLevel => parentId == null; @@ -38,6 +49,7 @@ class CommentEntity { bool? edited, int? replyCount, DateTime? updatedAt, + bool? spoiler, }) => CommentEntity( id: id, @@ -52,5 +64,8 @@ class CommentEntity { replyCount: replyCount ?? this.replyCount, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, + episodeKey: episodeKey, + spoiler: spoiler ?? this.spoiler, + atSec: atSec, ); } diff --git a/lib/features/comments/domain/episode_key.dart b/lib/features/comments/domain/episode_key.dart new file mode 100644 index 00000000..0f7d11f5 --- /dev/null +++ b/lib/features/comments/domain/episode_key.dart @@ -0,0 +1,31 @@ +/// Names an episode within a title for its comment thread and reactions. +/// +/// The same number watch history keys an episode by — `episodeNumber`, else +/// the index — so "have I finished this one" and "what do people say about +/// it" agree on which episode is meant. Null for a film: its comments are the +/// title's comments. +class EpisodeKey { + const EpisodeKey._(); + + static const String _prefix = 'e'; + + static String? of({ + required bool isSerial, + int? episodeNumber, + int? episodeIndex, + }) { + if (!isSerial) return null; + final n = episodeNumber ?? episodeIndex; + if (n == null || n < 0) return null; + return '$_prefix$n'; + } + + /// The episode number inside [key], or null when it is not one of ours. + static int? numberOf(String? key) { + if (key == null || !key.startsWith(_prefix)) return null; + return int.tryParse(key.substring(_prefix.length)); + } + + /// What a reaction is filed under: a film reacts under the empty key. + static String forReactions(String? key) => key ?? ''; +} diff --git a/lib/features/comments/domain/repositories/episode_comments_repository.dart b/lib/features/comments/domain/repositories/episode_comments_repository.dart new file mode 100644 index 00000000..bc44f33d --- /dev/null +++ b/lib/features/comments/domain/repositories/episode_comments_repository.dart @@ -0,0 +1,39 @@ +import 'package:soplay/core/error/result.dart'; +import '../entities/comment_entity.dart'; +import '../entities/comment_list.dart'; + +/// Episode threads and spoiler flags. +/// +/// Separate from [CommentsRepository] so the fakes that implement that one +/// keep compiling; [CommentsBloc] uses this when its repository offers it. +abstract class EpisodeCommentsRepository { + Future> getScopedComments({ + required String provider, + required String contentUrl, + String? episodeKey, + int page, + int limit, + }); + + Future> createScoped({ + required String provider, + required String contentUrl, + required String text, + String? parentId, + String? episodeKey, + bool spoiler, + int? atSec, + }); + + Future> editScoped({ + required String id, + required String text, + bool? spoiler, + }); + + /// Top-level comment count per episode key. + Future>> episodeCounts({ + required String provider, + required String contentUrl, + }); +} diff --git a/lib/features/comments/domain/spoiler_rules.dart b/lib/features/comments/domain/spoiler_rules.dart new file mode 100644 index 00000000..ce7cf4eb --- /dev/null +++ b/lib/features/comments/domain/spoiler_rules.dart @@ -0,0 +1,74 @@ +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/detail/domain/playback/watch_progress.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +/// How a comment is drawn with spoilers in mind. +enum SpoilerVisibility { + visible, + + /// Its author marked it a spoiler. + spoiler, + + /// It is about an episode the viewer has not finished. + unwatched, +} + +class SpoilerRules { + const SpoilerRules._(); + + /// Own comments and ones the viewer already tapped open always show. + /// + /// Watching the episode right now ([playheadSec] set) only hides what was + /// written about a later moment than the playhead; a comment with no + /// moment is the episode's general thread and shows. + static SpoilerVisibility visibility({ + required bool spoiler, + required bool isOwn, + required bool revealed, + required bool hideUnwatched, + required String? episodeKey, + required bool episodeFinished, + int? atSec, + int? playheadSec, + }) { + if (isOwn || revealed) return SpoilerVisibility.visible; + if (spoiler) return SpoilerVisibility.spoiler; + if (!hideUnwatched || episodeKey == null || episodeFinished) { + return SpoilerVisibility.visible; + } + if (playheadSec != null) { + return atSec != null && atSec > playheadSec + ? SpoilerVisibility.unwatched + : SpoilerVisibility.visible; + } + return SpoilerVisibility.unwatched; + } + + /// Whether history says the viewer has finished the episode [episodeKey]. + /// + /// Finished means watched past the threshold the trackers use, or moved on + /// to a later episode of the same title: people skip a recap episode and + /// still know what happened in it. + static bool episodeFinished( + String? episodeKey, + String contentUrl, + Iterable history, + ) { + final n = EpisodeKey.numberOf(episodeKey); + if (n == null) return true; + for (final row in history) { + if (row.contentUrl != contentUrl) continue; + final rowN = row.episodeNumber ?? row.episodeIndex; + if (rowN == null) continue; + if (rowN > n) return true; + if (rowN == n && + WatchProgress.isWatched( + Duration(milliseconds: row.positionMs), + Duration(milliseconds: row.durationMs), + )) { + return true; + } + } + return false; + } +} diff --git a/lib/features/comments/domain/timed_reactions.dart b/lib/features/comments/domain/timed_reactions.dart new file mode 100644 index 00000000..cbfda3d4 --- /dev/null +++ b/lib/features/comments/domain/timed_reactions.dart @@ -0,0 +1,260 @@ +/// The quick-bar emoji. The backend accepts exactly these +/// (src/services/timedReactionService.js). +const List kTimedReactionEmoji = [ + '🔥', + '😂', + '😮', + '😢', + '😍', + '👏', + '💀', + '😡', +]; + +const int kTimedReactionMaxText = 60; + +class ReactionItem { + const ReactionItem({ + required this.kind, + required this.value, + required this.count, + }); + + /// 'emoji' or 'text'. + final String kind; + final String value; + final int count; + + bool get isEmoji => kind == 'emoji'; + + factory ReactionItem.fromJson(Map json) => ReactionItem( + kind: json['kind'] as String? ?? 'emoji', + value: json['value'] as String? ?? '', + count: (json['count'] as num?)?.toInt() ?? 1, + ); +} + +/// Everything reacted at one second of the episode. +class ReactionBucket { + const ReactionBucket({ + required this.t, + required this.total, + required this.items, + }); + + final int t; + + /// All reactions at [t], including the ones [items] leaves out. + final int total; + final List items; + + factory ReactionBucket.fromJson(Map json) => ReactionBucket( + t: (json['t'] as num?)?.toInt() ?? 0, + total: (json['total'] as num?)?.toInt() ?? 0, + items: [ + for (final i in (json['items'] as List? ?? const [])) + if (i is Map) ReactionItem.fromJson(i.cast()), + ], + ); +} + +class ReactionTimeline { + ReactionTimeline(List buckets) + : buckets = List.unmodifiable( + [...buckets]..sort((a, b) => a.t.compareTo(b.t)), + ); + + static final ReactionTimeline empty = ReactionTimeline(const []); + + /// Sorted by second. + final List buckets; + + bool get isEmpty => buckets.isEmpty; + + factory ReactionTimeline.fromJson(Map json) => + ReactionTimeline([ + for (final b in (json['buckets'] as List? ?? const [])) + if (b is Map) ReactionBucket.fromJson(b.cast()), + ]); + + /// The timeline with one more reaction at [t] — the viewer's own, shown + /// without waiting for the next fetch. + ReactionTimeline withReaction(int t, String kind, String value) { + final out = []; + var placed = false; + for (final b in buckets) { + if (b.t != t) { + out.add(b); + continue; + } + placed = true; + final items = [...b.items]; + final i = items.indexWhere((x) => x.kind == kind && x.value == value); + if (i >= 0) { + items[i] = ReactionItem(kind: kind, value: value, count: items[i].count + 1); + } else { + items.add(ReactionItem(kind: kind, value: value, count: 1)); + } + out.add(ReactionBucket(t: t, total: b.total + 1, items: items)); + } + if (!placed) { + out.add( + ReactionBucket( + t: t, + total: 1, + items: [ReactionItem(kind: kind, value: value, count: 1)], + ), + ); + } + return ReactionTimeline(out); + } + + /// Reaction density in [bins] equal slices of [durationSec], scaled so the + /// busiest slice is 1. All zeros when there is nothing to draw. + List heatmap(int durationSec, {int bins = 100}) { + final out = List.filled(bins, 0); + if (durationSec <= 0 || bins <= 0) return out; + for (final b in buckets) { + if (b.t < 0 || b.t > durationSec) continue; + final i = b.t * bins ~/ durationSec; + out[i < bins ? i : bins - 1] += b.total.toDouble(); + } + var peak = 0.0; + for (final v in out) { + if (v > peak) peak = v; + } + if (peak <= 0) return out; + for (var i = 0; i < bins; i++) { + out[i] = out[i] / peak; + } + return out; + } +} + +/// One pill in the reaction lane: an emoji or text and how many people sent +/// it in the last few seconds — "🔥 ×12". +class ReactionPop { + const ReactionPop({ + required this.kind, + required this.value, + required this.count, + required this.lastSec, + }); + + final String kind; + final String value; + final int count; + + /// The latest second in the window this pill counts. + final int lastSec; + + /// Stable while the pill is on screen, so it updates in place. + String get id => '$kind:$value'; + + @override + bool operator ==(Object other) => + other is ReactionPop && + other.kind == kind && + other.value == value && + other.count == count && + other.lastSec == lastSec; + + @override + int get hashCode => Object.hash(kind, value, count, lastSec); + + @override + String toString() => 'ReactionPop($value x$count @$lastSec)'; +} + +/// Which reactions are on screen at a playhead position. +/// +/// A reaction shows for [window] after its second. A jump — a seek either +/// way, or a gap longer than [jumpTolerance] between ticks — starts the +/// window afresh at the new position, so landing somewhere does not burst +/// the last few seconds before it onto the screen at once. The same emoji +/// within the window groups into one pill, and at most [maxVisible] show: +/// the most-sent first, the most recent among equals. +/// +/// Pure: positions in, pills out. +class ReactionScheduler { + ReactionScheduler({ + this.maxVisible = 3, + this.window = const Duration(seconds: 4), + this.jumpTolerance = const Duration(seconds: 2), + }); + + final int maxVisible; + final Duration window; + final Duration jumpTolerance; + + ReactionTimeline _timeline = ReactionTimeline.empty; + Duration? _last; + int _anchorSec = 0; + + ReactionTimeline get timeline => _timeline; + + void load(ReactionTimeline timeline) => _timeline = timeline; + + void clear() { + _timeline = ReactionTimeline.empty; + _last = null; + _anchorSec = 0; + } + + /// The pills for [position]. + List at(Duration position) { + final last = _last; + final posSec = position.inSeconds; + if (last == null || + position < last || + position - last > jumpTolerance) { + _anchorSec = posSec; + } + _last = position; + if (_timeline.isEmpty || maxVisible <= 0) return const []; + + final fromSec = (posSec - window.inSeconds + 1) < _anchorSec + ? _anchorSec + : posSec - window.inSeconds + 1; + + final grouped = {}; + for (final b in _bucketsBetween(fromSec, posSec)) { + for (final item in b.items) { + final key = '${item.kind}:${item.value}'; + final prev = grouped[key]; + grouped[key] = ReactionPop( + kind: item.kind, + value: item.value, + count: (prev?.count ?? 0) + item.count, + lastSec: b.t, + ); + } + } + final pops = grouped.values.toList() + ..sort((a, b) { + final byCount = b.count.compareTo(a.count); + if (byCount != 0) return byCount; + final byTime = b.lastSec.compareTo(a.lastSec); + if (byTime != 0) return byTime; + return a.value.compareTo(b.value); + }); + return pops.length > maxVisible ? pops.sublist(0, maxVisible) : pops; + } + + Iterable _bucketsBetween(int fromSec, int toSec) sync* { + final list = _timeline.buckets; + var lo = 0; + var hi = list.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (list[mid].t < fromSec) { + lo = mid + 1; + } else { + hi = mid; + } + } + for (var i = lo; i < list.length && list[i].t <= toSec; i++) { + yield list[i]; + } + } +} diff --git a/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart b/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart index 6aa8bf4a..046f73f7 100644 --- a/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart +++ b/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/comments/domain/entities/comment_list.dart'; +import 'package:soplay/features/comments/domain/repositories/episode_comments_repository.dart'; import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; part 'comments_event.dart'; @@ -14,6 +16,7 @@ class CommentsBloc extends Bloc { String _provider = ''; String _contentUrl = ''; + String? _episodeKey; CommentsBloc({required this.repository, required this.hiveService}) : super(const CommentsState.initial()) { @@ -30,12 +33,14 @@ class CommentsBloc extends Bloc { String? get currentUserId => hiveService.getUser()?.id; bool get isLoggedIn => hiveService.isLoggedIn; + String? get episodeKey => _episodeKey; Future _onInit(CommentsInit event, Emitter emit) async { _provider = event.provider; _contentUrl = event.contentUrl; + _episodeKey = event.episodeKey; emit(state.copyWith(loading: true, error: null, items: const [])); - final result = await repository.getComments( + final result = await _getComments( provider: _provider, contentUrl: _contentUrl, page: 1, @@ -63,7 +68,7 @@ class CommentsBloc extends Bloc { Emitter emit, ) async { emit(state.copyWith(refreshing: true, error: null)); - final result = await repository.getComments( + final result = await _getComments( provider: _provider, contentUrl: _contentUrl, page: 1, @@ -89,7 +94,7 @@ class CommentsBloc extends Bloc { ) async { if (state.loadingMore || state.page >= state.totalPages) return; emit(state.copyWith(loadingMore: true)); - final result = await repository.getComments( + final result = await _getComments( provider: _provider, contentUrl: _contentUrl, page: state.page + 1, @@ -160,12 +165,7 @@ class CommentsBloc extends Bloc { return; } emit(state.copyWith(submitting: true, error: null)); - final result = await repository.create( - provider: _provider, - contentUrl: _contentUrl, - text: event.text, - parentId: event.parentId, - ); + final result = await _create(event); switch (result) { case Success(:final value): if (event.parentId == null) { @@ -216,7 +216,7 @@ class CommentsBloc extends Bloc { // the panel knows an edit has LANDED, so it can stop holding on to the // text it would otherwise have to put back. emit(state.copyWith(submitting: true, error: null)); - final result = await repository.edit(id: event.id, text: event.text); + final result = await _edit(event); switch (result) { case Success(:final value): emit(_replaceComment(state, value).copyWith(submitting: false)); @@ -260,6 +260,68 @@ class CommentsBloc extends Bloc { } } + /// The repository's episode and spoiler support, when it has any. + EpisodeCommentsRepository? get _scoped => switch (repository) { + final EpisodeCommentsRepository r => r, + _ => null, + }; + + Future> _getComments({ + required String provider, + required String contentUrl, + required int page, + }) { + final scoped = _scoped; + if (_episodeKey != null && scoped != null) { + return scoped.getScopedComments( + provider: provider, + contentUrl: contentUrl, + episodeKey: _episodeKey, + page: page, + ); + } + return repository.getComments( + provider: provider, + contentUrl: contentUrl, + page: page, + ); + } + + Future> _create(CommentsCreate event) { + final scoped = _scoped; + final needsScope = + _episodeKey != null || event.spoiler || event.atSec != null; + if (needsScope && scoped != null) { + return scoped.createScoped( + provider: _provider, + contentUrl: _contentUrl, + text: event.text, + parentId: event.parentId, + episodeKey: _episodeKey, + spoiler: event.spoiler, + atSec: event.atSec, + ); + } + return repository.create( + provider: _provider, + contentUrl: _contentUrl, + text: event.text, + parentId: event.parentId, + ); + } + + Future> _edit(CommentsEdit event) { + final scoped = _scoped; + if (event.spoiler != null && scoped != null) { + return scoped.editScoped( + id: event.id, + text: event.text, + spoiler: event.spoiler, + ); + } + return repository.edit(id: event.id, text: event.text); + } + CommentsState _replaceComment(CommentsState s, CommentEntity replaced) { final items = s.items.map((c) => c.id == replaced.id ? replaced : c).toList(); final map = >{}; diff --git a/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart b/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart index d1d368ca..7295c9ce 100644 --- a/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart +++ b/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart @@ -7,7 +7,14 @@ abstract class CommentsEvent { class CommentsInit extends CommentsEvent { final String provider; final String contentUrl; - const CommentsInit({required this.provider, required this.contentUrl}); + + /// Null keeps the title-level thread. + final String? episodeKey; + const CommentsInit({ + required this.provider, + required this.contentUrl, + this.episodeKey, + }); } class CommentsRefresh extends CommentsEvent { @@ -31,13 +38,23 @@ class CommentsLoadReplies extends CommentsEvent { class CommentsCreate extends CommentsEvent { final String text; final String? parentId; - const CommentsCreate({required this.text, this.parentId}); + final bool spoiler; + final int? atSec; + const CommentsCreate({ + required this.text, + this.parentId, + this.spoiler = false, + this.atSec, + }); } class CommentsEdit extends CommentsEvent { final String id; final String text; - const CommentsEdit({required this.id, required this.text}); + + /// Null leaves the flag as it was. + final bool? spoiler; + const CommentsEdit({required this.id, required this.text, this.spoiler}); } class CommentsDelete extends CommentsEvent { diff --git a/lib/features/comments/presentation/widgets/comment_avatar.dart b/lib/features/comments/presentation/widgets/comment_avatar.dart index 996c0e55..4aac6e27 100644 --- a/lib/features/comments/presentation/widgets/comment_avatar.dart +++ b/lib/features/comments/presentation/widgets/comment_avatar.dart @@ -33,6 +33,8 @@ class CommentAvatar extends StatelessWidget { ? Image.network( url, fit: BoxFit.cover, + cacheWidth: (size * MediaQuery.devicePixelRatioOf(context)) + .round(), errorBuilder: (_, _, _) => _placeholder(), loadingBuilder: (_, child, chunk) => chunk == null ? child : _placeholder(), diff --git a/lib/features/comments/presentation/widgets/comment_card.dart b/lib/features/comments/presentation/widgets/comment_card.dart index ed66865b..b9bfedde 100644 --- a/lib/features/comments/presentation/widgets/comment_card.dart +++ b/lib/features/comments/presentation/widgets/comment_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/presentation/widgets/report_sheet.dart'; import 'comment_avatar.dart'; @@ -61,7 +62,14 @@ class CommentCard extends StatelessWidget { '/u/${Uri.encodeComponent(comment.user.username)}', ) : null, - child: CommentAvatar(author: comment.user, size: compact ? 30 : 36), + child: FramedAvatar( + size: compact ? 30 : 36, + frame: comment.user.decoration?.frame, + child: CommentAvatar( + author: comment.user, + size: compact ? 30 : 36, + ), + ), ), const SizedBox(width: 10), Expanded( @@ -71,10 +79,9 @@ class CommentCard extends StatelessWidget { Row( children: [ Flexible( - child: Text( - comment.user.nameOrUsername, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: UserBadgeLine( + name: comment.user.nameOrUsername, + decoration: comment.user.decoration, style: const TextStyle( color: AppColors.textPrimary, fontSize: 13, diff --git a/lib/features/comments/presentation/widgets/comment_compose.dart b/lib/features/comments/presentation/widgets/comment_compose.dart index 7893c0f2..b607e440 100644 --- a/lib/features/comments/presentation/widgets/comment_compose.dart +++ b/lib/features/comments/presentation/widgets/comment_compose.dart @@ -12,6 +12,7 @@ class CommentCompose extends StatefulWidget { this.editTarget, this.initialText = '', this.onCancel, + this.spoiler, }); final Future Function(String text) onSubmit; @@ -22,6 +23,9 @@ class CommentCompose extends StatefulWidget { final String initialText; final VoidCallback? onCancel; + /// When set, a Spoiler toggle sits beside Send and drives this. + final ValueNotifier? spoiler; + @override State createState() => _CommentComposeState(); } @@ -167,6 +171,13 @@ class _CommentComposeState extends State { ), ), const SizedBox(width: 4), + if (widget.spoiler != null) ...[ + _SpoilerToggle( + value: widget.spoiler!, + enabled: widget.enabled && !widget.submitting, + ), + const SizedBox(width: 4), + ], Material( color: widget.enabled && !widget.submitting ? AppColors.primary @@ -205,3 +216,60 @@ class _CommentComposeState extends State { ); } } + +class _SpoilerToggle extends StatelessWidget { + const _SpoilerToggle({required this.value, required this.enabled}); + + final ValueNotifier value; + final bool enabled; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: value, + builder: (context, on, _) { + final color = on ? AppColors.primary : AppColors.textHint; + return Semantics( + toggled: on, + button: true, + child: InkWell( + onTap: enabled ? () => value.value = !on : null, + borderRadius: BorderRadius.circular(14), + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: on + ? AppColors.primary.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: color, width: 0.8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + on + ? Icons.visibility_off_rounded + : Icons.visibility_outlined, + size: 13, + color: color, + ), + const SizedBox(width: 4), + Text( + 'comments.spoiler'.tr(), + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/comments/presentation/widgets/comments_panel.dart b/lib/features/comments/presentation/widgets/comments_panel.dart index 462bda49..a030bbb7 100644 --- a/lib/features/comments/presentation/widgets/comments_panel.dart +++ b/lib/features/comments/presentation/widgets/comments_panel.dart @@ -5,33 +5,63 @@ 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/data/social_playback_prefs.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/comments/domain/spoiler_rules.dart'; +import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_card.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_compose.dart'; +import 'package:soplay/features/comments/presentation/widgets/spoiler_gate.dart'; class CommentsPanel extends StatelessWidget { const CommentsPanel({ super.key, required this.provider, required this.contentUrl, + this.episodeKey, + this.playheadSec, }); final String provider; final String contentUrl; + /// Null for the title's thread; an [EpisodeKey] for one episode's. + final String? episodeKey; + + /// The player's current second, when the panel is open over playback. + /// New comments are stamped with it, and comments about later moments stay + /// hidden until the viewer gets there. + final int? Function()? playheadSec; + @override Widget build(BuildContext context) { return BlocProvider( create: (_) => getIt() - ..add(CommentsInit(provider: provider, contentUrl: contentUrl)), - child: const _CommentsView(), + ..add(CommentsInit( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + )), + child: _CommentsView( + contentUrl: contentUrl, + episodeKey: episodeKey, + playheadSec: playheadSec, + ), ); } } class _CommentsView extends StatefulWidget { - const _CommentsView(); + const _CommentsView({ + required this.contentUrl, + this.episodeKey, + this.playheadSec, + }); + + final String contentUrl; + final String? episodeKey; + final int? Function()? playheadSec; @override State<_CommentsView> createState() => _CommentsViewState(); @@ -53,6 +83,42 @@ class _CommentsViewState extends State<_CommentsView> { /// did not. _PendingWrite? _pending; + final ValueNotifier _spoiler = ValueNotifier(false); + final Set _revealed = {}; + late final bool _hideUnwatched = const SocialPlaybackPrefs() + .hideUnwatchedSpoilers; + late final bool _episodeFinished = widget.episodeKey == null || + !getIt.isRegistered() || + SpoilerRules.episodeFinished( + widget.episodeKey, + widget.contentUrl, + getIt().getAll(), + ); + + @override + void dispose() { + _spoiler.dispose(); + super.dispose(); + } + + Widget _gate(CommentEntity c, String? currentUserId, Widget card) { + final visibility = SpoilerRules.visibility( + spoiler: c.spoiler, + isOwn: currentUserId != null && currentUserId == c.user.id, + revealed: _revealed.contains(c.id), + hideUnwatched: _hideUnwatched, + episodeKey: c.episodeKey, + episodeFinished: _episodeFinished, + atSec: c.atSec, + playheadSec: widget.playheadSec?.call(), + ); + return SpoilerGate( + visibility: visibility, + onReveal: () => setState(() => _revealed.add(c.id)), + child: card, + ); + } + void _startReply(CommentEntity c) { setState(() { _replyTo = c.id; @@ -66,6 +132,7 @@ class _CommentsViewState extends State<_CommentsView> { setState(() { _editId = c.id; _initialText = c.text; + _spoiler.value = c.spoiler; _replyTo = null; _replyToName = null; }); @@ -78,6 +145,7 @@ class _CommentsViewState extends State<_CommentsView> { _editId = null; _initialText = ''; }); + _spoiler.value = false; } Future _submit(String text) async { @@ -91,11 +159,17 @@ class _CommentsViewState extends State<_CommentsView> { editId: _editId, replyTo: _replyTo, replyToName: _replyToName, + spoiler: _spoiler.value, ); if (_editId != null) { - bloc.add(CommentsEdit(id: _editId!, text: text)); + bloc.add(CommentsEdit(id: _editId!, text: text, spoiler: _spoiler.value)); } else { - bloc.add(CommentsCreate(text: text, parentId: _replyTo)); + bloc.add(CommentsCreate( + text: text, + parentId: _replyTo, + spoiler: _spoiler.value, + atSec: widget.playheadSec?.call(), + )); } _cancel(); } @@ -111,6 +185,7 @@ class _CommentsViewState extends State<_CommentsView> { _replyTo = pending.replyTo; _replyToName = pending.replyToName; }); + _spoiler.value = pending.spoiler; } Widget _buildList( @@ -233,6 +308,7 @@ class _CommentsViewState extends State<_CommentsView> { onReplyLike: (id) => bloc.add(CommentsToggleLike(id)), onReplyEdit: _startEdit, onReplyDelete: _confirmDelete, + gate: (comment, card) => _gate(comment, currentUserId, card), ); }, ); @@ -321,6 +397,7 @@ class _CommentsViewState extends State<_CommentsView> { editTarget: _editId, initialText: _initialText, onCancel: _cancel, + spoiler: _spoiler, ), ), ], @@ -395,6 +472,7 @@ class _CommentTree extends StatelessWidget { required this.onReplyLike, required this.onReplyEdit, required this.onReplyDelete, + this.gate, }); final CommentEntity comment; @@ -411,12 +489,15 @@ class _CommentTree extends StatelessWidget { final void Function(String id) onReplyLike; final void Function(CommentEntity c) onReplyEdit; final void Function(CommentEntity c) onReplyDelete; + final Widget Function(CommentEntity c, Widget card)? gate; + + Widget _gated(CommentEntity c, Widget card) => gate?.call(c, card) ?? card; @override Widget build(BuildContext context) { return Column( children: [ - CommentCard( + _gated(comment, CommentCard( comment: comment, isOwner: currentUserId != null && currentUserId == comment.user.id, canInteract: canInteract, @@ -426,7 +507,7 @@ class _CommentTree extends StatelessWidget { onDelete: onDelete, onToggleReplies: onToggle, repliesExpanded: expanded, - ), + )), if (expanded) Padding( padding: const EdgeInsetsDirectional.only(start: 46), @@ -445,7 +526,7 @@ class _CommentTree extends StatelessWidget { ), ), for (final r in replies) - CommentCard( + _gated(r, CommentCard( comment: r, compact: true, isOwner: @@ -457,7 +538,7 @@ class _CommentTree extends StatelessWidget { onDelete: () => onReplyDelete(r), onToggleReplies: () {}, repliesExpanded: false, - ), + )), ], ), ), @@ -473,10 +554,12 @@ class _PendingWrite { required this.editId, required this.replyTo, required this.replyToName, + this.spoiler = false, }); final String text; final String? editId; final String? replyTo; final String? replyToName; + final bool spoiler; } diff --git a/lib/features/comments/presentation/widgets/episode_comments_sheet.dart b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart new file mode 100644 index 00000000..8bc919bb --- /dev/null +++ b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart @@ -0,0 +1,134 @@ +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/presentation/widgets/comments_panel.dart'; + +/// One episode's comment thread, as a side panel where there is width for one +/// (landscape, TV, desktop) and a bottom sheet on a portrait phone. +/// +/// Opened as a route so the thread's text field and its D-pad focus are its +/// own: the player's keyboard shortcuts never see the typing, and BACK closes +/// the thread rather than the player. +Future showEpisodeComments( + BuildContext context, { + required String provider, + required String contentUrl, + required String? episodeKey, + required String title, + int? Function()? playheadSec, +}) { + final size = MediaQuery.sizeOf(context); + final side = isTvPlatform || isDesktopPlatform || size.width > size.height; + final panel = EpisodeCommentsPanel( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + title: title, + playheadSec: playheadSec, + ); + if (!side) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => SizedBox(height: size.height * 0.75, child: panel), + ); + } + // The panel sits on the end edge: it slides in from there, and the inset on + // its inner edge is not its concern. + final rtl = Directionality.of(context) == TextDirection.rtl; + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, _, _) => Align( + alignment: AlignmentDirectional.centerEnd, + child: SizedBox( + width: isTvPlatform ? 460 : 380, + height: double.infinity, + child: Material( + color: AppColors.background, + child: SafeArea(left: rtl, right: !rtl, child: panel), + ), + ), + ), + transitionBuilder: (context, animation, _, child) => SlideTransition( + position: Tween(begin: Offset(rtl ? -1 : 1, 0), end: Offset.zero).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); +} + +class EpisodeCommentsPanel extends StatelessWidget { + const EpisodeCommentsPanel({ + super.key, + required this.provider, + required this.contentUrl, + required this.episodeKey, + required this.title, + this.playheadSec, + }); + + final String provider; + final String contentUrl; + final String? episodeKey; + final String title; + final int? Function()? playheadSec; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 4, 4), + child: Row( + children: [ + const Icon( + Icons.forum_rounded, + color: AppColors.textSecondary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: 'general.close'.tr(), + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.close_rounded, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Expanded( + child: CommentsPanel( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + playheadSec: playheadSec, + ), + ), + ], + ); + } +} diff --git a/lib/features/comments/presentation/widgets/reaction_heatmap.dart b/lib/features/comments/presentation/widgets/reaction_heatmap.dart new file mode 100644 index 00000000..8b66a90e --- /dev/null +++ b/lib/features/comments/presentation/widgets/reaction_heatmap.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// A thin strip over the seek bar, brighter where reactions cluster. +class ReactionHeatmap extends StatelessWidget { + const ReactionHeatmap({super.key, required this.bins}); + + /// 0..1 per slice of the episode; see [ReactionTimeline.heatmap]. + final List bins; + + @override + Widget build(BuildContext context) { + if (bins.isEmpty) return const SizedBox.shrink(); + return IgnorePointer( + child: CustomPaint( + painter: _HeatmapPainter(bins, AppColors.primary), + size: Size.infinite, + ), + ); + } +} + +class _HeatmapPainter extends CustomPainter { + _HeatmapPainter(this.bins, this.color); + + final List bins; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width / bins.length; + final paint = Paint(); + for (var i = 0; i < bins.length; i++) { + final v = bins[i]; + if (v <= 0.04) continue; + paint.color = color.withValues(alpha: 0.25 + 0.75 * v); + final h = size.height * (0.4 + 0.6 * v); + canvas.drawRect( + Rect.fromLTWH(i * w, size.height - h, w + 0.5, h), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _HeatmapPainter old) => + !identical(old.bins, bins) || old.color != color; +} diff --git a/lib/features/comments/presentation/widgets/reaction_lane.dart b/lib/features/comments/presentation/widgets/reaction_lane.dart new file mode 100644 index 00000000..ed5321a2 --- /dev/null +++ b/lib/features/comments/presentation/widgets/reaction_lane.dart @@ -0,0 +1,150 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +/// Other viewers' reactions as pills that fade in at their second and out a +/// few seconds later, in a column at the start edge of the video, clear of +/// the subtitles at the bottom and the controls along the edges. +class ReactionLane extends StatefulWidget { + const ReactionLane({super.key, required this.pops}); + + final ValueListenable> pops; + + @override + State createState() => _ReactionLaneState(); +} + +class _ReactionLaneState extends State { + static const Duration _fade = Duration(milliseconds: 280); + + /// On screen, including pills still fading out. + final List<_Shown> _shown = []; + + @override + void initState() { + super.initState(); + widget.pops.addListener(_sync); + _sync(); + } + + @override + void didUpdateWidget(covariant ReactionLane old) { + super.didUpdateWidget(old); + if (old.pops != widget.pops) { + old.pops.removeListener(_sync); + widget.pops.addListener(_sync); + _sync(); + } + } + + @override + void dispose() { + widget.pops.removeListener(_sync); + super.dispose(); + } + + void _sync() { + if (!mounted) return; + final next = {for (final p in widget.pops.value) p.id: p}; + setState(() { + for (final s in _shown) { + final p = next.remove(s.pop.id); + if (p != null) { + s.pop = p; + s.leaving = false; + } else if (!s.leaving) { + s.leaving = true; + Future.delayed(_fade, () { + if (!mounted) return; + setState(() => _shown.removeWhere((x) => x.leaving && x == s)); + }); + } + } + for (final p in next.values) { + _shown.add(_Shown(p)); + } + }); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final s in _shown) + TweenAnimationBuilder( + key: ValueKey(s.pop.id), + tween: Tween(begin: 0, end: s.leaving ? 0 : 1), + duration: _fade, + builder: (context, t, child) => Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * 10), + child: child, + ), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _Pill(pop: s.pop), + ), + ), + ], + ), + ); + } +} + +class _Shown { + _Shown(this.pop); + ReactionPop pop; + bool leaving = false; +} + +class _Pill extends StatelessWidget { + const _Pill({required this.pop}); + + final ReactionPop pop; + + @override + Widget build(BuildContext context) { + final emoji = pop.kind == 'emoji'; + return Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: EdgeInsets.symmetric(horizontal: emoji ? 8 : 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + pop.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: emoji ? 20 : 12, + fontWeight: FontWeight.w600, + ), + ), + ), + if (pop.count > 1) ...[ + const SizedBox(width: 4), + Text( + '×${pop.count}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/comments/presentation/widgets/reaction_picker.dart b/lib/features/comments/presentation/widgets/reaction_picker.dart new file mode 100644 index 00000000..8cb39228 --- /dev/null +++ b/lib/features/comments/presentation/widgets/reaction_picker.dart @@ -0,0 +1,160 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +typedef ReactionChoice = ({String kind, String value}); + +/// The quick bar: eight emoji and a short text option, over the bottom of the +/// video. Its own route, so typing never reaches the player's shortcuts and a +/// remote's BACK closes it. +Future showReactionPicker(BuildContext context) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black26, + transitionDuration: const Duration(milliseconds: 160), + pageBuilder: (context, _, _) => const _ReactionPicker(), + transitionBuilder: (context, animation, _, child) => FadeTransition( + opacity: animation, + child: child, + ), + ); +} + +class _ReactionPicker extends StatefulWidget { + const _ReactionPicker(); + + @override + State<_ReactionPicker> createState() => _ReactionPickerState(); +} + +class _ReactionPickerState extends State<_ReactionPicker> { + final TextEditingController _text = TextEditingController(); + bool _typing = false; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _sendText() { + final value = _text.text.trim(); + if (value.isEmpty) return; + Navigator.of(context).pop((kind: 'text', value: value)); + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottom + 96), + child: Material( + color: const Color(0xEE111111), + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.all(6), + child: _typing ? _buildTextRow() : _buildEmojiRow(), + ), + ), + ), + ), + ); + } + + Widget _buildEmojiRow() { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < kTimedReactionEmoji.length; i++) + _RoundButton( + autofocus: i == 0, + onTap: () => Navigator.of(context).pop( + (kind: 'emoji', value: kTimedReactionEmoji[i]), + ), + child: Text( + kTimedReactionEmoji[i], + style: const TextStyle(fontSize: 24), + ), + ), + _RoundButton( + tooltip: 'player.reaction_text'.tr(), + onTap: () => setState(() => _typing = true), + child: const Icon( + Icons.short_text_rounded, + color: Colors.white, + size: 22, + ), + ), + ], + ), + ); + } + + Widget _buildTextRow() { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 10), + Flexible( + child: TextField( + controller: _text, + autofocus: true, + maxLength: kTimedReactionMaxText, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendText(), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: 'player.reaction_text_hint'.tr(), + hintStyle: const TextStyle(color: Colors.white54), + border: InputBorder.none, + counterText: '', + isCollapsed: true, + ), + ), + ), + _RoundButton( + tooltip: 'support.send'.tr(), + onTap: _sendText, + child: Icon(Icons.send_rounded, color: AppColors.primary, size: 20), + ), + ], + ), + ); + } +} + +class _RoundButton extends StatelessWidget { + const _RoundButton({ + required this.onTap, + required this.child, + this.autofocus = false, + this.tooltip, + }); + + final VoidCallback onTap; + final Widget child; + final bool autofocus; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final button = InkWell( + onTap: onTap, + autofocus: autofocus, + customBorder: const CircleBorder(), + focusColor: Colors.white24, + child: SizedBox(width: 48, height: 48, child: Center(child: child)), + ); + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); + } +} diff --git a/lib/features/comments/presentation/widgets/spoiler_gate.dart b/lib/features/comments/presentation/widgets/spoiler_gate.dart new file mode 100644 index 00000000..797fb23a --- /dev/null +++ b/lib/features/comments/presentation/widgets/spoiler_gate.dart @@ -0,0 +1,86 @@ +import 'dart:ui' show ImageFilter; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/domain/spoiler_rules.dart'; + +/// A comment blurred behind "tap to reveal" unless [visibility] says it can +/// show. Focusable, so a remote can reveal it too. +class SpoilerGate extends StatelessWidget { + const SpoilerGate({ + super.key, + required this.visibility, + required this.onReveal, + required this.child, + }); + + final SpoilerVisibility visibility; + final VoidCallback onReveal; + final Widget child; + + @override + Widget build(BuildContext context) { + if (visibility == SpoilerVisibility.visible) return child; + final label = visibility == SpoilerVisibility.spoiler + ? 'comments.spoiler_hidden'.tr() + : 'comments.unwatched_hidden'.tr(); + return Stack( + children: [ + ExcludeSemantics( + child: IgnorePointer( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 7, sigmaY: 7), + child: child, + ), + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onReveal, + focusColor: AppColors.primary.withValues(alpha: 0.18), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 0.6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.visibility_off_rounded, + size: 14, + color: AppColors.textSecondary, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/detail/data/models/cast_model.dart b/lib/features/detail/data/models/cast_model.dart index 909498b6..4f62b73a 100644 --- a/lib/features/detail/data/models/cast_model.dart +++ b/lib/features/detail/data/models/cast_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/cast_entity.dart'; class CastModel extends CastEntity { @@ -8,8 +9,16 @@ class CastModel extends CastEntity { }); factory CastModel.fromJson(Map json) => CastModel( - id: json['id'] as String? ?? '', - name: json['name'] as String? ?? '', - image: json['image'] as String? ?? '', + id: jsonText(json['id']) ?? '', + name: jsonText(json['name']) ?? '', + image: jsonText(json['image'] ?? json['avatar'] ?? json['photo']) ?? '', ); + + /// A cast entry as a map or as a bare name; null for anything else. + static CastModel? tryParse(Object? raw) { + final map = jsonMap(raw); + if (map != null) return CastModel.fromJson(map); + final name = jsonText(raw); + return name == null ? null : CastModel(id: '', name: name, image: ''); + } } diff --git a/lib/features/detail/data/models/detail_model.dart b/lib/features/detail/data/models/detail_model.dart index dd4b5c09..db777232 100644 --- a/lib/features/detail/data/models/detail_model.dart +++ b/lib/features/detail/data/models/detail_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; import 'cast_model.dart'; import 'related_model.dart'; @@ -27,39 +28,45 @@ class DetailModel extends DetailEntity { }); factory DetailModel.fromJson(Map json) { - final castList = (json['cast'] as List? ?? []) - .map((e) => CastModel.fromJson(e as Map)) - .toList(); + // Zangetsu sources pass cast and screenshots through as they give them: + // a screenshot is often a bare url, and a cast member may be a bare name. + final castList = [ + for (final e in _list(json['cast'])) ?CastModel.tryParse(e), + ]; - final screenshotList = (json['screenshots'] as List? ?? []) - .map((e) => ScreenshotModel.fromJson(e as Map)) - .toList(); + final screenshotList = [ + for (final e in _list(json['screenshots'])) ?ScreenshotModel.tryParse(e), + ]; - final relatedList = (json['related'] as List? ?? []) - .map((e) => RelatedModel.fromJson(e as Map)) - .toList(); + final relatedList = jsonMaps( + json['related'], + ).map(RelatedModel.fromJson).toList(); - final genreList = (json['genres'] as List? ?? []) - .map((e) => e?.toString() ?? '') - .where((g) => g.isNotEmpty) - .toList(); + final genreList = [ + for (final e in _list(json['genres'])) + ?jsonText(e is Map ? e['name'] ?? e['label'] : e), + ]; return DetailModel( - provider: json['provider'] as String? ?? '', - contentId: json['contentId'] as String? ?? '', - contentUrl: json['contentUrl'] as String? ?? '', - title: json['title'] as String? ?? '', - description: json['description'] as String? ?? '', - thumbnail: json['thumbnail'] as String?, - year: (json['year'] as num?)?.toInt(), - duration: _nonEmpty(json['duration'] as String?), - country: _nonEmpty(json['country'] as String?), - director: _nonEmpty(json['director'] as String?), + provider: jsonText(json['provider']) ?? '', + contentId: jsonText(json['contentId']) ?? '', + contentUrl: jsonText(json['contentUrl']) ?? '', + title: jsonText(json['title']) ?? '', + description: json['description'] is String + ? json['description'] as String + : '', + thumbnail: json['thumbnail'] is String + ? json['thumbnail'] as String + : null, + year: jsonInt(json['year']), + duration: _nonEmpty(json['duration']), + country: _nonEmpty(json['country']), + director: _nonEmpty(json['director']), genres: genreList, cast: castList, - likes: (json['likes'] as num?)?.toInt() ?? 0, - dislikes: (json['dislikes'] as num?)?.toInt() ?? 0, - isSerial: json['isSerial'] as bool? ?? false, + likes: jsonInt(json['likes']) ?? 0, + dislikes: jsonInt(json['dislikes']) ?? 0, + isSerial: _nullableBool(json['isSerial']) ?? false, isFavorited: _nullableBool(json['isFavorited']), screenshots: screenshotList, related: relatedList, @@ -94,8 +101,11 @@ class DetailModel extends DetailEntity { return null; } - static String? _nonEmpty(String? value) { - if (value == null || value.trim().isEmpty) return null; + static List _list(Object? value) => value is List ? value : const []; + + /// Text only: a number here would be a unit nobody can name. + static String? _nonEmpty(Object? value) { + if (value is! String || value.trim().isEmpty) return null; return value.trim(); } } diff --git a/lib/features/detail/data/models/episode_model.dart b/lib/features/detail/data/models/episode_model.dart index 360a1110..62a29ca1 100644 --- a/lib/features/detail/data/models/episode_model.dart +++ b/lib/features/detail/data/models/episode_model.dart @@ -14,6 +14,8 @@ class EpisodeModel extends EpisodeEntity { super.overview, super.webUrl, super.scanlator, + super.season, + super.numberInSeason, }); factory EpisodeModel.fromJson(Map json) => EpisodeModel( @@ -29,6 +31,7 @@ class EpisodeModel extends EpisodeEntity { overview: _stringOrNull(json['overview']), webUrl: _stringOrNull(json['webUrl']), scanlator: _stringOrNull(json['scanlator']), + season: (json['season'] as num?)?.toInt(), ); static List _parseLangs(dynamic raw) { diff --git a/lib/features/detail/data/models/media_resolve_model.dart b/lib/features/detail/data/models/media_resolve_model.dart index 2866f1a8..7e6c55c1 100644 --- a/lib/features/detail/data/models/media_resolve_model.dart +++ b/lib/features/detail/data/models/media_resolve_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/data/models/subtitle_model.dart'; import 'package:soplay/features/detail/data/models/thumbnails_model.dart'; import 'package:soplay/features/detail/data/models/video_source_model.dart'; @@ -20,14 +21,14 @@ class MediaResolveModel extends MediaResolveEntity { factory MediaResolveModel.fromJson(Map json) { final typeRaw = json['type'] as String?; - final sources = (json['videoSources'] as List? ?? const []) - .whereType>() - .map(VideoSourceModel.fromJson) - .toList(growable: false); - final subs = (json['subtitles'] as List? ?? const []) - .whereType>() - .map(SubtitleModel.fromJson) - .toList(growable: false); + // Every map, whatever its type arguments: whereType> + // silently dropped each source a platform channel delivered. + final sources = jsonMaps( + json['videoSources'], + ).map(VideoSourceModel.fromJson).toList(growable: false); + final subs = jsonMaps( + json['subtitles'], + ).map(SubtitleModel.fromJson).toList(growable: false); return MediaResolveModel( videoUrl: json['videoUrl'] as String? ?? '', type: typeRaw == null || typeRaw.isEmpty ? null : typeRaw.toLowerCase(), diff --git a/lib/features/detail/data/models/playback_model.dart b/lib/features/detail/data/models/playback_model.dart index bd9f831b..79b9da1b 100644 --- a/lib/features/detail/data/models/playback_model.dart +++ b/lib/features/detail/data/models/playback_model.dart @@ -1,5 +1,7 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; import 'package:soplay/features/detail/domain/entities/playback_entity.dart'; +import 'package:soplay/features/detail/domain/entities/source_notice.dart'; import 'episode_model.dart'; import 'thumbnails_model.dart'; import 'video_source_model.dart'; @@ -20,16 +22,18 @@ class PlaybackModel extends PlaybackEntity { super.total, super.totalPages, super.sort, + super.playable, + super.notice, }); factory PlaybackModel.fromJson(Map json) { - final episodes = (json['episodes'] as List? ?? []) - .map((e) => EpisodeModel.fromJson(e as Map)) - .toList(); + final episodes = jsonMaps( + json['episodes'], + ).map(EpisodeModel.fromJson).toList(); - final videoSources = (json['videoSources'] as List? ?? []) - .map((e) => VideoSourceModel.fromJson(e as Map)) - .toList(); + final videoSources = jsonMaps( + json['videoSources'], + ).map(VideoSourceModel.fromJson).toList(); final src = json['playerSrc'] as String?; final typeRaw = json['type'] as String?; @@ -49,6 +53,16 @@ class PlaybackModel extends PlaybackEntity { total: (json['total'] as num?)?.toInt() ?? episodes.length, totalPages: (json['totalPages'] as num?)?.toInt() ?? 1, sort: (json['sort'] as String?)?.toLowerCase() ?? 'asc', + // A source that says nothing about playability is taken at its word that + // there is nothing to say; only `false` means "do not start a player". + playable: json['playable'] as bool? ?? true, + // Two spellings for one fact. `noticeCode` is on every answer that + // carries a notice; `playableReason` only on the ones that also set + // `playable: false`, and it holds the same token — so either will do and + // the first one present wins. + notice: + SourceNotice.read(json['noticeCode'] as String?) ?? + SourceNotice.read(json['playableReason'] as String?), ); } @@ -72,6 +86,8 @@ class PlaybackModel extends PlaybackEntity { total: total, totalPages: totalPages, sort: sort, + playable: playable, + notice: notice, ); static Map _parseHeaders(dynamic raw) { diff --git a/lib/features/detail/data/models/related_model.dart b/lib/features/detail/data/models/related_model.dart index 6b203aa4..6136827d 100644 --- a/lib/features/detail/data/models/related_model.dart +++ b/lib/features/detail/data/models/related_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/related_entity.dart'; class RelatedModel extends RelatedEntity { @@ -23,11 +24,12 @@ class RelatedModel extends RelatedEntity { slug: json['slug'] as String? ?? '', contentUrl: json['contentUrl'] as String? ?? json['url'] as String? ?? '', thumbnail: json['thumbnail'] as String?, - year: (json['year'] as num?)?.toInt(), - rating: (json['rating'] as num?)?.toInt() ?? 0, - qualities: json['qualities'] != null - ? List.from(json['qualities'] as List) - : const [], + year: jsonInt(json['year']), + rating: jsonInt(json['rating']) ?? 0, + qualities: [ + if (json['qualities'] case final List q) + for (final e in q) ?jsonText(e), + ], category: json['category'] as String? ?? '', ); } diff --git a/lib/features/detail/data/models/screenshot_model.dart b/lib/features/detail/data/models/screenshot_model.dart index b69549ae..83b198cd 100644 --- a/lib/features/detail/data/models/screenshot_model.dart +++ b/lib/features/detail/data/models/screenshot_model.dart @@ -1,11 +1,20 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/detail/domain/entities/screenshot_entity.dart'; class ScreenshotModel extends ScreenshotEntity { const ScreenshotModel({required super.full, required super.thumb}); - factory ScreenshotModel.fromJson(Map json) => - ScreenshotModel( - full: json['full'] as String? ?? '', - thumb: json['thumb'] as String? ?? '', - ); + factory ScreenshotModel.fromJson(Map json) { + final full = jsonText(json['full'] ?? json['url']) ?? ''; + return ScreenshotModel(full: full, thumb: jsonText(json['thumb']) ?? full); + } + + /// A screenshot as a `{full, thumb}` map or as a bare image url, which is + /// how Zangetsu sources send them; null for anything else. + static ScreenshotModel? tryParse(Object? raw) { + final map = jsonMap(raw); + if (map != null) return ScreenshotModel.fromJson(map); + final url = raw is String ? raw.trim() : ''; + return url.isEmpty ? null : ScreenshotModel(full: url, thumb: url); + } } diff --git a/lib/features/detail/data/models/subtitle_model.dart b/lib/features/detail/data/models/subtitle_model.dart index a725b575..041290f1 100644 --- a/lib/features/detail/data/models/subtitle_model.dart +++ b/lib/features/detail/data/models/subtitle_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/subtitles/subtitle_cipher.dart'; import 'package:soplay/features/detail/domain/entities/subtitle_entity.dart'; class SubtitleModel extends SubtitleEntity { @@ -6,6 +7,7 @@ class SubtitleModel extends SubtitleEntity { required super.file, required super.isDefault, super.headers, + super.cipher, }); factory SubtitleModel.fromJson(Map json) => SubtitleModel( @@ -21,6 +23,7 @@ class SubtitleModel extends SubtitleEntity { '', isDefault: json['default'] as bool? ?? json['isDefault'] as bool? ?? false, headers: _parseHeaders(json['headers']), + cipher: SubtitleCipher.fromJson(json['cipher']), ); static Map _parseHeaders(dynamic raw) { diff --git a/lib/features/detail/data/models/video_source_model.dart b/lib/features/detail/data/models/video_source_model.dart index 42e37e6c..efc4a81c 100644 --- a/lib/features/detail/data/models/video_source_model.dart +++ b/lib/features/detail/data/models/video_source_model.dart @@ -17,6 +17,8 @@ class VideoSourceModel extends VideoSourceEntity { super.rawLabel, super.warnings, super.useLocalProxy, + super.dropSegments, + super.hardsub, super.type, super.headers, super.localProxy, @@ -31,7 +33,9 @@ class VideoSourceModel extends VideoSourceEntity { quality: json['quality'] as String? ?? '', videoUrl: json['videoUrl'] as String? ?? '', isDefault: json['isDefault'] as bool? ?? false, - accessible: json['accessible'] as bool? ?? false, + // False only when a probe refused it. Providers do not probe, and reading + // absent as false left SourceLadder with nothing to walk. + accessible: json['accessible'] as bool? ?? true, // All optional and all absent from older providers, so every one falls // back to "not stated" rather than to a guess — a source that does not // say it is Dolby Vision must not be labelled as anything. @@ -44,6 +48,8 @@ class VideoSourceModel extends VideoSourceEntity { rawLabel: _nonEmpty(json['rawLabel'] as String?), warnings: _stringList(json['warnings']), useLocalProxy: json['useLocalProxy'] as bool? ?? false, + dropSegments: _nonEmpty(json['dropSegments'] as String?), + hardsub: _nonEmpty(json['hardsub'] as String?), type: typeRaw == null || typeRaw.isEmpty ? null : typeRaw.toLowerCase(), headers: _parseHeaders(json['headers']), localProxy: _parseDynamicMap(json['localProxy']), diff --git a/lib/features/detail/data/repositories/detail_repository_impl.dart b/lib/features/detail/data/repositories/detail_repository_impl.dart index 71c92b35..543e18bc 100644 --- a/lib/features/detail/data/repositories/detail_repository_impl.dart +++ b/lib/features/detail/data/repositories/detail_repository_impl.dart @@ -17,6 +17,8 @@ import 'package:soplay/features/detail/data/models/playback_model.dart'; import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; import 'package:soplay/features/detail/domain/entities/playback_entity.dart'; +import 'package:soplay/features/detail/domain/entities/source_notice.dart'; +import 'package:soplay/features/detail/domain/episode_seasons.dart'; import 'package:soplay/features/detail/domain/repositories/detail_repository.dart'; class DetailRepositoryImpl implements DetailRepository { @@ -361,6 +363,24 @@ class DetailRepositoryImpl implements DetailRepository { } on DioException catch (e) { final code = e.response?.statusCode; if (code == 422) { + // The backend says WHY a title cannot be resolved, and this used to + // discard the whole body and answer with one technical sentence in one + // language. A source that has moved a title into its own app, a page + // that never had a player and a serial resolved as a film are three + // different situations with three different next steps, and only the + // reason can tell them apart. The code arrives at the head of the + // message because the API's error handler forwards only `message`. + final notice = + SourceNotice.read(_noticeFrom(e)) ?? + SourceNotice.read(_messageFrom(e)); + if (notice != null) { + return Failure( + SourceNoticeException( + notice, + providerNotice: _providerNoticeFrom(e), + ), + ); + } return Failure(Exception('Video URL aniqlanmadi')); } if (code == 501) { @@ -442,8 +462,10 @@ class DetailRepositoryImpl implements DetailRepository { // Copy before sorting: `sort` mutates in place and throws // "Cannot modify an unmodifiable list" on a const/fixed-length list, which // several of the host adapters can legitimately produce. - final eps = model.episodes.toList(); - if (eps.length < 2) return model; + // Numbered by season first: two seasons that both start at 1 would + // otherwise sort into each other here. + final eps = EpisodeSeasons.number(model.episodes).toList(); + if (eps.length < 2) return model.copyWithEpisodes(eps); final desc = sort.toLowerCase() == 'desc'; final orig = Map.identity(); for (var i = 0; i < eps.length; i++) { @@ -467,6 +489,27 @@ class DetailRepositoryImpl implements DetailRepository { return raw.replaceFirst('Exception: ', ''); } + /// The reason code an error body names in a field of its own, for the day the + /// API's error handler forwards one. Until then it is absent and the code is + /// read out of the message instead. + String? _noticeFrom(DioException e) { + final data = e.response?.data; + if (data is! Map) return null; + for (final key in const ['noticeCode', 'playableReason', 'code']) { + final raw = data[key]; + if (raw is String && raw.isNotEmpty) return raw; + } + return null; + } + + /// The site's own notice, when the body carries it — for a log, never for the + /// viewer. See [SourceNoticeException.providerNotice]. + String? _providerNoticeFrom(DioException e) { + final data = e.response?.data; + final raw = data is Map ? data['providerNotice'] : null; + return raw is String && raw.trim().isNotEmpty ? raw.trim() : null; + } + String _messageFrom(DioException e) { // e.response?.data is dynamic and is a raw String for non-JSON error bodies // (502/503 HTML, Cloudflare challenge, plain text). A forced `as Map` would diff --git a/lib/features/detail/data/tmdb_relations.dart b/lib/features/detail/data/tmdb_relations.dart new file mode 100644 index 00000000..67dd374a --- /dev/null +++ b/lib/features/detail/data/tmdb_relations.dart @@ -0,0 +1,157 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:soplay/core/matching/title_match.dart'; +import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; + +/// A TMDB title the relations tab can stand on. +@immutable +class TmdbTitle { + const TmdbTitle({ + required this.id, + required this.isTv, + required this.title, + this.year, + this.isAnime = false, + }); + + final int id; + final bool isTv; + final String title; + final int? year; + + /// Japanese animation: AniList knows its sequels and side stories far + /// better than TMDB's "recommended", so the tab asks AniList for it. + final bool isAnime; +} + +/// What comes with a film or a series that is not anime: the rest of its +/// franchise and what TMDB recommends after it. +/// +/// AniList is where relations came from, and it knows only anime and manga. +/// A title search there for "Grey's Anatomy" either found nothing or found +/// whichever anime shared a word with it, and the tab showed that. TMDB is +/// asked through the backend's read-only proxy, which carries the token. +class TmdbRelations { + TmdbRelations(this._dio); + + final Dio _dio; + + static const _poster = 'https://image.tmdb.org/t/p/w342'; + + /// Relation types this source produces, ordered as they are shown. + static const collection = 'TMDB_COLLECTION'; + static const recommended = 'TMDB_RECOMMENDED'; + + /// The TMDB title [title] is, or null when nothing matches well enough to + /// trust: a wrong show's recommendations are worse than none. + Future find(String title, {int? year, bool? isSerial}) async { + final query = title.trim(); + if (query.isEmpty) return null; + final data = await _get('/tmdb/search/multi', {'query': query}); + final rows = [ + for (final r in _results(data)) + if (r['media_type'] == 'tv' || r['media_type'] == 'movie') + if (isSerial == null || (r['media_type'] == 'tv') == isSerial) r, + ]; + final best = TitleMatch.best( + rows, + query: query, + titleOf: _titleOf, + queryYear: year, + yearOf: _yearOf, + ); + if (best == null || !best.$2.isTrustworthy) return null; + return _tmdbTitle(best.$1); + } + + /// The relations of [title]: its franchise first, then recommendations. + Future> relationsOf(TmdbTitle title) async { + final kind = title.isTv ? 'tv' : 'movie'; + final out = []; + if (!title.isTv) { + final movie = await _get('/tmdb/movie/${title.id}', const {}); + final set = movie['belongs_to_collection']; + final setId = set is Map ? (set['id'] as num?)?.toInt() : null; + if (setId != null) { + final parts = + _results( + await _get('/tmdb/collection/$setId', const {}), + key: 'parts', + ).where((p) => (p['id'] as num?)?.toInt() != title.id).toList() + ..sort( + (a, b) => '${a['release_date'] ?? ''}'.compareTo( + '${b['release_date'] ?? ''}', + ), + ); + out.addAll(parts.map((p) => _relation(p, collection, isTv: false))); + } + } + final recs = _results( + await _get('/tmdb/$kind/${title.id}/recommendations', const {}), + ); + final seen = {for (final r in out) r.id}; + for (final r in recs.take(20)) { + final rel = _relation(r, recommended, isTv: title.isTv); + if (seen.add(rel.id)) out.add(rel); + } + return out; + } + + Future> _get( + String path, + Map query, + ) async { + final res = await _dio.get(path, queryParameters: query); + final data = res.data; + return data is Map ? data : const {}; + } + + static List> _results( + Map data, { + String key = 'results', + }) { + final raw = data[key]; + if (raw is! List) return const []; + return raw.whereType>().toList(); + } + + static String _titleOf(Map r) => + '${r['title'] ?? r['name'] ?? ''}'; + + static int? _yearOf(Map r) { + final date = '${r['release_date'] ?? r['first_air_date'] ?? ''}'; + return date.length >= 4 ? int.tryParse(date.substring(0, 4)) : null; + } + + static TmdbTitle _tmdbTitle(Map r) { + final genres = r['genre_ids']; + final animation = genres is List && genres.contains(16); + return TmdbTitle( + id: (r['id'] as num).toInt(), + isTv: r['media_type'] == 'tv', + title: _titleOf(r), + year: _yearOf(r), + isAnime: animation && r['original_language'] == 'ja', + ); + } + + static AnilistRelation _relation( + Map r, + String type, { + required bool isTv, + }) { + final poster = r['poster_path']; + final tv = r['media_type'] == null ? isTv : r['media_type'] == 'tv'; + return AnilistRelation( + id: (r['id'] as num?)?.toInt() ?? 0, + relationType: type, + title: _titleOf(r), + format: tv ? 'TV' : 'MOVIE', + year: _yearOf(r), + coverImage: poster is String && poster.isNotEmpty + ? '$_poster$poster' + : null, + ); + } +} diff --git a/lib/features/detail/domain/download_choices.dart b/lib/features/detail/domain/download_choices.dart index 79472c5c..5f102d2d 100644 --- a/lib/features/detail/domain/download_choices.dart +++ b/lib/features/detail/domain/download_choices.dart @@ -115,17 +115,37 @@ abstract final class DownloadChoices { return true; } + /// Whether [url] is the player's own local HLS proxy + /// (`http://127.0.0.1:/hls//…`, see `LocalHlsProxy.register`). + /// + /// Such a url only answers while that registration lives in this app, and + /// a download outlives the player that registered it. The torrent engine's + /// `/stream/…` address is a different thing and is not matched. + static bool isLocalProxyUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'http') return false; + if (uri.host != '127.0.0.1' && uri.host != 'localhost') return false; + return uri.path.startsWith('/hls/'); + } + /// The offers, best first, current stream included. /// /// [currentUrl] is the RESOLVED url — post-sniff where a sniff happened — /// which is why the playing source can always be offered even under a /// directive. + /// + /// [currentUpstreamUrl] and [currentUpstreamHeaders] are the url and headers + /// before the local HLS proxy rewrote them. When [currentUrl] is a proxy + /// address they are offered instead: the 127.0.0.1 url stops answering when + /// the player closes, and the download is still running then. static List from({ required List sources, required int currentIndex, required String? currentUrl, required Map currentHeaders, required bool hasDirective, + String? currentUpstreamUrl, + Map currentUpstreamHeaders = const {}, }) { final offers = []; final seen = {}; @@ -157,8 +177,21 @@ abstract final class DownloadChoices { ? sources[currentIndex] : null; if (currentUrl != null) { - // The resolved headers, which is what the sniff produced where one ran. - add(current, currentUrl, isCurrent: true, headers: currentHeaders); + final upstream = currentUpstreamUrl ?? ''; + if (upstream.isNotEmpty && + !isLocalProxyUrl(upstream) && + isLocalProxyUrl(currentUrl)) { + add( + current, + upstream, + isCurrent: true, + headers: currentUpstreamHeaders, + ); + } else { + // The resolved headers, which is what the sniff produced where one + // ran. + add(current, currentUrl, isCurrent: true, headers: currentHeaders); + } } // Under a directive every other url is an embed page rather than a stream. diff --git a/lib/features/detail/domain/entities/detail_args.dart b/lib/features/detail/domain/entities/detail_args.dart index f6018ad3..f71007f9 100644 --- a/lib/features/detail/domain/entities/detail_args.dart +++ b/lib/features/detail/domain/entities/detail_args.dart @@ -26,6 +26,12 @@ class DetailArgs { /// opens on it. final int? focusEpisode; + /// Opened from a `/play` link: the episode number to start, and where in + /// it (or in the film). Unlike [resumeEpisodeIndex], these are the sharer's + /// place, not this viewer's history. + final int? startEpisode; + final int? startPositionMs; + const DetailArgs({ required this.contentUrl, this.preview, @@ -34,5 +40,7 @@ class DetailArgs { this.provider, this.heroTag, this.focusEpisode, + this.startEpisode, + this.startPositionMs, }); } diff --git a/lib/features/detail/domain/entities/episode_entity.dart b/lib/features/detail/domain/entities/episode_entity.dart index cf4f79f5..6ce2cea6 100644 --- a/lib/features/detail/domain/entities/episode_entity.dart +++ b/lib/features/detail/domain/entities/episode_entity.dart @@ -24,6 +24,17 @@ class EpisodeEntity { /// same chapter once per group; the chapter list filters by it. final String? scanlator; + /// The season this episode belongs to, when the source lists more than one + /// in a single run — a CloudStream series hands every season over at once. + /// Null for a single-season list, so nothing about those changes. + final int? season; + + /// The episode's number within [season], as the source wrote it. [episode] + /// is then the position in the whole run: two seasons both start at 1, and + /// the number is what downloads, history and the player tell episodes apart + /// by — Lupin's S2E1 used to be a second "episode 1", sorted in among S1's. + final int? numberInSeason; + const EpisodeEntity({ required this.episode, required this.label, @@ -37,5 +48,11 @@ class EpisodeEntity { this.overview, this.webUrl, this.scanlator, + this.season, + this.numberInSeason, }); + + /// "S2 E1" for an episode of a multi-season run, the plain number otherwise. + String get numberLabel => + season == null ? '$episode' : 'S$season E${numberInSeason ?? episode}'; } diff --git a/lib/features/detail/domain/entities/episodes_args.dart b/lib/features/detail/domain/entities/episodes_args.dart index 9d062d78..373848a5 100644 --- a/lib/features/detail/domain/entities/episodes_args.dart +++ b/lib/features/detail/domain/entities/episodes_args.dart @@ -28,6 +28,11 @@ class EpisodesArgs { /// marks it. final int? focusEpisode; + /// Opened from a `/play` link: play this episode number straight away, + /// from [startPositionMs] when given. + final int? playEpisode; + final int? startPositionMs; + const EpisodesArgs({ required this.title, required this.episodes, @@ -42,5 +47,7 @@ class EpisodesArgs { this.resumeFromHistory = false, this.offline = false, this.focusEpisode, + this.playEpisode, + this.startPositionMs, }); } diff --git a/lib/features/detail/domain/entities/playback_entity.dart b/lib/features/detail/domain/entities/playback_entity.dart index e02f7c3e..973b5445 100644 --- a/lib/features/detail/domain/entities/playback_entity.dart +++ b/lib/features/detail/domain/entities/playback_entity.dart @@ -1,4 +1,5 @@ import 'episode_entity.dart'; +import 'source_notice.dart'; import 'thumbnails_entity.dart'; import 'video_source_entity.dart'; @@ -18,6 +19,16 @@ class PlaybackEntity { final int totalPages; final String sort; + /// Whether the source says this title can be played at all. + /// + /// Defaults to true: a source that says nothing is a source with nothing + /// wrong, and every host but the backend's own providers says nothing. + final bool playable; + + /// Why, when it cannot — or why the page is the wrong one to play from. + /// Null when the source raised nothing. + final SourceNotice? notice; + const PlaybackEntity({ required this.provider, required this.contentUrl, @@ -33,5 +44,7 @@ class PlaybackEntity { this.total = 0, this.totalPages = 1, this.sort = 'asc', + this.playable = true, + this.notice, }); } diff --git a/lib/features/detail/domain/entities/source_notice.dart b/lib/features/detail/domain/entities/source_notice.dart new file mode 100644 index 00000000..42b4bf43 --- /dev/null +++ b/lib/features/detail/domain/entities/source_notice.dart @@ -0,0 +1,88 @@ +/// Why a source cannot play a title, as the backend states it. +/// +/// A source refusing one title is not a failure of the app, of the network or +/// of the title: it is a fact about that source, and the backend now says which +/// fact in a field instead of leaving it to be guessed from a sentence. The app +/// used to throw the whole body away and show "Video URL aniqlanmadi" — a +/// technical phrase, in Uzbek, whatever had actually happened and whatever +/// language the viewer reads — so a title the site had simply moved into its own +/// app looked like a bug in Sozo. +/// +/// The code reaches the app two ways, because the two endpoints differ. The +/// episodes response carries it as a field (`noticeCode`, and `playableReason` +/// beside `playable: false`). A failed media resolve carries it at the head of +/// the message string, `SOURCE_MOVED_TO_APP: ...`, because the API's error +/// handler serialises only `message` — see `mediaError` in +/// sozo-backend/src/providers/asilmedia.js, which repeats the code there for +/// exactly this reason. [read] accepts both shapes so neither caller has to +/// know which it is holding. +enum SourceNotice { + /// The site now sells its own app for this title and keeps no player on the + /// page. Nothing is left to resolve: another source is the only way to watch. + movedToApp('SOURCE_MOVED_TO_APP'), + + /// The page carries no player, and no promotion either — a record of a title + /// the source does not actually host. + noPlayer('SOURCE_NO_PLAYER'), + + /// A serial's page was resolved as a film. The episodes exist and resolve + /// perfectly; they are reached through the episode list rather than here, so + /// this one is a wrong turn rather than a dead end. + isSeries('SOURCE_IS_SERIES'); + + const SourceNotice(this.code); + + /// The token the backend sends. Stable: it crosses the HTTP boundary inside a + /// message string as well as in a field. + final String code; + + /// What the viewer is told, in their own language. + String get messageKey => switch (this) { + SourceNotice.movedToApp => 'detail.notice_moved_to_app', + SourceNotice.noPlayer => 'detail.notice_no_player', + SourceNotice.isSeries => 'detail.notice_is_series', + }; + + /// Whether this source can play this title at all. + /// + /// False for the two dead ends, and true for [isSeries]: the episodes are + /// there, so hiding Play would take away the one affordance that works. + bool get playable => this == SourceNotice.isSeries; + + /// The notice [raw] names, or null when it names none. + /// + /// Accepts the bare code and a message that begins with it, so the same call + /// serves `noticeCode`, `playableReason` and a 422 body's `message`. Anything + /// unrecognised is null rather than a guess — a code this app has not been + /// taught is better shown as the plain failure it was than explained wrongly. + static SourceNotice? read(String? raw) { + if (raw == null) return null; + final head = raw.trim().split(':').first.trim(); + if (head.isEmpty) return null; + for (final notice in values) { + if (notice.code == head) return notice; + } + return null; + } +} + +/// A playback failure that carries its reason. +/// +/// Thrown as the error of a `Failure` so the page can branch on the reason +/// rather than parse the sentence it would otherwise have had to print. The +/// message is still a sentence, for a log or a screen that has not been taught +/// this type. +class SourceNoticeException implements Exception { + const SourceNoticeException(this.notice, {this.providerNotice}); + + final SourceNotice notice; + + /// What the site itself printed where the player used to be, in the site's + /// own words and language. Never shown to the viewer — it is the source's + /// advertisement, not an explanation — but it is what a maintainer reading a + /// log needs to see why a title was gated. + final String? providerNotice; + + @override + String toString() => 'SourceNoticeException(${notice.code})'; +} diff --git a/lib/features/detail/domain/entities/subtitle_entity.dart b/lib/features/detail/domain/entities/subtitle_entity.dart index f5434553..92cedeee 100644 --- a/lib/features/detail/domain/entities/subtitle_entity.dart +++ b/lib/features/detail/domain/entities/subtitle_entity.dart @@ -1,13 +1,19 @@ +import 'package:soplay/core/subtitles/subtitle_cipher.dart'; + class SubtitleEntity { final String label; final String file; final bool isDefault; final Map headers; + /// Set when the file's cue text is encrypted; the loader decrypts it. + final SubtitleCipher? cipher; + const SubtitleEntity({ required this.label, required this.file, this.isDefault = false, this.headers = const {}, + this.cipher, }); } diff --git a/lib/features/detail/domain/entities/video_source_entity.dart b/lib/features/detail/domain/entities/video_source_entity.dart index 0bcb4219..78558f20 100644 --- a/lib/features/detail/domain/entities/video_source_entity.dart +++ b/lib/features/detail/domain/entities/video_source_entity.dart @@ -7,6 +7,14 @@ class VideoSourceEntity { final bool isDefault; final bool accessible; final bool useLocalProxy; + + /// A pattern for segments that are not part of the video, such as a spliced-in + /// ad. Server-supplied; the stream then goes through the local proxy, which + /// leaves them out. See `dropHlsSegments`. + final String? dropSegments; + + /// The language burned into the picture, if the source says so. Server-supplied. + final String? hardsub; final String? type; final Map headers; final Map localProxy; @@ -85,6 +93,8 @@ class VideoSourceEntity { this.rawLabel, this.warnings = const [], this.useLocalProxy = false, + this.dropSegments, + this.hardsub, this.type, this.headers = const {}, this.localProxy = const {}, diff --git a/lib/features/detail/domain/episode_seasons.dart b/lib/features/detail/domain/episode_seasons.dart new file mode 100644 index 00000000..d9d8b956 --- /dev/null +++ b/lib/features/detail/domain/episode_seasons.dart @@ -0,0 +1,95 @@ +import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; + +/// A run that a source lists as one, season after season. +/// +/// A CloudStream series hands every season over in one list, each episode +/// numbered within its own season. The app tells episodes apart by number — +/// the download id, the history entry, the player's Next — so Lupin's five +/// seasons were five episode 1s, sorted in among each other ("Chapter 1", +/// "Chapter 6", "Chapter 1"…), and playing one marked the others watched. +abstract final class EpisodeSeasons { + /// [episodes] with each one's season kept and its number made unique. + /// + /// When two seasons share a number the whole run is numbered 1, 2, 3… in + /// season order — specials (season 0) last — and the source's own number + /// moves to [EpisodeEntity.numberInSeason]. Season 1 keeps its numbers, so + /// what was downloaded or watched of it before still lines up. A run whose + /// seasons already number on (a second season starting at 13) keeps them. + /// + /// A list with fewer than two seasons comes back as it was, season dropped: + /// "S1 E3" on a single-season show says nothing "3" does not. + static List number(List episodes) { + final seasons = { + for (final e in episodes) + if (e.season != null) e.season!, + }; + if (seasons.length < 2) { + if (!episodes.any((e) => e.season != null)) return episodes; + return [for (final e in episodes) _with(e, e.episode, null, null)]; + } + + final indexed = [ + for (var i = 0; i < episodes.length; i++) (i, episodes[i]), + ]; + indexed.sort((a, b) { + var c = _seasonRank(a.$2.season).compareTo(_seasonRank(b.$2.season)); + if (c == 0) c = a.$2.episode.compareTo(b.$2.episode); + if (c == 0) c = a.$1.compareTo(b.$1); + return c; + }); + + final seen = {}; + final collide = episodes.any((e) => !seen.add(e.episode)); + return [ + for (var i = 0; i < indexed.length; i++) + _with( + indexed[i].$2, + collide ? i + 1 : indexed[i].$2.episode, + indexed[i].$2.season, + indexed[i].$2.episode, + ), + ]; + } + + /// The seasons in [episodes], in order, with how many episodes each has. + static List<(int, int)> of(List episodes) { + final counts = {}; + for (final e in episodes) { + final s = e.season; + if (s != null) counts[s] = (counts[s] ?? 0) + 1; + } + final out = counts.entries.map((e) => (e.key, e.value)).toList() + ..sort((a, b) => _seasonRank(a.$1).compareTo(_seasonRank(b.$1))); + return out; + } + + /// Seasons ascending, the specials after the last one, and an episode the + /// source gave no season after everything. + static int _seasonRank(int? season) => switch (season) { + null => 1 << 30, + 0 => (1 << 30) - 1, + final s => s, + }; + + static EpisodeEntity _with( + EpisodeEntity e, + int episode, + int? season, + int? numberInSeason, + ) => EpisodeEntity( + episode: episode, + label: e.label, + mediaRef: e.mediaRef, + availableLangs: e.availableLangs, + hasSub: e.hasSub, + hasDub: e.hasDub, + image: e.image, + airdate: e.airdate, + runtime: e.runtime, + overview: e.overview, + webUrl: e.webUrl, + scanlator: e.scanlator, + season: season, + numberInSeason: numberInSeason, + ); +} diff --git a/lib/features/detail/domain/playback/desktop_volume.dart b/lib/features/detail/domain/playback/desktop_volume.dart new file mode 100644 index 00000000..892acb00 --- /dev/null +++ b/lib/features/detail/domain/playback/desktop_volume.dart @@ -0,0 +1,40 @@ +/// The in-app volume on desktop, where there is no system level to follow. +/// +/// Every controller starts at 100%, so the level and mute have to be put back +/// on each new one (a new episode, a retry, a server switch). +library; + +class DesktopVolume { + const DesktopVolume({this.level = 1.0, this.muted = false}); + + /// 1.0 is 100%. libmpv accepts up to [boostMax] with `volume-max`. + static const double boostMax = 1.5; + + final double level; + final bool muted; + + double get applied => muted ? 0 : level; + + DesktopVolume withLevel(double value, {double max = 1.0}) { + final v = value.clamp(0.0, max).toDouble(); + return DesktopVolume(level: v <= 0.001 ? level : v, muted: v <= 0.001); + } + + DesktopVolume toggleMute() => + DesktopVolume(level: level <= 0.001 ? 1.0 : level, muted: !muted); + + /// Clamped to what the engine accepts now, without forgetting the stored + /// level (a 140% set on mpv survives an episode on the platform player). + Future applyTo( + Future Function(double) setVolume, { + double max = 1.0, + }) => setVolume(applied.clamp(0.0, max).toDouble()); + + static DesktopVolume restore(Object? level, Object? muted) { + final l = level is num ? level.toDouble() : 1.0; + return DesktopVolume( + level: l <= 0.001 ? 1.0 : l.clamp(0.0, boostMax).toDouble(), + muted: muted == true, + ); + } +} diff --git a/lib/features/detail/domain/playback/end_of_stream.dart b/lib/features/detail/domain/playback/end_of_stream.dart new file mode 100644 index 00000000..0a03eb11 --- /dev/null +++ b/lib/features/detail/domain/playback/end_of_stream.dart @@ -0,0 +1,66 @@ +/// Decides when an episode has ended, once. +/// +/// The player used to trust only "two seconds left". Streams whose playlist +/// overstates the duration never get there: the engine reports completion, or +/// the playhead freezes a few seconds short and buffers forever, and the +/// episode neither advanced nor counted as finished. +library; + +class EndOfStreamDetector { + /// Inside this much of the end, the last moments are being played. + static const Duration nearEnd = Duration(seconds: 2); + + /// Inside this much of the end, a playhead that stops moving is the end. + static const Duration stallZone = Duration(seconds: 5); + + /// How long the playhead must stay put in [stallZone]. + static const Duration stallFor = Duration(seconds: 3); + + Duration? _lastPosition; + Duration? _movedAt; + bool _fired = false; + + bool get fired => _fired; + + /// True exactly once per ending. Moving back out of the last stretch (a seek + /// back) re-arms it. + bool observe({ + required Duration position, + required Duration duration, + required bool isCompleted, + required bool isPlaying, + required bool isBuffering, + required Duration now, + }) { + if (duration <= Duration.zero) return false; + final remaining = duration - position; + + if (_lastPosition != position) { + _lastPosition = position; + _movedAt = now; + } + + if (remaining > stallZone + const Duration(seconds: 1) && !isCompleted) { + _fired = false; + return false; + } + if (_fired) return false; + + final movedAt = _movedAt ?? now; + final stalled = + remaining <= stallZone && + (isPlaying || isBuffering) && + now - movedAt >= stallFor; + if (isCompleted || remaining <= nearEnd || stalled) { + _fired = true; + return true; + } + return false; + } + + void reset() { + _lastPosition = null; + _movedAt = null; + _fired = false; + } +} diff --git a/lib/features/detail/domain/playback/lock_pill.dart b/lib/features/detail/domain/playback/lock_pill.dart new file mode 100644 index 00000000..fa68542e --- /dev/null +++ b/lib/features/detail/domain/playback/lock_pill.dart @@ -0,0 +1,36 @@ +/// The unlock pill on a locked screen: shown on lock and on every tap, gone +/// [visibleFor] later. Unlocking takes a tap on the pill while it is showing, +/// so a stray touch in a pocket only brings it back. +library; + +class LockPill { + LockPill({this.visibleFor = const Duration(seconds: 3)}); + + final Duration visibleFor; + Duration? _shownAt; + + void show(Duration now) => _shownAt = now; + + void hide() => _shownAt = null; + + bool isVisible(Duration now) { + final at = _shownAt; + return at != null && now - at < visibleFor; + } + + /// When the pill should next be repainted as gone, or null if hidden. + Duration? hidesAt() { + final at = _shownAt; + return at == null ? null : at + visibleFor; + } + + /// A tap on the pill: unlocks only if it was visible. Otherwise it is shown. + bool tapPill(Duration now) { + if (isVisible(now)) { + hide(); + return true; + } + show(now); + return false; + } +} diff --git a/lib/features/detail/domain/playback/playback_fault.dart b/lib/features/detail/domain/playback/playback_fault.dart index bdd834a2..343aa68d 100644 --- a/lib/features/detail/domain/playback/playback_fault.dart +++ b/lib/features/detail/domain/playback/playback_fault.dart @@ -35,6 +35,12 @@ enum PlaybackFaultKind { /// The stream could not be fetched. The connection is the first suspect. network('player.fault.network'), + /// The server answered 404 or 410: the file is gone from this mirror. + notFound('player.fault.not_found'), + + /// The server answered 5xx or 429: its problem, not the device's. + serverError('player.fault.server_error'), + /// The resolve produced no playable source for this episode. noSourceForEpisode('player.fault.no_source'), @@ -90,21 +96,99 @@ class PlaybackFault { if (lower.contains('mediacodec') || lower.contains('could not open codec') || lower.contains('decoder') || + lower.contains('error decoding') || + lower.contains('error while decoding') || lower.contains('renderer')) { return PlaybackFault(PlaybackFaultKind.decoder, raw: raw); } + final status = httpStatusOf(lower); + if (status != null) { + if (status == 404 || status == 410) { + return PlaybackFault(PlaybackFaultKind.notFound, raw: raw); + } + if (status >= 500 || status == 429) { + return PlaybackFault(PlaybackFaultKind.serverError, raw: raw); + } + return PlaybackFault(PlaybackFaultKind.sourceBlocked, raw: raw); + } + if (lower.contains('5xx server error')) { + return PlaybackFault(PlaybackFaultKind.serverError, raw: raw); + } + // libmpv's "Failed to open ": the host answered with a refusal + // (often a 403 without the Referer it expects) or not at all. The raw + // line, with its long address, read as the whole message on screen. if (lower.contains('source error') || + lower.contains('failed to open http') || lower.contains('failed to recognize file format') || lower.contains('unrecognizedinputformat') || lower.contains('nodeclaredbrand')) { return PlaybackFault(PlaybackFaultKind.sourceBlocked, raw: raw); } - if (lower.contains('http data source')) { + // A connection that dropped or never came up. Said as advice about the + // network in the viewer's language, rather than as ffmpeg's or Dio's + // English: img_2e4886 is Dio's "The connection errored: Connection reset + // by peer This indicates an error which most likely cannot be solved by + // the library." on the error screen, wrong about the remedy as well as in + // the wrong language. + if (lower.contains('http data source') || + lower.contains('ffurl_read') || + lower.contains('connection reset') || + lower.contains('connection timed out') || + lower.contains('connection errored') || + // Dio's timeouts: "The request connection took longer than 0:00:15…". + lower.contains('took longer than') || + lower.contains('failed to resolve hostname') || + lower.contains('failed host lookup') || + lower.contains('network is unreachable') || + lower.contains('network connection was lost')) { return PlaybackFault(PlaybackFaultKind.network, raw: raw); } return PlaybackFault(PlaybackFaultKind.unknown, raw: raw); } + static final RegExp _statusAfterLabel = RegExp( + r'(?:status code(?: of)?|response code|http error|server returned|' + r'statuscode|http/\d(?:\.\d)?)[:=\s]*(\d{3})\b', + ); + static final RegExp _statusBeforeReason = RegExp( + r'\b([45]\d{2})\s*[-:]?\s*(?:forbidden|not found|gone|unauthorized|' + r'bad request|too many requests|internal server error|bad gateway|' + r'service unavailable|gateway time-?out)', + ); + + /// The HTTP status a failure names, from Dio, ExoPlayer, ffmpeg or an error + /// page's body, or null. Only 4xx and 5xx; a bare number is not enough. + static int? httpStatusOf(String lower) { + for (final re in [_statusAfterLabel, _statusBeforeReason]) { + for (final m in re.allMatches(lower)) { + final code = int.tryParse(m.group(1)!); + if (code != null && code >= 400 && code < 600) return code; + } + } + return null; + } + + /// The key for what the error screen says once nothing is left to try, or + /// null to say what [classify] makes of [raw]. + /// + /// The last failure alone is a poor summary of a walk across several + /// servers: four mirrors that each failed in their own way ended on the + /// fourth one's raw sentence, which reads as though only that one was + /// tried. A dropped connection is still named as such — it is the one cause + /// the viewer can do something about — and a start that never produced a + /// frame says so rather than quoting a Dart TimeoutException. + static String? exhaustedKey( + String raw, { + required bool timedOut, + required int serversTried, + }) { + final fault = classify(raw); + if (fault.kind == PlaybackFaultKind.network) return fault.messageKey; + if (serversTried > 1) return 'player.all_servers_failed'; + if (timedOut) return 'player.resolve_timeout'; + return null; + } + /// Whether the device cannot play this stream as encoded, so the browser is /// the honest suggestion rather than another retry. bool get isCodecFault => diff --git a/lib/features/detail/domain/playback/player_system_policy.dart b/lib/features/detail/domain/playback/player_system_policy.dart new file mode 100644 index 00000000..c7b2e92c --- /dev/null +++ b/lib/features/detail/domain/playback/player_system_policy.dart @@ -0,0 +1,210 @@ +import 'dart:ui' show AppLifecycleState, Rect, Size; + +/// Whether the app leaving the foreground pauses the player. +/// +/// `inactive` is not leaving: on a phone it is the notification shade, a +/// permission dialog, split-screen focus or Control Center. The desktop keeps +/// playing and floats instead. +bool pausesOnLifecycle( + AppLifecycleState state, { + required bool desktop, + required bool inPip, +}) { + if (desktop || inPip) return false; + return state == AppLifecycleState.paused || state == AppLifecycleState.hidden; +} + +enum PipChange { none, entered, restored, dismissed } + +/// Android picture-in-picture, as the player sees it. +/// +/// [active] goes true before the system is asked, so the lifecycle callbacks +/// fired while the window shrinks never mistake it for leaving the app. +class AndroidPipTracker { + bool _active = false; + + bool get active => _active; + + void beginEnter() => _active = true; + + void enterFailed() => _active = false; + + /// The activity's PiP mode changed. [dismissed] is the window closed with + /// its X or swiped away, rather than expanded back into the app. + PipChange onModeChanged({required bool inPip, required bool dismissed}) { + if (inPip) { + _active = true; + return PipChange.entered; + } + final was = _active; + _active = false; + if (dismissed) return PipChange.dismissed; + return was ? PipChange.restored : PipChange.none; + } + + PipChange onResumed() { + if (!_active) return PipChange.none; + _active = false; + return PipChange.restored; + } +} + +enum AudioFocusEvent { gain, loss, lossTransient, duck, noisy } + +AudioFocusEvent? audioFocusEventFromWire(String? name) => switch (name) { + 'gain' => AudioFocusEvent.gain, + 'loss' => AudioFocusEvent.loss, + 'loss_transient' => AudioFocusEvent.lossTransient, + 'duck' => AudioFocusEvent.duck, + 'noisy' => AudioFocusEvent.noisy, + _ => null, +}; + +class AudioFocusResponse { + const AudioFocusResponse({ + this.pause = false, + this.resume = false, + this.volume, + }); + + static const none = AudioFocusResponse(); + + final bool pause; + final bool resume; + + /// The player's own volume to set, null to leave it. + final double? volume; + + @override + bool operator ==(Object other) => + other is AudioFocusResponse && + other.pause == pause && + other.resume == resume && + other.volume == volume; + + @override + int get hashCode => Object.hash(pause, resume, volume); + + @override + String toString() => + 'AudioFocusResponse(pause: $pause, resume: $resume, volume: $volume)'; +} + +/// What the player does about audio focus and the headphones coming out. +/// +/// A call or an alarm pauses and plays on afterwards; another app taking +/// focus for good, or unplugged headphones, pause and stay paused. +class AudioFocusPolicy { + static const double duckVolume = 0.3; + + bool _pausedForTransient = false; + bool _ducked = false; + + AudioFocusResponse on(AudioFocusEvent event, {required bool playing}) { + switch (event) { + case AudioFocusEvent.gain: + final resume = _pausedForTransient && !playing; + final volume = _ducked ? 1.0 : null; + _pausedForTransient = false; + _ducked = false; + return AudioFocusResponse(resume: resume, volume: volume); + case AudioFocusEvent.loss: + case AudioFocusEvent.noisy: + _pausedForTransient = false; + return playing + ? const AudioFocusResponse(pause: true) + : AudioFocusResponse.none; + case AudioFocusEvent.lossTransient: + if (!playing) return AudioFocusResponse.none; + _pausedForTransient = true; + return const AudioFocusResponse(pause: true); + case AudioFocusEvent.duck: + if (_ducked) return AudioFocusResponse.none; + _ducked = true; + return const AudioFocusResponse(volume: duckVolume); + } + } + + /// The viewer pressed play themselves while a call had it paused: the + /// call ending must not toggle it again. + void onPlayingChanged(bool playing) { + if (playing) _pausedForTransient = false; + } +} + +/// The viewer's brightness inside the player, kept so it can be handed back +/// to the system on the way out and applied again on the way back in. +class BrightnessKeeper { + double? _level; + + double? get level => _level; + + void userSet(double value) => _level = value.clamp(0.0, 1.0).toDouble(); + + /// Whether going to the background has a level to hand back. + bool onBackground() => _level != null; + + /// The level to apply again on return, if the viewer set one. + double? onResume() => _level; + + void onExit() => _level = null; +} + +/// The picture's rectangle on a screen of [screen], letterboxed: what Android +/// animates from when it shrinks the activity into the floating window. +Rect? pipSourceRect(Size screen, Size video) { + if (screen.isEmpty || video.isEmpty) return null; + final scale = (screen.width / video.width) < (screen.height / video.height) + ? screen.width / video.width + : screen.height / video.height; + final w = video.width * scale; + final h = video.height * scale; + return Rect.fromLTWH((screen.width - w) / 2, (screen.height - h) / 2, w, h); +} + +/// Android refuses an aspect ratio outside 1:2.39 to 2.39:1. +(int, int) pipAspect(Size video) { + final w = video.width.round(); + final h = video.height.round(); + if (w <= 0 || h <= 0) return (16, 9); + final aspect = w / h; + if (aspect < 1 / 2.39 || aspect > 2.39) return (16, 9); + return (w, h); +} + +/// Throttles playback-state updates to the media session: the system +/// extrapolates the position itself, so only a change of state, speed or a +/// jump needs sending. +class MediaSessionGate { + static const int driftMs = 1500; + + bool? _playing; + double? _speed; + int _positionMs = 0; + int _sentAtMs = 0; + + bool shouldSend({ + required bool playing, + required int positionMs, + required double speed, + required int nowMs, + }) { + var send = playing != _playing || speed != _speed; + if (!send) { + final elapsed = playing ? ((nowMs - _sentAtMs) * speed).round() : 0; + send = (positionMs - (_positionMs + elapsed)).abs() > driftMs; + } + if (send) { + _playing = playing; + _speed = speed; + _positionMs = positionMs; + _sentAtMs = nowMs; + } + return send; + } + + void reset() { + _playing = null; + _speed = null; + } +} diff --git a/lib/features/detail/domain/playback/retry_policy.dart b/lib/features/detail/domain/playback/retry_policy.dart index d183ec23..30fb6460 100644 --- a/lib/features/detail/domain/playback/retry_policy.dart +++ b/lib/features/detail/domain/playback/retry_policy.dart @@ -46,6 +46,73 @@ abstract final class RetryPolicy { /// Consecutive attempts on ONE source before moving on. static const int maxAttemptsPerSource = 2; + /// In-place reconnects after a mid-stream network drop, before the drop is + /// handed to the ordinary walk (another mirror, a re-resolve). + /// + /// A connection that went away — a lift, a tunnel, a cell handover — is not + /// a broken server, and the server that was playing a minute ago is the + /// likeliest to play again. log_2e4886 is a vidapi episode lost twice to + /// exactly this: the stream was fine, the phone's connection was not. + static const int maxNetworkReconnects = 3; + + /// Played time after which a stream that dropped and came back has earned + /// a fresh [maxNetworkReconnects]. Without it a two-hour film on a patchy + /// connection would spend the budget on drops an hour apart. + static const Duration networkReconnectsRenewAfter = Duration(seconds: 60); + + /// How long to wait before reconnecting after a network drop, by attempt. + /// + /// Long enough for a handover to finish, short enough that a viewer who + /// sees "reconnecting" is not left wondering whether anything is happening. + static Duration networkBackoff(int attempt) { + const steps = [2, 4, 8]; + final i = attempt < 0 ? 0 : attempt; + return Duration(seconds: steps[i < steps.length ? i : steps.length - 1]); + } + + /// The reconnects already spent, given [playedSinceLast] — the playing time + /// since the last one. Back to zero once the stream has run for + /// [networkReconnectsRenewAfter]. + static int networkReconnectsSpent({ + required int reconnects, + required Duration playedSinceLast, + }) => playedSinceLast >= networkReconnectsRenewAfter ? 0 : reconnects; + + /// Whether [msg] says the connection went away, rather than that the server + /// refused or the file is bad. + /// + /// libmpv/ffmpeg ("tcp: ffurl_read returned 0xffffff92" — ETIMEDOUT —, + /// "Failed to resolve hostname", "Connection timed out"), Dio ("The + /// connection errored: Connection reset by peer") and AVFoundation (-1005, + /// -1009) each have their own words for it; these are the ones the support + /// logs show. ExoPlayer's "Source error" is deliberately absent: it says the + /// same for a 403 on an expired token, which reconnecting cannot fix. + static bool isNetworkDrop(String msg) { + final l = msg.toLowerCase(); + return l.contains('ffurl_read') || + l.contains('connection reset') || + l.contains('connection timed out') || + l.contains('connection errored') || + l.contains('failed to resolve hostname') || + l.contains('failed host lookup') || + l.contains('network is unreachable') || + l.contains('network connection was lost') || + l.contains('-1005') || + l.contains('-1009'); + } + + /// Whether a stream that was playing and lost its connection should be + /// re-opened where it was, on the same server, rather than walked away from. + /// + /// Not for live, which has its own reconnect loop, and only while the + /// in-place budget lasts; after that the drop is an ordinary recoverable + /// error and the ladder takes over. + static bool shouldReconnectInPlace({ + required String message, + required bool isLive, + required int reconnects, + }) => !isLive && reconnects < maxNetworkReconnects && isNetworkDrop(message); + /// How long to wait before reconnecting a dropped channel. /// /// Fast enough that a blip is invisible, backing off so a channel that is @@ -103,9 +170,15 @@ abstract final class RetryPolicy { l.contains('timeout') || l.contains('-1001') || l.contains('-1005') || + // The connection went away, not the file: another attempt — at the + // position the viewer had reached — is the answer. Before this a + // "tcp: ffurl_read returned 0xffffff92" a minute into a vidapi episode + // went straight to the error screen (log_2e4886). + isNetworkDrop(msg) || l.contains('source error') || l.contains('mediacodec') || - l.contains('decoder'); + l.contains('decoder') || + l.contains('error decoding'); } /// What to do about [message], given what has already been spent. diff --git a/lib/features/detail/domain/playback/tap_seek.dart b/lib/features/detail/domain/playback/tap_seek.dart new file mode 100644 index 00000000..62907595 --- /dev/null +++ b/lib/features/detail/domain/playback/tap_seek.dart @@ -0,0 +1,113 @@ +/// Taps on the picture: single taps toggle the controls, double taps on a side +/// seek, and while the seek ripple is up every further tap on that side adds +/// another step (YouTube, MX Player). No Flutter import, so it is testable on +/// its own with a fake clock. +library; + +enum TapZone { left, centre, right } + +enum TapAction { + /// Wait: this may be the first half of a double tap. Call + /// [TapSeekTracker.flush] once [TapSeekTracker.doubleTapWindow] has passed. + pending, + toggleControls, + seekBack, + seekForward, + togglePlay, +} + +class TapSeekTracker { + TapSeekTracker({ + this.doubleTapWindow = const Duration(milliseconds: 260), + this.rippleWindow = const Duration(milliseconds: 700), + }); + + final Duration doubleTapWindow; + + /// How long the ripple stays after the last step; the page's ripple timer + /// must use the same value. + final Duration rippleWindow; + + TapZone? _pendingZone; + Duration? _pendingAt; + TapZone? _seekZone; + Duration? _lastSeekAt; + int _steps = 0; + + /// Steps taken in the current run of taps on one side. + int get steps => _steps; + + bool get hasPending => _pendingZone != null; + + static TapZone zoneOf(double dx, double width) { + if (width <= 0) return TapZone.centre; + if (dx < width * 0.3) return TapZone.left; + if (dx > width * 0.7) return TapZone.right; + return TapZone.centre; + } + + bool rippleActive(Duration now) { + final last = _lastSeekAt; + return last != null && now - last <= rippleWindow; + } + + TapAction onTap( + TapZone zone, + Duration now, { + required bool canSeek, + bool centreTogglesPlay = true, + }) { + final side = zone != TapZone.centre; + if (side && canSeek && _seekZone == zone && rippleActive(now)) { + _pendingZone = null; + _pendingAt = null; + return _seek(zone, now, continuing: true); + } + + final pendingAt = _pendingAt; + if (pendingAt != null && + _pendingZone == zone && + now - pendingAt <= doubleTapWindow) { + _pendingZone = null; + _pendingAt = null; + if (side && canSeek) return _seek(zone, now, continuing: false); + if (!side && centreTogglesPlay) return TapAction.togglePlay; + return TapAction.toggleControls; + } + + final doublePlausible = side ? canSeek : centreTogglesPlay; + if (!doublePlausible) { + _pendingZone = null; + _pendingAt = null; + return TapAction.toggleControls; + } + _pendingZone = zone; + _pendingAt = now; + return TapAction.pending; + } + + /// The pending tap, resolved as a single tap once its window is over, or + /// null if nothing is pending (a second tap took it, or the window is open). + TapAction? flush(Duration now) { + final at = _pendingAt; + if (at == null || now - at < doubleTapWindow) return null; + _pendingZone = null; + _pendingAt = null; + return TapAction.toggleControls; + } + + void reset() { + _pendingZone = null; + _pendingAt = null; + _seekZone = null; + _lastSeekAt = null; + _steps = 0; + } + + TapAction _seek(TapZone zone, Duration now, {required bool continuing}) { + _steps = continuing && _seekZone == zone ? _steps + 1 : 1; + _seekZone = zone; + _lastSeekAt = now; + return zone == TapZone.left ? TapAction.seekBack : TapAction.seekForward; + } +} diff --git a/lib/features/detail/domain/services/alternate_source_service.dart b/lib/features/detail/domain/services/alternate_source_service.dart index 80ceb2c6..81ad99cc 100644 --- a/lib/features/detail/domain/services/alternate_source_service.dart +++ b/lib/features/detail/domain/services/alternate_source_service.dart @@ -109,6 +109,7 @@ class AlternateSourceService { Stream find({ required String title, required String excludeProvider, + /// The provider the title is being read on. Its id, not its category: /// the kind is derived from the id because that is the only value that is /// unambiguous for catalogues and extensions alike — see [_kindAllows]. @@ -300,6 +301,20 @@ class AlternateSourceService { Future buildArgs({ required AlternateSource source, required int? episodeNumber, + SeasonEpisode? seasonEpisode, + Duration resumeAt = Duration.zero, + }) async => (await resolveArgs( + source: source, + episodeNumber: episodeNumber, + seasonEpisode: seasonEpisode, + resumeAt: resumeAt, + )).args; + + /// [buildArgs], with the reason when there is nothing to play. + Future resolveArgs({ + required AlternateSource source, + required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) async { final playback = (await _episodes( @@ -308,27 +323,36 @@ class AlternateSourceService { )).getOrNull(); if (playback == null) { debugPrint('$_tag episodes failed for ${source.provider.id}'); - return null; + return const AlternateArgs.failed(AlternateArgsFailure.unreachable); } final List episodes = playback.episodes; if (playback.isSerial || episodes.isNotEmpty) { - if (episodes.isEmpty) return null; + if (episodes.isEmpty) { + return const AlternateArgs.failed(AlternateArgsFailure.noEpisode); + } var window = playback; var index = 0; - if (episodeNumber != null && episodeNumber > 0) { + final bySeason = seasonEpisode == null + ? -1 + : indexOfSeasonEpisode(episodes, seasonEpisode); + if (bySeason >= 0) { + index = bySeason; + } else if (episodeNumber != null && episodeNumber > 0) { index = episodes.indexWhere((e) => e.episode == episodeNumber); if (index < 0) { // Only the first page came back — a hundred episodes. Episode 240 // of a long run is on a later one, and "this source does not have // it" was the answer every long show got. final found = await _findOnLaterPage(source, playback, episodeNumber); - if (found == null) return null; + if (found == null) { + return const AlternateArgs.failed(AlternateArgsFailure.noEpisode); + } (window, index) = found; } } final size = playback.size > 0 ? playback.size : episodes.length; - return PlayerArgs( + return AlternateArgs(PlayerArgs( title: source.item.title, provider: source.provider.id, // The NEW provider's headers. The caller used to pass the ones the @@ -350,11 +374,13 @@ class AlternateSourceService { totalEpisodes: playback.total, pageSize: size, sort: playback.sort, - ); + )); } - if (playback.videoSources.isEmpty) return null; - return PlayerArgs( + if (playback.videoSources.isEmpty) { + return const AlternateArgs.failed(AlternateArgsFailure.nothingPlayable); + } + return AlternateArgs(PlayerArgs( title: source.item.title, provider: source.provider.id, headers: playback.headers, @@ -363,7 +389,7 @@ class AlternateSourceService { videoSources: List.of(playback.videoSources), movieUrl: playback.playerSrc, resumePosition: resumeAt, - ); + )); } /// Finds episode [number] on a page past the first. @@ -400,3 +426,46 @@ class AlternateSourceService { return null; } } + +/// Why [AlternateSourceService.resolveArgs] came back empty. +enum AlternateArgsFailure { + /// The source's page for the title could not be loaded. + unreachable, + + /// The source has the show but not this episode. + noEpisode, + + /// A film page with no stream on it. + nothingPlayable, +} + +/// What [AlternateSourceService.resolveArgs] produced: arguments, or why not. +class AlternateArgs { + const AlternateArgs(PlayerArgs this.args) : failure = null; + const AlternateArgs.failed(AlternateArgsFailure this.failure) : args = null; + + final PlayerArgs? args; + final AlternateArgsFailure? failure; +} + +/// An episode as a season and its number in that season: S2E1. +typedef SeasonEpisode = ({int season, int number}); + +/// Where [wanted] is in [episodes], or -1. +/// +/// The run's own numbering is a position in one source's list — S2E1 is +/// episode 11 on a source whose first season has ten — so between two +/// sources that list seasons the season and its number are what agree. A +/// source that lists no seasons can only be matched on a first season. +@visibleForTesting +int indexOfSeasonEpisode(List episodes, SeasonEpisode wanted) { + if (episodes.any((e) => e.season != null)) { + return episodes.indexWhere( + (e) => + e.season == wanted.season && + (e.numberInSeason ?? e.episode) == wanted.number, + ); + } + if (wanted.season != 1) return -1; + return episodes.indexWhere((e) => e.episode == wanted.number); +} diff --git a/lib/features/detail/domain/services/auto_resolve_walk.dart b/lib/features/detail/domain/services/auto_resolve_walk.dart new file mode 100644 index 00000000..1f99a6da --- /dev/null +++ b/lib/features/detail/domain/services/auto_resolve_walk.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:math' as math; + +/// Why Auto moved past a source. +enum AutoSkipReason { + /// The title it found was not close enough to pick for someone. + noMatch, + + /// It has the show but not this episode. + noEpisode, + + /// It could not be reached, or had nothing to play. + error, +} + +/// What one try at a candidate produced: something to play, or why not. +class AutoAttempt { + const AutoAttempt.found(R this.value) : reason = null; + const AutoAttempt.skipped(AutoSkipReason this.reason) : value = null; + + final R? value; + final AutoSkipReason? reason; +} + +/// A candidate Auto moved past, and why. +class AutoSkip { + const AutoSkip(this.candidate, this.reason); + + final T candidate; + final AutoSkipReason reason; +} + +enum AutoWalkEnd { found, exhausted, capped, stopped } + +class AutoWalkResult { + const AutoWalkResult(this.end, [this.value]); + + final AutoWalkEnd end; + + /// Set only when [end] is [AutoWalkEnd.found]. + final R? value; +} + +/// Goes down a list that may still be growing and returns the first candidate +/// that plays. +/// +/// The list is read again on every step because search results keep arriving +/// while the walk runs. While the list has nothing new and [searching] is +/// still true, the walk waits for more. +class AutoResolveWalk { + AutoResolveWalk({ + required this.candidates, + required this.searching, + required this.idOf, + required this.isEligible, + required this.attempt, + this.cap = maxCandidates, + this.poll = const Duration(milliseconds: 250), + this.onChange, + }); + + /// More than this and the viewer has waited long enough to pick by hand. + static const int maxCandidates = 20; + + final List Function() candidates; + final bool Function() searching; + final String Function(T) idOf; + + /// Whether a candidate is good enough to try without asking. + final bool Function(T) isEligible; + final Future> Function(T) attempt; + final int cap; + final Duration poll; + + /// Called whenever [current], [index], [total] or [skipped] may have changed. + final void Function()? onChange; + + final Set _seen = {}; + final List> _skipped = []; + final Completer _stop = Completer(); + T? _current; + int _attempts = 0; + bool _running = false; + + /// The candidate being tried right now, or null while waiting for more. + T? get current => _current; + + /// Which try this is, from 1. + int get index => _attempts; + + /// The tries this walk will make with what is known so far. Grows while + /// search results arrive, and never passes [cap]. + int get total { + var waiting = 0; + for (final c in candidates()) { + if (isEligible(c) && !_seen.contains(idOf(c))) waiting++; + } + return math.min(cap, _attempts + waiting); + } + + List> get skipped => List.unmodifiable(_skipped); + + bool get stopped => _stop.isCompleted; + + /// Ends the walk now. A try still in flight is ignored when it lands. + void stop() { + if (_stop.isCompleted) return; + _stop.complete(); + _current = null; + onChange?.call(); + } + + Future> run() async { + if (_running) throw StateError('an AutoResolveWalk runs once'); + _running = true; + while (!stopped) { + if (_attempts >= cap) return _end(AutoWalkEnd.capped); + T? next; + for (final c in candidates()) { + if (_seen.contains(idOf(c)) || !isEligible(c)) continue; + next = c; + break; + } + if (next == null) { + if (!searching()) { + _skipIneligible(); + return _end(AutoWalkEnd.exhausted); + } + await Future.any([Future.delayed(poll), _stop.future]); + continue; + } + final candidate = next; + _seen.add(idOf(candidate)); + _attempts++; + _current = candidate; + onChange?.call(); + + AutoAttempt? outcome; + try { + outcome = await Future.any?>([ + attempt(candidate), + _stop.future.then((_) => null), + ]); + } catch (_) { + outcome = AutoAttempt.skipped(AutoSkipReason.error); + } + if (stopped || outcome == null) break; + _current = null; + final reason = outcome.reason; + if (reason == null) return _end(AutoWalkEnd.found, outcome.value); + _skipped.add(AutoSkip(candidate, reason)); + onChange?.call(); + } + return AutoWalkResult(AutoWalkEnd.stopped); + } + + /// The ones never tried because the match was too weak, so the end state + /// can still say why they were passed over. + void _skipIneligible() { + for (final c in candidates()) { + final id = idOf(c); + if (isEligible(c) || _seen.contains(id)) continue; + _seen.add(id); + _skipped.add(AutoSkip(c, AutoSkipReason.noMatch)); + } + } + + AutoWalkResult _end(AutoWalkEnd end, [R? value]) { + _current = null; + onChange?.call(); + return AutoWalkResult(end, value); + } +} diff --git a/lib/features/detail/domain/vtt_thumbnails.dart b/lib/features/detail/domain/vtt_thumbnails.dart new file mode 100644 index 00000000..4a1e9f2d --- /dev/null +++ b/lib/features/detail/domain/vtt_thumbnails.dart @@ -0,0 +1,91 @@ +/// One cue of a WebVTT thumbnail track: a picture, or a cell of a sprite sheet, +/// for a stretch of the video. +class VttThumbnail { + final Duration start; + final Duration end; + final String imageUrl; + final int x; + final int y; + final int w; + final int h; + + const VttThumbnail({ + required this.start, + required this.end, + required this.imageUrl, + this.x = 0, + this.y = 0, + this.w = 0, + this.h = 0, + }); + + bool get hasSprite => w > 0 && h > 0; + + bool contains(Duration position) => position >= start && position < end; +} + +// WebVTT lets a cue under an hour leave the hours out (`01:30.000`), and +// generators do; requiring them dropped every such cue and the track with it. +final RegExp _cueTimes = RegExp( + r'(?:(\d{2,}):)?(\d{2}):(\d{2})[\.,](\d{3})\s*-->\s*' + r'(?:(\d{2,}):)?(\d{2}):(\d{2})[\.,](\d{3})', +); + +Duration _timeOf(Match m, int group) => Duration( + hours: int.parse(m.group(group) ?? '0'), + minutes: int.parse(m.group(group + 1)!), + seconds: int.parse(m.group(group + 2)!), + milliseconds: int.parse(m.group(group + 3)!), +); + +/// The cues of a thumbnail track, with image addresses resolved against +/// [baseUrl], the track's own address. +List parseVttThumbnails(String vttBody, String baseUrl) { + final lines = vttBody.split('\n').map((l) => l.trim()).toList(); + final results = []; + + for (var i = 0; i < lines.length; i++) { + final match = _cueTimes.firstMatch(lines[i]); + if (match == null) continue; + + String? imageRef; + for (var j = i + 1; j < lines.length; j++) { + if (lines[j].isNotEmpty) { + imageRef = lines[j]; + break; + } + } + if (imageRef == null) continue; + + var url = imageRef; + var x = 0, y = 0, w = 0, h = 0; + final hashIdx = imageRef.indexOf('#xywh='); + if (hashIdx >= 0) { + url = imageRef.substring(0, hashIdx); + final coords = imageRef.substring(hashIdx + 6).split(','); + if (coords.length == 4) { + x = int.tryParse(coords[0]) ?? 0; + y = int.tryParse(coords[1]) ?? 0; + w = int.tryParse(coords[2]) ?? 0; + h = int.tryParse(coords[3]) ?? 0; + } + } + + if (!url.startsWith('http')) { + url = Uri.parse(baseUrl).resolve(url).toString(); + } + + results.add( + VttThumbnail( + start: _timeOf(match, 1), + end: _timeOf(match, 5), + imageUrl: url, + x: x, + y: y, + w: w, + h: h, + ), + ); + } + return results; +} diff --git a/lib/features/detail/presentation/pages/actor_page.dart b/lib/features/detail/presentation/pages/actor_page.dart index b122530e..feb43e99 100644 --- a/lib/features/detail/presentation/pages/actor_page.dart +++ b/lib/features/detail/presentation/pages/actor_page.dart @@ -14,6 +14,7 @@ import 'package:soplay/features/home/presentation/bloc/view_all/view_all_state.d import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/home/presentation/widgets/home_ui_helpers.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/core/widgets/load_more_failed_footer.dart'; class ActorArgs { final String id; @@ -304,6 +305,7 @@ class _ActorScaffoldState extends State<_ActorScaffold> { childCount: state.items.length, ), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, mainAxisSpacing: 14, crossAxisSpacing: 8, @@ -327,6 +329,14 @@ class _ActorScaffoldState extends State<_ActorScaffold> { ), ), ), + if (state.loadMoreFailed) + SliverToBoxAdapter( + child: LoadMoreFailedFooter( + onRetry: () => context.read().add( + ViewAllLoadMore(retry: true), + ), + ), + ), ]; } } diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 5934a74e..f78bca4a 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -3,6 +3,7 @@ import 'package:soplay/core/content/content_mode.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_about_tab.dart'; import 'package:soplay/core/content/catalogue.dart'; import 'package:soplay/features/detail/domain/services/catalogue_resolver.dart'; +import 'package:soplay/features/download/presentation/download_messages.dart'; import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; @@ -16,6 +17,7 @@ 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/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/star_prompt_banner.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/banners/domain/entities/banner_item.dart'; import 'package:soplay/features/banners/presentation/widgets/banners_carousel.dart'; @@ -44,11 +46,17 @@ import 'package:soplay/features/my_list/data/private_list_service.dart'; import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; import 'package:soplay/features/private_list/presentation/private_unlock.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:soplay/core/deeplink/deeplink_links.dart'; +import 'package:soplay/features/download/domain/repositories/download_repository.dart'; +import 'package:soplay/features/notifications/presentation/widgets/notification_priming.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_relations_tab.dart'; +import 'package:soplay/features/detail/domain/entities/source_notice.dart'; import 'package:soplay/features/detail/presentation/widgets/alternate_source_sheet.dart'; +import 'package:soplay/features/detail/presentation/widgets/source_notice_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_cast_tab.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_comments_tab.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_tab_panes.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_hero.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_info.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_more_menu.dart'; @@ -66,7 +74,6 @@ import 'package:soplay/features/detail/domain/entities/subtitle_entity.dart'; import 'package:soplay/features/download/domain/entities/download_request.dart'; import 'package:soplay/features/download/domain/usecases/enqueue_download_usecase.dart'; import 'package:soplay/features/download/domain/usecases/get_downloads_usecase.dart'; -import 'package:soplay/features/download/presentation/download_messages.dart'; import 'package:soplay/features/download/presentation/widgets/offline_copy_banner.dart'; import 'package:showcaseview/showcaseview.dart'; import 'package:soplay/features/support/presentation/widgets/support_suggestion.dart'; @@ -108,6 +115,8 @@ class DetailPage extends StatelessWidget { provider: provider, autoPlay: args.autoPlay, resumeEpisodeIndex: args.resumeEpisodeIndex, + startEpisode: args.startEpisode, + startPositionMs: args.startPositionMs, preview: args.preview, heroTag: args.heroTag, focusEpisode: args.focusEpisode, @@ -122,6 +131,8 @@ class _DetailScaffold extends StatelessWidget { this.provider, this.autoPlay = false, this.resumeEpisodeIndex, + this.startEpisode, + this.startPositionMs, this.preview, this.heroTag, this.focusEpisode, @@ -130,6 +141,8 @@ class _DetailScaffold extends StatelessWidget { final String? provider; final bool autoPlay; final int? resumeEpisodeIndex; + final int? startEpisode; + final int? startPositionMs; /// What the caller already knew about this title — poster, name, year. /// Enough to draw the top of the page before the request lands. @@ -147,6 +160,10 @@ class _DetailScaffold extends StatelessWidget { value: SystemUiOverlayStyle.light, child: Scaffold( backgroundColor: AppColors.background, + bottomNavigationBar: const StarPromptBanner( + margin: EdgeInsets.fromLTRB(12, 0, 12, 12), + safeBottom: true, + ), body: BlocListener( listenWhen: (prev, curr) { if (curr is! DetailLoaded) return false; @@ -216,6 +233,8 @@ class _DetailScaffold extends StatelessWidget { offline: offline, autoPlay: autoPlay, resumeEpisodeIndex: resumeEpisodeIndex, + startEpisode: startEpisode, + startPositionMs: startPositionMs, heroTag: heroTag, focusEpisode: focusEpisode, ); @@ -276,6 +295,8 @@ class _DetailView extends StatefulWidget { this.offline = false, this.autoPlay = false, this.resumeEpisodeIndex, + this.startEpisode, + this.startPositionMs, this.heroTag, this.focusEpisode, }); @@ -292,6 +313,10 @@ class _DetailView extends StatefulWidget { final bool resolvingSource; final bool autoPlay; final int? resumeEpisodeIndex; + + /// From a `/play` link. See [DetailArgs.startEpisode]. + final int? startEpisode; + final int? startPositionMs; final String? heroTag; /// The new episode a release notification was about. See @@ -359,6 +384,18 @@ class _DetailViewState extends State<_DetailView> late bool _resumeOnOpen = widget.autoPlay && widget.resumeEpisodeIndex != null; + /// A `/play` link's place, spent by the first play it starts. + late int? _linkEpisode = widget.startEpisode; + late int? _linkPositionMs = widget.startPositionMs; + + /// The link's position if one is still unspent, else [fallback]. + Duration _startOr(Duration fallback) { + final ms = _linkPositionMs; + _linkPositionMs = null; + _linkEpisode = null; + return ms == null ? fallback : Duration(milliseconds: ms); + } + @override void initState() { super.initState(); @@ -430,9 +467,10 @@ class _DetailViewState extends State<_DetailView> unawaited(getIt().markPrivateShowcaseSeen()); } + /// No setState: [DetailTabPanes] listens to the controller itself, and + /// rebuilding this page rebuilt the header and both bars on every switch. void _onTabChanged() { if (!_tabController.indexIsChanging) return; - if (mounted) setState(() {}); _settleOnTabs(); } @@ -531,43 +569,26 @@ class _DetailViewState extends State<_DetailView> _ => tab, }; - Widget _buildTabContent(DetailEntity detail) { - final tab = _tabs[_tabController.index]; - // Tabs used to cut over on the frame the index changed, which on a page - // this dense reads as a flicker rather than a change of view. Aligned to - // the top so the outgoing and incoming panes do not slide against each - // other while they cross-fade. - return AnimatedSwitcher( - duration: const Duration(milliseconds: 160), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - layoutBuilder: (current, previous) => Stack( - alignment: Alignment.topCenter, - children: [...previous, ?current], - ), - child: KeyedSubtree( - key: ValueKey('detail-tab-$tab'), - child: switch (tab) { - 'About' => DetailAboutTab(record: detail.record!), - 'Similar' => DetailRelatedSection(related: detail.related), - 'Relations' => DetailRelationsTab( - provider: detail.provider, - contentUrl: detail.contentUrl, - title: detail.title, - ), - 'Cast' => DetailCastTab(cast: detail.cast, director: detail.director), - 'Comments' => DetailCommentsTab( - provider: detail.provider, - contentUrl: detail.contentUrl, - ), - 'Screenshots' => DetailScreenshotsSection( - screenshots: detail.screenshots, - ), - _ => const SizedBox.shrink(), - }, - ), - ); - } + Widget _buildTabPane(DetailEntity detail, String tab) => switch (tab) { + 'About' => DetailAboutTab(record: detail.record!), + 'Similar' => DetailRelatedSection(related: detail.related), + 'Relations' => DetailRelationsTab( + provider: detail.provider, + contentUrl: detail.contentUrl, + title: detail.title, + year: detail.year, + isSerial: detail.isSerial, + tmdbId: detail.record?.tmdbId, + anilistId: detail.record?.anilistId, + ), + 'Cast' => DetailCastTab(cast: detail.cast, director: detail.director), + 'Comments' => DetailCommentsTab( + provider: detail.provider, + contentUrl: detail.contentUrl, + ), + 'Screenshots' => DetailScreenshotsSection(screenshots: detail.screenshots), + _ => const SizedBox.shrink(), + }; void _goBack() { if (context.canPop()) { @@ -633,7 +654,9 @@ class _DetailViewState extends State<_DetailView> movieUrl: local.url, type: local.type, showDownloadAction: false, - resumePosition: Duration(milliseconds: history?.positionMs ?? 0), + resumePosition: _startOr( + Duration(milliseconds: history?.positionMs ?? 0), + ), ), ); } @@ -641,6 +664,15 @@ class _DetailViewState extends State<_DetailView> /// The last episode list came from the saved copy; the list page says so. bool _episodesOffline = false; + /// What the source said about this title, once it has said anything. + /// + /// Kept in state because the answer only arrives when the playback payload + /// does — after Play has been pressed once — and a Play button that is known + /// to lead nowhere should not keep offering itself. Set only for a notice the + /// source called unplayable; a serial resolved as a film still plays, through + /// its episode list. + SourceNotice? _unplayable; + /// Play and download need the same thing first — the provider's playback /// payload — and it arrives asynchronously through the bloc. Rather than a /// second loader, the intent is remembered and the one result is routed when @@ -667,6 +699,12 @@ class _DetailViewState extends State<_DetailView> /// worse than showing the list where each row has its own button and several /// can be picked at once. void _handleDownload(PlaybackEntity playback) { + // Nothing to download either, for the same reason there is nothing to + // play: the source has said it holds no file for this title. + if (!playback.playable) { + _onSourceNotice(playback.notice ?? SourceNotice.noPlayer, playback); + return; + } if (playback.provider.opensReader || playback.isSerial) { if (playback.episodes.isEmpty) { _showSnack('detail.no_episodes'.tr()); @@ -713,7 +751,7 @@ class _DetailViewState extends State<_DetailView> if (!mounted) return; final media = prepared.media; if (media == null) { - _showSnack('detail.download_needs_playback'.tr()); + _showSnack(downloadBuildFailureMessage(prepared.failure!)); return; } url = media.videoUrl; @@ -748,7 +786,7 @@ class _DetailViewState extends State<_DetailView> if (!mounted) return; final media = prepared.media; if (media == null) { - _showSnack('detail.download_needs_playback'.tr()); + _showSnack(downloadBuildFailureMessage(prepared.failure!)); return; } url = media.videoUrl; @@ -781,6 +819,14 @@ class _DetailViewState extends State<_DetailView> ); if (!mounted) return; _showSnack(downloadOutcomeMessage(outcome)); + if (outcome == EnqueueOutcome.started) { + unawaited( + primeNotificationsForFirstDownload( + context, + titleHint: widget.detail.title, + ), + ); + } } void _toggleMyList() { @@ -1038,12 +1084,10 @@ class _DetailViewState extends State<_DetailView> } /// The public link to this title — the one Share sends and Copy hands over. - String get _shareLink { - final params = {'url': widget.detail.contentUrl}; - final provider = widget.detail.provider.trim(); - if (provider.isNotEmpty) params['provider'] = provider; - return Uri.https('sozo.azamov.me', '/detail', params).toString(); - } + String get _shareLink => buildShareLink( + contentUrl: widget.detail.contentUrl, + provider: widget.detail.provider, + ); void _onShare() { Share.share('${widget.detail.title}\n$_shareLink'); @@ -1100,6 +1144,14 @@ class _DetailViewState extends State<_DetailView> } void _handlePlayback(PlaybackEntity playback) { + // The source has declared this title unplayable. Starting a player against + // it would spin on nothing, and the episode list behind it is empty by the + // same declaration, so the honest move is to say what happened and offer + // the one step that can still work. + if (!playback.playable) { + _onSourceNotice(playback.notice ?? SourceNotice.noPlayer, playback); + return; + } // A reading source ALWAYS goes through the chapter list, even when it // reports a single chapter (one-shots, and Mangayomi novels that expose one // entry). Falling through to the movie path would hand a chapter url to the @@ -1142,9 +1194,37 @@ class _DetailViewState extends State<_DetailView> _playMovieDirect(playback); } + /// Acts on a reason the source gave for not playing a title. + /// + /// [playback] is whatever payload is in hand, so the one recoverable case can + /// recover: a serial's page resolved as a film has its episodes already, and + /// the list is where they play from. + void _onSourceNotice(SourceNotice notice, PlaybackEntity? playback) { + if (notice == SourceNotice.isSeries && + playback != null && + playback.episodes.isNotEmpty) { + _openEpisodes(playback); + return; + } + if (!notice.playable && _unplayable != notice) { + setState(() => _unplayable = notice); + } + unawaited( + showSourceNoticeSheet( + context, + notice: notice, + onFindOther: _onFindOtherSources, + ), + ); + } + void _openEpisodes(PlaybackEntity playback) { final resume = _resumeOnOpen; _resumeOnOpen = false; + final linkEpisode = _linkEpisode; + final linkPositionMs = linkEpisode == null ? null : _linkPositionMs; + _linkEpisode = null; + _linkPositionMs = null; context.push( '/episodes', extra: EpisodesArgs( @@ -1160,9 +1240,11 @@ class _DetailViewState extends State<_DetailView> size: playback.size, total: playback.total, totalPages: playback.totalPages, - resumeFromHistory: resume, + resumeFromHistory: resume && linkEpisode == null, offline: _episodesOffline, focusEpisode: _takeFocus(), + playEpisode: linkEpisode, + startPositionMs: linkPositionMs, ), ); } @@ -1210,6 +1292,7 @@ class _DetailViewState extends State<_DetailView> if (historyItem != null) { resumePos = Duration(milliseconds: historyItem.positionMs); } + resumePos = _startOr(resumePos); if (!await confirmPlayerEngine(context) || !mounted) return; context.push( '/player', @@ -1298,6 +1381,7 @@ class _DetailViewState extends State<_DetailView> if (historyItem != null) { resumePos = Duration(milliseconds: historyItem.positionMs); } + resumePos = _startOr(resumePos); if (!await confirmPlayerEngine(context) || !mounted) return; context.push( '/player', @@ -1320,6 +1404,13 @@ class _DetailViewState extends State<_DetailView> ), ); case Failure(:final error): + // A resolve that failed for a reason the source named is not an error + // to flash past in a snackbar: it is a fact about this title on this + // source, and it decides what the page offers from here on. + if (error is SourceNoticeException) { + _onSourceNotice(error.notice, playback); + return; + } _showSnack(error.toString().replaceFirst('Exception: ', '')); } } @@ -1479,6 +1570,7 @@ class _DetailViewState extends State<_DetailView> resolvingSource: widget.resolvingSource, onChangeSource: widget.via == null ? null : _changeSource, onFindSource: _onFindOtherSources, + unplayable: _unplayable, ), ), if (widget.offline) @@ -1546,7 +1638,11 @@ class _DetailViewState extends State<_DetailView> 36) .clamp(0.0, double.infinity), ), - child: _buildTabContent(detail), + child: DetailTabPanes( + controller: _tabController, + tabs: _tabs, + paneBuilder: (_, tab) => _buildTabPane(detail, tab), + ), ), ), ), diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index af1cca41..38c29643 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/core/localization/line_height.dart'; import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'dart:async'; import 'dart:ui'; @@ -10,6 +11,7 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/features/notifications/presentation/widgets/notification_priming.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/domain/episode_blocks.dart'; import 'package:soplay/core/error/result.dart'; @@ -18,6 +20,7 @@ import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/data/title_prefs_store.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; +import 'package:soplay/features/detail/domain/episode_seasons.dart'; import 'package:soplay/features/manga/data/chapter_read_store.dart'; import 'package:soplay/features/detail/domain/entities/episodes_args.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; @@ -35,6 +38,10 @@ import 'package:soplay/features/download/domain/usecases/get_downloads_usecase.d import 'package:soplay/features/download/presentation/download_messages.dart'; import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/domain/entities/history_item.dart'; +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; +import 'package:soplay/features/comments/domain/repositories/episode_comments_repository.dart'; +import 'package:soplay/features/comments/presentation/widgets/episode_comments_sheet.dart'; /// Focus highlight for the full-width rows on this page. /// @@ -102,6 +109,48 @@ class _EpisodesPageState extends State { HistoryItem? _historyItem; + /// Top-level comments per episode key; empty until (and unless) it loads. + Map _commentCounts = const {}; + + Future _loadCommentCounts() async { + if (_isManga || widget.args.offline || widget.args.contentUrl.isEmpty) { + return; + } + if (!getIt.isRegistered()) return; + final repo = switch (getIt()) { + final EpisodeCommentsRepository r => r, + _ => null, + }; + if (repo == null) return; + final result = await repo.episodeCounts( + provider: widget.args.provider, + contentUrl: widget.args.contentUrl, + ); + if (!mounted) return; + if (result case Success(:final value) when value.isNotEmpty) { + setState(() => _commentCounts = value); + } + } + + int _commentCountFor(int i) => + _commentCounts[EpisodeKey.of( + isSerial: true, + episodeNumber: _episodes[i].episode, + )] ?? + 0; + + Future _openEpisodeComments(int i) async { + final episode = _episodes[i]; + await showEpisodeComments( + context, + provider: widget.args.provider, + contentUrl: widget.args.contentUrl, + episodeKey: EpisodeKey.of(isSerial: true, episodeNumber: episode.episode), + title: 'player.episode_comments'.tr(args: [episode.numberLabel]), + ); + if (mounted) unawaited(_loadCommentCounts()); + } + /// Indices picked in multi-select. Empty means normal browsing. /// /// Downloading a season one tap at a time is the kind of chore that makes @@ -203,6 +252,49 @@ class _EpisodesPageState extends State { } } + /// The season the list shows, for a source that lists several in one run. + /// Null until picked: the season of the episode being watched, else the + /// first. + int? _season; + + /// The seasons among the loaded episodes, in order, with their counts. + List<(int, int)> get _seasons => EpisodeSeasons.of(_episodes); + + /// The season on screen, or null when the run has only one. + int? get _activeSeason { + final seasons = _seasons; + if (seasons.length < 2) return null; + final picked = _season; + if (picked != null && seasons.any((s) => s.$1 == picked)) return picked; + final watching = _indexOfHistoryEpisode(); + if (watching >= 0) { + final s = _episodes[watching].season; + if (s != null) return s; + } + return seasons.first.$1; + } + + void _setSeason(int season) { + if (season == _activeSeason) return; + setState(() { + _season = season; + _invalidateDerived(); + }); + if (_scroll.hasClients) _scroll.jumpTo(0); + } + + /// Puts the season holding [index] on screen, so a row being flashed or + /// played from history is in the list to be seen. + void _showSeasonOf(int index) { + if (index < 0 || index >= _episodes.length) return; + final s = _episodes[index].season; + if (s == null || s == _activeSeason) return; + setState(() { + _season = s; + _invalidateDerived(); + }); + } + List? _blocksCache; /// Which block the reader is looking at, as opposed to where the window @@ -237,7 +329,12 @@ class _EpisodesPageState extends State { _refreshHistory(); _refreshRead(); _maybeAutoFill(); - if (widget.args.resumeFromHistory) { + unawaited(_loadCommentCounts()); + if (widget.args.playEpisode != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_playLinkedEpisode(widget.args.playEpisode!)); + }); + } else if (widget.args.resumeFromHistory) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_resumeFromHistory()); }); @@ -253,6 +350,7 @@ class _EpisodesPageState extends State { Future _focusOn(int number) async { final index = _episodes.indexWhere((e) => e.episode == number); if (index >= 0) { + _showSeasonOf(index); _flashRow(index, hold: const Duration(milliseconds: 3200)); return; } @@ -293,6 +391,29 @@ class _EpisodesPageState extends State { await _playFrom(index); } + /// Plays the episode a `/play` link named, at the link's position. A number + /// the run does not have leaves the list on screen. + Future _playLinkedEpisode(int number) async { + var index = _episodes.indexWhere((e) => e.episode == number); + if (index < 0) { + final block = blockContaining( + number, + total: _total, + size: _size, + descending: _sort == 'desc', + firstNumber: _firstNumber, + ); + if (block == null || !await _jumpToBlock(block) || !mounted) return; + index = _episodes.indexWhere((e) => e.episode == number); + if (index < 0) return; + } + final ms = widget.args.startPositionMs; + await _playFrom( + index, + start: ms == null ? null : Duration(milliseconds: ms), + ); + } + int _indexOfHistoryEpisode() { for (var i = 0; i < _episodes.length; i++) { if (_isHistoryEpisode(i)) return i; @@ -545,7 +666,12 @@ class _EpisodesPageState extends State { final group = _group != null && _groups.any((g) => g.$1 == _group) ? _group : null; - bool inGroup(int i) => group == null || _episodes[i].scanlator == group; + // A typed search looks through every season: "Chapter 6" is wherever it + // is, not only in the season on screen. + final season = _query.isEmpty ? _activeSeason : null; + bool inGroup(int i) => + (group == null || _episodes[i].scanlator == group) && + (season == null || _episodes[i].season == season); if (_query.isEmpty) { return [ for (var i = 0; i < _episodes.length; i++) @@ -812,7 +938,7 @@ class _EpisodesPageState extends State { } } - Future _playFrom(int index) async { + Future _playFrom(int index, {Duration? start}) async { if (widget.args.offline) { await _playOffline(index); return; @@ -836,7 +962,9 @@ class _EpisodesPageState extends State { return; } - final resumeMs = isHistoryEntry ? _historyItem!.positionMs : 0; + final resumeMs = + start?.inMilliseconds ?? + (isHistoryEntry ? _historyItem!.positionMs : 0); // The engine question belongs before the player exists — see // confirmPlayerEngine. if (!await confirmPlayerEngine(context) || !mounted) return; @@ -962,11 +1090,7 @@ class _EpisodesPageState extends State { // needsPlayback: playing the episode once runs the WebView sniff, and // the player can then download the resolved stream. if (!quiet) { - _toast( - resolved.failure == DownloadBuildFailure.needsPlayback - ? 'detail.download_needs_playback'.tr() - : 'detail.download_resolve_failed'.tr(), - ); + _toast(downloadBuildFailureMessage(resolved.failure!)); } return false; } @@ -989,6 +1113,14 @@ class _EpisodesPageState extends State { ), ); + if (outcome == EnqueueOutcome.started && mounted) { + unawaited( + primeNotificationsForFirstDownload( + context, + titleHint: widget.args.title, + ), + ); + } if (outcome != EnqueueOutcome.started && !quiet && mounted) { _toast(downloadOutcomeMessage(outcome)); } @@ -1248,6 +1380,15 @@ class _EpisodesPageState extends State { onPick: _jumpToBlock, ), ), + // Seasons, when the source lists them all in one run. + if (_seasons.length > 1) + SliverToBoxAdapter( + child: _SeasonStrip( + seasons: _seasons, + selected: _activeSeason, + onPick: _setSeason, + ), + ), // Translation groups, when the source lists more than // one — the same chapter three times over otherwise. if (_isManga && _groups.length > 1) @@ -1346,6 +1487,10 @@ class _EpisodesPageState extends State { read: _isManga && _read.contains(_episodes[i].episode), + commentCount: _commentCountFor(i), + onComments: _isManga || widget.args.offline + ? null + : () => _openEpisodeComments(i), ); if (!widget.args.offline || _isDownloaded(i)) { return row; @@ -1856,11 +2001,11 @@ class _EpisodesAppBar extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w700, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), @@ -2052,7 +2197,8 @@ String episodeRowTitle(EpisodeEntity episode) { // it threw during build, which took the whole episode list down rather than // one row's title. final number = match.group(1) ?? match.group(2); - if (number != null && int.tryParse(number) == episode.episode) { + if (number != null && + int.tryParse(number) == (episode.numberInSeason ?? episode.episode)) { return ''; } return label; @@ -2075,6 +2221,8 @@ class _EpisodeRow extends StatelessWidget { this.progress, this.onDownload, this.read = false, + this.commentCount = 0, + this.onComments, }); final EpisodeEntity episode; @@ -2118,6 +2266,10 @@ class _EpisodeRow extends StatelessWidget { /// ticks. The row stays fully tappable — re-reading is normal. final bool read; + /// Shown as a small chip when there is at least one; a tap opens the thread. + final int commentCount; + final VoidCallback? onComments; + @override Widget build(BuildContext context) { final hasSub = episode.hasSub == true; @@ -2152,7 +2304,7 @@ class _EpisodeRow extends StatelessWidget { if (showImage) ...[ _EpisodeThumb( image: episode.image, - episode: episode.episode, + episode: episode.numberInSeason ?? episode.episode, headers: headers, ), const SizedBox(width: 12), @@ -2168,7 +2320,9 @@ class _EpisodeRow extends StatelessWidget { ConstrainedBox( constraints: const BoxConstraints(minWidth: 44), child: Text( - _episodeNumberLabel(episode.episode), + _episodeNumberLabel( + episode.numberInSeason ?? episode.episode, + ), maxLines: 1, style: TextStyle( color: progress != null @@ -2222,6 +2376,8 @@ class _EpisodeRow extends StatelessWidget { ], ), ), + if (commentCount > 0 && onComments != null && !selecting) + _CommentCountChip(count: commentCount, onTap: onComments!), if (hasSub) const _LangChip(label: 'SUB', primary: true), if (hasSub && hasDub) const SizedBox(width: 4), if (hasDub) const _LangChip(label: 'DUB', primary: false), @@ -2305,6 +2461,47 @@ class _EpisodeRow extends StatelessWidget { } } +class _CommentCountChip extends StatelessWidget { + const _CommentCountChip({required this.count, required this.onTap}); + + final int count; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + focusColor: _kTvRowFocusFill, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + size: 13, + color: AppColors.textHint, + ), + const SizedBox(width: 3), + Text( + count > 999 ? '999+' : '$count', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} + /// The bottom action bar shown while episodes are selected. /// /// Bottom rather than in the app bar: on a tall phone the primary action of a @@ -2771,3 +2968,57 @@ class _GroupStrip extends StatelessWidget { ); } } + +/// One chip per season, for a source that lists every season in one run. +class _SeasonStrip extends StatelessWidget { + const _SeasonStrip({ + required this.seasons, + required this.selected, + required this.onPick, + }); + + final List<(int, int)> seasons; + final int? selected; + final ValueChanged onPick; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 46, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 4, 16, 6), + children: [ + for (final (season, count) in seasons) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: ChoiceChip( + label: Text( + '${season == 0 ? 'episodes.specials'.tr() : 'episodes.season_n'.tr(args: ['$season'])} · $count', + maxLines: 1, + ), + selected: season == selected, + showCheckmark: false, + onSelected: (_) => onPick(season), + selectedColor: AppColors.primary.withValues(alpha: 0.22), + backgroundColor: AppColors.surface, + side: BorderSide( + color: season == selected + ? AppColors.primary + : AppColors.border, + width: 0.8, + ), + labelStyle: TextStyle( + color: season == selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontWeight: FontWeight.w700, + fontSize: 12.5, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.aniskip.dart b/lib/features/detail/presentation/pages/player_page.aniskip.dart index 27213e45..9939f1a2 100644 --- a/lib/features/detail/presentation/pages/player_page.aniskip.dart +++ b/lib/features/detail/presentation/pages/player_page.aniskip.dart @@ -92,7 +92,14 @@ extension _PlayerAniSkip on _PlayerPageState { if (identical(active, _activeSkip)) return; if (active == null && _activeSkip == null) return; - if (active != null && _hive.autoSkipIntro) { + // A guest who may not seek waits for the host's skip to arrive. + if (active != null && + _hive.autoSkipIntro && + !PartyRules.blocksLocalControl( + inParty: _inParty, + canControl: _canPartyControl, + applyingRemote: _applyingRemote, + )) { _takeSkip(active); return; } @@ -107,12 +114,8 @@ extension _PlayerAniSkip on _PlayerPageState { Future _takeSkip(SkipInterval interval) async { _skips.take(interval); if (mounted) setState(() => _activeSkip = null); - try { - await _controller?.seekTo(_skips.targetFor(interval)); - } catch (_) { - // A failed seek leaves playback where it was, which is survivable; the - // offer stays retired so it does not loop. - } + // Through _seekTo, so a party host's skip reaches the room. + _seekTo(_skips.targetFor(interval)); } /// The floating "Skip opening / Skip ending" button, or nothing. @@ -124,38 +127,62 @@ extension _PlayerAniSkip on _PlayerPageState { final label = active.type == 'ed' ? 'player.skip_ending'.tr() : 'player.skip_opening'.tr(); + // A remote with the controls hidden: OK takes it (see _onTvPlayerKey). + final okHint = isTvPlatform && !_controlsVisible; return Positioned( right: 20, bottom: _controlsVisible ? 92 : 28, - child: AnimatedOpacity( - opacity: 1, - duration: const Duration(milliseconds: 150), - child: Material( - color: Colors.black.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(8), + child: _tvRing( + radius: 14, + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, child: InkWell( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), onTap: () => _takeSkip(active), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w700, + child: MediaGlass( + radius: 14, + dim: Colors.black.withValues(alpha: 0.6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + if (okHint) + Text( + 'player.ok_to_skip'.tr(), + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(width: 8), + Icon( + Icons.fast_forward_rounded, + color: AppColors.primaryLight, + size: 18, ), - ), - const SizedBox(width: 6), - const Icon( - Icons.fast_forward_rounded, - color: Colors.white, - size: 18, - ), - ], + ], + ), ), ), ), @@ -178,6 +205,16 @@ extension _PlayerAniSkip on _PlayerPageState { /// stacking another player on top of it. extension _PlayerAlternateSources on _PlayerPageState { /// The episode number to look for on the other source, or null for a film. + /// The episode playing as S2E1, when this source lists seasons. + SeasonEpisode? get _currentSeasonEpisode { + if (!widget.args.isSerial) return null; + if (_episodeIndex < 0 || _episodeIndex >= _episodes.length) return null; + final e = _episodes[_episodeIndex]; + final season = e.season; + if (season == null) return null; + return (season: season, number: e.numberInSeason ?? e.episode); + } + int? get _currentEpisodeNumber { if (!widget.args.isSerial) return null; if (_episodeIndex < 0 || _episodeIndex >= _episodes.length) { @@ -207,6 +244,7 @@ extension _PlayerAlternateSources on _PlayerPageState { provider: widget.args.provider, category: _hive.providerCategory(widget.args.provider), episodeNumber: _currentEpisodeNumber, + seasonEpisode: _currentSeasonEpisode, resumeAt: resumeAt, ); if (args == null || !mounted) return; diff --git a/lib/features/detail/presentation/pages/player_page.cast.dart b/lib/features/detail/presentation/pages/player_page.cast.dart index 060f5e5b..67114d05 100644 --- a/lib/features/detail/presentation/pages/player_page.cast.dart +++ b/lib/features/detail/presentation/pages/player_page.cast.dart @@ -42,7 +42,7 @@ extension _PlayerCast on _PlayerPageState { : const []; final subtitles = [ for (final s in active) - if (CastHandoff.isSendable(s.file)) + if (CastHandoff.isSendable(s.file) && s.cipher == null) CastSubtitle( url: s.file, label: s.label, @@ -78,23 +78,26 @@ extension _PlayerCast on _PlayerPageState { final cast = _cast; cast.startDiscovery(); - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.background, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(18)), - ), - builder: (sheetContext) => AnimatedBuilder( - animation: cast, - builder: (context, _) => _CastSheet( - cast: cast, - onPick: (device) async { - Navigator.of(sheetContext).pop(); - await _castTo(device, media); - }, - ), + Widget sheet(BuildContext sheetContext) => AnimatedBuilder( + animation: cast, + builder: (context, _) => _CastSheet( + cast: cast, + onPick: (device) async { + Navigator.of(sheetContext).pop(); + await _castTo(device, media); + }, ), ); + await (_iosSheets + ? _showSheet(builder: sheet) + : showModalBottomSheet( + context: context, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + builder: sheet, + )); cast.stopDiscovery(); } diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index d580c97d..782798aa 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -76,6 +76,7 @@ extension _PlayerControls on _PlayerPageState { if (_controlsVisible) { _controlsAnimation.forward(); _scheduleHide(); + _maybeOfferNativeControls(); } else { _controlsAnimation.reverse(); _hideTimer?.cancel(); @@ -122,18 +123,26 @@ extension _PlayerControls on _PlayerPageState { ); } + /// The loudest the desktop slider goes: 150% on mpv, 100% otherwise. + double get _maxVolume => + isDesktopPlatform ? (_controller?.maxVolume ?? 1.0) : 1.0; + void _setPlayerVolume(double v) { - final clamped = v.clamp(0.0, 1.0); - setState(() => _volume = clamped); if (isDesktopPlatform) { - _controller?.setVolume(clamped); - } else { - unawaited(_setSystemVolume(clamped)); + _setDesktopVolume(_desktopVolume.withLevel(v, max: _maxVolume)); + return; } + final clamped = v.clamp(0.0, 1.0); + setState(() => _volume = clamped); + unawaited(_setSystemVolume(clamped)); _scheduleHide(); } void _toggleMute() { + if (isDesktopPlatform) { + _setDesktopVolume(_desktopVolume.toggleMute()); + return; + } if (_volume > 0.001) { _volumeBeforeMute = _volume; _setPlayerVolume(0); @@ -142,6 +151,17 @@ extension _PlayerControls on _PlayerPageState { } } + void _setDesktopVolume(DesktopVolume next) { + setState(() { + _desktopVolume = next; + _volume = next.applied; + }); + final c = _controller; + if (c != null) unawaited(next.applyTo(c.setVolume, max: c.maxVolume)); + unawaited(_hive.saveDesktopVolume(next.level, next.muted)); + _scheduleHide(); + } + void _seekRelative(Duration delta) { if (_partyBlockLocal()) return; // There is nothing to skip forward into on a broadcast, and skipping back @@ -157,6 +177,7 @@ extension _PlayerControls on _PlayerPageState { : next > c.value.duration ? c.value.duration : next; + _stallWatch.noteSeek(_now); c.seekTo(clamped); _scheduleHide(); if (!_isLive) { @@ -171,6 +192,7 @@ extension _PlayerControls on _PlayerPageState { // `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; + _stallWatch.noteSeek(_now); c.seekTo(position); _scheduleHide(); if (!_isLive) { @@ -230,6 +252,12 @@ extension _PlayerControls on _PlayerPageState { }); return; } + // In the corner there is no route of this page's to pop: the page under + // the corner player belongs to the viewer, not to the player. + if (_docked) { + _closeDock(); + return; + } if (context.canPop()) { context.pop(); } else { @@ -342,6 +370,13 @@ extension _PlayerControls on _PlayerPageState { _PlayerFit.fill => Icons.fit_screen_rounded, }; + /// AVKit's own pair for fit and zoom, and the stretch arrows for fill. + static String _fitSymbol(_PlayerFit fit) => switch (fit) { + _PlayerFit.contain => 'aspectratio', + _PlayerFit.cover => 'arrow.up.left.and.arrow.down.right', + _PlayerFit.fill => 'rectangle.arrowtriangle.2.outward', + }; + String _fitLabel(_PlayerFit fit) { switch (fit) { case _PlayerFit.contain: @@ -353,6 +388,15 @@ extension _PlayerControls on _PlayerPageState { } } + /// "Title · Episode 5", for a support request about what is playing. + String get _supportContentLabel { + final eps = widget.args.episodes; + if (_episodeIndex >= 0 && _episodeIndex < eps.length) { + return '${widget.args.title} · ${eps[_episodeIndex].label}'; + } + return widget.args.title; + } + /// Whether the seek bar can show a frame it made itself. /// /// Every stream but two: a live broadcast, where there is nothing ahead to @@ -368,15 +412,6 @@ extension _PlayerControls on _PlayerPageState { /// out rather than drawing a skeleton that never fills: HLS on iOS, which /// AVAssetImageGenerator cannot read, and DASH anywhere. Downloads are /// included: a local file is the cheapest preview there is. - /// "Title · Episode 5", for a support request about what is playing. - String get _supportContentLabel { - final eps = widget.args.episodes; - if (_episodeIndex >= 0 && _episodeIndex < eps.length) { - return '${widget.args.title} · ${eps[_episodeIndex].label}'; - } - return widget.args.title; - } - bool get _canGeneratePreview => FramePreviewService.isSupported && _videoUrl != null && @@ -386,176 +421,299 @@ extension _PlayerControls on _PlayerPageState { !_isEncrypted && TorrentStreamUrl.parse(_videoUrl) == null; - Widget _buildVideoLayer() { - if (_initializing) { - return ColoredBox( - color: Colors.black, - child: _LoadingOverlay( - stage: _stage, - title: _episodeTitle(), - serverSwitch: _serverSwitch, + Widget _buildCompactStatus() { + final error = _errorMessage; + return ColoredBox( + color: Colors.black, + child: Center( + child: error == null + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ) + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Colors.white70, + size: 22, + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Text( + error, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// The raw failure, when it says more than the headline. + String? get _errorDetail { + final raw = _errorRaw?.trim(); + if (raw == null || raw.isEmpty || raw == _errorMessage) return null; + return raw; + } + + /// Offered once the native engine on Android could not decode the stream. + bool get _canTryMpv => + _isCodecError && + defaultTargetPlatform == TargetPlatform.android && + !isAndroidEmulator && + !_mpvForSession && + _onPlatformPlayer && + !_isDash && + (_playSourceUrl ?? _videoUrl) != null; + + Widget _buildErrorView() { + final guest = _inParty && !_isPartyHost; + final detail = _errorDetail; + final buttons = [ + _PlayerGlassButton( + onPressed: _retry, + // Without this the error screen opens with nothing focused, so the + // remote has no landing spot and reads as frozen. + autofocus: isTvPlatform, + primary: true, + icon: Icons.refresh_rounded, + label: 'general.try_again'.tr(), + ), + if (_canTryMpv) + _PlayerGlassButton( + onPressed: _tryWithMpv, + icon: Icons.memory_rounded, + label: 'player.try_mpv'.tr(), ), - ); - } - final pluginCap = _pluginRequired; - if (pluginCap != null) { - // A party:content identity this device cannot resolve (missing on-device - // plugin). Show the actionable install view — not a generic error — and - // let the guest back out to the lobby to retry after installing. - return ColoredBox( - color: Colors.black, - child: PartyPluginRequiredView( - provider: _partyState.room?.content?.provider ?? widget.args.provider, - installTarget: pluginCap.installTarget, - onBack: () => Navigator.of(context).maybePop(), + if (_isCodecError && _videoUrl != null) + _PlayerGlassButton( + onPressed: () => launchUrl( + Uri.parse(_videoUrl!), + mode: LaunchMode.externalApplication, + ), + icon: Icons.open_in_browser_rounded, + label: 'player.play_in_browser'.tr(), ), - ); - } - if (_errorMessage != null) { - return ColoredBox( - color: Colors.black, - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( + // The mirrors this source already has come before other sources: a + // title on four servers where only one failed needs a server, not a + // search across every provider. + if (_affordances.hasServers && !guest) + _PlayerGlassButton( + onPressed: _openServerSheet, + icon: Icons.dns_rounded, + label: 'player.change_server'.tr(), + ), + if (!_isLive && !guest) + _PlayerGlassButton( + onPressed: _openAlternateSources, + icon: Icons.swap_horiz_rounded, + label: 'player.alt_sources'.tr(), + ), + // Tested against the RAW failure, not the translated one. + if (_cfWall != null || isCloudflareError(_errorRaw ?? _errorMessage)) + _PlayerGlassButton( + onPressed: () async { + final ok = _cfWall != null + ? await _solveStreamWall() + : await requestCloudflareSolve(context, widget.args.provider); + if (ok && mounted) _retry(); + }, + icon: Icons.shield_outlined, + label: 'cloudflare.solve'.tr(), + ), + ]; + final links = [ + TextButton.icon( + onPressed: () => context.push( + '/support/new', + extra: SupportRequestArgs( + category: SupportCategory.playback, + provider: widget.args.provider, + content: _supportContentLabel, + screen: 'player', + ), + ), + style: TextButton.styleFrom(foregroundColor: Colors.white60), + icon: const Icon(Icons.support_agent_rounded, size: 18), + label: Text('support.contact_support'.tr()), + ), + TextButton.icon( + onPressed: () => LogViewerSheet.show(context), + style: TextButton.styleFrom(foregroundColor: Colors.white60), + icon: const Icon(Icons.bug_report_outlined, size: 18), + label: Text('player.view_logs'.tr()), + ), + ]; + return ColoredBox( + color: Colors.black, + child: SafeArea( + child: LayoutBuilder( + builder: (context, box) { + final compact = box.maxHeight < 420; + final headline = Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.error_outline_rounded, color: Colors.white70, - size: 48, + size: compact ? 32 : 48, ), - const SizedBox(height: 12), + SizedBox(height: compact ? 8 : 12), Text( _errorMessage!, textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 14), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _retry, - // Without this the error screen opens with nothing focused, - // so the remote has no landing spot and reads as frozen. - autofocus: isTvPlatform, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, ), - icon: const Icon(Icons.refresh_rounded, size: 18), - label: Text('general.try_again'.tr()), ), - if (_isCodecError && _videoUrl != null) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: () { - launchUrl( - Uri.parse(_videoUrl!), - mode: LaunchMode.externalApplication, - ); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white24), - ), - icon: const Icon(Icons.open_in_browser_rounded, size: 18), - label: Text('player.play_in_browser'.tr()), - ), - ], - // Offered on every playback failure, not only on the ones we - // can name. A source that has moved domain, minted a token the - // CDN now refuses, or simply lost the episode fails in a dozen - // different ways, and from the viewer's chair they are one - // problem: this show will not play here. The answer is the same - // in all of them. - // The mirrors this source already has, BEFORE offering to go - // looking at other sources. - // - // The screen jumped straight to "Try another source", which - // searches every other provider in the app — a slow, uncertain - // thing to do when the title in front of you is carried on - // four servers and only the one that was tried has failed. A - // title with several servers had no way to pick a different - // one from the error screen at all: the control exists, on the - // bar, which is not on screen when playback never started. - if (_affordances.hasServers && - !(_inParty && !_isPartyHost)) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: _openServerSheet, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: const BorderSide(color: Colors.white38), - ), - icon: const Icon(Icons.dns_rounded, size: 18), - label: Text('player.change_server'.tr()), - ), - ], - if (!_isLive && !(_inParty && !_isPartyHost)) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: _openAlternateSources, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white24), + if (detail != null) _buildErrorDetails(detail), + ], + ); + final Widget body; + if (compact) { + body = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: headline), + const SizedBox(width: 24), + Expanded( + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [...buttons, ...links], ), - icon: const Icon(Icons.swap_horiz_rounded, size: 18), - label: Text('player.alt_sources'.tr()), ), ], - // Tested against the RAW failure, not the translated one — - // see [_errorRaw]. Against `_errorMessage` this never matched. - if (_cfWall != null || - isCloudflareError(_errorRaw ?? _errorMessage)) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: () async { - // The stream's own host when that is what the challenge - // stands in front of; the source's site otherwise. - final ok = _cfWall != null - ? await _solveStreamWall() - : await requestCloudflareSolve( - context, - widget.args.provider, - ); - if (ok && mounted) _retry(); - }, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white70, - side: const BorderSide(color: Colors.white24), - ), - icon: const Icon(Icons.shield_outlined, size: 18), - label: Text('cloudflare.solve'.tr()), - ), + ); + } else { + body = Column( + mainAxisSize: MainAxisSize.min, + children: [ + headline, + const SizedBox(height: 16), + for (final b in buttons) ...[b, const SizedBox(height: 10)], + ...links, ], - const SizedBox(height: 10), - // Straight to a playback request, with this title, source and - // the player log already attached (the viewer can remove it). - TextButton.icon( - onPressed: () => context.push( - '/support/new', - extra: SupportRequestArgs( - category: SupportCategory.playback, - provider: widget.args.provider, - content: _supportContentLabel, - screen: 'player', - ), + ); + } + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: compact ? 24 : 32, + vertical: 16, + ), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: box.maxHeight - 32), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 760 : 420), + child: body, ), - style: TextButton.styleFrom(foregroundColor: Colors.white60), - icon: const Icon(Icons.support_agent_rounded, size: 18), - label: Text('support.contact_support'.tr()), ), - TextButton.icon( - onPressed: () => LogViewerSheet.show(context), - style: TextButton.styleFrom(foregroundColor: Colors.white60), - icon: const Icon(Icons.bug_report_outlined, size: 18), - label: Text('player.view_logs'.tr()), + ), + ); + }, + ), + ), + ); + } + + Widget _buildErrorDetails(String detail) { + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => + setState(() => _errorDetailsOpen = !_errorDetailsOpen), + style: TextButton.styleFrom( + foregroundColor: Colors.white54, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('player.fault.details'.tr()), + Icon( + _errorDetailsOpen + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 16, ), ], ), ), + if (_errorDetailsOpen) + SelectableText( + detail, + textAlign: TextAlign.center, + maxLines: 6, + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ], + ), + ); + } + + Widget _buildVideoLayer() { + // The small player has no room for the loading card or the error card, + // whose buttons alone are taller than the corner box. A spinner, or the + // reason in a line or two; Try again and the other sources are one click + // away in the full player. + if (_compact && (_initializing || _errorMessage != null)) { + return _buildCompactStatus(); + } + if (_initializing) { + return ColoredBox( + color: Colors.black, + child: _LoadingOverlay( + stage: _stage, + title: _episodeTitle(), + serverSwitch: _serverSwitch, + onStopSwitch: _stopAutoSwitch, + stopFocus: _tvStopSwitchFocus, ), ); } + final pluginCap = _pluginRequired; + if (pluginCap != null) { + // A party:content identity this device cannot resolve (missing on-device + // plugin). Show the actionable install view — not a generic error — and + // let the guest back out to the lobby to retry after installing. + return ColoredBox( + color: Colors.black, + child: PartyPluginRequiredView( + provider: _partyState.room?.content?.provider ?? widget.args.provider, + installTarget: pluginCap.installTarget, + onBack: () => Navigator.of(context).maybePop(), + ), + ); + } + if (_errorMessage != null) return _buildErrorView(); final c = _controller; if (c == null || !c.value.isInitialized) { return const ColoredBox(color: Colors.black); @@ -741,7 +899,7 @@ extension _PlayerControls on _PlayerPageState { } Widget _buildThumbnailImage( - _VttThumbnail thumb, { + VttThumbnail thumb, { double displayWidth = 160, double displayHeight = 90, }) { @@ -764,6 +922,14 @@ extension _PlayerControls on _PlayerPageState { headers: _spriteHeaders, filterQuality: FilterQuality.low, gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSync) => + frame == null && !wasSync + ? SizedBox( + width: displayWidth / sx, + height: displayHeight / sy, + child: const _PreviewSkeleton(), + ) + : child, errorBuilder: (_, _, _) { _onThumbnailFailed(); return SizedBox(width: displayWidth, height: displayHeight); @@ -783,6 +949,13 @@ extension _PlayerControls on _PlayerPageState { fit: BoxFit.cover, filterQuality: FilterQuality.low, gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSync) => frame == null && !wasSync + ? SizedBox( + width: displayWidth, + height: displayHeight, + child: const _PreviewSkeleton(), + ) + : child, errorBuilder: (_, _, _) { _onThumbnailFailed(); return SizedBox(width: displayWidth, height: displayHeight); @@ -951,7 +1124,48 @@ extension _PlayerControls on _PlayerPageState { ); } + /// Locks the screen against touches until [_unlock]. A firm tap in the + /// hand says it took: the controls vanish with it, so nothing on screen + /// confirms it otherwise. + void _lock() { + if (isMobilePlatform) HapticFeedback.mediumImpact(); + setState(() { + _locked = true; + _controlsVisible = false; + _controlsAnimation.reverse(); + _hideTimer?.cancel(); + }); + _showLockPill(); + } + + void _unlock() { + if (isMobilePlatform) HapticFeedback.mediumImpact(); + _lockPillTimer?.cancel(); + _lockPill.hide(); + setState(() => _locked = false); + } + + /// Brings the unlock pill back for [LockPill.visibleFor]. + void _showLockPill() { + if (!_locked) return; + _lockPill.show(_now); + _lockPillTimer?.cancel(); + _lockPillTimer = Timer(_lockPill.visibleFor, () { + if (mounted) setState(() {}); + }); + setState(() {}); + } + + void _onUnlockPillTap() { + if (_lockPill.tapPill(_now)) { + _unlock(); + } else { + _showLockPill(); + } + } + Widget _buildLockOverlay() { + final visible = _lockPill.isVisible(_now); return Positioned( bottom: 0, left: 0, @@ -960,40 +1174,49 @@ extension _PlayerControls on _PlayerPageState { child: Padding( padding: const EdgeInsets.only(bottom: 24), child: Center( - child: GestureDetector( - onTap: () => setState(() => _locked = false), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 18, - vertical: 10, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Colors.white.withValues(alpha: 0.15), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.lock_rounded, - color: Colors.white, - size: 16, - ), - const SizedBox(width: 8), - Text( - 'player.tap_to_unlock'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: !visible + ? const SizedBox(key: ValueKey('pill-off'), height: 40) + : KeyedSubtree( + key: const ValueKey('pill-on'), + child: _unlockPill( + GestureDetector( + onTap: _onUnlockPillTap, + // Words on it, so the deeper dimming: white 12pt text keeps + // AA over a white frame. + child: MediaGlass( + radius: 24, + dim: Colors.black.withValues(alpha: 0.6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 10, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.lock_rounded, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'player.tap_to_unlock'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), ), ), - ], - ), - ), ), ), ), @@ -1013,13 +1236,23 @@ extension _PlayerControls on _PlayerPageState { final canDownload = a.canDownload; final isBuffering = c != null && c.value.isBuffering; - final overlay = FadeTransition( - opacity: _controlsAnimation, - child: IgnorePointer( - ignoring: !_controlsVisible, - child: Stack( - children: [ - const Positioned.fill(child: _ControlsScrim()), + // While the controls fade, their glass is held to its tint: an opacity + // over refracting glass composites the glass and the picture under it on + // every frame of the fade. The group gives the refraction back once the + // fade has settled. + // + // The native bars fade themselves, in UIKit: an opacity over a hosted + // view is a per-frame composite of it. So on that path the overlay is + // held at full, and only what Flutter draws fades ([_fadeFlutter]). + final native = _nativeBars; + final body = IgnorePointer( + ignoring: !_controlsVisible, + child: Stack( + children: [ + Positioned.fill(child: _fadeFlutter(const _ControlsScrim())), + if (native) + _buildNativeTopBar() + else Positioned( top: 0, left: 0, @@ -1030,164 +1263,178 @@ extension _PlayerControls on _PlayerPageState { horizontal: 12, vertical: 8, ), - child: Row( - children: [ - _IconButton( - icon: Icons.arrow_back_ios_new_rounded, - onTap: _exit, - ), - const SizedBox(width: 3), - Expanded( - flex: 2, - // Text takes the hit but has no gesture, so a tap on - // the title did nothing while the same tap anywhere - // else on the dimmed video hides the controls. - child: IgnorePointer( - child: Text( - _episodeTitle(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w700, - shadows: _kControlShadow, + // One read of the picture for every button along the top, + // not one each (MediaGlass). + child: MediaGlassLayer( + child: Row( + children: [ + _IconButton( + icon: Icons.arrow_back_ios_new_rounded, + onTap: _exit, + ), + const SizedBox(width: 3), + Expanded( + flex: 2, + // Text takes the hit but has no gesture, so a tap on + // the title did nothing while the same tap anywhere + // else on the dimmed video hides the controls. + child: IgnorePointer( + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, + ), ), ), ), - ), - // The action set outgrows a portrait phone. It used to - // take half the bar and scroll the surplus off the left, - // where nobody found it — orientation and Watch Party were - // simply invisible. Now the actions get the larger share - // and scale a few percent if they still do not fit, so - // every one of them is on screen. - Expanded( - flex: 5, - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // TV and desktop only. A phone draws its bar - // from the viewer's arrangement below, which - // has the language pill in it — adding this one - // as well put two SUB pills side by side. - if (hasLangSwitcher && - (isTvPlatform || isDesktopPlatform)) ...[ - _LangPill( - label: (_currentLang ?? _kSubLang) - .toUpperCase(), - onTap: _openLangSheet, - ), - const SizedBox(width: 5), - ], - // CloudStream (cs:) sources are excluded from Watch2Gether - // for now (they need an on-device plugin per peer). - // - if ((isTvPlatform || isDesktopPlatform) && - (_inParty || - !widget.args.provider.startsWith( - 'cs:', - ))) ...[ - _IconButton( - icon: _inParty - ? Icons.groups_rounded - : Icons.groups_2_rounded, - color: _inParty ? AppColors.primary : null, - onTap: _openWatchParty, - ), - const SizedBox(width: 2), - ], - // TV drops three phone-only affordances outright rather - // than adapting them: orientation lock is meaningless on - // a fixed landscape panel, PiP semantics differ on - // leanback, and the touch lock swaps in an overlay whose - // only escape is a non-focusable tap target — on a remote - // that is an unrecoverable state. - if (isTvPlatform) ...[ - _IconButton( - icon: Icons.closed_caption_rounded, - onTap: _openSubtitleSheet, - ), - const SizedBox(width: 2), - if (canDownload) ...[ - _IconButton( - icon: Icons.download_rounded, - onTap: _startDownload, + // The action set outgrows a portrait phone. It used to + // take half the bar and scroll the surplus off the left, + // where nobody found it — orientation and Watch Party were + // simply invisible. Now the actions get the larger share + // and scale a few percent if they still do not fit, so + // every one of them is on screen. + Expanded( + flex: 5, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // TV and desktop only. A phone draws its bar + // from the viewer's arrangement below, which + // has the language pill in it — adding this one + // as well put two SUB pills side by side. + if (hasLangSwitcher && + (isTvPlatform || isDesktopPlatform)) ...[ + _LangPill( + label: (_currentLang ?? _kSubLang) + .toUpperCase(), + onTap: _openLangSheet, ), - const SizedBox(width: 2), + const SizedBox(width: 5), ], - _IconButton( - icon: Icons.settings_rounded, - onTap: _openSettingsSheet, - ), - if (hasServers) ...[ - const SizedBox(width: 2), + // CloudStream (cs:) sources are excluded from Watch2Gether + // for now (they need an on-device plugin per peer). + // + if ((isTvPlatform || isDesktopPlatform) && + (_inParty || + !widget.args.provider.startsWith( + 'cs:', + ))) ...[ _IconButton( - icon: Icons.dns_rounded, - onTap: _openServerSheet, + icon: _inParty + ? Icons.groups_rounded + : Icons.groups_2_rounded, + color: _inParty ? AppColors.primary : null, + onTap: _openWatchParty, ), - ], - if (hasQualities) ...[ const SizedBox(width: 2), + ], + // TV drops three phone-only affordances outright rather + // than adapting them: orientation lock is meaningless on + // a fixed landscape panel, PiP semantics differ on + // leanback, and the touch lock swaps in an overlay whose + // only escape is a non-focusable tap target — on a remote + // that is an unrecoverable state. + if (isTvPlatform) ...[ _IconButton( - icon: Icons.high_quality_rounded, - onTap: () => _openPanel(_SidePanel.quality), + icon: Icons.closed_caption_rounded, + onTap: _openSubtitleSheet, ), - ], - if (hasEpisodes) ...[ const SizedBox(width: 2), + if (canDownload) ...[ + _IconButton( + icon: Icons.download_rounded, + onTap: _startDownload, + ), + const SizedBox(width: 2), + ], _IconButton( - icon: Icons.video_library_rounded, - onTap: () => - _openPanel(_SidePanel.episodes), + icon: Icons.settings_rounded, + onTap: _openSettingsSheet, + ), + if (hasServers) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.dns_rounded, + onTap: _openServerSheet, + ), + ], + if (hasQualities) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.high_quality_rounded, + onTap: () => + _openPanel(_SidePanel.quality), + ), + ], + if (hasEpisodes) ...[ + const SizedBox(width: 2), + _IconButton( + icon: Icons.video_library_rounded, + onTap: () => + _openPanel(_SidePanel.episodes), + ), + ], + ] else if (!isDesktopPlatform) ...[ + // Phone: the viewer's arrangement, in their + // order. Every control stays ON a bar — a + // previous pass filed six of them behind a ⋯ + // sheet to buy room and had to undo it, because + // a control one tap away is a control people + // use and one two taps away is a control they + // stop reaching for. What changed is only that + // WHICH controls sit here is now a preference + // instead of an argument about a Row. + // + // The capacity that PlayerControlsLayout + // enforces is this bar's: the group is wrapped + // in a FittedBox, so an extra button does not + // wrap or scroll, it shrinks all of them. + ..._controlsFor( + PlayerControlSlot.topBar, + topBar: true, ), ], - ] else if (!isDesktopPlatform) ...[ - // Phone: the viewer's arrangement, in their - // order. Every control stays ON a bar — a - // previous pass filed six of them behind a ⋯ - // sheet to buy room and had to undo it, because - // a control one tap away is a control people - // use and one two taps away is a control they - // stop reaching for. What changed is only that - // WHICH controls sit here is now a preference - // instead of an argument about a Row. - // - // The capacity that PlayerControlsLayout - // enforces is this bar's: the group is wrapped - // in a FittedBox, so an extra button does not - // wrap or scroll, it shrinks all of them. - ..._controlsFor( - PlayerControlSlot.topBar, - topBar: true, - ), ], - ], + ), ), ), - ), - if (isDesktopPlatform) - ValueListenableBuilder( - valueListenable: DesktopWindow.immersive, - builder: (_, imm, _) => imm - ? const WindowButtons() - : const SizedBox.shrink(), - ), - ], + if (isDesktopPlatform) + ValueListenableBuilder( + valueListenable: DesktopWindow.immersive, + builder: (_, imm, _) => imm + ? const WindowButtons() + : const SizedBox.shrink(), + ), + ], + ), ), ), ), ), - if (initialized && !isDesktopPlatform) _buildCenterPlayCluster(c), - if (initialized) - _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), - ], - ), + if (initialized && !isDesktopPlatform) _buildCenterPlayCluster(c), + if (initialized) + _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), + ], ), ); + final overlay = native + ? FadeTransition(opacity: kAlwaysCompleteAnimation, child: body) + : FadeTransition( + opacity: _controlsAnimation, + child: GlassHoldWhile( + source: _controlsAnimation, + moving: () => _controlsAnimation.isAnimating, + child: body, + ), + ); // IgnorePointer stops taps but leaves every button focusable, so on a // D-pad an invisible control would still take focus and fire on OK. @@ -1201,16 +1448,23 @@ extension _PlayerControls on _PlayerPageState { // indicator is on screen: this, or the ring on the play button. Widget? spinner; if (isBuffering) { - spinner = const IgnorePointer( + spinner = IgnorePointer( child: Center( - child: SizedBox( - width: 44, - height: 44, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.8, - ), - ), + // The system's own spinner on an iPhone, as AVKit draws it over a + // stalled video; Material's ring everywhere else. + child: defaultTargetPlatform == TargetPlatform.iOS + ? const CupertinoActivityIndicator( + radius: 16, + color: Colors.white, + ) + : const SizedBox( + width: 44, + height: 44, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.8, + ), + ), ), ); if (!isDesktopPlatform) { @@ -1261,42 +1515,239 @@ extension _PlayerControls on _PlayerPageState { Widget _buildCenterPlayCluster(PlayerController c) { final step = _seekSeconds; final centered = _layout.of(PlayerControlSlot.center); - return PlayerTransportRow( - previous: !_layoutDrivesBars || centered.contains('previous') - ? _centerControl('previous') - : null, - next: !_layoutDrivesBars || centered.contains('next') - ? _centerControl('next') - : null, - rewind: _CenterIconButton( - icon: _rewindIconFor(step), - onTap: () { - _seekRelative(Duration(seconds: -step)); - _showSeekRipple(-1); - }, + final previous = !_layoutDrivesBars || centered.contains('previous') + ? _centerControl('previous') + : null; + final next = !_layoutDrivesBars || centered.contains('next') + ? _centerControl('next') + : null; + void rewind() { + _seekRelative(Duration(seconds: -step)); + _showSeekRipple(-1); + } + + void forward() { + _seekRelative(Duration(seconds: step)); + _showSeekRipple(1); + } + + final flutter = MediaGlassLayer( + child: PlayerTransportRow( + previous: previous, + next: next, + rewind: _CenterIconButton(icon: _rewindIconFor(step), onTap: rewind), + playPause: ValueListenableBuilder( + valueListenable: c, + builder: (_, value, _) => _CenterIconButton( + icon: value.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onTap: _togglePlay, + large: true, + busy: value.isBuffering, + focusNode: isTvPlatform ? _tvPlayFocus : null, + ), + ), + forward: _CenterIconButton(icon: _forwardIconFor(step), onTap: forward), ), - playPause: ValueListenableBuilder( - valueListenable: c, - builder: (_, value, _) => _CenterIconButton( - icon: value.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - onTap: _togglePlay, - large: true, - busy: value.isBuffering, - focusNode: isTvPlatform ? _tvPlayFocus : null, + ); + if (!_nativeBars) return flutter; + + final episodes = previous != null || next != null; + NativeBarItem episode(String id, Widget? shown, bool enabled, int to) => + NativeBarItem( + id: id, + symbol: playerSymbol(id), + semanticLabel: _controlName(id), + hidden: shown == null, + enabled: enabled, + extent: 52, + glyph: 17, + onPressed: () => _partyEpisodeNav(to), + ); + return _Selected<(bool, bool)>( + listenable: c, + select: () => (c.value.isPlaying, c.value.isBuffering), + builder: (context, state) => Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: episodes ? 420 : 280), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: NativeGlassBar( + look: NativeBarLook.cluster, + // Transport has physical direction even in an RTL interface. + direction: TextDirection.ltr, + spread: BarSpread.even, + shown: _controlsVisible, + accent: AppColors.primary, + items: [ + if (episodes) + episode( + 'previous', + previous, + _hasPrevEpisode, + _episodeIndex - 1, + ), + NativeBarItem( + id: 'rewind', + symbol: rewindSymbol(step), + semanticLabel: 'player.rewind_by'.tr(args: ['$step']), + extent: 52, + glyph: 22, + onPressed: rewind, + ), + NativeBarItem( + id: 'play', + symbol: playerSymbol(state.$1 ? 'pause' : 'play'), + semanticLabel: (state.$1 ? 'player.pause' : 'player.play') + .tr(), + extent: 72, + glyph: 28, + prominent: true, + busy: state.$2, + onPressed: _togglePlay, + ), + NativeBarItem( + id: 'forward', + symbol: forwardSymbol(step), + semanticLabel: 'player.forward_by'.tr(args: ['$step']), + extent: 52, + glyph: 22, + onPressed: forward, + ), + if (episodes) + episode('next', next, _hasNextEpisode, _episodeIndex + 1), + ], + fallback: (_) => _fadeFlutter(flutter), + ), + ), ), ), - forward: _CenterIconButton( - icon: _forwardIconFor(step), - onTap: () { - _seekRelative(Duration(seconds: step)); - _showSeekRipple(1); - }, + ); + } + + /// What Flutter draws on the native path fades on its own; elsewhere the + /// whole overlay fades and this is a no-op. + Widget _fadeFlutter(Widget child) => _nativeBars + ? FadeTransition(opacity: _controlsAnimation, child: child) + : child; + + /// A native menu holds the controls up while it is open. + void _nativeMenuOpened() => _hideTimer?.cancel(); + + void _nativeMenuClosed() => _scheduleHide(); + + /// The phone's top bar on the native path: back and the actions as two + /// glass bars, the title between them in Flutter. No FittedBox: a scale + /// over a hosted view does not reach UIKit, so a bar that does not fit + /// draws its Flutter fallback instead. + Widget _buildNativeTopBar() { + final faces = _facesFor(PlayerControlSlot.topBar, topBar: true); + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + NativeGlassBar( + look: NativeBarLook.cluster, + shown: _controlsVisible, + items: [ + NativeBarItem( + id: 'back', + symbol: playerSymbol('back'), + semanticLabel: 'general.back'.tr(), + extent: 44, + size: 38, + onPressed: _exit, + ), + ], + fallback: (_) => _fadeFlutter( + MediaGlassLayer( + child: _IconButton( + icon: Icons.arrow_back_ios_new_rounded, + onTap: _exit, + ), + ), + ), + ), + const SizedBox(width: 3), + Expanded( + flex: 2, + child: _fadeFlutter( + IgnorePointer( + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, + ), + ), + ), + ), + ), + Expanded( + flex: 5, + child: Align( + alignment: Alignment.centerRight, + child: faces.isEmpty + ? const SizedBox.shrink() + : NativeGlassBar( + look: NativeBarLook.capsule, + shown: _controlsVisible, + accent: AppColors.primary, + onMenuOpen: _nativeMenuOpened, + onMenuClose: _nativeMenuClosed, + items: [for (final f in faces) f.toNative()], + fallback: (_) => _fadeFlutter( + MediaGlassLayer( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final f in faces) _flutterIcon(f), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), ), ); } + /// The unlock pill: the one control while the screen is locked, up + /// whatever the controls' fade is doing. + Widget _unlockPill(Widget flutter) => NativeGlassBar( + look: NativeBarLook.capsule, + dim: Colors.black.withValues(alpha: 0.6), + items: [ + NativeBarItem( + id: 'unlock', + symbol: playerSymbol('unlock'), + title: 'player.tap_to_unlock'.tr(), + semanticLabel: 'player.tap_to_unlock'.tr(), + glyph: 13, + onPressed: _onUnlockPillTap, + ), + ], + fallback: (_) => flutter, + ); + Widget _buildDesktopControlRow( PlayerController c, bool hasEpisodes, @@ -1314,6 +1765,7 @@ extension _PlayerControls on _PlayerPageState { alignment: Alignment.centerLeft, child: _DesktopVolumeControl( volume: _volume, + max: _maxVolume, onChanged: _setPlayerVolume, onToggleMute: _toggleMute, ), @@ -1417,14 +1869,17 @@ extension _PlayerControls on _PlayerPageState { onTap: () => _openPanel(_SidePanel.episodes), ), ], - // The mini window: this player at 426 wide, above every - // other window. It had no button at all — the phone's PiP + // The mini player: a tap docks it in a corner of the app, + // a long press pops the whole window out above every other + // window. It had no button at all — the phone's PiP // control lives in the layout rows, which this row does // not draw — so only the I key could reach it. const SizedBox(width: 4), _IconButton( icon: Icons.picture_in_picture_alt_rounded, - onTap: _toggleDesktopMini, + onTap: _openMiniPlayer, + onLongPress: _toggleDesktopMini, + tooltip: 'player.mini_popout_hint'.tr(), ), const SizedBox(width: 4), _IconButton( @@ -1532,7 +1987,7 @@ extension _PlayerControls on _PlayerPageState { Widget _buildScrubPreviewCard( Duration position, { - Duration? current, + required Duration total, double? caretX, }) { final w = _previewWidth; @@ -1540,8 +1995,9 @@ extension _PlayerControls on _PlayerPageState { return _ScrubPreviewCard( frame: _previewImage(position, w, h), width: w, - time: _formatDuration(position), - delta: current == null ? '' : _signedDelta(position, current), + time: total > Duration.zero + ? '${_formatDuration(position)} / ${_formatDuration(total)}' + : _formatDuration(position), segment: _segmentAt(position), caretX: caretX, ); @@ -1648,6 +2104,7 @@ extension _PlayerControls on _PlayerPageState { clipBehavior: Clip.none, children: [ bar, + _buildReactionHeatmap(inset), if (showPreview) ValueListenableBuilder( valueListenable: _seekBarDragging, @@ -1659,7 +2116,7 @@ extension _PlayerControls on _PlayerPageState { bottom: 40, child: _buildScrubPreviewCard( previewPosition, - current: value.position, + total: value.duration, caretX: thumbX - left, ), ), @@ -1760,36 +2217,89 @@ extension _PlayerControls on _PlayerPageState { return out; } + /// The viewer-facing name of [id], for VoiceOver on the native bars. + String _controlName(String id) => + PlayerControlCatalogue.byId(id)?.labelKey.tr() ?? id; + /// One control as it appears on the TOP bar: a bare icon, no label. Widget? _topBarControl(String id) { + final face = _topBarFace(id); + return face == null ? null : _flutterIcon(face); + } + + /// [face] as the Flutter top-bar button it has always been. + Widget _flutterIcon(PlayerControlFace face) { + if (face.pill) { + // Its own gap: every other control on this bar is an _IconButton, whose + // 44pt tap target already carries 3pt of transparent padding a side. + // The pill has none, so without this it sits flush against its + // neighbour. + return Padding( + padding: const EdgeInsets.only(right: 5), + child: _LangPill(label: face.label!, onTap: face.onTap!), + ); + } + return _IconButton( + icon: face.icon, + enabled: face.enabled, + color: face.active ? AppColors.primary : null, + onTap: face.onTap!, + onLongPress: face.onLongPress, + tooltip: face.tooltip, + ); + } + + PlayerControlFace _sleepFace({String? label}) { + final menu = _nativeBars ? _sleepMenu : null; + return PlayerControlFace( + id: 'sleep', + icon: Icons.bedtime_rounded, + semanticLabel: _controlName('sleep'), + label: label, + // Filled while armed, but not tinted: the Flutter button never was. + symbol: _sleepArmed ? 'moon.zzz.fill' : 'moon.zzz', + onTap: _openSleepSheet, + menu: menu?.rows, + menuPrimary: menu != null, + onMenu: menu?.pick, + ); + } + + /// One control as the top bar shows it, or null when it cannot exist now. + PlayerControlFace? _topBarFace(String id) { final a = _affordances; + final name = _controlName(id); switch (id) { case 'language': if (!a.hasLangs) return null; - // Its own gap: every other control on this bar is an _IconButton, whose - // 44pt tap target already carries 3pt of transparent padding a side. - // The pill has none, so without this it sits flush against its - // neighbour. - return Padding( - padding: const EdgeInsets.only(right: 5), - child: _LangPill( - label: (_currentLang ?? _kSubLang).toUpperCase(), - onTap: _openLangSheet, - ), - ); + return PlayerControlFace( + id: id, + icon: Icons.translate_rounded, + semanticLabel: name, + pill: true, + label: (_currentLang ?? _kSubLang).toUpperCase(), + onTap: _openLangSheet, + ).withMenu(_nativeBars ? _langMenu : null); case 'subtitles': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.closed_caption_rounded, + semanticLabel: name, onTap: _openSubtitleSheet, - ); + ).withMenu(_nativeBars ? _subtitleMenu : null); case 'settings': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.settings_rounded, + semanticLabel: name, onTap: _openSettingsSheet, ); case 'orientation': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.screen_rotation_rounded, + symbol: orientationSymbol(portrait: _isPortrait), + semanticLabel: name, onTap: _toggleOrientation, ); case 'lock': @@ -1797,123 +2307,174 @@ extension _PlayerControls on _PlayerPageState { // tap target the D-pad cannot reach; the button is landscape-only for // that reason, not for room. if (_isPortrait) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.lock_rounded, - onTap: () => setState(() { - _locked = true; - _controlsVisible = false; - _controlsAnimation.reverse(); - _hideTimer?.cancel(); - }), + semanticLabel: name, + onTap: _lock, ); case 'stats': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.info_rounded, - color: _showPlayerInfo ? AppColors.primary : null, + semanticLabel: name, + active: _showPlayerInfo, onTap: () => setState(() => _showPlayerInfo = !_showPlayerInfo), ); case 'speed': - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.speed_rounded, + semanticLabel: name, onTap: _cycleSpeed, onLongPress: _openSpeedSheet, - ); + ).withMenu(_nativeBars ? _speedMenu : null); case 'server': if (!a.hasServers) return null; - return _IconButton(icon: Icons.dns_rounded, onTap: _openServerSheet); + return PlayerControlFace( + id: id, + icon: Icons.dns_rounded, + semanticLabel: name, + onTap: _openServerSheet, + ).withMenu(_nativeBars ? _serverMenu : null); case 'quality': if (!a.hasQualities) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.high_quality_rounded, + semanticLabel: name, onTap: () => _openPanel(_SidePanel.quality), - ); + ).withMenu(_nativeBars ? _qualityMenu : null); case 'episodes': if (!a.hasEpisodes) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.video_library_rounded, + semanticLabel: name, onTap: () => _openPanel(_SidePanel.episodes), ); case 'previous': if (!a.hasEpisodes) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.skip_previous_rounded, + semanticLabel: name, enabled: _hasPrevEpisode, onTap: () => _partyEpisodeNav(_episodeIndex - 1), ); case 'next': if (!a.hasEpisodes) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.skip_next_rounded, + semanticLabel: name, enabled: _hasNextEpisode, onTap: () => _partyEpisodeNav(_episodeIndex + 1), ); case 'shader': if (!(_controller?.supportsShaders ?? false)) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.auto_awesome_rounded, - color: _shaderPreset.isOff ? null : AppColors.primary, + semanticLabel: name, + active: !_shaderPreset.isOff, onTap: _openShaderSheet, ); case 'fit': - return _IconButton( + return PlayerControlFace( + id: id, icon: _fitIcon(_fit), + symbol: _fitSymbol(_fit), + semanticLabel: name, // Forward on a tap, back on a hold. Three modes and a glyph that // names the current one leaves nothing for a dialog to add — and a // hold that steps backwards is worth more than one that opens a list - // of what the taps already walk through. + // of what the taps already walk through. The native button opens + // all three as its menu instead, the current one checked. onTap: _cycleFit, onLongPress: () => _cycleFit(step: -1), - ); + ).withMenu(_nativeBars ? _fitMenu : null); case 'sleep': if (_isLive) return null; - return _IconButton(icon: Icons.bedtime_rounded, onTap: _openSleepSheet); + return _sleepFace(); case 'cast': if (!_canCast) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.cast_rounded, - color: _cast.isCasting ? AppColors.primary : null, + semanticLabel: name, + active: _cast.isCasting, onTap: _openCastSheet, ); case 'party': // CloudStream sources need an on-device plugin per peer, so they are // out of Watch2Gether until every peer can resolve them. if (!_inParty && widget.args.provider.startsWith('cs:')) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: _inParty ? Icons.groups_rounded : Icons.groups_2_rounded, - color: _inParty ? AppColors.primary : null, + semanticLabel: name, + active: _inParty, onTap: _openWatchParty, ); case 'pip': // `floating: ^6.0.0` ships an Android plugin and nothing else, and // _enterPip swallows the MissingPluginException, so on iOS this was a - // tap that did nothing. On the desktop it is the mini window. + // tap that did nothing. On the desktop a tap docks the player in a + // corner of the app, and a long press pops the window out above the + // others. if (isDesktopPlatform) { - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.picture_in_picture_alt_rounded, - onTap: _toggleDesktopMini, + onTap: _openMiniPlayer, + onLongPress: _toggleDesktopMini, + semanticLabel: name, + tooltip: 'player.mini_popout_hint'.tr(), ); } if (!isAndroidPlatform && !isIosPlatform) return null; - return _IconButton( + return PlayerControlFace( + id: id, icon: Icons.picture_in_picture_rounded, + semanticLabel: name, onTap: _enterPip, ); case 'download': if (!a.canDownload) return null; - return _IconButton(icon: Icons.download_rounded, onTap: _startDownload); + return PlayerControlFace( + id: id, + icon: Icons.download_rounded, + semanticLabel: name, + onTap: _startDownload, + ); } return null; } - /// One control as it appears on the BOTTOM row. + /// [face] as the Flutter bottom-row button it has always been. + Widget _flutterBottom(PlayerControlFace face) { + if (!face.labelled) return _flutterIcon(face); + return _BottomTextButton( + icon: face.icon, + label: face.label!, + compactLabel: face.compactLabel, + compact: face.compact, + enabled: face.enabled, + onTap: face.onTap!, + ); + } + + /// One control as the bottom row shows it. /// /// Labelled where the label carries live state the icon cannot — the current /// server, the resolved quality, the speed, the sleep countdown. Everything /// else falls back to the same icon the top bar uses, so a control keeps its /// identity wherever the viewer puts it. - Widget? _bottomControl(String id) { + PlayerControlFace? _bottomFace(String id) { final a = _affordances; final compact = _compactBottomBar; + final name = _controlName(id); switch (id) { case 'lock': // Portrait has exactly one way out of the lock overlay and it is a tap @@ -1923,22 +2484,20 @@ extension _PlayerControls on _PlayerPageState { // no room — and it still has to render, or the viewer would simply // lose the control. if (isTvPlatform || _isPortrait) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.lock_rounded, + semanticLabel: name, label: 'player.lock'.tr(), compact: compact, - enabled: true, - onTap: () => setState(() { - _locked = true; - _controlsVisible = false; - _controlsAnimation.reverse(); - _hideTimer?.cancel(); - }), + onTap: _lock, ); case 'previous': if (!a.hasEpisodes) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.skip_previous_rounded, + semanticLabel: name, label: 'player.previous'.tr(), compact: compact, enabled: _hasPrevEpisode, @@ -1946,86 +2505,92 @@ extension _PlayerControls on _PlayerPageState { ); case 'next': if (!a.hasEpisodes) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.skip_next_rounded, + semanticLabel: name, label: 'general.next'.tr(), compact: compact, enabled: _hasNextEpisode, onTap: () => _partyEpisodeNav(_episodeIndex + 1), ); case 'speed': - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.speed_rounded, + semanticLabel: name, label: '${_playbackSpeed.toStringAsFixed(_playbackSpeed == _playbackSpeed.roundToDouble() ? 0 : 2)}x', compact: compact, - enabled: true, onTap: _openSpeedSheet, - ); + onLongPress: _openSpeedSheet, + ).withMenu(_nativeBars ? _speedMenu : null); case 'server': if (isTvPlatform || !a.hasServers) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.dns_rounded, + semanticLabel: name, label: _currentServer ?? '—', compact: compact, - enabled: true, onTap: _openServerSheet, - ); + ).withMenu(_nativeBars ? _serverMenu : null); case 'quality': if (isTvPlatform || !a.hasQualities) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.high_quality_rounded, + semanticLabel: name, label: _qualityChipLabel, // The height is the point of this button, so it stays readable // even where every other label is dropped. compactLabel: _qualityChipShort, compact: compact, - enabled: true, onTap: () => _openPanel(_SidePanel.quality), - ); + ).withMenu(_nativeBars ? _qualityMenu : null); case 'episodes': if (isTvPlatform || !a.hasEpisodes) return null; - return _BottomTextButton( + return PlayerControlFace( // The same glyph the top bar draws. A control that changes its face // when the viewer moves it stops being the control they moved. + id: id, icon: Icons.video_library_rounded, + semanticLabel: name, label: 'player.episodes'.tr(), compact: compact, - enabled: true, onTap: () => _openPanel(_SidePanel.episodes), ); case 'shader': if (!_roomForExtras) return null; if (!(_controller?.supportsShaders ?? false)) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: Icons.auto_awesome_rounded, + semanticLabel: name, label: _shaderPreset.isOff ? 'general.off'.tr() : _shaderPreset.labelKey.tr(), - enabled: true, + active: !_shaderPreset.isOff, onTap: _openShaderSheet, ); case 'fit': if (!_roomForExtras) return null; - return _BottomTextButton( + return PlayerControlFace( + id: id, icon: _fitIcon(_fit), + symbol: _fitSymbol(_fit), + semanticLabel: name, label: _fitLabel(_fit), - enabled: true, // Cycles. There are three fits, the button is already showing which // one is on, and it changes under the finger — so a sheet listing // three radio buttons was a modal over the video to answer a question // the button itself answers. No toast either: the label IS the // feedback, and a snackbar repeating it is one message too many. onTap: () => _cycleFit(toast: false), - ); + ).withMenu(_nativeBars ? _fitMenu : null); case 'sleep': if (!_roomForExtras || _isLive) return null; - return _BottomTextButton( - icon: Icons.bedtime_rounded, - label: _sleepValueLabel, - enabled: true, - onTap: _openSleepSheet, - ); + return _sleepFace(label: _sleepValueLabel); case 'party': case 'pip': case 'download': @@ -2033,17 +2598,25 @@ extension _PlayerControls on _PlayerPageState { // top bar, and rendering them here as well is the duplication the // `&& !isTvPlatform` guards used to prevent. if (isTvPlatform || isDesktopPlatform) return null; - return _topBarControl(id); + return _topBarFace(id); default: // No live value to print, so the icon form is the honest one and the // top bar already builds it. - return _topBarControl(id); + return _topBarFace(id); } } /// The controls for [slot], in the viewer's order, minus the ones that cannot /// exist right now. - List _controlsFor(PlayerControlSlot slot, {required bool topBar}) { + List _controlsFor(PlayerControlSlot slot, {required bool topBar}) => [ + for (final face in _facesFor(slot, topBar: topBar)) + topBar ? _flutterIcon(face) : _flutterBottom(face), + ]; + + List _facesFor( + PlayerControlSlot slot, { + required bool topBar, + }) { // A television's top bar is where the D-pad lands first and a desktop // window has its own row; the editor is a phone screen and must not govern // either. They render the shipped arrangement instead — which is also why @@ -2060,12 +2633,9 @@ extension _PlayerControls on _PlayerPageState { if (topBar || !lifted.contains(id)) id, if (topBar && slot == PlayerControlSlot.topBar) ...lifted, ]; - final out = []; - for (final id in ids) { - final w = topBar ? _topBarControl(id) : _bottomControl(id); - if (w != null) out.add(w); - } - return out; + return [ + for (final id in ids) ?(topBar ? _topBarFace(id) : _bottomFace(id)), + ]; } /// A landscape phone has room the portrait one does not, so it shows more. @@ -2082,7 +2652,9 @@ extension _PlayerControls on _PlayerPageState { /// Rebuilt from the controller's own notifier so the buffer and position /// rows move; everything else on the panel is steady between loads. Widget _buildPlayerInfoOverlay() { - if (!_showPlayerInfo) return const SizedBox.shrink(); + // A panel sized for the full player; in the small one it would cover the + // picture. + if (!_showPlayerInfo || _compact) return const SizedBox.shrink(); final c = _controller; if (c == null) return const SizedBox.shrink(); return ValueListenableBuilder( @@ -2144,68 +2716,69 @@ extension _PlayerControls on _PlayerPageState { // every drag event (120 Hz on a Samsung S22), and without a // boundary each of those repainted the whole controls overlay — // scrim, title, every icon and its text shadow — not the bar. - RepaintBoundary( - child: ValueListenableBuilder( - valueListenable: _sliderDragValue, - builder: (_, dragVal, _) { - return ValueListenableBuilder( - valueListenable: c, - builder: (_, value, _) { - if (_isLive) return _buildLiveBar(); - final duration = value.duration.inMilliseconds == 0 - ? Duration.zero - : value.duration; - final maxMs = duration.inMilliseconds.toDouble().clamp( - 1.0, - double.infinity, - ); - final sliderVal = - dragVal ?? - (duration.inMilliseconds == 0 - ? 0.0 - : value.position.inMilliseconds - .clamp(0, duration.inMilliseconds) - .toDouble()); - final displayPos = dragVal != null - ? Duration(milliseconds: dragVal.toInt()) - : value.position; - - return Row( - children: [ - IgnorePointer( - child: Text( - _positionLabel(displayPos, duration), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - shadows: _kControlShadow, + _fadeFlutter( + RepaintBoundary( + child: ValueListenableBuilder( + valueListenable: _sliderDragValue, + builder: (_, dragVal, _) { + return ValueListenableBuilder( + valueListenable: c, + builder: (_, value, _) { + if (_isLive) return _buildLiveBar(); + final duration = value.duration.inMilliseconds == 0 + ? Duration.zero + : value.duration; + final maxMs = duration.inMilliseconds + .toDouble() + .clamp(1.0, double.infinity); + final sliderVal = + dragVal ?? + (duration.inMilliseconds == 0 + ? 0.0 + : value.position.inMilliseconds + .clamp(0, duration.inMilliseconds) + .toDouble()); + final displayPos = dragVal != null + ? Duration(milliseconds: dragVal.toInt()) + : value.position; + + return Row( + children: [ + IgnorePointer( + child: Text( + _positionLabel(displayPos, duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + shadows: _kControlShadow, + ), ), ), - ), - Expanded( - child: _buildSeekBar( - value: value, - sliderVal: sliderVal.clamp(0.0, maxMs), - maxMs: maxMs, - scrubbing: dragVal != null, - previewPosition: displayPos, + Expanded( + child: _buildSeekBar( + value: value, + sliderVal: sliderVal.clamp(0.0, maxMs), + maxMs: maxMs, + scrubbing: dragVal != null, + previewPosition: displayPos, + ), ), - ), - IgnorePointer( - child: Text( - _formatDuration(duration), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - shadows: _kControlShadow, + IgnorePointer( + child: Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + shadows: _kControlShadow, + ), ), ), - ), - ], - ); - }, - ); - }, + ], + ); + }, + ); + }, + ), ), ), const SizedBox(height: 4), @@ -2227,11 +2800,11 @@ extension _PlayerControls on _PlayerPageState { // and the corners are what the viewer is complaining about now. Builder( builder: (context) { - final left = _controlsFor( + final left = _facesFor( PlayerControlSlot.bottomLeft, topBar: false, ); - final rightAll = _controlsFor( + final rightAll = _facesFor( PlayerControlSlot.bottomRight, topBar: false, ); @@ -2252,9 +2825,34 @@ extension _PlayerControls on _PlayerPageState { _fittingControls(box.maxWidth, left.length), ) .toList(); - return PlayerBottomControlRow( - leading: left, - trailing: right, + final flutter = MediaGlassLayer( + child: PlayerBottomControlRow( + leading: [for (final f in left) _flutterBottom(f)], + trailing: [ + for (final f in right) _flutterBottom(f), + ], + ), + ); + if (!_nativeBars || left.length + right.length == 0) { + return _fadeFlutter(flutter); + } + final trailingGroup = left.isEmpty ? 0 : 1; + return Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + shown: _controlsVisible, + accent: AppColors.primary, + onMenuOpen: _nativeMenuOpened, + onMenuClose: _nativeMenuClosed, + items: [ + for (final f in left) f.toNative(), + for (final f in right) + f.toNative(group: trailingGroup), + ], + // Too long even tightened: the Flutter row, + // which scrolls. + fallback: (_) => _fadeFlutter(flutter), + ), ); }, ); diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index a38d82b3..59aa3535 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1,4 +1,5 @@ import 'package:soplay/core/player/media_segment_relay.dart'; +import 'package:soplay/core/player/player_dock.dart'; import 'package:soplay/features/search/data/source_health_store.dart'; import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'package:share_plus/share_plus.dart'; @@ -13,10 +14,11 @@ import 'package:soplay/core/player/color_profile.dart'; import 'package:soplay/core/player/shader_presets.dart'; import 'package:soplay/core/player/shader_store.dart'; import 'package:soplay/features/detail/data/title_prefs_store.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/stats/data/watch_stats_store.dart'; import 'package:soplay/core/network/external_dio.dart'; import 'package:soplay/core/network/cf_bypass_service.dart'; -import 'package:soplay/core/network/fetch_metadata.dart'; +import 'package:soplay/core/network/media_request_headers.dart'; import 'package:soplay/core/network/user_agent.dart'; import 'dart:async'; import 'dart:io'; @@ -25,9 +27,11 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:file_picker/file_picker.dart'; import 'package:dio/dio.dart'; -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:floating/floating.dart'; import 'package:gal/gal.dart'; +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -51,10 +55,17 @@ import 'package:soplay/core/player/source_ladder.dart'; import 'package:soplay/features/detail/domain/download_choices.dart'; import 'package:soplay/features/detail/domain/player_affordances.dart'; import 'package:soplay/features/detail/domain/playback/episode_window.dart'; +import 'package:soplay/features/detail/domain/playback/desktop_volume.dart'; +import 'package:soplay/features/detail/domain/playback/end_of_stream.dart'; +import 'package:soplay/features/detail/domain/playback/lock_pill.dart'; +import 'package:soplay/features/detail/domain/playback/tap_seek.dart'; +import 'package:soplay/core/player/mpv_auto_quality.dart'; import 'package:soplay/features/detail/domain/playback/playback_fault.dart'; import 'package:soplay/features/detail/domain/playback/retry_policy.dart'; import 'package:soplay/features/detail/domain/playback/skip_offers.dart'; import 'package:soplay/features/detail/domain/playback/wakelock_holds.dart'; +import 'package:soplay/features/detail/domain/playback/player_system_policy.dart'; +import 'package:soplay/core/system/system_controls.dart'; import 'package:soplay/features/detail/domain/playback/watch_progress.dart'; import 'package:soplay/features/detail/domain/player_controls_layout.dart'; import 'package:soplay/features/detail/domain/playback/party_rules.dart'; @@ -67,12 +78,26 @@ import 'package:soplay/core/widgets/server_badge.dart'; import 'package:soplay/core/system/app_orientation.dart'; import 'package:soplay/core/system/desktop_window.dart'; import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/system/star_prompt.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/glass/glass_hold.dart'; +import 'package:soplay/core/glass/media_glass.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/native/native_bar_layout.dart' show BarSpread; +import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/core/native/sf_symbols.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_control_face.dart'; +import 'package:soplay/features/detail/presentation/widgets/native_controls_offer.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_sheet_look.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; import 'package:soplay/features/mal/data/mal_tracker.dart'; import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; import 'package:soplay/features/cloudflare/cloudflare_solver_page.dart'; import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; +import 'package:soplay/features/detail/domain/services/alternate_source_service.dart' show SeasonEpisode; import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; import 'package:soplay/features/detail/domain/usecases/get_episodes_usecase.dart'; import 'package:soplay/features/detail/domain/entities/extractor_config_entity.dart'; @@ -88,6 +113,7 @@ import 'package:soplay/features/detail/domain/scrub_curve.dart'; import 'package:soplay/features/trakt/data/trakt_tracker.dart'; import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; import 'package:soplay/features/detail/domain/video_option_groups.dart'; +import 'package:soplay/features/detail/domain/vtt_thumbnails.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/detail/data/aniskip_service.dart'; import 'package:soplay/features/detail/presentation/pages/player_controls_page.dart'; @@ -126,6 +152,14 @@ import 'package:soplay/features/jellyfin/data/jellyfin_reporter.dart'; import 'package:soplay/features/automation/data/auto_download_service.dart'; import 'package:soplay/features/automation/data/automation_settings.dart'; import 'package:soplay/features/automation/domain/prefetch_slot.dart'; +import 'package:soplay/features/comments/data/datasources/timed_reactions_data_source.dart'; +import 'package:soplay/features/comments/data/social_playback_prefs.dart'; +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; +import 'package:soplay/features/comments/presentation/widgets/episode_comments_sheet.dart'; +import 'package:soplay/features/comments/presentation/widgets/reaction_heatmap.dart'; +import 'package:soplay/features/comments/presentation/widgets/reaction_lane.dart'; +import 'package:soplay/features/comments/presentation/widgets/reaction_picker.dart'; part 'player_page.models.dart'; part 'player_page.widgets.dart'; @@ -143,6 +177,12 @@ part 'player_page.party.dart'; part 'player_page.tv.dart'; part 'player_page.jellyfin.dart'; part 'player_page.upnext.dart'; +part 'player_page.reactions.dart'; +part 'player_page.menus.dart'; +part 'player_page.sheets.dart'; +part 'player_page.native_offer.dart'; +part 'player_page.system.dart'; +part 'player_page.toast.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = RetryPolicy.maxLifetimeRetries; @@ -221,15 +261,50 @@ class _PlayerPageState extends State /// Desktop picture-in-picture: the window is the small always-on-top /// player. [_isPip] is set with it, which hides the full controls. bool _desktopMini = false; + + /// Desktop picture-in-picture inside the app: this page is the corner + /// player over whatever screen the viewer moved on to (see [PlayerDock]). + /// [_isPip] is set with it, as for [_desktopMini], which hides the full + /// controls. + bool _docked = false; + + /// The window floated above the other apps by itself, when the viewer + /// switched away from a corner player (see [_floatWhenLeaving]). Leaving + /// the floating window puts the picture back in the corner it came from. + bool _floatedFromDock = false; + + /// Either compact form: the always-on-top window or the corner player. + bool get _compact => _desktopMini || _docked; + + /// The desktop key handler's node, held so docking can let go of the + /// keyboard and a click on the corner player can take it back. + final FocusNode _keyFocus = FocusNode(debugLabel: 'desktopPlayerKeys'); final ValueNotifier _miniHover = ValueNotifier(false); bool _resumeAfterPause = false; - bool _lastPipPlaying = false; + + /// Android PiP, audio focus, media session and brightness bookkeeping. + /// See player_page.system.dart. + final AndroidPipTracker _pipTracker = AndroidPipTracker(); + final AudioFocusPolicy _audioFocusPolicy = AudioFocusPolicy(); + final BrightnessKeeper _brightnessKeeper = BrightnessKeeper(); + final MediaSessionGate _mediaGate = MediaSessionGate(); + bool _systemPlaying = false; + bool _autoPipArmed = false; + late final bool _autoPipSetting; + String? _mediaMetaKey; + bool _brightnessHandedBack = false; PlayerController? _controller; bool _preferPlatformPlayer = false; String? _currentQuality; String? _videoUrl; + /// The url and headers a download should take while [_videoUrl] is a local + /// proxy address that stops answering once the player closes. Only set when + /// the upstream needs no rewriting to play (the media-segment relay): a + /// signed or transformed CDN refuses it without the proxy. + ({String url, Map headers})? _downloadUpstream; + /// The url as it was handed to `_initializeWith`, before the WebView sniff /// and before the local proxy rewrote it. /// @@ -445,6 +520,22 @@ class _PlayerPageState extends State bool get _hasPrevEpisode => _window.hasPrev; bool _controlsVisible = true; bool _locked = false; + + /// Whether the controls are the phone's own glass buttons (iOS 26+), + /// decided once on the first build so Flutter and UIKit never swap under + /// the viewer — again only when Reduce Transparency is thrown. + bool _nativeControls = false; + bool? _nativeDecidedUnder; + + /// Settings → Player → Controls style, read once as the page opens; only + /// a yes to the one-time offer changes it while the page is up. + late PlayerControlsStyle _controlsStyle = _hive.playerControlsStyle; + bool _nativeOfferPending = true; + bool _nativeOfferOpen = false; + + /// The small players draw no native bars: the corner player is moved and + /// clipped as a whole, which a hosted view cannot follow. + bool get _nativeBars => _nativeControls && !_compact; _SidePanel _panel = _SidePanel.none; String? _currentLang; @@ -486,7 +577,7 @@ class _PlayerPageState extends State SubtitleStyle _subtitleStyle = SubtitleStyle.defaults(); String? _thumbnailsKey; - List<_VttThumbnail> _vttThumbnails = const []; + List _vttThumbnails = const []; ThumbnailsEntity? _storyboard; /// What the source's thumbnails are fetched with: the sprite sheets sit on @@ -558,6 +649,10 @@ class _PlayerPageState extends State /// See _pushSystemLevels in player_page.pip.dart. bool _brightnessDirty = false; bool _volumeDirty = false; + + /// A swipe's starting level is being read; see _rebaseSwipeLevel. + bool _swipeLevelReady = true; + int _swipeToken = 0; Timer? _systemLevelTimer; final ValueNotifier<_SwipeIndicator?> _swipeIndicator = ValueNotifier<_SwipeIndicator?>(null); @@ -593,6 +688,8 @@ class _PlayerPageState extends State Timer? _historyTimer; late final AnimationController _controlsAnimation; + final _EpisodeSocial _social = _EpisodeSocial(); + late final AnimationController _seekRippleController; Timer? _seekRippleTimer; int _seekRippleDirection = 0; @@ -609,6 +706,16 @@ class _PlayerPageState extends State bool _autoRetrying = false; + /// Mid-stream network drops re-opened in place, and [_playbackWatch]'s + /// reading at the last one. See `_reconnectInPlace`. + int _networkReconnects = 0; + Duration _networkReconnectWatch = Duration.zero; + + /// The last position the stream reported while playing cleanly. ExoPlayer + /// replaces its whole value on an error (position 0:00), so a retry that + /// read the controller restarted the episode from the beginning. + Duration _lastPlayedPosition = Duration.zero; + /// Stream hosts already asked whether a Cloudflare challenge is what made /// them refuse — once each per player, since the answer costs a request. final Set _cfChecked = {}; @@ -680,6 +787,7 @@ class _PlayerPageState extends State debugLabel: 'tvPlayerPanel', ); final FocusNode _tvPlayFocus = FocusNode(debugLabel: 'tvPlayerPlay'); + final FocusNode _tvStopSwitchFocus = FocusNode(debugLabel: 'tvStopSwitch'); final FocusNode _tvSeekFocus = FocusNode(debugLabel: 'tvPlayerSeek'); /// Debounce that turns a burst of left/right presses into a single seek. @@ -696,6 +804,46 @@ class _PlayerPageState extends State /// guard that protects against accidental BACK. bool _tvPopAllowed = false; + /// Monotonic time for the tap, lock and end-of-stream state machines. + final Stopwatch _clock = Stopwatch()..start(); + Duration get _now => _clock.elapsed; + + final TapSeekTracker _tapSeek = TapSeekTracker(); + Timer? _tapTimer; + + final LockPill _lockPill = LockPill(); + Timer? _lockPillTimer; + + final EndOfStreamDetector _endOfStream = EndOfStreamDetector(); + Timer? _endWatch; + + /// mpv Auto: stalls counted for a step down, and the `hls-bitrate` in force + /// after one (null = the start choice). [_autoBitrateBefore] is for Undo. + final StallWatchdog _stallWatch = StallWatchdog(); + String? _autoBitrate; + String? _autoBitrateBefore; + + /// "Try with the mpv engine" after a codec fault on the native engine. For + /// this player only; the engine setting is untouched. + bool _mpvForSession = false; + + /// mpv failed mid-playback once and was reopened with `hwdec=no`. + bool _mpvSoftwareRetried = false; + + /// The orientation was turned to portrait for a vertical clip, so a + /// landscape episode after it turns it back. + bool _autoPortrait = false; + PlayerController? _orientedFor; + + DesktopVolume _desktopVolume = const DesktopVolume(); + + final ValueNotifier<_PlayerToast?> _toastNote = ValueNotifier(null); + Timer? _toastTimer; + bool _errorDetailsOpen = false; + + final FocusNode _tvUpNextFocus = FocusNode(debugLabel: 'tvUpNext'); + bool _upNextShown = false; + @override void initState() { super.initState(); @@ -716,6 +864,13 @@ class _PlayerPageState extends State _longPressBoost = _hive.getLongPressBoost(); _brightnessGestureEnabled = _hive.brightnessGestureEnabled; _volumeGestureEnabled = _hive.volumeGestureEnabled; + _autoPipSetting = _hive.autoPipEnabled; + if (isDesktopPlatform) { + final stored = _hive.getDesktopVolume(); + _desktopVolume = DesktopVolume.restore(stored.level, stored.muted); + _volume = _desktopVolume.applied; + } + _endWatch = Timer.periodic(const Duration(seconds: 1), (_) => _checkEnd()); _window = _window.at( widget.args.initialEpisodeIndex.clamp( 0, @@ -746,6 +901,7 @@ class _PlayerPageState extends State WidgetsBinding.instance.addObserver(this); _pipChannel.setMethodCallHandler(_onPipMethodCall); if (isIosPlatform) _iosPipChannel.setMethodCallHandler(_onIosPipCall); + _attachPlayerSystem(); unawaited(_loadSystemControlValues()); PlayerLog.instance ..clear() @@ -773,13 +929,21 @@ class _PlayerPageState extends State case AppLifecycleState.inactive: case AppLifecycleState.hidden: case AppLifecycleState.paused: - if (!isDesktopPlatform && - c.value.isInitialized && - c.value.isPlaying && - !_isPip) { + // Inactive is another app in front. Hidden is Cmd-H or the window + // gone entirely, where a floating player is the one thing not wanted. + if (isDesktopPlatform && state == AppLifecycleState.inactive) { + unawaited(_floatWhenLeaving()); + } + final leaving = pausesOnLifecycle( + state, + desktop: isDesktopPlatform, + inPip: _isPip || _pipTracker.active, + ); + if (leaving && c.value.isInitialized && c.value.isPlaying) { _resumeAfterPause = true; c.pause(); } + if (leaving) _handBackBrightness(); // Backgrounded is the last callback Android reliably delivers before // it is free to kill the process, and `dispose` is not delivered at // all when it does. Keeping playing in PiP or on the desktop does not @@ -788,9 +952,16 @@ class _PlayerPageState extends State _saveHistory(); break; case AppLifecycleState.resumed: - if (_isPip && mounted) { + // Back from Android's or iOS's floating window. On the desktop + // "resumed" only means the window got focus back — a click on the + // always-on-top mini window, or on the app around the corner player — + // and clearing _isPip there drew the full control bar into a 426-px + // window, under and over the mini controls. + _pipTracker.onResumed(); + if (_isPip && !_compact && mounted) { setState(() => _isPip = false); } + _reapplyBrightness(); if (_resumeAfterPause && c.value.isInitialized) { c.play(); } @@ -812,6 +983,11 @@ class _PlayerPageState extends State // Leaving the player from the mini window must not leave the app a // thumbnail above everything else. if (_desktopMini) unawaited(DesktopWindow.exitMini()); + // Closed from the corner, or disposed some other way while there: the + // dock must not keep pointing at a page that no longer exists. + final pageKey = widget.key; + if (pageKey is GlobalKey) PlayerDock.instance.forget(pageKey); + _keyFocus.dispose(); _miniHover.dispose(); // Leaving mid-play is a pause as far as Trakt's "watching now" goes. if (_traktPlaying) _syncTraktScrobble(false); @@ -852,6 +1028,7 @@ class _PlayerPageState extends State _partyDispose(); WidgetsBinding.instance.removeObserver(this); _pipChannel.setMethodCallHandler(null); + _detachPlayerSystem(); if (isIosPlatform) { _iosPipChannel.setMethodCallHandler(null); // Leaving the player while the floating window is up: it goes too. @@ -861,14 +1038,23 @@ class _PlayerPageState extends State _hideTimer?.cancel(); _historyTimer?.cancel(); _seekRippleTimer?.cancel(); + _tapTimer?.cancel(); + _lockPillTimer?.cancel(); + _endWatch?.cancel(); + _toastTimer?.cancel(); + _toastNote.dispose(); + _tvUpNextFocus.dispose(); + PlayerController.mpvHwdec = null; _systemLevelTimer?.cancel(); _tvSeekCommit?.cancel(); _tvRootFocus.dispose(); _tvPanelFocus.dispose(); _tvPlayFocus.dispose(); + _tvStopSwitchFocus.dispose(); _tvSeekFocus.dispose(); _tvPanelScroll?.dispose(); _controlsAnimation.dispose(); + _social.dispose(); _seekRippleController.dispose(); _scrub.dispose(); _seekBarDragging.dispose(); @@ -891,17 +1077,39 @@ class _PlayerPageState extends State super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final rt = GlassSense.reduceTransparencyOf(context); + if (_nativeDecidedUnder != rt) { + _nativeDecidedUnder = rt; + _nativeControls = NativeControls.decide( + context, + chosen: _controlsStyle, + ); + } + } + @override Widget build(BuildContext context) { + if (_docked) _reportDockAspect(); return PopScope( // BACK is the *only* dismiss affordance on a remote, so on TV the player // intercepts it and unwinds one layer at a time (panel -> pending scrub -> // controls -> exit) rather than dumping the viewer out mid-episode. // _exit() latches _tvPopAllowed when the exit is genuinely intended. // Phone and desktop keep the unconditional pop they ship today. - canPop: !isTvPlatform || _tvPopAllowed, + // Locked, a phone's BACK only brings the unlock pill back. + canPop: (!isTvPlatform && !_locked) || _tvPopAllowed, onPopInvokedWithResult: (didPop, _) { + if (!didPop && _locked && !isTvPlatform) { + _showLockPill(); + return; + } if (didPop || !isTvPlatform) { + // Docking pops the route too, but the page plays on in the corner: + // its wakelock and window state are still in use. + if (_docked) return; _restoreSystemUi(); return; } @@ -941,40 +1149,45 @@ class _PlayerPageState extends State // As an ancestor these recognisers are in the hit-test path for // every touch, whatever is drawn on top. behavior: HitTestBehavior.opaque, - onTap: _locked + // Touch screens resolve taps themselves (_onVideoTap), so a + // single tap waits only when a double tap could follow. + onTap: _tapTap ? null - : _desktopMini + : _locked + ? _showLockPill + : _compact ? _togglePlay : _toggleControls, - onDoubleTapDown: _locked || isDesktopPlatform - ? null - : (d) => _onDoubleTapDown(d, constraints), - onDoubleTap: _locked + onTapUp: _tapTap + ? (d) => _locked + ? _showLockPill() + : _onVideoTap(d.localPosition, constraints) + : null, + onDoubleTap: _locked || _tapTap ? null + : _docked + ? _expandFromDock : _desktopMini ? _toggleDesktopMini - : isDesktopPlatform - ? _toggleFullscreen - : () {}, + : _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. - // In the mini window a drag moves the window instead. - onScaleStart: _locked || _desktopMini + // In the mini window a drag moves the window instead, and + // in the corner player it moves the corner player. + onScaleStart: _locked || _compact ? null : (d) => _onScaleStart(d, constraints), - onScaleUpdate: _locked || _desktopMini + onScaleUpdate: _locked || _compact ? null : (d) => _onScaleUpdate(d, constraints), - onScaleEnd: _locked || _desktopMini ? null : _onScaleEnd, - onLongPressStart: _locked || _desktopMini + onScaleEnd: _locked || _compact ? null : _onScaleEnd, + onLongPressStart: _locked || _compact ? null : _onLongPressStart, - onLongPressEnd: _locked || _desktopMini - ? null - : _onLongPressEnd, + onLongPressEnd: _locked || _compact ? null : _onLongPressEnd, child: Listener( // Seen before any recogniser claims the touch, and in the // order they happen: where the finger went down (the @@ -983,47 +1196,61 @@ class _PlayerPageState extends State onPointerDown: (e) { _pointerDownAt = e.localPosition; _pointerCancelled = false; + // The corner player gave the keyboard to the page under + // it; a click on the player takes it back. + if (_docked && !_keyFocus.hasFocus) { + _keyFocus.requestFocus(); + } }, onPointerCancel: (_) => _pointerCancelled = true, - 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, + child: NativeControlsScope( + native: _nativeBars, + child: PlayerControlsStyleScope( + style: _controlsStyle, + child: Stack( + fit: StackFit.expand, + children: [ + _buildVideoLayer(), + _buildSubtitleOverlay(), + if (!_locked) _buildReactionLane(), + 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) _buildUpNextPrompt(), + if (!_locked) _buildEpisodeSocialRail(), + if (!_locked && _panel != _SidePanel.none) + _buildSidePanel(), + if (!_locked && _inParty) _buildPartyReactionsLayer(), + if (_compact) _buildDesktopMiniOverlay(), + if (!_compact) _buildPlayerToast(), + // 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) _buildUpNextPrompt(), - if (!_locked && _panel != _SidePanel.none) - _buildSidePanel(), - if (!_locked && _inParty) _buildPartyReactionsLayer(), - if (_desktopMini) _buildDesktopMiniOverlay(), - // 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(), - ], + ), ), ), ), @@ -1049,15 +1276,34 @@ class _PlayerPageState extends State child: child, ); } + if (isAndroidPlatform) { + // A phone or tablet with a hardware keyboard: Space and the arrows. + return Focus( + focusNode: _keyFocus, + autofocus: true, + onKeyEvent: (node, event) => + node.hasPrimaryFocus && _kAndroidPlayerKeys.contains(event.logicalKey) + ? _onPlayerKey(node, event) + : KeyEventResult.ignored, + child: child, + ); + } if (!isDesktopPlatform) return child; - return Focus(autofocus: true, onKeyEvent: _onPlayerKey, child: child); + return Focus( + focusNode: _keyFocus, + // Not when the page arrives in the corner: the keyboard stays with the + // page the viewer went to. + autofocus: !_docked, + onKeyEvent: _onPlayerKey, + child: child, + ); } Widget _wrapHover(Widget child) { if (!isDesktopPlatform) return child; return MouseRegion( onHover: (_) => _revealControlsForHover(), - cursor: _controlsVisible || _desktopMini + cursor: _controlsVisible || _compact ? MouseCursor.defer : SystemMouseCursors.none, child: child, @@ -1080,6 +1326,18 @@ class _PlayerPageState extends State _toggleDesktopMini(); return KeyEventResult.handled; } + if (_docked) { + // In the corner, Esc hands the keyboard back to the page underneath + // instead of closing the video — PlayerDockHost takes it from here, as + // it knows where that page is — and I opens the full player again. + // Nothing that opens a sheet: there is no room for one. + if (k == LogicalKeyboardKey.escape) return KeyEventResult.ignored; + if (k == LogicalKeyboardKey.keyI) { + if (event is! KeyRepeatEvent) _expandFromDock(); + return KeyEventResult.handled; + } + if (!_kDockKeys.contains(k)) return KeyEventResult.ignored; + } for (final shortcut in _playerShortcuts) { if (!shortcut.keys.contains(k)) continue; // Swallowed rather than ignored when the party forbids it: the press was @@ -1230,14 +1488,19 @@ class _PlayerPageState extends State label: 'player.fullscreen'.tr(), onKey: (_) => _toggleFullscreen(), ), - // The mini window: above other windows, the same player at 426 wide. + // I: the corner player, which stays while the rest of the app is used. + // Shift+I: the whole window as a small always-on-top player, for watching + // over other apps — the only way above them, since a second window would + // need a second engine that cannot share this texture. // I rather than P, which is already the previous episode. _PlayerShortcut( keys: const [LogicalKeyboardKey.keyI], - hint: 'I', - label: 'player.pip'.tr(), + hint: 'I · ⇧ I', + label: 'player.shortcut_pip'.tr(), available: isDesktopPlatform, - onKey: (_) => _toggleDesktopMini(), + onKey: (_) => _desktopMini || HardwareKeyboard.instance.isShiftPressed + ? _toggleDesktopMini() + : _openMiniPlayer(), ), _PlayerShortcut( keys: const [LogicalKeyboardKey.keyS], @@ -1539,3 +1802,31 @@ const _kDecileKeys = [ /// (player_page.panels.dart): a key that landed between its rows would leave /// the sheet open with nothing selected. const _kKeyboardSpeeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +/// What the keyboard can still do while the player is in the corner: play, +/// seek, volume, mute and the episode keys. The rest open a sheet or change +/// the window, and the corner has room for neither. +const _kDockKeys = [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.mediaPlayPause, + LogicalKeyboardKey.keyK, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyJ, + LogicalKeyboardKey.keyL, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.keyM, + LogicalKeyboardKey.keyP, + LogicalKeyboardKey.keyN, +]; + +/// The keys a hardware keyboard drives on an Android phone or tablet. +/// Media keys are left to the media session. +const _kAndroidPlayerKeys = [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, +]; diff --git a/lib/features/detail/presentation/pages/player_page.gestures.dart b/lib/features/detail/presentation/pages/player_page.gestures.dart index f60c652f..e3a850dd 100644 --- a/lib/features/detail/presentation/pages/player_page.gestures.dart +++ b/lib/features/detail/presentation/pages/player_page.gestures.dart @@ -2,18 +2,52 @@ part of 'player_page.dart'; extension _PlayerGestures on _PlayerPageState { - void _onDoubleTapDown(TapDownDetails details, BoxConstraints constraints) { - final dx = details.localPosition.dx; - final width = constraints.maxWidth; - final leftEdge = width * 0.3; - final rightEdge = width * 0.7; - final step = _seekStep; - if (dx < leftEdge) { - _seekRelative(-step); - _showSeekRipple(-1); - } else if (dx > rightEdge) { - _seekRelative(step); - _showSeekRipple(1); + /// Phones and tablets: taps are resolved here rather than by a double-tap + /// recogniser, which held every single tap back by 300 ms. + bool get _tapTap => !isDesktopPlatform && !_compact; + + void _onVideoTap(Offset at, BoxConstraints constraints) { + final c = _controller; + final ready = + c != null && + c.value.isInitialized && + !_initializing && + _errorMessage == null; + final action = _tapSeek.onTap( + TapSeekTracker.zoneOf(at.dx, constraints.maxWidth), + _now, + canSeek: ready && !_isLive && _panel == _SidePanel.none, + centreTogglesPlay: ready && _panel == _SidePanel.none, + ); + _tapTimer?.cancel(); + if (action == TapAction.pending) { + _tapTimer = Timer(_tapSeek.doubleTapWindow, () { + if (!mounted) return; + final late = _tapSeek.flush(_now); + if (late != null) _runTapAction(late); + }); + return; + } + _runTapAction(action); + } + + void _runTapAction(TapAction action) { + switch (action) { + case TapAction.pending: + return; + case TapAction.toggleControls: + _toggleControls(); + case TapAction.togglePlay: + _togglePlay(); + if (isMobilePlatform) HapticFeedback.selectionClick(); + case TapAction.seekBack: + case TapAction.seekForward: + final dir = action == TapAction.seekBack ? -1 : 1; + _seekRelative(_seekStep * dir); + _showSeekRipple(dir); + // The ripple is on the far side of the thumb; the tick is what says + // the tap landed while the eyes stay on the picture. + if (isMobilePlatform) HapticFeedback.selectionClick(); } } @@ -122,7 +156,7 @@ extension _PlayerGestures on _PlayerPageState { setState(() => _seekRippleDirection = direction); _seekRippleController.forward(from: 0); _seekRippleTimer?.cancel(); - _seekRippleTimer = Timer(const Duration(milliseconds: 700), () { + _seekRippleTimer = Timer(_tapSeek.rippleWindow, () { if (!mounted) return; setState(() { _seekRippleDirection = 0; @@ -224,6 +258,19 @@ extension _PlayerGestures on _PlayerPageState { } void _onPanStart(DragStartDetails d, BoxConstraints constraints) { + // A swipe from the system's own gesture edges (back, home, the shade) + // belongs to the system, not to brightness, volume or the scrub. + final from = _pointerDownAt ?? d.localPosition; + if (startsInSystemGestureArea( + from, + constraints.biggest, + MediaQuery.of(context).systemGestureInsets, + )) { + _dragStart = null; + _dragIsHorizontal = null; + _dragSwipeType = null; + return; + } _dragStart = d.localPosition; _dragIsHorizontal = null; _dragSwipeType = null; @@ -260,6 +307,7 @@ extension _PlayerGestures on _PlayerPageState { } else { final isLeft = start.dx < constraints.maxWidth * 0.5; _dragSwipeType = isLeft ? _SwipeType.brightness : _SwipeType.volume; + if (!isDesktopPlatform) unawaited(_rebaseSwipeLevel(_dragSwipeType!)); } } @@ -276,19 +324,52 @@ extension _PlayerGestures on _PlayerPageState { final delta = -(d.delta.dy) / (constraints.maxHeight * 0.7); if (_dragSwipeType == _SwipeType.brightness) { _brightness = (_brightness + delta).clamp(0.0, 1.0).toDouble(); - _pushSystemLevels(brightness: true); + if (_swipeLevelReady) _pushSystemLevels(brightness: true); _swipeIndicator.value = _SwipeIndicator( _SwipeType.brightness, _brightness, ); } else { _volume = (_volume + delta).clamp(0.0, 1.0).toDouble(); - _pushSystemLevels(volume: true); + if (_swipeLevelReady) _pushSystemLevels(volume: true); _swipeIndicator.value = _SwipeIndicator(_SwipeType.volume, _volume); } } } + /// The level a swipe starts from, read fresh: the volume keys and + /// auto-brightness move them while the player is open. Movement made while + /// the read is in flight is kept and added on top. + Future _rebaseSwipeLevel(_SwipeType type) async { + final token = ++_swipeToken; + final brightness = type == _SwipeType.brightness; + final startLevel = brightness ? _brightness : _volume; + _swipeLevelReady = false; + double? fresh; + try { + fresh = await _systemControlsChannel.invokeMethod( + brightness ? 'getBrightness' : 'getVolume', + ); + } catch (_) {} + if (!mounted || token != _swipeToken) return; + _swipeLevelReady = true; + final moved = (brightness ? _brightness : _volume) - startLevel; + if (fresh != null) { + final level = (fresh + moved).clamp(0.0, 1.0).toDouble(); + if (brightness) { + _brightness = level; + } else { + _volume = level; + } + if (_dragSwipeType == type) { + _swipeIndicator.value = _SwipeIndicator(type, level); + } + } + if (moved != 0) { + _pushSystemLevels(brightness: brightness, volume: !brightness); + } + } + void _onPanEnd(DragEndDetails d) { if (_dragIsHorizontal == true) { _onHDragEnd(d); @@ -315,3 +396,10 @@ extension _PlayerGestures on _PlayerPageState { _dragSwipeType = null; } } + +/// Whether [at] lies in the strips the system keeps for its own gestures. +bool startsInSystemGestureArea(Offset at, Size size, EdgeInsets insets) => + at.dx < insets.left || + at.dx > size.width - insets.right || + at.dy < insets.top || + at.dy > size.height - insets.bottom; diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 1510eddf..88213dff 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -12,10 +12,13 @@ extension _PlayerHistory on _PlayerPageState { /// and the session left no trace at all. Which is exactly the report. void _scheduleHistorySave() { _historyTimer?.cancel(); - _historyTimer = Timer.periodic(const Duration(seconds: 5), (_) { + _historyTimer = Timer.periodic(const Duration(seconds: 5), (t) { // Quiet: this tick is for surviving a kill, not for the screens under // the player, which catch up from the save on pause and on exit. - _saveHistory(notify: false); + // Except once a minute while docked in the corner: then the screens + // under the player are the ones being looked at, and Continue Watching + // should keep up. + _saveHistory(notify: _docked && t.tick % 12 == 0); _jellyfinProgress(); // Deliberately not inside _saveHistory: that returns early for a // finished episode, a title with no url and a session under ten seconds, @@ -158,7 +161,13 @@ 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) + .then((_) { + if (getIt.isRegistered()) { + getIt().onWatched(); + } + }), ); } @@ -337,6 +346,8 @@ extension _PlayerHistory on _PlayerPageState { currentUrl: _videoUrl, currentHeaders: _headers.isNotEmpty ? _headers : widget.args.headers, hasDirective: _extractorConfig != null, + currentUpstreamUrl: _downloadUpstream?.url, + currentUpstreamHeaders: _downloadUpstream?.headers ?? const {}, ); if (offers.isEmpty) return null; if (offers.length == 1) return offers.single; @@ -442,12 +453,7 @@ extension _PlayerHistory on _PlayerPageState { if (!mounted) return null; if (sniffed == null || sniffed.url.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('detail.download_needs_playback'.tr()), - behavior: SnackBarBehavior.floating, - ), - ); + _toast('detail.download_needs_playback'.tr()); return null; } return DownloadChoice( @@ -524,12 +530,6 @@ extension _PlayerHistory on _PlayerPageState { ), ); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(downloadOutcomeMessage(outcome)), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); + _toast(downloadOutcomeMessage(outcome), icon: Icons.download_rounded); } } diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 8ba1fd34..71551e8b 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -2,15 +2,6 @@ part of 'player_page.dart'; extension _PlayerMedia on _PlayerPageState { - String? _defaultRefererFor(String provider) { - switch (provider.toLowerCase()) { - case 'asilmedia': - return 'https://asilmedia.org/'; - default: - return null; - } - } - bool _isHlsType(String? type) { final t = type?.trim().toLowerCase(); return t == 'hls' || t == 'm3u8'; @@ -29,7 +20,8 @@ extension _PlayerMedia on _PlayerPageState { } /// The headers a network stream is requested with: the app's defaults, then - /// whatever the source asked for on top. + /// whatever the source asked for on top (see [mediaRequestHeaders], which + /// the downloader shares). /// /// Nothing at all for loopback — the local HLS proxy already carries the /// upstream headers, and it is our own server. @@ -38,16 +30,11 @@ extension _PlayerMedia on _PlayerPageState { Map sourceHeaders, ) { if (uri.host == '127.0.0.1' || uri.host == 'localhost') return {}; - final merged = { - 'User-Agent': kSozoUserAgent, - 'Accept': '*/*', - 'Accept-Language': 'uz,ru;q=0.9,en;q=0.8', - }; - final defaultReferer = _defaultRefererFor(widget.args.provider); - if (defaultReferer != null) merged['Referer'] = defaultReferer; - merged.addAll(sourceHeaders); - addFetchMetadata(merged, uri); - return merged; + return mediaRequestHeaders( + uri, + sourceHeaders, + provider: widget.args.provider, + ); } /// The same headers, plus any Cloudflare clearance already earned for this @@ -263,6 +250,7 @@ extension _PlayerMedia on _PlayerPageState { if (!keepRetryCount) { _retryAttempts = 0; _lifetimeRetries = 0; + _networkReconnects = 0; _resetLadder(); // A crop tuned for a 2.39:1 film is wrong for the 16:9 episode after it. _resetZoom(); @@ -270,11 +258,16 @@ extension _PlayerMedia on _PlayerPageState { // The previous episode's opening/ending times do not apply to this one, // and leaving them would offer a skip at the wrong minute. _resetSkipTimes(); + _resetEpisodeSocial(); // A new episode is a new thing to finish. Without this, watching six in a // row would count as one. _countedComplete = false; _endHandled = false; + _endOfStream.reset(); + _stallWatch.reset(); _videoTrackAppliedFor = null; + // The previous episode's playhead is no place to retry this one from. + _lastPlayedPosition = Duration.zero; // The next episode's first frame is a new "watching now" on Trakt; the // start replaces the previous one there, so no pause is needed first. _traktPlaying = false; @@ -354,9 +347,12 @@ extension _PlayerMedia on _PlayerPageState { case Success(:final value): return (value: value, lang: lang); case Failure(:final error): + final raw = error.toString().replaceFirst('Exception: ', ''); setState(() { _initializing = false; - _errorMessage = error.toString().replaceFirst('Exception: ', ''); + // Translated headline; the provider's own words go under Details. + _errorMessage = _resolveFailureMessage(raw); + _errorRaw = raw; }); return null; } @@ -501,13 +497,15 @@ extension _PlayerMedia on _PlayerPageState { ); } } - if (pick < 0) pick = subs.indexWhere((s) => s.isDefault); + // The viewer's language before the source's default, which is usually + // English whatever else is on offer. if (pick < 0) { final wanted = _hive.getSubtitleTranslateLang().toLowerCase(); pick = subs.indexWhere( (s) => SubtitleAutoTranslate.labelMatchesLanguage(s.label, wanted), ); } + if (pick < 0) pick = subs.indexWhere((s) => s.isDefault); if (pick >= 0) unawaited(_loadSubtitle(pick, remember: false)); } @@ -637,6 +635,37 @@ extension _PlayerMedia on _PlayerPageState { return remembered ?? _hive.preferredQuality; } + /// The height to pin out of a parsed master, or Auto to leave the choice to + /// the engine. See [QualityPreference.pinFor]. + /// + /// On the platform player the source's own label counts as a pick: a master + /// labelled "1080p" played there adaptively, and ExoPlayer starts from a + /// bandwidth estimate — on mobile data the bottom rung — so "1080p" on the + /// button looked like 480p on the screen. libmpv keeps its own Auto, which + /// opens the top rendition anyway (PlayerController.hlsBitrateFor). + int get _pinHeight { + final idx = _currentSourceIndex; + final source = idx >= 0 && idx < _videoSources.length + ? _videoSources[idx] + : null; + return QualityPreference.pinFor( + preference: _qualityPreference, + labelledHeight: source == null + ? null + : source.height ?? VideoOptionGroups.resolutionOf(source.quality), + adaptiveEngine: _onPlatformPlayer, + ); + } + + /// Whether the next stream opens on the platform player (ExoPlayer, + /// AVPlayer) rather than libmpv — the same choice + /// `PlayerController.networkUrl` makes. An external player is neither: it + /// is handed the url and does its own choosing. + bool get _onPlatformPlayer => + _preferPlatformPlayer || + isAndroidEmulator || + (resolvePlayerEngine() == PlayerEngine.native && !_mpvForSession); + /// Starts a fresh walk. Called wherever what is playing genuinely changes — /// a new episode, a new movie, an explicit pick — never on a retry, which is /// the whole point of keeping the set. @@ -810,7 +839,7 @@ extension _PlayerMedia on _PlayerPageState { if (_currentSourceIndex >= 0 && _currentSourceIndex < _videoSources.length) { final current = _videoSources[_currentSourceIndex]; - if (current.useLocalProxy) return current; + if (current.useLocalProxy || current.dropSegments != null) return current; } final host = Uri.tryParse(url)?.host; if (host == null || host.isEmpty) return null; @@ -860,7 +889,7 @@ extension _PlayerMedia on _PlayerPageState { ); return null; } - if (!source.useLocalProxy) { + if (!source.useLocalProxy && source.dropSegments == null) { // If this fires for a uzmovi source, the backend flag or the // localProxy/requestTransform maps were dropped somewhere between resolve // and here — the player then hits the protected CDN directly and fails. @@ -881,6 +910,8 @@ extension _PlayerMedia on _PlayerPageState { headers: upstreamHeaders, localProxy: source.localProxy, requestTransform: source.requestTransform, + keepOriginHeaders: source.dropSegments != null, + dropSegments: source.dropSegments, ); _plog('routing through local HLS proxy: $proxied'); return _ProxiedTarget(url: proxied, headers: const {}); @@ -968,6 +999,7 @@ extension _PlayerMedia on _PlayerPageState { Duration resumeAt = Duration.zero, PartyPlayback? party, int? intentGeneration, + bool reconnect = false, }) async { final generation = intentGeneration ?? ++_mediaGeneration; if (!mounted || generation != _mediaGeneration) return; @@ -980,6 +1012,7 @@ extension _PlayerMedia on _PlayerPageState { var effUrl = url; var effHeaders = headers; var effType = type; + var sniffedStream = false; // A file the device must sign itself (see UrlSigner): signed here, on // every start, so a retry after the token expires asks for a fresh one. @@ -1036,6 +1069,7 @@ extension _PlayerMedia on _PlayerPageState { ); if (!mounted || generation != _mediaGeneration) return; if (sniffed != null) { + sniffedStream = true; effUrl = sniffed.url; // Sniffed headers win: they are the ones the page actually sent, and // the CDN gates on exactly those. @@ -1086,7 +1120,7 @@ extension _PlayerMedia on _PlayerPageState { effType, generation, ); - final wantsUrlPin = _qualityPreference != QualityPreference.auto; + final wantsUrlPin = _pinHeight != QualityPreference.auto; VideoSourceEntity? pinned; if (wantsUrlPin) { pinned = await expansion.timeout( @@ -1119,6 +1153,8 @@ extension _PlayerMedia on _PlayerPageState { type: effType, resumeAt: resumeAt, party: party, + sniffed: sniffedStream, + reconnect: reconnect, ); } @@ -1236,6 +1272,7 @@ extension _PlayerMedia on _PlayerPageState { localProxy: parent.localProxy, requestTransform: parent.requestTransform, longLived: true, + dropSegments: parent.dropSegments, ) : v.url; rows.add( @@ -1248,6 +1285,7 @@ extension _PlayerMedia on _PlayerPageState { type: 'hls', headers: upHeaders, useLocalProxy: !separateRenditions && parent.useLocalProxy, + dropSegments: parent.dropSegments, localProxy: parent.localProxy, requestTransform: parent.requestTransform, drm: parent.drm, @@ -1360,7 +1398,7 @@ extension _PlayerMedia on _PlayerPageState { // this a pick of "Server · 720p" fell back to Auto on every next episode. final want = QualityPreference.pick( rows.map((r) => r.height ?? 0), - _qualityPreference, + _pinHeight, ); if (want == null) return null; return rows.where((r) => r.height == want).firstOrNull; @@ -1396,6 +1434,8 @@ extension _PlayerMedia on _PlayerPageState { required String? type, Duration resumeAt = Duration.zero, PartyPlayback? party, + bool sniffed = false, + bool reconnect = false, }) async { if (!mounted || generation != _mediaGeneration) return; if (url.isEmpty) { @@ -1411,7 +1451,9 @@ extension _PlayerMedia on _PlayerPageState { // — the single funnel every playback path passes through — means Sozo's own // torrent search, a CloudStream plugin that returns a magnet, and a pasted // deeplink all behave identically, instead of each growing its own version. - if (TorrentLinks.isTorrentLink(url)) { + final torrent = + TorrentLinks.isTorrentLink(url) || TorrentStreamUrl.parse(url) != null; + if (torrent) { final handle = await TorrentPlayback.prepareLink( context, url, @@ -1452,6 +1494,10 @@ extension _PlayerMedia on _PlayerPageState { if (!mounted || generation != _mediaGeneration) return; final effectiveUrl = proxied?.url ?? url; final effectiveHeaders = proxied?.headers ?? headers; + _downloadUpstream = + proxied != null && MediaSegmentRelay.needsManifestProxy(url) + ? (url: url, headers: headers) + : null; final fmt = isHls ? 'hls' @@ -1501,6 +1547,7 @@ extension _PlayerMedia on _PlayerPageState { controller = PlayerController.networkUrl( fileUri, preferPlatform: _preferPlatformPlayer, + preferMediaKit: _mpvForSession, formatHint: VideoFormat.hls, videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: false), ); @@ -1512,6 +1559,7 @@ extension _PlayerMedia on _PlayerPageState { controller = PlayerController.file( file, preferPlatform: _preferPlatformPlayer, + preferMediaKit: _mpvForSession, videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: false), ); _headers = const {}; @@ -1536,13 +1584,22 @@ extension _PlayerMedia on _PlayerPageState { : null; if (drm != null) _plog('drm: $drm'); - PlayerController.mpvHlsBitrate = PlayerController.hlsBitrateFor( - _qualityPreference, - ); + PlayerController.mpvHlsBitrate = + (_qualityPreference == QualityPreference.auto + ? _autoBitrate + : null) ?? + MpvAutoQuality.startBitrate( + preference: _qualityPreference, + network: _qualityPreference == QualityPreference.auto + ? await _networkKind() + : NetworkKind.unknown, + ); + if (!mounted || generation != _mediaGeneration) return; controller = PlayerController.networkUrl( uri, httpHeaders: mergedHeaders, preferPlatform: _preferPlatformPlayer, + preferMediaKit: _mpvForSession, formatHint: isHls ? VideoFormat.hls : isDash @@ -1564,8 +1621,30 @@ extension _PlayerMedia on _PlayerPageState { _isLive = true; } + // Bounded: see [startupBudget] for why neither engine can be left to it + // and how long each kind of stream gets. A start that runs out lands in + // the catch below like any other failure — "timeout" is recoverable, so + // the walk moves to the next server under the "Switching to …" notice, + // and only an exhausted ladder reaches the error screen. A controller + // that hands the stream from libmpv to the platform player mid-start + // gives the new engine its own allowance. + final budget = startupBudget( + local: isLocal, + torrent: torrent, + sniffed: sniffed, + live: _isLive, + ); try { - await controller.initialize(); + final init = controller.initialize(); + if (budget == null) { + await init; + } else { + await withinStartupBudget( + init, + budget: budget, + restarts: controller.backendSwitches, + ); + } _plog('initialize completed in ${stopwatch.elapsedMilliseconds}ms'); if (!mounted || generation != _mediaGeneration || @@ -1623,6 +1702,7 @@ extension _PlayerMedia on _PlayerPageState { _playbackSpeed = party.rate; } await controller.setPlaybackSpeed(_playbackSpeed); + await _reapplyVolume(controller); if (!mounted || generation != _mediaGeneration) return; if (!_isLive) { final expected = party.expectedPositionAt(DateTime.now()); @@ -1638,6 +1718,7 @@ extension _PlayerMedia on _PlayerPageState { } } else { await controller.setPlaybackSpeed(_playbackSpeed); + await _reapplyVolume(controller); if (!mounted || generation != _mediaGeneration) return; if (resumeAt > Duration.zero && !_isLive) { await controller.seekTo(resumeAt); @@ -1706,6 +1787,7 @@ extension _PlayerMedia on _PlayerPageState { // submissions timed against a different cut. Unawaited because a skip // offer is an extra — playback must never wait on a third-party lookup. unawaited(_loadSkipTimes()); + unawaited(_loadEpisodeSocial()); // After the provider's own tracks have been set, so it can see whether // one of them already reads in the viewer's language. Unawaited and // silent unless it finds something: playback must never wait on it. @@ -1723,7 +1805,26 @@ extension _PlayerMedia on _PlayerPageState { AnalyticsProp.reason: _isDecoderError(raw) ? 'codec' : 'load', }, ); + // A reconnect after a dropped connection that met the network still + // down: the same server again, after a longer wait, while the in-place + // budget lasts. Walking to another mirror here would spend the ladder on + // a phone that is simply offline. + if (reconnect && + RetryPolicy.shouldReconnectInPlace( + message: raw, + isLive: _isLive, + reconnects: _networkReconnects, + )) { + _networkReconnects++; + _autoRetrying = true; + unawaited(_reconnectInPlace()); + return; + } + // Not after a start that ran out of time: a challenge answers at once, + // and probing a host that is trickling would only add its own wait + // before the next server. if (code != 'channel-error' && + e is! TimeoutException && !_isDecoderError(raw) && !_isLive && await _solveStreamCloudflare(generation)) { @@ -1807,9 +1908,7 @@ extension _PlayerMedia on _PlayerPageState { _autoRetry(); return; } - msg = raw.isEmpty - ? PlaybackFaultKind.unknown.messageKey.tr() - : _humanizeError(raw); + msg = _exhaustedMessage(raw, timedOut: e is TimeoutException); } setState(() { _initializing = false; @@ -1822,18 +1921,38 @@ extension _PlayerMedia on _PlayerPageState { /// A failure, in the viewer's language. /// /// Classification lives in [PlaybackFault] where it can be tested without a - /// widget; this only renders it. `unknown` keeps the engine's own words — - /// an untranslated detail is more use than a translated non-answer. - String _faultMessage(PlaybackFault fault) { - if (fault.kind == PlaybackFaultKind.unknown && fault.raw.isNotEmpty) { - return fault.raw; - } - return fault.messageKey.tr(); + /// widget; this only renders it. The engine's own words are kept in + /// [_errorRaw] and shown under Details on the error screen. + String _faultMessage(PlaybackFault fault) => fault.messageKey.tr(); + + /// A failed resolve, as a headline: a named fault when there is one, else + /// "no link from this source". The raw message is the error's details. + String _resolveFailureMessage(String raw) { + final fault = PlaybackFault.classify(raw); + return fault.kind == PlaybackFaultKind.unknown + ? 'player.fault.resolve_failed'.tr() + : fault.messageKey.tr(); } String _humanizeError(String raw) => _faultMessage(PlaybackFault.classify(raw)); + /// What the error screen says once nothing is left to try. See + /// [PlaybackFault.exhaustedKey]: a dropped connection by name, several + /// failed servers as such, a start that never produced a frame as a + /// timeout — not the last server's raw sentence or a Dart TimeoutException. + String _exhaustedMessage(String raw, {bool timedOut = false}) { + final key = PlaybackFault.exhaustedKey( + raw, + timedOut: timedOut, + serversTried: _triedSourceUrls.length, + ); + if (key != null) return key.tr(); + return raw.isEmpty + ? PlaybackFaultKind.unknown.messageKey.tr() + : _humanizeError(raw); + } + /// Whether the device simply cannot decode this stream. /// /// Distinct from a recoverable error, and the distinction is the whole point: @@ -1881,16 +2000,41 @@ extension _PlayerMedia on _PlayerPageState { final v = c.value; _syncWakelock(v.isPlaying); + _syncPlayerSystem(); _syncTraktScrobble(v.isPlaying); _applyEmbeddedSubtitleChoice(); _applyPendingAudioChoice(); _applyRememberedVideoTrack(); + if (v.isInitialized && !v.hasError && v.position > Duration.zero) { + _lastPlayedPosition = v.position; + } if (v.hasError) { final msg = v.errorDescription; if (msg != null && msg != _lastError && mounted) { _lastError = msg; _plog('playback error: $msg', level: LogLevel.error); + // The connection went away under a stream that was playing: the same + // server, where the viewer was, before anything else. Walking to the + // next mirror is for a server that failed; this one was fine a moment + // ago (log_2e4886: a vidapi episode lost twice to a phone whose + // connection dropped, not to vidapi). + _networkReconnects = RetryPolicy.networkReconnectsSpent( + reconnects: _networkReconnects, + playedSinceLast: _playbackWatch.elapsed - _networkReconnectWatch, + ); + if (!_autoRetrying && + RetryPolicy.shouldReconnectInPlace( + message: msg, + isLive: _isLive, + reconnects: _networkReconnects, + )) { + _networkReconnects++; + _networkReconnectWatch = _playbackWatch.elapsed; + _autoRetrying = true; + unawaited(_reconnectInPlace()); + return; + } // 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 @@ -1917,6 +2061,24 @@ extension _PlayerMedia on _PlayerPageState { // deliberately returns false for it, so without this the walk stopped // here with untried mirrors left and the viewer had to open Quality and // pick one by hand. + // mpv that played and then failed: once, the same stream at the same + // place with software decoding, before this server is given up on. + if (!_autoRetrying && + !_isLive && + c.isMpv && + !_mpvSoftwareRetried && + _lastPlayedPosition > Duration.zero && + PlaybackFault.classify(msg).kind != PlaybackFaultKind.network) { + _mpvSoftwareRetried = true; + PlayerController.mpvHwdec = 'no'; + _autoRetrying = true; + _plog( + 'mpv failed mid-playback — reopening with hwdec=no', + level: LogLevel.warn, + ); + unawaited(_reopenAt(_lastPlayedPosition, autoplay: true)); + return; + } if (!_autoRetrying && _isDecoderError(msg)) { _decoderAvoidCodec = _currentSourceIndex >= 0 && @@ -1933,7 +2095,14 @@ extension _PlayerMedia on _PlayerPageState { } setState(() => _isCodecError = true); } - setState(() => _errorMessage = _humanizeError(msg)); + // A dropped connection that outlasted the reconnects is said in the + // viewer's language (PlaybackFault.classify), not as ffmpeg's + // "tcp: ffurl_read returned 0xffffff92". The raw line is kept for + // the Cloudflare check and the report. + setState(() { + _errorMessage = _humanizeError(msg); + _errorRaw = msg; + }); } return; } @@ -1966,7 +2135,16 @@ extension _PlayerMedia on _PlayerPageState { if (v.isBuffering != _wasBuffering) { _wasBuffering = v.isBuffering; changed = true; + if (v.isBuffering) _onBufferingStall(c); + } + if (v.isInitialized && + !identical(_orientedFor, c) && + v.size.width > 0 && + v.size.height > 0) { + _orientedFor = c; + _autoOrient(v.size); } + _maybeOfferNativeControls(); if (v.isInitialized && v.duration.inMilliseconds > 0) { if (WatchProgress.isWatched(v.position, v.duration)) { @@ -1976,51 +2154,213 @@ extension _PlayerMedia on _PlayerPageState { // not add a completion each time they cross it. if (!_countedComplete) { _countedComplete = true; - if (!_hive.isIncognito) unawaited(_watchStats.recordCompleted()); + if (!_hive.isIncognito) { + unawaited(_watchStats.recordCompleted()); + if (widget.args.provider != 'trailer') { + unawaited(StarPrompt.episodeCompleted()); + } + } } } _updateActiveSkip(v.position); + _tickEpisodeSocial(v.position); _maybePrefetchNext(v.position, v.duration); - final remaining = v.duration - v.position; - final isEnding = remaining <= const Duration(seconds: 2); - if (isEnding) { - // Guests in a party never self-advance — they wait for the host's - // next party:content. The host auto-advances and emits it. - final guestInParty = _inParty && !_isPartyHost; - // Auto-advance is opt-out, not opt-in: it is what the player has - // always done. Turning it off leaves the episode parked on its last - // frame, which is also what makes the history entry below correct. - // A sleep timer set to "end of episode" parks here rather than - // advancing. It deliberately reuses the auto-advance-off path below, - // so the episode is marked finished exactly as it would be for someone - // who turned auto-advance off — the timer changes when playback stops, - // not what counts as watched. - if (!guestInParty && - !_sleepAtEpisodeEnd && - !_upNextDismissed && - _hive.autoPlayNextEpisode && - widget.args.isSerial && - _hasNextEpisode) { - _saveHistoryForNextEpisode(); - // Marks the next load as a continuation rather than an opening, so - // "start paused" does not stop a run that is already going. - _autoAdvanced = true; - _loadEpisode(_episodeIndex + 1); - return; - } - if (!_endHandled) { - _endHandled = true; - _recordFinishedWithoutAdvancing(); - } - if (_sleepAtEpisodeEnd) unawaited(_fireSleepTimer()); - } + if (_checkEnd()) return; } if (changed && mounted) setState(() {}); } + /// Looks for the end of the episode: the last two seconds, the engine's + /// completion, or a playhead stuck just short of the end (an overstated + /// duration). Also run once a second, since a stuck playhead sends no + /// updates. True when it moved on to the next episode. + bool _checkEnd() { + final c = _controller; + if (c == null || !mounted || _isLive) return false; + final v = c.value; + if (!v.isInitialized || v.hasError || v.duration <= Duration.zero) { + return false; + } + final ended = _endOfStream.observe( + position: v.position, + duration: v.duration, + isCompleted: v.isCompleted, + isPlaying: v.isPlaying, + isBuffering: v.isBuffering, + now: _now, + ); + if (!ended) return false; + _plog('episode ended at ${v.position} of ${v.duration}'); + return _onEpisodeEnded(); + } + + bool _onEpisodeEnded() { + // Guests in a party never self-advance — they wait for the host's next + // party:content. The host auto-advances and emits it. + final guestInParty = _inParty && !_isPartyHost; + // Auto-advance is opt-out. A sleep timer set to "end of episode" parks + // here instead, through the same path as auto-advance being off, so the + // episode is marked finished either way. + if (!guestInParty && + !_sleepAtEpisodeEnd && + !_upNextDismissed && + _hive.autoPlayNextEpisode && + widget.args.isSerial && + _hasNextEpisode) { + _saveHistoryForNextEpisode(); + // A continuation, so "start paused" does not stop a run in progress. + _autoAdvanced = true; + _loadEpisode(_episodeIndex + 1); + return true; + } + if (!_endHandled) { + _endHandled = true; + _recordFinishedWithoutAdvancing(); + } + if (_sleepAtEpisodeEnd) unawaited(_fireSleepTimer()); + return false; + } + + /// mpv Auto: a stall during playback counts toward stepping down a rung. + void _onBufferingStall(PlayerController c) { + if (!c.isMpv || + _isLive || + !_isHls || + _qualityPreference != QualityPreference.auto || + _lastPlayedPosition <= Duration.zero || + _autoRetrying) { + return; + } + if (_stallWatch.recordStall(_now)) unawaited(_lowerAutoQuality(c)); + } + + Future _lowerAutoQuality(PlayerController c) async { + final current = PlayerController.mpvHlsBitrate; + final size = c.value.size; + final next = MpvAutoQuality.stepDown( + currentHeight: QualityPreference.displayHeight( + size.width.round(), + size.height.round(), + ), + current: current, + ); + if (next == null) return; + _autoBitrateBefore ??= current; + _autoBitrate = next; + _plog( + 'repeated stalls — hls-bitrate $current -> $next', + level: LogLevel.warn, + ); + _toast( + 'player.quality_lowered'.tr(), + icon: Icons.network_check_rounded, + duration: const Duration(seconds: 6), + action: 'player.undo'.tr(), + onAction: () => unawaited(_undoLowerQuality()), + ); + _autoRetrying = true; + await _reopenAt( + _retryPosition, + autoplay: c.value.isPlaying || c.value.isBuffering, + ); + } + + Future _undoLowerQuality() async { + final before = _autoBitrateBefore; + if (before == null) return; + _stallWatch.disable(); + _autoBitrate = before; + _autoBitrateBefore = null; + _plog('lowered quality undone — hls-bitrate $before'); + final v = _controller?.value; + await _reopenAt( + _retryPosition, + autoplay: v == null || v.isPlaying || v.isBuffering, + ); + } + + /// Wi-Fi or a cable, mobile data, or unknown — for Auto's starting rung. + Future _networkKind() async { + try { + final r = await Connectivity().checkConnectivity().timeout( + const Duration(milliseconds: 600), + ); + if (r.contains(ConnectivityResult.wifi) || + r.contains(ConnectivityResult.ethernet)) { + return NetworkKind.unmetered; + } + if (r.contains(ConnectivityResult.mobile)) return NetworkKind.cellular; + } catch (_) {} + return NetworkKind.unknown; + } + + /// Opens what is playing again, on the same server, at [at]: a lower mpv + /// rung, software decoding, or another engine. + Future _reopenAt(Duration at, {required bool autoplay}) async { + if (!mounted) return; + final url = _playSourceUrl ?? _videoUrl; + if (url == null) { + _autoRetrying = false; + return; + } + final headers = _playSourceUrl != null ? _playSourceHeaders : _headers; + final type = _playSourceType; + _autoplayOverride = autoplay; + setState(() { + _initializing = true; + _stage = _LoadingStage.loading; + _errorMessage = null; + _errorRaw = null; + _isCodecError = false; + }); + final generation = await _disposeController(); + if (!mounted || generation != _mediaGeneration) return; + await _initializeWith( + intentGeneration: generation, + url: url, + headers: headers, + type: type, + resumeAt: at, + ); + if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } + + /// "Try with the mpv engine" on the error screen: this stream, from where it + /// was, on media_kit. Only for this player. + Future _tryWithMpv() async { + _plog('codec fault on the native engine — trying mpv for this session'); + _mpvForSession = true; + _preferPlatformPlayer = false; + await _reopenAt(_retryPosition, autoplay: true); + } + + /// A vertical clip turns the phone to portrait, and the next landscape + /// video turns it back. A choice the viewer made by hand is left alone. + void _autoOrient(Size size) { + if (!isMobilePlatform || isTvPlatform || _isPip) return; + final vertical = size.width / size.height < 1; + if (vertical && !_isPortrait) { + _autoPortrait = true; + unawaited(_toggleOrientation()); + } else if (!vertical && _isPortrait && _autoPortrait) { + _autoPortrait = false; + unawaited(_toggleOrientation()); + } + } + + Future _reapplyVolume(PlayerController controller) async { + if (!isDesktopPlatform) return; + try { + await _desktopVolume.applyTo( + controller.setVolume, + max: controller.maxVolume, + ); + } catch (_) {} + } + /// What history says once an episode has played out and nothing advanced. /// /// This used to call `_history.remove(contentUrl)` for everything, on every @@ -2097,9 +2437,7 @@ extension _PlayerMedia on _PlayerPageState { // `dispose`'s sync pushed that to every other device. Quality and language // switches have always carried the position through; a retry is the same // move for a worse reason. - final keepPosition = _isLive - ? Duration.zero - : (_controller?.value.position ?? Duration.zero); + final keepPosition = _retryPosition; // Every remaining mirror, in ladder order — not `+ 1` once and done. _markCurrentTried(); @@ -2109,6 +2447,13 @@ extension _PlayerMedia on _PlayerPageState { ).next(); if (nextIdx != null) { final next = _videoSources[nextIdx]; + // The same from -> to screen a manual pick gets, with the attempt count + // and a way out. Read before _currentQuality moves to the new row. + final step = ServerSwitch.automatic( + from: _currentServer, + to: VideoOptionGroups.serverOf(next.quality), + attempt: _lifetimeRetries, + ); setState(() { _initializing = true; _stage = _LoadingStage.loading; @@ -2117,31 +2462,37 @@ extension _PlayerMedia on _PlayerPageState { _isCodecError = false; _currentSourceIndex = nextIdx; _currentQuality = next.quality; + _serverSwitch = step; }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('player.switching_to'.tr(args: [next.quality])), - backgroundColor: Colors.black87, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), + _tvFocusStopSwitch(); + try { + final generation = await _disposeController(); + await Future.delayed(const Duration(milliseconds: 350)); + if (!mounted || generation != _mediaGeneration) return; + await _initializeWith( + intentGeneration: generation, + url: next.videoUrl, + headers: next.headers.isNotEmpty ? next.headers : _resolveHeaders, + type: _typeOf(next), + resumeAt: keepPosition, ); + if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } finally { + // Not a later step's overlay, which a failure here may have set up. + if (mounted && identical(_serverSwitch, step)) { + setState(() => _serverSwitch = null); + } } - final generation = await _disposeController(); - await Future.delayed(const Duration(milliseconds: 350)); - if (!mounted || generation != _mediaGeneration) return; - await _initializeWith( - intentGeneration: generation, - url: next.videoUrl, - headers: next.headers.isNotEmpty ? next.headers : _resolveHeaders, - type: _typeOf(next), - resumeAt: keepPosition, - ); - if (mounted && generation == _mediaGeneration) _autoRetrying = false; return; } + // Nothing else to switch to: the same source again, re-resolved for a + // serial or reopened for a film, on the same screen as a switch, with the + // attempt count and Stop. + final again = ServerSwitch.retrySame( + to: _currentServer ?? _episodeTitle(), + attempt: _lifetimeRetries, + ); setState(() { _initializing = true; _stage = widget.args.isSerial @@ -2150,37 +2501,183 @@ extension _PlayerMedia on _PlayerPageState { _errorMessage = null; _errorRaw = null; _isCodecError = false; + _serverSwitch = again; }); - final generation = await _disposeController(); - await Future.delayed(const Duration(milliseconds: 350)); - if (!mounted || generation != _mediaGeneration) return; - if (widget.args.isSerial) { - _autoRetrying = false; - await _loadEpisode( - _episodeIndex, - keepRetryCount: true, - resumeAt: keepPosition, - ); - return; - } else if (_videoUrl != null) { + _tvFocusStopSwitch(); + try { + final generation = await _disposeController(); + await Future.delayed(const Duration(milliseconds: 350)); if (!mounted || generation != _mediaGeneration) return; - await _initializeWith( - intentGeneration: generation, - // Same reason as the manual retry: re-run what produced the stream, - // not the stream. See [_playSourceUrl]. - url: _playSourceUrl ?? _videoUrl!, - headers: _playSourceUrl != null ? _playSourceHeaders : _headers, - type: _mediaType, - resumeAt: keepPosition, - ); - } else { + if (widget.args.isSerial) { + _autoRetrying = false; + await _loadEpisode( + _episodeIndex, + keepRetryCount: true, + resumeAt: keepPosition, + ); + return; + } else if (_videoUrl != null) { + await _initializeWith( + intentGeneration: generation, + // Same reason as the manual retry: re-run what produced the stream, + // not the stream. See [_playSourceUrl]. + url: _playSourceUrl ?? _videoUrl!, + headers: _playSourceUrl != null ? _playSourceHeaders : _headers, + type: _playSourceType, + resumeAt: keepPosition, + ); + } else { + _autoRetrying = false; + await _bootstrap(); + return; + } + if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } finally { + if (mounted && identical(_serverSwitch, again)) { + setState(() => _serverSwitch = null); + } + } + } + + /// On TV, the Stop of an automatic switch takes focus as it appears. + void _tvFocusStopSwitch() { + if (!isTvPlatform) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _tvStopSwitchFocus.context != null) { + _tvStopSwitchFocus.requestFocus(); + } + }); + } + + /// The viewer's Stop on an automatic server switch. + /// + /// Drops the stream being opened and shows the error screen, which already + /// offers Try again, the server list and other sources. + Future _stopAutoSwitch() async { + if (!mounted || _serverSwitch?.automatic != true) return; + _plog('automatic server switch stopped by the viewer'); + setState(() { + _serverSwitch = null; + _initializing = false; + _autoRetrying = false; + _isCodecError = false; + _errorMessage = 'player.auto_switch_stopped'.tr(); + _errorRaw = null; + }); + // Bumps the generation, so the start in flight bails at its next check + // and disposes whatever it had opened. + await _disposeController(); + } + + /// Where a retry picks up: the playhead, else the last position the stream + /// reported while playing, else where the start was headed. + /// + /// Reading the controller alone restarted episodes from 0:00. ExoPlayer + /// replaces its whole value on an error (video_player's + /// `VideoPlayerValue.erroneous`), and a controller that failed to start has + /// no position at all — so a drop thirty minutes in, or a second attempt + /// after a first one that did not open, began the episode again. + Duration get _retryPosition { + if (_isLive) return Duration.zero; + final v = _controller?.value; + if (v != null && + v.isInitialized && + !v.hasError && + v.position > Duration.zero) { + return v.position; + } + if (_lastPlayedPosition > Duration.zero) return _lastPlayedPosition; + return _pendingResume; + } + + /// The type to re-open [_playSourceUrl] with. + /// + /// Its source's own when a source carries that url: [_mediaType] is what + /// was finally played, so for an embed page it is the type the sniff found + /// ("hls"), and re-opening the page with it skipped the sniff and handed + /// the page itself to the decoder. + String? get _playSourceType { + final url = _playSourceUrl; + if (url != null) { + for (final s in _videoSources) { + if (s.videoUrl == url) return _typeOf(s); + } + } + return _mediaType; + } + + /// Re-opens the stream that was playing, on the same server and where it + /// was, after its connection dropped. + /// + /// The counterpart of [_liveReconnect] for a file: wait a little longer + /// each time ([RetryPolicy.networkBackoff]), and for the device to report a + /// network at all, then start the same source again at the same position. + /// Bounded by [RetryPolicy.maxNetworkReconnects]; after that a drop goes + /// the ordinary way, to another mirror or a fresh resolve. + Future _reconnectInPlace() async { + if (!mounted) return; + final intent = ++_mediaGeneration; + final attempt = _networkReconnects; + final at = _retryPosition; + _plog( + 'connection dropped at $at — reconnecting in place (attempt $attempt)', + level: LogLevel.warn, + ); + setState(() { + _initializing = true; + _stage = _LoadingStage.loading; + _errorMessage = null; + _errorRaw = null; + _isCodecError = false; + }); + _toast('player.reconnecting'.tr(), icon: Icons.wifi_rounded); + await Future.delayed(RetryPolicy.networkBackoff(attempt - 1)); + await _awaitConnectivity(const Duration(seconds: 30)); + if (!mounted || intent != _mediaGeneration) return; + + final url = _playSourceUrl ?? _videoUrl; + if (url == null) { _autoRetrying = false; - await _bootstrap(); return; } + final headers = _playSourceUrl != null ? _playSourceHeaders : _headers; + final type = _playSourceType; + final generation = await _disposeController(); + if (!mounted || generation != _mediaGeneration) return; + await _initializeWith( + intentGeneration: generation, + url: url, + headers: headers, + type: type, + resumeAt: at, + reconnect: true, + ); if (mounted && generation == _mediaGeneration) _autoRetrying = false; } + /// Waits, up to [limit], for the device to report a network again. + /// + /// Reconnecting while the phone has none fails at once and spends an + /// attempt; waiting for the radio is what a person would do. Best-effort: + /// a platform that cannot tell is treated as online. + Future _awaitConnectivity(Duration limit) async { + bool online(List r) => + r.any((c) => c != ConnectivityResult.none); + final connectivity = Connectivity(); + try { + if (online(await connectivity.checkConnectivity())) return; + final back = Completer(); + final sub = connectivity.onConnectivityChanged.listen((r) { + if (online(r) && !back.isCompleted) back.complete(); + }); + try { + await back.future.timeout(limit, onTimeout: () {}); + } finally { + await sub.cancel(); + } + } catch (_) {} + } + Future _disposeController() async { _jellyfinStop(); final generation = ++_mediaGeneration; @@ -2306,9 +2803,7 @@ extension _PlayerMedia on _PlayerPageState { // retry is nearly always a mid-episode drop-out, and reloading from zero // would throw away however far the viewer had got. A live stream has no // meaningful position to come back to, so it starts at the edge. - final keepPosition = _isLive - ? Duration.zero - : (_controller?.value.position ?? Duration.zero); + final keepPosition = _retryPosition; if (widget.args.isSerial) { await _loadEpisode(_episodeIndex, resumeAt: keepPosition); } else if (_videoUrl != null) { @@ -2328,7 +2823,7 @@ extension _PlayerMedia on _PlayerPageState { // back into the sniffer. url: _playSourceUrl ?? _videoUrl!, headers: _playSourceUrl != null ? _playSourceHeaders : _headers, - type: _mediaType, + type: _playSourceType, resumeAt: keepPosition, ); } @@ -2373,8 +2868,9 @@ extension _PlayerMedia on _PlayerPageState { if (!mounted) return; final body = response.data; if (body != null && body.isNotEmpty) { - _vttThumbnails = _VttThumbnail.parse(body, url); + _vttThumbnails = parseVttThumbnails(body, url); _plog('loaded ${_vttThumbnails.length} VTT thumbnails'); + _precacheSpriteSheets(); } } catch (e) { _plog('VTT thumbnails load error: $e', level: LogLevel.warn); @@ -2382,6 +2878,22 @@ extension _PlayerMedia on _PlayerPageState { } } + /// The first sheets, so the opening drag does not wait on the network. + void _precacheSpriteSheets() { + final sheets = _vttThumbnails.map((t) => t.imageUrl).toSet().take(2); + for (final sheet in sheets) { + // A sheet that never answers has no error to report, and the card would + // sit on its skeleton with a working decoder right there. + unawaited( + precacheImage( + NetworkImage(sheet, headers: _spriteHeaders), + context, + onError: (_, _) => _onThumbnailFailed(), + ).timeout(const Duration(seconds: 8), onTimeout: _onThumbnailFailed), + ); + } + } + /// A sprite sheet failed to load: stop trusting the source's thumbnails /// for this stream and let the decoder make the preview instead. void _onThumbnailFailed() { @@ -2399,29 +2911,43 @@ extension _PlayerMedia on _PlayerPageState { /// Starts filling the seek-preview grid once playback has settled. /// - /// Eight seconds in, not at once: the first seconds are when playback is + /// Three seconds in, not at once: the first seconds are when playback is /// fighting for bandwidth to fill its buffer, and a scrub that early is /// rare. Skipped when the source has a storyboard (its sprites already are /// the grid), for live streams, and for anything shorter than a minute. - void _schedulePreviewWarm(int generation) { + void _schedulePreviewWarm(int generation, {int waits = 0}) { _previewWarm?.cancel(); - final hasThumbnails = _vttThumbnails.isNotEmpty || _storyboard != null; - if (hasThumbnails && !_thumbnailsFailed) return; + bool sprites() => + (_vttThumbnails.isNotEmpty || _storyboard != null) && + !_thumbnailsFailed; + if (sprites()) return; // Soon after playback settles: most scrubs come in the first minute. // On mobile data it waits longer, so the opening segments come first. _previewWarm = Timer(const Duration(seconds: 3), () async { if (!mounted || generation != _mediaGeneration) return; + // A VTT is fetched while this waits; the grid would duplicate it. + if (sprites()) return; if (!_canGeneratePreview || _isLive) return; final url = _videoUrl; final ms = _controller?.value.duration.inMilliseconds ?? 0; - if (url == null || ms < 60000) return; - var metered = true; - try { - final c = await Connectivity().checkConnectivity(); - metered = - !(c.contains(ConnectivityResult.wifi) || - c.contains(ConnectivityResult.ethernet)); - } catch (_) {} + if (url == null) return; + // The engine may not know the length yet on a slow stream. Asked once, + // that episode went without a grid to the end. + if (ms <= 0 && waits < 10) { + _schedulePreviewWarm(generation, waits: waits + 1); + return; + } + if (ms < 60000) return; + // A download costs no data, whatever the phone is connected to. + var metered = !(url.startsWith('/') || url.startsWith('file:')); + if (metered) { + try { + final c = await Connectivity().checkConnectivity(); + metered = + !(c.contains(ConnectivityResult.wifi) || + c.contains(ConnectivityResult.ethernet)); + } catch (_) {} + } if (metered) await Future.delayed(const Duration(seconds: 5)); if (!mounted || generation != _mediaGeneration) return; FramePreviewService.warm( @@ -2448,7 +2974,7 @@ extension _PlayerMedia on _PlayerPageState { return '${widget.args.provider}|$content|$ep|$_currentLang'; } - _VttThumbnail? _thumbnailAt(Duration position) { + VttThumbnail? _thumbnailAt(Duration position) { final sb = _storyboard; if (sb != null && sb.isStoryboard) { final c = _controller; @@ -2465,7 +2991,7 @@ extension _PlayerMedia on _PlayerPageState { final row = idx ~/ cols; final cellW = (sb.width! / cols).round(); final cellH = (sb.height! / rows).round(); - return _VttThumbnail( + return VttThumbnail( start: Duration.zero, end: Duration.zero, imageUrl: sb.template!, diff --git a/lib/features/detail/presentation/pages/player_page.menus.dart b/lib/features/detail/presentation/pages/player_page.menus.dart new file mode 100644 index 00000000..19516c33 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.menus.dart @@ -0,0 +1,166 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The native pull-down menus of the iOS 26 controls: a flat choice opens +/// as the phone's own menu on the button, where the Flutter sheet held +/// nothing more. Built only on that path ([_nativeBars]); the Flutter +/// buttons keep their sheets. +extension _PlayerMenus on _PlayerPageState { + PlayerMenu get _speedMenu => choiceMenu( + values: _PlayerPanels._speedPresets, + isCurrent: (s) => (s - _playbackSpeed).abs() < 0.01, + label: _speedLabel, + onPick: _setSpeed, + ); + + PlayerMenu get _fitMenu => choiceMenu<_PlayerFit>( + values: _PlayerFit.values, + isCurrent: (f) => f == _fit, + label: _fitLabel, + symbol: _PlayerControls._fitSymbol, + onPick: _setFit, + ); + + /// Everything the sleep sheet offers. + PlayerMenu get _sleepMenu { + String label(Duration d) => d.inMinutes < 60 + ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) + : 'player.sleep_hours'.tr(args: ['${d.inHours}']); + return PlayerMenu([ + if (_sleepArmed) + [ + PlayerMenuOption( + key: 'off', + title: 'general.off'.tr(), + onPick: _cancelSleepTimer, + ), + ], + [ + for (final d in _PlayerSleepTimer._presets) + PlayerMenuOption( + key: d, + title: label(d), + onPick: () => _armSleepDuration(d), + ), + ], + if (widget.args.isSerial) + [ + PlayerMenuOption( + key: 'episode', + title: 'player.sleep_episode_end'.tr(), + checked: _sleepAtEpisodeEnd, + onPick: _armSleepAtEpisodeEnd, + ), + ], + ]); + } + + /// Sub or dub; null with one to choose from. + PlayerMenu? get _langMenu { + final langs = _availableLangsForCurrentEpisode(); + if (langs.length < 2) return null; + return choiceMenu( + values: langs, + isCurrent: (l) => l == _currentLang, + label: _langLabel, + onPick: _switchLang, + ); + } + + /// The hosts, each with the heights it offers; null with one host. + PlayerMenu? get _serverMenu { + final servers = _sourceServers; + if (servers.length < 2) return null; + final labels = _sourceLabels; + return choiceMenu( + values: servers, + isCurrent: (s) => s == _currentServer, + label: (s) => s, + subtitle: (s) { + final q = VideoOptionGroups.qualitiesFor(labels, s).join(' · '); + return q.isEmpty ? null : q; + }, + onPick: _switchServer, + ); + } + + /// The rows the quality panel lists, the panel's own choice of them. + PlayerMenu? get _qualityMenu { + final tracks = _engineVideoTracks; + final active = _controller?.activeVideoTrackId; + QualityMenuEntry file(int i, {required bool selected}) { + final src = _videoSources[i]; + final worst = StreamWarning.worst(src); + return QualityMenuEntry( + key: ('file', i), + label: _qualityLabel(src.quality), + detail: + worst?.message ?? + (src.isDefault ? 'player.recommended'.tr() : null), + blocking: worst?.risk == StreamRisk.blocking, + selected: selected, + onPick: () => _switchQuality(src), + ); + } + + if (tracks.isNotEmpty) { + return qualityMenu( + renditions: [ + for (final t in tracks) + QualityMenuEntry( + key: ('track', t.id), + label: t.isAuto ? 'player.auto'.tr() : t.label, + detail: t.isAuto ? 'player.auto_quality_desc_mpv'.tr() : t.detail, + selected: t.id == active, + onPick: () => _switchVideoTrack(t), + ), + ], + files: [for (final i in _otherServerSources) file(i, selected: false)], + ); + } + return qualityMenu( + renditions: [ + for (final i in _currentServerSources) + file(i, selected: i == _currentSourceIndex), + ], + files: const [], + ); + } + + /// The tracks, Off, and the full sheet last. + PlayerMenu? get _subtitleMenu { + final c = _controller; + final embedded = c?.subtitleTracks ?? const []; + return subtitleMenu( + offLabel: 'player.off'.tr(), + offSelected: + _activeSubtitleIndex == -1 && c?.activeSubtitleTrackId == null, + onOff: _disableSubtitle, + tracks: [ + for (final t in embedded) + SubtitleMenuTrack( + key: ('embedded', t.id), + label: t.hasMetadata + ? t.label + : 'player.subtitle_n'.tr(args: ['${t.ordinal}']), + subtitle: 'player.subtitle_in_video'.tr(), + selected: + _activeSubtitleIndex == -1 && c?.activeSubtitleTrackId == t.id, + onPick: () => _selectEmbeddedSubtitle(t), + ), + for (var i = 0; i < _subtitles.length; i++) + SubtitleMenuTrack( + key: ('file', i), + label: _subtitles[i].label, + subtitle: i == _secondarySubtitleIndex + ? 'player.second_track'.tr() + : null, + selected: i == _activeSubtitleIndex, + onPick: () => _loadSubtitle(i), + ), + ], + moreLabel: 'detail.more_options'.tr(), + onMore: _openSubtitleSheet, + ); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.models.dart b/lib/features/detail/presentation/pages/player_page.models.dart index e3778207..91b59382 100644 --- a/lib/features/detail/presentation/pages/player_page.models.dart +++ b/lib/features/detail/presentation/pages/player_page.models.dart @@ -26,12 +26,74 @@ enum _LoadingStage { resolving, loading } /// server, and that it is on its way — not a generic spinner that looks /// identical to the failure they were trying to escape. class ServerSwitch { - const ServerSwitch({required this.from, required this.to}); + const ServerSwitch({ + required this.from, + required this.to, + this.attempt, + this.maxAttempts, + this.sameServer = false, + }); + + /// One step of the automatic ladder after a failure, counted against + /// [RetryPolicy.maxLifetimeRetries] so the viewer can see the end coming. + factory ServerSwitch.automatic({ + required String? from, + required String to, + required int attempt, + }) { + final shown = attempt < 1 ? 1 : attempt; + return ServerSwitch( + from: from, + to: to, + attempt: shown, + maxAttempts: shown > RetryPolicy.maxLifetimeRetries + ? shown + : RetryPolicy.maxLifetimeRetries, + ); + } + + /// The same source again (re-resolved or reopened) after the ladder ran out + /// of other servers. Still automatic, so the viewer can stop it. + factory ServerSwitch.retrySame({required String to, required int attempt}) { + final step = ServerSwitch.automatic(from: to, to: to, attempt: attempt); + return ServerSwitch( + from: null, + to: to, + attempt: step.attempt, + maxAttempts: step.maxAttempts, + sameServer: true, + ); + } /// Null when nothing was playing yet, so the overlay shows a destination /// rather than a journey. final String? from; final String to; + + /// Set only for an automatic switch, which the viewer can stop. + final int? attempt; + final int? maxAttempts; + + /// Set by [ServerSwitch.retrySame]: "Trying again", not "Switching". + final bool sameServer; + + bool get automatic => attempt != null; +} + +/// The player's one notice at a time: a glass toast at the top centre, with an +/// optional action (Undo). +class _PlayerToast { + const _PlayerToast( + this.message, { + required this.icon, + this.action, + this.onAction, + }); + + final String message; + final IconData icon; + final String? action; + final VoidCallback? onAction; } /// `zoom` carries a scale factor (1.0-3.0), not a 0-1 level like the other @@ -141,97 +203,6 @@ class _ScrubState { (previewPosition() - baseline).inSeconds.abs() < 2; } -class _VttThumbnail { - final Duration start; - final Duration end; - final String imageUrl; - final int x; - final int y; - final int w; - final int h; - - const _VttThumbnail({ - required this.start, - required this.end, - required this.imageUrl, - this.x = 0, - this.y = 0, - this.w = 0, - this.h = 0, - }); - - bool get hasSprite => w > 0 && h > 0; - - bool contains(Duration position) => position >= start && position < end; - - static List<_VttThumbnail> parse(String vttBody, String baseUrl) { - final lines = vttBody.split('\n').map((l) => l.trim()).toList(); - final results = <_VttThumbnail>[]; - final timePattern = RegExp( - r'(\d{2}):(\d{2}):(\d{2})[\.,](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[\.,](\d{3})', - ); - - for (var i = 0; i < lines.length; i++) { - final match = timePattern.firstMatch(lines[i]); - if (match == null) continue; - - final start = Duration( - hours: int.parse(match.group(1)!), - minutes: int.parse(match.group(2)!), - seconds: int.parse(match.group(3)!), - milliseconds: int.parse(match.group(4)!), - ); - final end = Duration( - hours: int.parse(match.group(5)!), - minutes: int.parse(match.group(6)!), - seconds: int.parse(match.group(7)!), - milliseconds: int.parse(match.group(8)!), - ); - - String? imageRef; - for (var j = i + 1; j < lines.length; j++) { - if (lines[j].isNotEmpty) { - imageRef = lines[j]; - break; - } - } - if (imageRef == null) continue; - - var url = imageRef; - var x = 0, y = 0, w = 0, h = 0; - final hashIdx = imageRef.indexOf('#xywh='); - if (hashIdx >= 0) { - url = imageRef.substring(0, hashIdx); - final coords = imageRef.substring(hashIdx + 6).split(','); - if (coords.length == 4) { - x = int.tryParse(coords[0]) ?? 0; - y = int.tryParse(coords[1]) ?? 0; - w = int.tryParse(coords[2]) ?? 0; - h = int.tryParse(coords[3]) ?? 0; - } - } - - if (!url.startsWith('http')) { - final baseUri = Uri.parse(baseUrl); - url = baseUri.resolve(url).toString(); - } - - results.add( - _VttThumbnail( - start: start, - end: end, - imageUrl: url, - x: x, - y: y, - w: w, - h: h, - ), - ); - } - return results; - } -} - class _ProxiedTarget { const _ProxiedTarget({required this.url, required this.headers}); final String url; diff --git a/lib/features/detail/presentation/pages/player_page.native_offer.dart b/lib/features/detail/presentation/pages/player_page.native_offer.dart new file mode 100644 index 00000000..acfbf696 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.native_offer.dart @@ -0,0 +1,57 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The one-time "Try the new player?" offer, and applying a yes in place. +extension _PlayerNativeOffer on _PlayerPageState { + /// Asked when the controls show and when playback changes state; shows the + /// offer once the picture is up, the controls are, and no finger is busy. + void _maybeOfferNativeControls() { + if (!_nativeOfferPending || _nativeOfferOpen || !mounted) return; + final c = _controller; + if (c == null) return; + final v = c.value; + if (!v.isInitialized || v.hasError || v.isBuffering) return; + if (_initializing || _errorMessage != null || _isCodecError) return; + if (_serverSwitch != null || _autoRetrying) return; + // Paused, or within the first moments of playing. + if (v.isPlaying && _playbackWatch.elapsed > const Duration(seconds: 20)) { + return; + } + if (!_controlsVisible || _locked || _compact || _isPip) return; + if (_panel != _SidePanel.none || _cast.isCasting) return; + if (_speedBoost.value || + _seekBarDragging.value || + _sliderDragValue.value != null || + _swipeIndicator.value != null || + _dragSwipeType != null) { + return; + } + final offer = NativeControls.shouldOffer( + context, + stored: _hive.storedPlayerControlsStyle, + alreadyOffered: _hive.nativeControlsOffered, + ); + if (!offer) { + _nativeOfferPending = false; + return; + } + _nativeOfferOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + _nativeOfferPending = false; + final style = await presentNativeControlsOffer(context, _hive); + _nativeOfferOpen = false; + if (mounted) _applyControlsStyle(style); + }); + WidgetsBinding.instance.scheduleFrame(); + } + + /// Swaps the controls in this player without reopening it. + void _applyControlsStyle(PlayerControlsStyle style) { + if (style == _controlsStyle) return; + setState(() { + _controlsStyle = style; + _nativeControls = NativeControls.decide(context, chosen: style); + }); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index 3a23f32e..5db305c6 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -79,49 +79,27 @@ extension _PlayerPanels on _PlayerPageState { void _openLangSheet() { final langs = _availableLangsForCurrentEpisode(); if (langs.isEmpty) return; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.translate_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.audio_language'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), + ..._sheetHead( + Icons.translate_rounded, + 'player.audio_language'.tr(), ), - const Divider(color: Colors.white12, height: 1), - for (final l in langs) - _OptionTile( - label: _langLabel(l), - selected: l == _currentLang, - onTap: () { - Navigator.of(sheetContext).pop(); - _switchLang(l); - }, - ), + ..._sheetGroup([ + for (final l in langs) + _OptionTile( + label: _langLabel(l), + selected: l == _currentLang, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchLang(l); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -281,9 +259,14 @@ extension _PlayerPanels on _PlayerPageState { final pinned = source.height ?? VideoOptionGroups.resolutionOf(source.quality); if (pinned != null) return '${playing ?? pinned}p'; - return _hasParsedSiblings(source.quality) - ? withPlaying - : _qualityLabel(source.quality); + if (!_hasParsedSiblings(source.quality)) { + return _qualityLabel(source.quality); + } + // On the platform player Auto is ExoPlayer's own ABR, and video_player + // reports only the size it started at. "Auto · 1080p" stayed on screen + // while the picture adapted down to the lowest rung, which is how + // "1080p looks like 480p" reached support. Plain Auto there. + return (c?.supportsVideoTracks ?? false) ? withPlaying : auto; } /// For the icon-only portrait row, where "Auto · 720p" does not fit: the @@ -332,53 +315,28 @@ extension _PlayerPanels on _PlayerPageState { final labels = _sourceLabels; final servers = _sourceServers; if (servers.length < 2) return; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.dns_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.server'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - for (final server in servers) - _ServerTile( - label: server, - qualities: VideoOptionGroups.qualitiesFor( - labels, - server, - ).join(' · '), - selected: server == _currentServer, - onTap: () { - Navigator.of(sheetContext).pop(); - _switchServer(server); - }, - ), + ..._sheetHead(Icons.dns_outlined, 'player.server'.tr()), + ..._sheetGroup([ + for (final server in servers) + _ServerTile( + label: server, + qualities: VideoOptionGroups.qualitiesFor( + labels, + server, + ).join(' · '), + selected: server == _currentServer, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchServer(server); + }, + ), + ], inset: 56), const SizedBox(height: 8), ], ), @@ -412,13 +370,7 @@ extension _PlayerPanels on _PlayerPageState { void _openSettingsSheet() { final a = _affordances; final hasLangs = a.hasLangs; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( // StatefulBuilder so a row can change its own value without the sheet // closing and reopening — see the aspect-ratio tile below. builder: (sheetContext) => StatefulBuilder( @@ -427,334 +379,311 @@ extension _PlayerPanels on _PlayerPageState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.settings_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'general.settings'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - // Switching provider without losing your place. - // - // The app carries the same title on several ecosystems at once, - // and until now the only way to leave a struggling source was to - // back out and start the episode again somewhere else. Offered - // here rather than only on the error screen, because a source - // that is buffering badly has not failed yet — that is exactly - // when someone wants out, and exactly when they have a position - // worth keeping. - // - // Not for live: a channel is one stream, and there is no other - // provider carrying the same minute of it. - if (!_isLive) - _SettingsTile( - icon: Icons.swap_horiz_rounded, - label: 'player.change_source'.tr(), - value: _providerLabel, - onTap: () { - Navigator.of(sheetContext).pop(); - _openAlternateSources(keepPosition: true); - }, - ), - // Speed, server, quality and subtitles used to sit here as well - // as on the bar, calling the very same functions. A settings list - // is where things go when they have nowhere else; those all have - // a button one tap away, so the copies were padding a list that - // was already nineteen rows long. Removed, not moved. - // - // Download is the exception and stays: the bar button for it is - // in the PHONE branch only, so on desktop this sheet is the only - // way to start one. Removing it took the feature off Windows, - // macOS and Linux entirely. - if (isDesktopPlatform && a.canDownload) + ..._sheetHead(Icons.settings_outlined, 'general.settings'.tr()), + ..._sheetGroup([ + // Switching provider without losing your place. + // + // The app carries the same title on several ecosystems at once, + // and until now the only way to leave a struggling source was to + // back out and start the episode again somewhere else. Offered + // here rather than only on the error screen, because a source + // that is buffering badly has not failed yet — that is exactly + // when someone wants out, and exactly when they have a position + // worth keeping. + // + // Not for live: a channel is one stream, and there is no other + // provider carrying the same minute of it. + if (!_isLive) + _SettingsTile( + icon: Icons.swap_horiz_rounded, + label: 'player.change_source'.tr(), + value: _providerLabel, + onTap: () { + Navigator.of(sheetContext).pop(); + _openAlternateSources(keepPosition: true); + }, + ), + // Speed, server, quality and subtitles used to sit here as well + // as on the bar, calling the very same functions. A settings list + // is where things go when they have nowhere else; those all have + // a button one tap away, so the copies were padding a list that + // was already nineteen rows long. Removed, not moved. + // + // Download is the exception and stays: the bar button for it is + // in the PHONE branch only, so on desktop this sheet is the only + // way to start one. Removing it took the feature off Windows, + // macOS and Linux entirely. + if (isDesktopPlatform && a.canDownload) + _SettingsTile( + icon: Icons.download_rounded, + label: 'movie.download'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _startDownload(); + }, + ), + // Three values, stepped in place. + // + // This opened a SECOND sheet over the first to choose one of + // three — a whole modal, covering the video, for a setting whose + // entire interaction is "the next one". The row cycles now and + // shows where it landed, and the sheet it is in stays open so + // two steps cost two taps. _SettingsTile( - icon: Icons.download_rounded, - label: 'movie.download'.tr(), - value: '', + icon: Icons.aspect_ratio_rounded, + label: 'player.aspect'.tr(), + value: _fitLabel(_fit), onTap: () { - Navigator.of(sheetContext).pop(); - _startDownload(); + _cycleFit(toast: false); + setSheetState(() {}); }, ), - // Three values, stepped in place. - // - // This opened a SECOND sheet over the first to choose one of - // three — a whole modal, covering the video, for a setting whose - // entire interaction is "the next one". The row cycles now and - // shows where it landed, and the sheet it is in stays open so - // two steps cost two taps. - _SettingsTile( - icon: Icons.aspect_ratio_rounded, - label: 'player.aspect'.tr(), - value: _fitLabel(_fit), - onTap: () { - _cycleFit(toast: false); - setSheetState(() {}); - }, - ), - if (!isDesktopPlatform && - !_preferPlatformPlayer && - (_controller?.supportsVideoTracks ?? false)) + if (!isDesktopPlatform && + !_preferPlatformPlayer && + (_controller?.supportsVideoTracks ?? false)) + _SettingsTile( + icon: Icons.play_circle_outline, + label: 'profile.player_engine_native'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _playWithSystemPlayer(); + }, + ), + // Absent on the platform player, which has no runtime picture + // control at all. Offering a menu that silently does nothing + // teaches people the app is broken rather than that the feature + // is unavailable here. + if (_controller?.supportsColorProfile ?? false) + _SettingsTile( + icon: Icons.tune_rounded, + label: 'player.color_profile'.tr(), + value: _colorProfile.labelKey.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _openColorSheet(); + }, + ), + // Only on libmpv, which is the one backend that can read a frame + // back. The platform player and the DRM one keep their pixels in + // a texture the Dart side never sees, so the row is absent there + // rather than present and producing nothing. + // + // Live has no frame worth sharing and no title to attach it to. + if (!_isLive && (_controller?.supportsShaders ?? false)) + _SettingsTile( + icon: Icons.photo_camera_outlined, + label: 'player.share_frame'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _shareCurrentFrame(); + }, + ), + // Sharing and keeping are different intentions, and routing + // "keep" through the share sheet made it a three-tap detour via + // whichever app the system decided could save a file. The frame + // people wanted was also the one the system screenshot key could + // not give them: that one carries the controls, the seek bar and + // the status bar burnt into it, at the screen's resolution + // rather than the video's. + if (!_isLive && (_controller?.supportsShaders ?? false)) + _SettingsTile( + icon: Icons.save_alt_rounded, + label: 'player.save_frame'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _saveCurrentFrame(); + }, + ), + // Always listed, disabled where it cannot run. + // + // It used to disappear entirely on the platform player, which is + // the default on most devices — so Anime4K, sharpen, deblur and + // denoise were invisible to the people most likely to want them, + // with nothing on screen to say the feature existed or why it was + // absent. A row that explains itself beats a row that is not + // there. + if (_controller?.supportsShaders ?? false) + _SettingsTile( + icon: Icons.auto_awesome_rounded, + label: 'player.shaders'.tr(), + value: _shaderPreset.isOff + ? 'general.off'.tr() + : _shaderPreset.labelKey.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _openShaderSheet(); + }, + ) + else + _SettingsTile( + icon: Icons.auto_awesome_rounded, + label: 'player.shaders'.tr(), + value: _mediaKitMissing(), + onTap: null, + ), + // Shown here, and not only in Settings, so a mode that suppresses + // history cannot sit on unnoticed for weeks: the sheet is the one + // surface a viewer opens mid-episode, and this is where they will + // wonder why nothing is in Continue Watching. + if (_hive.isIncognito) + _SettingsTile( + icon: Icons.visibility_off_outlined, + label: 'profile.incognito'.tr(), + value: 'general.on'.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _hive.setIncognito(false); + if (!mounted) return; + setState(() {}); + _toast('profile.incognito_off'.tr()); + }, + ), + // Hidden rather than disabled when the stream cannot be cast — a + // torrent handle or a downloaded file has no address a device on + // the network could fetch, and a greyed-out row invites a tap + // that can only ever explain itself. + if (_canCast) + _SettingsTile( + icon: _cast.isCasting + ? Icons.cast_connected_rounded + : Icons.cast_rounded, + label: 'player.cast'.tr(), + value: _cast.device?.name ?? '—', + onTap: () { + Navigator.of(sheetContext).pop(); + if (_cast.isCasting) { + _stopCasting(); + } else { + _openCastSheet(); + } + }, + ), + // Not offered for live TV: a broadcast has no end to stop at, and + // a countdown that pauses a channel is just a mute button with + // extra steps. + if (!_isLive) + _SettingsTile( + icon: Icons.bedtime_outlined, + label: 'player.sleep_timer'.tr(), + value: _sleepValueLabel, + onTap: () { + Navigator.of(sheetContext).pop(); + _openSleepSheet(); + }, + ), + if (hasLangs) + _SettingsTile( + icon: Icons.translate_rounded, + label: 'player.audio_language'.tr(), + value: _currentLang == null + ? '—' + : _langLabel(_currentLang!), + onTap: () { + Navigator.of(sheetContext).pop(); + _openLangSheet(); + }, + ), + if (_subtitles.isNotEmpty || + (_controller?.subtitleTracks.isNotEmpty ?? false)) + _SettingsTile( + icon: Icons.text_fields_rounded, + label: 'player.subtitle_style'.tr(), + value: '${_subtitleStyle.fontSize.round()}px', + onTap: () { + Navigator.of(sheetContext).pop(); + _openSubtitleAppearanceSheet(); + }, + ), + // Audio tracks are a media_kit capability: video_player exposes no + // track-selection API at all. Under the default engine the tile + // stays disabled and says why, rather than opening an empty sheet. + if (!hasLangs) + Builder( + builder: (_) { + final c = _controller; + final supported = c?.supportsAudioTracks ?? false; + final tracks = + c?.audioTracks ?? const []; + if (!supported) { + return _SettingsTile( + icon: Icons.audiotrack_outlined, + label: 'player.audio_track'.tr(), + value: _mediaKitMissing(onlyLabel: true), + onTap: null, + ); + } + return _SettingsTile( + icon: Icons.audiotrack_outlined, + label: 'player.audio_track'.tr(), + value: tracks.length < 2 + ? 'player.audio_track_single'.tr() + : _activeAudioTrackLabel(), + onTap: tracks.length < 2 + ? null + : () { + Navigator.of(sheetContext).pop(); + _openAudioTrackSheet(); + }, + ); + }, + ), + if (ExternalPlayer.isSupported && _videoUrl != null) + _SettingsTile( + icon: Icons.open_in_new_rounded, + label: 'player.external_player'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _handOffToExternalPlayer(); + }, + ), + // Was `showDownloadAction && provider != 'uzmovi'` — the url + // Beside the diagnostics log, which answers the same question + // for whoever wrote the player. This one answers it for whoever + // is watching — and is small enough to screenshot into a report. _SettingsTile( - icon: Icons.play_circle_outline, - label: 'profile.player_engine_native'.tr(), - value: '', + icon: Icons.info_outline_rounded, + label: 'player.info_title'.tr(), + value: _showPlayerInfo + ? 'player.on'.tr() + : 'player.off'.tr(), onTap: () { Navigator.of(sheetContext).pop(); - _playWithSystemPlayer(); + setState(() => _showPlayerInfo = !_showPlayerInfo); }, ), - // Absent on the platform player, which has no runtime picture - // control at all. Offering a menu that silently does nothing - // teaches people the app is broken rather than that the feature - // is unavailable here. - if (_controller?.supportsColorProfile ?? false) _SettingsTile( icon: Icons.tune_rounded, - label: 'player.color_profile'.tr(), - value: _colorProfile.labelKey.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _openColorSheet(); - }, - ), - // Only on libmpv, which is the one backend that can read a frame - // back. The platform player and the DRM one keep their pixels in - // a texture the Dart side never sees, so the row is absent there - // rather than present and producing nothing. - // - // Live has no frame worth sharing and no title to attach it to. - if (!_isLive && (_controller?.supportsShaders ?? false)) - _SettingsTile( - icon: Icons.photo_camera_outlined, - label: 'player.share_frame'.tr(), + label: 'player.info_fields_title'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _shareCurrentFrame(); + _openInfoFieldsPicker(); }, ), - // Sharing and keeping are different intentions, and routing - // "keep" through the share sheet made it a three-tap detour via - // whichever app the system decided could save a file. The frame - // people wanted was also the one the system screenshot key could - // not give them: that one carries the controls, the seek bar and - // the status bar burnt into it, at the screen's resolution - // rather than the video's. - if (!_isLive && (_controller?.supportsShaders ?? false)) _SettingsTile( - icon: Icons.save_alt_rounded, - label: 'player.save_frame'.tr(), + icon: Icons.dashboard_customize_outlined, + label: 'player.layout_title'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _saveCurrentFrame(); + _openControlsLayoutPage(); }, ), - // Always listed, disabled where it cannot run. - // - // It used to disappear entirely on the platform player, which is - // the default on most devices — so Anime4K, sharpen, deblur and - // denoise were invisible to the people most likely to want them, - // with nothing on screen to say the feature existed or why it was - // absent. A row that explains itself beats a row that is not - // there. - if (_controller?.supportsShaders ?? false) - _SettingsTile( - icon: Icons.auto_awesome_rounded, - label: 'player.shaders'.tr(), - value: _shaderPreset.isOff - ? 'general.off'.tr() - : _shaderPreset.labelKey.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _openShaderSheet(); - }, - ) - else _SettingsTile( - icon: Icons.auto_awesome_rounded, - label: 'player.shaders'.tr(), - value: _mediaKitMissing(), - onTap: null, - ), - // Shown here, and not only in Settings, so a mode that suppresses - // history cannot sit on unnoticed for weeks: the sheet is the one - // surface a viewer opens mid-episode, and this is where they will - // wonder why nothing is in Continue Watching. - if (_hive.isIncognito) - _SettingsTile( - icon: Icons.visibility_off_outlined, - label: 'profile.incognito'.tr(), - value: 'general.on'.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _hive.setIncognito(false); - if (!mounted) return; - setState(() {}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('profile.incognito_off'.tr()), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); - }, - ), - // Hidden rather than disabled when the stream cannot be cast — a - // torrent handle or a downloaded file has no address a device on - // the network could fetch, and a greyed-out row invites a tap - // that can only ever explain itself. - if (_canCast) - _SettingsTile( - icon: _cast.isCasting - ? Icons.cast_connected_rounded - : Icons.cast_rounded, - label: 'player.cast'.tr(), - value: _cast.device?.name ?? '—', - onTap: () { - Navigator.of(sheetContext).pop(); - if (_cast.isCasting) { - _stopCasting(); - } else { - _openCastSheet(); - } - }, - ), - // Not offered for live TV: a broadcast has no end to stop at, and - // a countdown that pauses a channel is just a mute button with - // extra steps. - if (!_isLive) - _SettingsTile( - icon: Icons.bedtime_outlined, - label: 'player.sleep_timer'.tr(), - value: _sleepValueLabel, - onTap: () { - Navigator.of(sheetContext).pop(); - _openSleepSheet(); - }, - ), - if (hasLangs) - _SettingsTile( - icon: Icons.translate_rounded, - label: 'player.audio_language'.tr(), - value: _currentLang == null - ? '—' - : _langLabel(_currentLang!), - onTap: () { - Navigator.of(sheetContext).pop(); - _openLangSheet(); - }, - ), - if (_subtitles.isNotEmpty || - (_controller?.subtitleTracks.isNotEmpty ?? false)) - _SettingsTile( - icon: Icons.text_fields_rounded, - label: 'player.subtitle_style'.tr(), - value: '${_subtitleStyle.fontSize.round()}px', - onTap: () { - Navigator.of(sheetContext).pop(); - _openSubtitleAppearanceSheet(); - }, - ), - // Audio tracks are a media_kit capability: video_player exposes no - // track-selection API at all. Under the default engine the tile - // stays disabled and says why, rather than opening an empty sheet. - if (!hasLangs) - Builder( - builder: (_) { - final c = _controller; - final supported = c?.supportsAudioTracks ?? false; - final tracks = - c?.audioTracks ?? const []; - if (!supported) { - return _SettingsTile( - icon: Icons.audiotrack_outlined, - label: 'player.audio_track'.tr(), - value: _mediaKitMissing(onlyLabel: true), - onTap: null, - ); - } - return _SettingsTile( - icon: Icons.audiotrack_outlined, - label: 'player.audio_track'.tr(), - value: tracks.length < 2 - ? 'player.audio_track_single'.tr() - : _activeAudioTrackLabel(), - onTap: tracks.length < 2 - ? null - : () { - Navigator.of(sheetContext).pop(); - _openAudioTrackSheet(); - }, - ); - }, - ), - if (ExternalPlayer.isSupported && _videoUrl != null) - _SettingsTile( - icon: Icons.open_in_new_rounded, - label: 'player.external_player'.tr(), + icon: Icons.bug_report_outlined, + label: 'player.diagnostics_logs'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _handOffToExternalPlayer(); + LogViewerSheet.show(context); }, ), - // Was `showDownloadAction && provider != 'uzmovi'` — the url - // Beside the diagnostics log, which answers the same question - // for whoever wrote the player. This one answers it for whoever - // is watching — and is small enough to screenshot into a report. - _SettingsTile( - icon: Icons.info_outline_rounded, - label: 'player.info_title'.tr(), - value: _showPlayerInfo ? 'player.on'.tr() : 'player.off'.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - setState(() => _showPlayerInfo = !_showPlayerInfo); - }, - ), - _SettingsTile( - icon: Icons.tune_rounded, - label: 'player.info_fields_title'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - _openInfoFieldsPicker(); - }, - ), - _SettingsTile( - icon: Icons.dashboard_customize_outlined, - label: 'player.layout_title'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - _openControlsLayoutPage(); - }, - ), - _SettingsTile( - icon: Icons.bug_report_outlined, - label: 'player.diagnostics_logs'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - LogViewerSheet.show(context); - }, - ), + ], inset: 56), const SizedBox(height: 8), ], ), @@ -791,49 +720,27 @@ extension _PlayerPanels on _PlayerPageState { if (c == null || !c.supportsAudioTracks) return; final tracks = c.audioTracks; if (tracks.length < 2) return; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.audiotrack_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.audio_track'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), + ..._sheetHead( + Icons.audiotrack_outlined, + 'player.audio_track'.tr(), ), - const Divider(color: Colors.white12, height: 1), - for (final t in tracks) - _OptionTile( - label: _audioTrackLabel(t), - selected: t.id == c.activeAudioTrackId, - onTap: () { - Navigator.of(sheetContext).pop(); - _switchAudioTrack(t.id); - }, - ), + ..._sheetGroup([ + for (final t in tracks) + _OptionTile( + label: _audioTrackLabel(t), + selected: t.id == c.activeAudioTrackId, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchAudioTrack(t.id); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -905,6 +812,7 @@ extension _PlayerPanels on _PlayerPageState { if (j != _activeSubtitleIndex) j, ]) if (_subtitles[i].file.startsWith('http') && + _subtitles[i].cipher == null && ExternalPlayer.gatingHeaders(_subtitles[i].headers).isEmpty) (url: _subtitles[i].file, label: _subtitles[i].label), ]; @@ -915,58 +823,34 @@ extension _PlayerPanels on _PlayerPageState { subtitles: handed, ); if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('player.external_player_none'.tr())), - ); + _toast('player.external_player_none'.tr()); } } + /// The speeds the sheet lists, and the native speed button's menu. + static const List _speedPresets = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + void _openSpeedSheet() { - const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + const speeds = _speedPresets; + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.speed_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.speed'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - for (final s in speeds) - _OptionTile( - label: - '${s.toStringAsFixed(s == s.roundToDouble() ? 0 : 2)}x', - selected: s == _playbackSpeed, - onTap: () { - Navigator.of(sheetContext).pop(); - _setSpeed(s); - }, - ), + ..._sheetHead(Icons.speed_rounded, 'player.speed'.tr()), + ..._sheetGroup([ + for (final s in speeds) + _OptionTile( + label: + '${s.toStringAsFixed(s == s.roundToDouble() ? 0 : 2)}x', + selected: s == _playbackSpeed, + onTap: () { + Navigator.of(sheetContext).pop(); + _setSpeed(s); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -983,53 +867,28 @@ extension _PlayerPanels on _PlayerPageState { /// an approximation of it, so there is no way to get stuck somewhere worse /// than where you started. void _openColorSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: StatefulBuilder( builder: (context, setSheetState) => Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.tune_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.color_profile'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - for (final profile in ColorProfile.all) - _OptionTile( - label: profile.labelKey.tr(), - selected: profile.id == _colorProfile.id, - // The sheet stays open. Comparing two looks means switching - // back and forth, and a menu that closes on every tap makes - // that four gestures instead of one. - onTap: () { - _setColorProfile(profile); - setSheetState(() {}); - }, - ), + ..._sheetHead(Icons.tune_rounded, 'player.color_profile'.tr()), + ..._sheetGroup([ + for (final profile in ColorProfile.all) + _OptionTile( + label: profile.labelKey.tr(), + selected: profile.id == _colorProfile.id, + // The sheet stays open. Comparing two looks means switching + // back and forth, and a menu that closes on every tap makes + // that four gestures instead of one. + onTap: () { + _setColorProfile(profile); + setSheetState(() {}); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -1051,13 +910,7 @@ extension _PlayerPanels on _PlayerPageState { /// part of the same judgement — "is this better, and does it still run /// smoothly" — and that can only be made while something is playing. void _openShaderSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: StatefulBuilder( @@ -1357,6 +1210,38 @@ extension _PlayerPanels on _PlayerPageState { ), ); + final title = isQuality ? 'player.quality'.tr() : 'player.episodes'.tr(); + if (asSheet && _iosSheets) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.55, + ), + child: PlayerSheetLook( + ios: true, + child: IosSheetFrame( + child: SafeArea( + top: false, + child: FocusScope( + node: _tvPanelFocus, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + IosSheetHeader(title: title, onClose: _closePanel), + Flexible(child: list), + ], + ), + ), + ), + ), + ), + ), + ); + } + final body = Column( mainAxisSize: asSheet ? MainAxisSize.min : MainAxisSize.max, children: [ @@ -1375,7 +1260,7 @@ extension _PlayerPanels on _PlayerPageState { child: Row( children: [ Text( - isQuality ? 'player.quality'.tr() : 'player.episodes'.tr(), + title, style: const TextStyle( color: Colors.white, fontSize: 16, @@ -1454,6 +1339,17 @@ class _ServerTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + leading: ServerBadge(name: label, selected: selected), + title: label, + subtitle: qualities, + accessory: IosRowAccessory.check, + selected: selected, + choice: true, + onTap: onTap, + ); + } return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, diff --git a/lib/features/detail/presentation/pages/player_page.party.dart b/lib/features/detail/presentation/pages/player_page.party.dart index b0d14510..7595d3b9 100644 --- a/lib/features/detail/presentation/pages/player_page.party.dart +++ b/lib/features/detail/presentation/pages/player_page.party.dart @@ -70,6 +70,7 @@ extension _PlayerParty on _PlayerPageState { void _stopPartyBinding() { _partyBindingActive = false; + _party.reportBuffering(false); _partyHeartbeat?.cancel(); _partyHeartbeat = null; _partyDrift?.cancel(); @@ -185,8 +186,37 @@ extension _PlayerParty on _PlayerPageState { } void _onDriftTick() { + _partyReportBuffering(); final pb = _lastPartyPlayback; if (pb != null && _followsParty) unawaited(_applyRemoteSync(pb)); + _partyWaitForAllTick(); + } + + /// Loading a new episode counts as buffering: from the room's side it is the + /// same wait. An error or a missing plugin does not, since waiting on it + /// would never end. + void _partyReportBuffering() { + if (!_inParty) return; + final c = _controller; + final stalled = + _errorMessage == null && + _pluginRequired == null && + (c == null || !c.value.isInitialized || c.value.isBuffering); + _party.reportBuffering(stalled); + } + + /// Host side of "wait for everyone": hold while a guest is stuck, then go + /// on. Not routed through [_partyEmit] because it is not a user action. + void _partyWaitForAllTick() { + final c = _controller; + if (c == null || !c.value.isInitialized || _isLive) return; + final paused = _party.waitForAllTick( + localPlaying: c.value.isPlaying, + positionSec: c.value.position.inMilliseconds / 1000.0, + pause: () => unawaited(c.pause()), + play: () => unawaited(c.play()), + ); + if (paused) _partyToast('watch_party.waiting_for_others'); } // --------------------------------------------------------------------------- @@ -260,14 +290,7 @@ extension _PlayerParty on _PlayerPageState { void _partyToast(String key) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(key.tr()), - backgroundColor: Colors.black87, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); + _toast(key.tr(), icon: Icons.groups_rounded); } // --------------------------------------------------------------------------- @@ -408,9 +431,11 @@ extension _PlayerParty on _PlayerPageState { if (defaultIdx >= 0) _loadSubtitle(defaultIdx, remember: false); } case Failure(:final error): + final raw = error.toString().replaceFirst('Exception: ', ''); setState(() { _initializing = false; - _errorMessage = error.toString().replaceFirst('Exception: ', ''); + _errorMessage = _resolveFailureMessage(raw); + _errorRaw = raw; }); } } diff --git a/lib/features/detail/presentation/pages/player_page.pip.dart b/lib/features/detail/presentation/pages/player_page.pip.dart index 31dbce4d..6ebd2f8f 100644 --- a/lib/features/detail/presentation/pages/player_page.pip.dart +++ b/lib/features/detail/presentation/pages/player_page.pip.dart @@ -14,6 +14,10 @@ extension _PlayerPip on _PlayerPageState { } Future _onPipMethodCall(MethodCall call) async { + if (call.method == 'onPipModeChanged') { + if (mounted) _onAndroidPipMode((call.arguments as Map?) ?? const {}); + return; + } if (call.method != 'onPipAction') return; final action = call.arguments; if (action is! String) return; @@ -42,27 +46,23 @@ extension _PlayerPip on _PlayerPageState { } Future _refreshPipActions() async { + if (!isAndroidPlatform) return; final c = _controller; if (c == null || !c.value.isInitialized) return; - final isPlaying = c.value.isPlaying; - final hasPrev = _hasPrevEpisode; - final hasNext = _hasNextEpisode; - if (isPlaying == _lastPipPlaying) { - try { - await _pipChannel.invokeMethod('updatePiPActions', { - 'isPlaying': isPlaying, - 'hasPrev': hasPrev, - 'hasNext': hasNext, - }); - } catch (_) {} - return; - } - _lastPipPlaying = isPlaying; try { await _pipChannel.invokeMethod('updatePiPActions', { - 'isPlaying': isPlaying, - 'hasPrev': hasPrev, - 'hasNext': hasNext, + 'isPlaying': c.value.isPlaying, + 'hasPrev': widget.args.isSerial && _hasPrevEpisode, + 'hasNext': widget.args.isSerial && _hasNextEpisode, + 'seekSeconds': _seekSeconds, + 'labels': { + 'play': 'player.play'.tr(), + 'pause': 'player.pause'.tr(), + 'rewind': 'player.rewind_by'.tr(args: ['$_seekSeconds']), + 'forward': 'player.forward_by'.tr(args: ['$_seekSeconds']), + 'prev': 'player.previous'.tr(), + 'next': 'player.next_episode'.tr(), + }, }); } catch (_) {} } @@ -74,21 +74,17 @@ extension _PlayerPip on _PlayerPageState { try { final available = await _floating.isPipAvailable; if (!available) return; - final size = c.value.size; - Rational ratio = const Rational.landscape(); - if (size.width > 0 && size.height > 0) { - final w = size.width.round(); - final h = size.height.round(); - if (w > 0 && h > 0) { - final candidate = Rational(w, h); - final aspect = candidate.aspectRatio; - if (aspect >= 1 / 2.39 && aspect <= 2.39) { - ratio = candidate; - } - } - } - final result = await _floating.enable(ImmediatePiP(aspectRatio: ratio)); - if (result == PiPStatus.enabled && mounted) { + final (w, h) = pipAspect(c.value.size); + await _refreshPipActions(); + // Marked before asking, so the lifecycle callbacks fired while the + // window shrinks do not read as leaving the app and pause it. + _pipTracker.beginEnter(); + _isPip = true; + final result = await _floating.enable( + ImmediatePiP(aspectRatio: Rational(w, h)), + ); + if (result == PiPStatus.enabled) { + if (!mounted) return; setState(() { _isPip = true; _controlsVisible = false; @@ -96,10 +92,18 @@ extension _PlayerPip on _PlayerPageState { _panel = _SidePanel.none; }); _controlsAnimation.reverse(); - _lastPipPlaying = !c.value.isPlaying; - _refreshPipActions(); + } else { + _rollBackPipEntry(); } - } catch (_) {} + } catch (_) { + _rollBackPipEntry(); + } + } + + void _rollBackPipEntry() { + _pipTracker.enterFailed(); + if (!mounted) return; + setState(() => _isPip = false); } /// iOS: the episode goes to a native player the system can float — the @@ -157,10 +161,31 @@ extension _PlayerPip on _PlayerPageState { } } - /// Desktop picture-in-picture: the whole window becomes a small player - /// above every other window, and back. + /// The PiP button and the I key: the corner player when this page can go + /// there, the always-on-top window otherwise. + void _openMiniPlayer() { + if (_canDock) { + unawaited(_dockToCorner()); + } else { + unawaited(_toggleDesktopMini()); + } + } + + /// Whether this page can move into the corner player: on the desktop, when + /// it was opened through PlayerRoute (which keys it), and not while a + /// television is playing — the corner would only show the "playing on" + /// card. + bool get _canDock => + PlayerDock.supported && widget.key is GlobalKey && !_cast.isCasting; + + /// Desktop picture-in-picture, the long way round: the whole window becomes + /// a small player above every other window, and back. The corner player + /// ([_dockToCorner]) cannot float over other apps; this is how to watch + /// over them. Future _toggleDesktopMini() async { if (!isDesktopPlatform) return; + // From the corner the way out is the full player first. + if (_docked) return; if (_desktopMini) { setState(() { _desktopMini = false; @@ -168,6 +193,10 @@ extension _PlayerPip on _PlayerPageState { }); _miniHover.value = false; await DesktopWindow.exitMini(); + if (_floatedFromDock) { + _floatedFromDock = false; + if (mounted) await _dockToCorner(); + } return; } final size = _controller?.value.size ?? Size.zero; @@ -186,16 +215,113 @@ extension _PlayerPip on _PlayerPageState { await DesktopWindow.enterMini(aspect: aspect); } - /// The mini window's own controls, shown while the pointer is over it: - /// play or pause in the middle, back to the full window and a step either - /// way at the top, and how far along under it. The rest of the window is a - /// handle to move it by. + /// Switching to another app with a video playing floats it above that app, + /// as Telegram does: the window becomes the small always-on-top player. + /// It stays there until the viewer leaves it from its own controls, so a + /// click on it to pause does not throw it back to full size. A paused + /// video, a television playing it and the floating window itself are left + /// alone. + Future _floatWhenLeaving() async { + if (!isDesktopPlatform || _desktopMini || !mounted) return; + if (_cast.isCasting) return; + final c = _controller; + if (c == null || !c.value.isInitialized || !c.value.isPlaying) return; + if (_docked) { + // The floating window shows this page; from the corner it is a box in + // another screen, so it comes back to its own route first. + _expandFromDock(); + if (_docked) return; + _floatedFromDock = true; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + } + await _toggleDesktopMini(); + } + + /// The corner player, Telegram-style. This page leaves the route stack and + /// carries on, State and all, in a small box over whatever the viewer opens + /// next. Nothing is handed over or rebuilt: the GlobalKey PlayerRoute gave + /// this page moves it into PlayerDockHost in the same frame, so history + /// ticks, auto-advance, subtitles, party sync and retries keep running as + /// they were. + Future _dockToCorner() async { + final key = widget.key; + if (!_canDock || _docked || key is! GlobalKey) return; + if (_desktopMini) await _toggleDesktopMini(); + if (!mounted) return; + if (_isFullscreen) { + _isFullscreen = false; + unawaited(DesktopWindow.setFullscreen(false)); + } + _hideTimer?.cancel(); + setState(() { + _docked = true; + _isPip = true; + _controlsVisible = false; + _panel = _SidePanel.none; + }); + _controlsAnimation.reverse(); + _miniHover.value = false; + _keyFocus.unfocus(); + // A save that notifies, unlike the five-second tick: Continue Watching on + // the page under the corner should show this title now, not when it is + // paused. + _saveHistory(); + PlayerDock.instance.dock( + key: key, + args: widget.args, + videoSize: _controller?.value.size ?? Size.zero, + ); + if (context.canPop()) { + context.pop(); + } else { + context.go('/main'); + } + } + + /// From the corner back to the full player, the same State again. + void _expandFromDock() { + if (!_docked || !PlayerDock.instance.expand()) return; + setState(() { + _docked = false; + _isPip = false; + }); + _miniHover.value = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _keyFocus.requestFocus(); + _revealControlsForHover(); + }); + } + + /// Closes the corner player; dispose saves history and stops the stream. + void _closeDock() => unawaited(PlayerDock.instance.close()); + + /// A new episode or rendition can change the picture's shape, and the + /// corner box follows it. After the frame: the dock notifies its host, + /// which must not happen during this page's build. + void _reportDockAspect() { + final size = _controller?.value.size; + if (size == null || size.isEmpty) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _docked) PlayerDock.instance.reportVideoSize(size); + }); + } + + /// The small player's own controls, shown while the pointer is over it — + /// in the always-on-top window and in the corner player alike. Play or + /// pause in the middle with the neighbouring episodes either side, the + /// title and the way back to the full player along the top (and, in the + /// corner, closing it), and a timeline to click along the bottom. The rest + /// is a handle: in the always-on-top window it moves the OS window, in the + /// corner it moves the corner player. Widget _buildDesktopMiniOverlay() { final c = _controller; + final inApp = _docked; Widget round( IconData icon, String tip, - VoidCallback onTap, { + VoidCallback? onTap, { double size = 18, }) => Tooltip( message: tip, @@ -207,114 +333,212 @@ extension _PlayerPip on _PlayerPageState { onTap: onTap, child: Padding( padding: const EdgeInsets.all(7), - child: Icon(icon, color: Colors.white, size: size), + child: Icon( + icon, + // Dimmed rather than absent at either end of a season, so the + // row does not shift under the pointer. + color: Colors.white.withValues(alpha: onTap == null ? 0.38 : 1), + size: size, + ), ), ), ), ); - return Positioned.fill( - child: MouseRegion( - onEnter: (_) => _miniHover.value = true, - onHover: (_) => _miniHover.value = true, - onExit: (_) => _miniHover.value = false, - child: ValueListenableBuilder( - valueListenable: _miniHover, - builder: (context, hover, _) => AnimatedOpacity( - opacity: hover ? 1 : 0, + const scrim = DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x99000000), Color(0x22000000), Color(0x99000000)], + ), + ), + ); + final dock = PlayerDock.instance; + final controls = MouseRegion( + onEnter: (_) => _miniHover.value = true, + onHover: (_) => _miniHover.value = true, + onExit: (_) => _miniHover.value = false, + child: ValueListenableBuilder( + valueListenable: _miniHover, + builder: (context, hover, _) { + // Loading or failed, the controls stay up: closing or going back to + // the full player — where Try again and the other sources are — are + // the useful moves then. + final show = hover || _initializing || _errorMessage != null; + return AnimatedOpacity( + opacity: show ? 1 : 0, duration: const Duration(milliseconds: 160), child: IgnorePointer( - ignoring: !hover, - child: Stack( - fit: StackFit.expand, - children: [ - const DragToMoveArea( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x99000000), - Color(0x22000000), - Color(0x99000000), + ignoring: !show, + child: LayoutBuilder( + builder: (context, box) { + final roomy = box.maxWidth >= 320; + final episodes = _affordances.hasEpisodes; + // Neighbouring episodes outrank the ten-second steps when + // there is room for only one pair; the arrow keys still step. + final steps = !episodes || box.maxWidth >= 360; + final gap = SizedBox(width: roomy ? 14 : 8); + return Stack( + fit: StackFit.expand, + children: [ + if (inApp) + GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => dock.dragStart(), + onPanUpdate: (d) => dock.dragBy(d.delta), + onPanEnd: (d) => + dock.dragEnd(d.velocity.pixelsPerSecond), + onPanCancel: () { + if (dock.dragging) dock.dragEnd(Offset.zero); + }, + child: scrim, + ) + else + const DragToMoveArea(child: scrim), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (episodes) ...[ + round( + Icons.skip_previous_rounded, + 'player.previous'.tr(), + _hasPrevEpisode + ? () => _partyEpisodeNav(_episodeIndex - 1) + : null, + ), + gap, + ], + if (steps) ...[ + round( + Icons.fast_rewind_rounded, + 'player.rewind_by'.tr(args: ['$_seekSeconds']), + () => _seekRelative(-_seekStep), + ), + gap, + ], + if (c != null) + ListenableBuilder( + listenable: c, + builder: (_, _) => round( + c.value.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + c.value.isPlaying + ? 'player.pause'.tr() + : 'player.play'.tr(), + _togglePlay, + size: 30, + ), + ), + if (steps) ...[ + gap, + round( + Icons.fast_forward_rounded, + 'player.forward_by'.tr(args: ['$_seekSeconds']), + () => _seekRelative(_seekStep), + ), + ], + if (episodes) ...[ + gap, + round( + Icons.skip_next_rounded, + 'player.next_episode'.tr(), + _hasNextEpisode + ? () => _partyEpisodeNav(_episodeIndex + 1) + : null, + ), + ], ], ), ), - ), - ), - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - round( - Icons.fast_rewind_rounded, - 'player.rewind'.tr(), - () => _seekRelative(-_seekStep), + if (inApp) + Positioned( + top: 6, + left: 6, + child: round( + Icons.close_rounded, + 'general.close'.tr(), + _closeDock, + ), ), - const SizedBox(width: 14), - if (c != null) - ListenableBuilder( - listenable: c, - builder: (_, _) => round( - c.value.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - c.value.isPlaying - ? 'player.pause'.tr() - : 'player.play'.tr(), - _togglePlay, - size: 30, + if (roomy) + Positioned( + left: 48, + right: 48, + top: 14, + child: Text( + _episodeTitle(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + shadows: _kControlShadow, ), ), - const SizedBox(width: 14), - round( - Icons.fast_forward_rounded, - 'player.forward'.tr(), - () => _seekRelative(_seekStep), ), - ], - ), - ), - Positioned( - top: 6, - right: 6, - child: round( - Icons.open_in_full_rounded, - 'player.mini_restore'.tr(), - _toggleDesktopMini, - ), - ), - if (c != null) - Positioned( - left: 10, - right: 10, - bottom: 8, - child: ListenableBuilder( - listenable: c, - builder: (_, _) { - final d = c.value.duration.inMilliseconds; - final p = c.value.position.inMilliseconds; - return ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: d > 0 ? (p / d).clamp(0.0, 1.0) : 0, - minHeight: 3, - backgroundColor: Colors.white24, - valueColor: AlwaysStoppedAnimation( - AppColors.primary, - ), - ), - ); - }, + Positioned( + top: 6, + right: 6, + child: round( + Icons.open_in_full_rounded, + 'player.mini_restore'.tr(), + inApp ? _expandFromDock : _toggleDesktopMini, + ), ), - ), - ], + if (c != null && !_isLive) + Positioned( + left: 10, + right: 10, + bottom: 6, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (roomy) + ListenableBuilder( + listenable: c, + builder: (_, _) => Text( + '${_formatDuration(c.value.position)}' + ' / ${_formatDuration(c.value.duration)}', + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w600, + fontFeatures: [ + FontFeature.tabularFigures(), + ], + shadows: _kControlShadow, + ), + ), + ), + _MiniSeekBar(controller: c, onSeek: _seekTo), + ], + ), + ), + ], + ); + }, ), ), - ), - ), + ); + }, ), ); + return Positioned.fill( + // In the corner the page's text is scaled down with the box (see + // PlayerDockHost); these few labels are the controls' own, and they + // stay readable at any size. + child: inApp + ? MediaQuery.withClampedTextScaling( + minScaleFactor: 1, + child: controls, + ) + : controls, + ); } Future _startup() async { @@ -380,6 +604,8 @@ extension _PlayerPip on _PlayerPageState { await WakelockHolds.release(this); return; } + _brightnessKeeper.onExit(); + unawaited(SystemControls.resetBrightness()); try { await SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, @@ -417,6 +643,7 @@ extension _PlayerPip on _PlayerPageState { } Future _setSystemBrightness(double value) async { + _brightnessKeeper.userSet(value); try { await _systemControlsChannel.invokeMethod('setBrightness', { 'value': value, diff --git a/lib/features/detail/presentation/pages/player_page.reactions.dart b/lib/features/detail/presentation/pages/player_page.reactions.dart new file mode 100644 index 00000000..17094eeb --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.reactions.dart @@ -0,0 +1,268 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// Per-episode comments and timed reactions in the player. +/// +/// Reactions are fetched once per episode, after the duration is known (the +/// heatmap needs it), and replayed from the position listener the skip offers +/// already use. Everything here is optional: a failed fetch leaves the +/// episode without reactions and playback does not notice. +class _EpisodeSocial { + final ReactionScheduler scheduler = ReactionScheduler(); + final ValueNotifier> pops = ValueNotifier(const []); + final ValueNotifier> heatmap = ValueNotifier(const []); + final bool overlayOn = const SocialPlaybackPrefs().reactionsOverlay; + + /// Bumped per episode, so a late answer for the previous one is dropped. + int generation = 0; + int durationSec = 0; + DateTime? lastPost; + + void dispose() { + pops.dispose(); + heatmap.dispose(); + } +} + +extension _PlayerEpisodeSocial on _PlayerPageState { + String? get _socialContentUrl { + final url = widget.args.contentUrl; + if (url == null || url.isEmpty || _isLive) return null; + return url; + } + + EpisodeEntity? get _socialEpisode => + widget.args.isSerial && + _episodeIndex >= 0 && + _episodeIndex < _episodes.length + ? _episodes[_episodeIndex] + : null; + + String? get _socialEpisodeKey => EpisodeKey.of( + isSerial: widget.args.isSerial, + episodeNumber: _socialEpisode?.episode, + episodeIndex: _episodeIndex, + ); + + int? _socialPlayheadSec() { + final c = _controller; + if (c == null || !c.value.isInitialized) return null; + return c.value.position.inSeconds; + } + + void _resetEpisodeSocial() { + _social.generation++; + _social.scheduler.clear(); + _social.durationSec = 0; + _social.pops.value = const []; + _social.heatmap.value = const []; + } + + Future _loadEpisodeSocial() async { + if (!_social.overlayOn) return; + final url = _socialContentUrl; + if (url == null) return; + final generation = _social.generation; + try { + final timeline = await getIt().fetch( + provider: widget.args.provider, + contentUrl: url, + episodeKey: EpisodeKey.forReactions(_socialEpisodeKey), + ); + if (!mounted || generation != _social.generation) return; + _applyReactionTimeline(timeline); + } catch (e) { + _plog('reactions unavailable: $e'); + } + } + + void _applyReactionTimeline(ReactionTimeline timeline) { + if (_social.durationSec <= 0) { + final c = _controller; + if (c != null && c.value.isInitialized) { + _social.durationSec = c.value.duration.inSeconds; + } + } + _social.scheduler.load(timeline); + _social.heatmap.value = timeline.isEmpty + ? const [] + : timeline.heatmap(_social.durationSec); + } + + void _tickEpisodeSocial(Duration position) { + if (!_social.overlayOn || _social.scheduler.timeline.isEmpty) return; + final next = _social.scheduler.at(position); + final current = _social.pops.value; + if (next.length == current.length) { + var same = true; + for (var i = 0; i < next.length && same; i++) { + same = next[i] == current[i]; + } + if (same) return; + } + _social.pops.value = next; + } + + void _socialSnack(String message) { + if (!mounted) return; + _toast(message); + } + + Future _openReactionPicker() async { + final url = _socialContentUrl; + if (url == null) return; + if (!_hive.isLoggedIn) { + _socialSnack('comments.please_sign_in'.tr()); + return; + } + _hideTimer?.cancel(); + final choice = await showReactionPicker(context); + if (!mounted) return; + _scheduleHide(); + if (choice == null) return; + final last = _social.lastPost; + if (last != null && + DateTime.now().difference(last) < const Duration(seconds: 5)) { + _socialSnack('player.reaction_wait'.tr()); + return; + } + final atSec = _socialPlayheadSec() ?? 0; + _social.lastPost = DateTime.now(); + final episodeKey = EpisodeKey.forReactions(_socialEpisodeKey); + final generation = _social.generation; + _applyReactionTimeline( + _social.scheduler.timeline.withReaction(atSec, choice.kind, choice.value), + ); + try { + await getIt().post( + provider: widget.args.provider, + contentUrl: url, + episodeKey: episodeKey, + atSec: atSec, + kind: choice.kind, + value: choice.value, + ); + } on DioException catch (e) { + if (generation != _social.generation) return; + final data = e.response?.data; + final message = data is Map ? data['message']?.toString() : null; + _socialSnack( + e.response?.statusCode == 429 + ? 'player.reaction_limit'.tr() + : (message ?? 'player.reaction_failed'.tr()), + ); + } catch (_) { + _socialSnack('player.reaction_failed'.tr()); + } + } + + Future _openEpisodeComments() async { + final url = _socialContentUrl; + if (url == null) return; + final ep = _socialEpisode; + _hideTimer?.cancel(); + await showEpisodeComments( + context, + provider: widget.args.provider, + contentUrl: url, + episodeKey: _socialEpisodeKey, + title: ep == null + ? widget.args.title + : 'player.episode_comments'.tr(args: [ep.numberLabel]), + playheadSec: _socialPlayheadSec, + ); + if (mounted) _scheduleHide(); + } + + /// Other viewers' reactions, at the start edge between the top bar and the + /// subtitles. + Widget _buildReactionLane() { + if (!_social.overlayOn || _compact) return const SizedBox.shrink(); + return PositionedDirectional( + start: 16, + top: 0, + bottom: 0, + child: Align( + alignment: const AlignmentDirectional(-1, -0.15), + child: ReactionLane(pops: _social.pops), + ), + ); + } + + /// React and Comments, on the end edge while the controls are up. + Widget _buildEpisodeSocialRail() { + // The native rail stays built while hidden, so UIKit can fade it out. + if (_compact || + (!_controlsVisible && !_nativeBars) || + _socialContentUrl == null) { + return const SizedBox.shrink(); + } + Widget flutter(BuildContext _) { + if (!_controlsVisible) return const SizedBox.shrink(); + return FadeTransition( + opacity: _controlsAnimation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _IconButton( + icon: Icons.add_reaction_outlined, + tooltip: 'player.react'.tr(), + onTap: _openReactionPicker, + ), + const SizedBox(height: 8), + _IconButton( + icon: Icons.forum_outlined, + tooltip: 'player.comments'.tr(), + onTap: _openEpisodeComments, + ), + ], + ), + ); + } + + return PositionedDirectional( + end: 8, + top: 0, + bottom: 0, + child: Center( + child: _nativeBars + ? NativeGlassBar( + look: NativeBarLook.capsule, + axis: Axis.vertical, + shown: _controlsVisible, + items: [ + NativeBarItem( + id: 'react', + symbol: playerSymbol('react'), + semanticLabel: 'player.react'.tr(), + onPressed: _openReactionPicker, + ), + NativeBarItem( + id: 'comments', + symbol: playerSymbol('comments'), + semanticLabel: 'player.comments'.tr(), + onPressed: _openEpisodeComments, + ), + ], + fallback: flutter, + ) + : flutter(context), + ), + ); + } + + /// The strip over the seek bar; [inset] is the track's own inset. + Widget _buildReactionHeatmap(double inset) { + if (!_social.overlayOn || _isLive) return const SizedBox.shrink(); + return Positioned( + left: inset, + right: inset, + top: 2, + height: 4, + child: ValueListenableBuilder>( + valueListenable: _social.heatmap, + builder: (_, bins, _) => ReactionHeatmap(bins: bins), + ), + ); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.sheets.dart b/lib/features/detail/presentation/pages/player_page.sheets.dart new file mode 100644 index 00000000..6f799320 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.sheets.dart @@ -0,0 +1,49 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The player's sheets, dressed as iOS 26's on the native controls' path +/// ([showPlayerSheet], [PlayerSheetLook]); the same sheets as ever +/// everywhere else, widget for widget. +extension _PlayerSheets on _PlayerPageState { + bool get _iosSheets => _nativeControls; + + Future _showSheet({ + required WidgetBuilder builder, + double desktopMaxWidth = 460, + }) => showPlayerSheet( + context: context, + ios: _iosSheets, + desktopMaxWidth: desktopMaxWidth, + builder: builder, + ); + + /// A sheet's title: the icon, the words and the rule under them, or iOS's + /// centred title with its close button. + List _sheetHead(IconData icon, String title) => _iosSheets + ? [IosSheetHeader(title: title)] + : [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + Icon(icon, color: Colors.white, size: 18), + const SizedBox(width: 10), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + ]; + + /// [rows] as one inset group on the iOS path, as they were elsewhere. + List _sheetGroup(List rows, {double inset = 16}) => _iosSheets + ? [IosSheetGroup(separatorInset: inset, children: rows)] + : rows; +} diff --git a/lib/features/detail/presentation/pages/player_page.sleep.dart b/lib/features/detail/presentation/pages/player_page.sleep.dart index bacd99f7..a2b5c349 100644 --- a/lib/features/detail/presentation/pages/player_page.sleep.dart +++ b/lib/features/detail/presentation/pages/player_page.sleep.dart @@ -120,81 +120,54 @@ extension _PlayerSleepTimer on _PlayerPageState { setState(() => _controlsVisible = true); _controlsAnimation.forward(); } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('player.sleep_fired'.tr()), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 4), - ), + _toast( + 'player.sleep_fired'.tr(), + icon: Icons.bedtime_rounded, + duration: const Duration(seconds: 4), ); } void _openSleepSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.bedtime_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.sleep_timer'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - _SleepOptionTile( - label: 'general.off'.tr(), - selected: !_sleepArmed, - onTap: () { - Navigator.of(sheetContext).pop(); - _cancelSleepTimer(); - }, - ), - for (final d in _PlayerSleepTimer._presets) + ..._sheetHead(Icons.bedtime_outlined, 'player.sleep_timer'.tr()), + ..._sheetGroup([ _SleepOptionTile( - label: d.inMinutes < 60 - ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) - : 'player.sleep_hours'.tr(args: ['${d.inHours}']), - selected: false, + label: 'general.off'.tr(), + selected: !_sleepArmed, onTap: () { Navigator.of(sheetContext).pop(); - _armSleepDuration(d); - }, - ), - // Only meaningful for a series: on a film "end of episode" and - // "end of file" are the same thing, and the player already stops - // there on its own. - if (widget.args.isSerial) - _SleepOptionTile( - label: 'player.sleep_episode_end'.tr(), - selected: _sleepAtEpisodeEnd, - onTap: () { - Navigator.of(sheetContext).pop(); - _armSleepAtEpisodeEnd(); + _cancelSleepTimer(); }, ), + for (final d in _PlayerSleepTimer._presets) + _SleepOptionTile( + label: d.inMinutes < 60 + ? 'player.sleep_minutes'.tr(args: ['${d.inMinutes}']) + : 'player.sleep_hours'.tr(args: ['${d.inHours}']), + selected: false, + onTap: () { + Navigator.of(sheetContext).pop(); + _armSleepDuration(d); + }, + ), + // Only meaningful for a series: on a film "end of episode" and + // "end of file" are the same thing, and the player already stops + // there on its own. + if (widget.args.isSerial) + _SleepOptionTile( + label: 'player.sleep_episode_end'.tr(), + selected: _sleepAtEpisodeEnd, + onTap: () { + Navigator.of(sheetContext).pop(); + _armSleepAtEpisodeEnd(); + }, + ), + ]), const SizedBox(height: 8), ], ), @@ -217,6 +190,15 @@ class _SleepOptionTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + title: label, + accessory: IosRowAccessory.check, + selected: selected, + choice: true, + onTap: onTap, + ); + } return ListTile( dense: true, onTap: onTap, diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index 2b5b0b06..89dcc3aa 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -158,8 +158,12 @@ extension _PlayerSubtitles on _PlayerPageState { _toast('player.subtitle_failed_download'.tr()); return null; } + var bytes = response.data ?? const []; + final cipher = sub.cipher; + if (cipher != null) bytes = await cipher.decryptBytes(bytes); + if (!mounted) return null; result = parseSubtitleBytes( - response.data ?? const [], + bytes, url: sub.file, declaredFormat: declaredFormat, ); @@ -239,91 +243,66 @@ extension _PlayerSubtitles on _PlayerPageState { } void _openSubtitleSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( builder: (sheetContext) => SafeArea( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.subtitles_rounded, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'player.subtitles'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - _OptionTile( - label: 'player.off'.tr(), - selected: - _activeSubtitleIndex == -1 && - _controller?.activeSubtitleTrackId == null, - onTap: () { - Navigator.of(sheetContext).pop(); - _disableSubtitle(); - }, - ), - // The stream's own tracks — an MKV's, an HLS master's. They - // were drawn by the engine in a fixed style, could not be turned - // off, and never appeared here. - for (final track - in _controller?.subtitleTracks ?? - const []) + ..._sheetHead(Icons.subtitles_rounded, 'player.subtitles'.tr()), + ..._sheetGroup([ _OptionTile( - label: track.hasMetadata - ? track.label - : 'player.subtitle_n'.tr(args: ['${track.ordinal}']), - subtitle: 'player.subtitle_in_video'.tr(), + label: 'player.off'.tr(), selected: _activeSubtitleIndex == -1 && - _controller?.activeSubtitleTrackId == track.id, + _controller?.activeSubtitleTrackId == null, onTap: () { Navigator.of(sheetContext).pop(); - _selectEmbeddedSubtitle(track); - }, - ), - for (var i = 0; i < _subtitles.length; i++) - _OptionTile( - label: _subtitles[i].label, - // Named only when it IS the second track, so the row reads - // normally for everyone who never uses this. - subtitle: i == _secondarySubtitleIndex - ? 'player.second_track'.tr() - : null, - selected: i == _activeSubtitleIndex, - onTap: () { - Navigator.of(sheetContext).pop(); - _loadSubtitle(i); - }, - // Long-press adds it as a SECOND track rather than replacing - // the first — the two languages side by side, which is what - // somebody learning one of them is here for. A separate - // picker would have been a second list of the same tracks. - onLongPress: () { - Navigator.of(sheetContext).pop(); - _toggleSecondarySubtitle(i); + _disableSubtitle(); }, ), + // The stream's own tracks — an MKV's, an HLS master's. They + // were drawn by the engine in a fixed style, could not be turned + // off, and never appeared here. + for (final track + in _controller?.subtitleTracks ?? + const []) + _OptionTile( + label: track.hasMetadata + ? track.label + : 'player.subtitle_n'.tr(args: ['${track.ordinal}']), + subtitle: 'player.subtitle_in_video'.tr(), + selected: + _activeSubtitleIndex == -1 && + _controller?.activeSubtitleTrackId == track.id, + onTap: () { + Navigator.of(sheetContext).pop(); + _selectEmbeddedSubtitle(track); + }, + ), + for (var i = 0; i < _subtitles.length; i++) + _OptionTile( + label: _subtitles[i].label, + // Named only when it IS the second track, so the row reads + // normally for everyone who never uses this. + subtitle: i == _secondarySubtitleIndex + ? 'player.second_track'.tr() + : null, + selected: i == _activeSubtitleIndex, + onTap: () { + Navigator.of(sheetContext).pop(); + _loadSubtitle(i); + }, + // Long-press adds it as a SECOND track rather than replacing + // the first — the two languages side by side, which is what + // somebody learning one of them is here for. A separate + // picker would have been a second list of the same tracks. + onLongPress: () { + Navigator.of(sheetContext).pop(); + _toggleSecondarySubtitle(i); + }, + ), + ]), // Otherwise the gesture is undiscoverable: nothing on a list of // track names suggests that holding one does something else. if (_subtitles.length > 1) @@ -509,37 +488,6 @@ extension _PlayerSubtitles on _PlayerPageState { ); } - void _toast(String message, {IconData icon = Icons.info_outline_rounded}) { - if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - messenger.clearSnackBars(); - messenger.showSnackBar( - SnackBar( - 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() { @@ -654,13 +602,7 @@ extension _PlayerSubtitles on _PlayerPageState { text: widget.args.title.replaceAll(RegExp(r'\(.*?\)'), '').trim(), ); - await showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + await _showSheet( desktopMaxWidth: 520, builder: (sheetCtx) { var started = false; @@ -711,6 +653,7 @@ extension _PlayerSubtitles on _PlayerPageState { isSerial: widget.args.isSerial, season: _currentSeasonNumber(), episode: _currentEpisodeNumber(), + want: _hive.getSubtitleTranslateLang(), ); if (!ctx.mounted) return; setSheet(() { @@ -1010,6 +953,8 @@ extension _PlayerSubtitles on _PlayerPageState { 'de': 'Deutsch', 'fr': 'Français', 'es': 'Español', + 'vi': 'Tiếng Việt', + 'hi': 'हिन्दी', }; return map[code.toLowerCase()] ?? code.toUpperCase(); } @@ -1119,6 +1064,8 @@ extension _PlayerSubtitles on _PlayerPageState { 'dut': 'nl', 'pol': 'pl', 'ind': 'id', + 'vie': 'vi', + 'hin': 'hi', }; return map[code]; } @@ -1136,6 +1083,12 @@ extension _PlayerSubtitles on _PlayerPageState { Future _maybeAutoTranslate() async { final target = _hive.getSubtitleTranslateLang(); if (target.isEmpty || _isLive || _autoTranslateDone) return; + // Already in the picture: a second copy would be drawn over the first. + final playing = + _currentSourceIndex >= 0 && _currentSourceIndex < _videoSources.length + ? _videoSources[_currentSourceIndex] + : null; + if (playing?.hardsub?.toLowerCase() == target.toLowerCase()) return; // Already readable. Checked before the network call, not after — the // common case must cost nothing. if (SubtitleAutoTranslate.anyMatches( @@ -1197,6 +1150,7 @@ extension _PlayerSubtitles on _PlayerPageState { isSerial: widget.args.isSerial, season: _currentSeasonNumber(), episode: _currentEpisodeNumber(), + want: _hive.getSubtitleTranslateLang(), ); } catch (_) { if (mounted) _toast('player.translate_failed'.tr()); @@ -1207,6 +1161,15 @@ extension _PlayerSubtitles on _PlayerPageState { _toast('player.no_subtitles_found'.tr()); return; } + // Somebody's own subtitle in the wanted language beats a machine's copy of + // an English one, and costs no allowance. + final target = _hive.getSubtitleTranslateLang(); + for (final r in results) { + if (SubtitleAutoTranslate.labelMatchesLanguage(r.language, target)) { + await _applyOnlineSubtitle(r); + return; + } + } // Prefer an English source — it is what the providers carry most of and // what the models translate best. OnlineSubtitle? best; @@ -1352,8 +1315,10 @@ extension _PlayerSubtitles on _PlayerPageState { } } catch (e) { _plog('subtitle translate error: $e', level: LogLevel.warn); + // Past the first slice the track stays, and the rest of the file is still + // in the source language, which the viewer should not have to discover. + if (mounted) _toast('player.translate_failed'.tr()); if (mounted && applied == 0) { - _toast('player.translate_failed'.tr()); // The same cleanup the limit branch does, which this one did not. // // The track is seeded with a COPY OF THE SOURCE so cues can be filled @@ -1434,13 +1399,7 @@ extension _PlayerSubtitles on _PlayerPageState { ]; void _openSubtitleSyncSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( desktopMaxWidth: 460, builder: (sheetContext) { return StatefulBuilder( @@ -1676,13 +1635,7 @@ extension _PlayerSubtitles on _PlayerPageState { } void _openSubtitleAppearanceSheet() { - showAdaptiveModal( - context: context, - backgroundColor: const Color(0xFF111111), - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), + _showSheet( desktopMaxWidth: 520, builder: (sheetContext) { return StatefulBuilder( diff --git a/lib/features/detail/presentation/pages/player_page.system.dart b/lib/features/detail/presentation/pages/player_page.system.dart new file mode 100644 index 00000000..4c357303 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.system.dart @@ -0,0 +1,233 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// Android audio focus, the media session and auto-enter PiP. +const MethodChannel _playerSystemChannel = MethodChannel( + 'soplay/player_system', +); + +extension _PlayerSystem on _PlayerPageState { + void _attachPlayerSystem() { + if (!isAndroidPlatform) return; + _playerSystemChannel.setMethodCallHandler(_onPlayerSystemCall); + unawaited(_systemInvoke('attach')); + } + + void _detachPlayerSystem() { + if (!isAndroidPlatform) return; + _playerSystemChannel.setMethodCallHandler(null); + unawaited(_systemInvoke('detach')); + if (_autoPipArmed) { + _autoPipArmed = false; + unawaited(_pipInvoke('setAutoPip', {'enabled': false})); + } + } + + Future _systemInvoke(String method, [Object? args]) async { + try { + return await _playerSystemChannel.invokeMethod(method, args); + } catch (_) { + return null; + } + } + + Future _pipInvoke(String method, Object? args) async { + try { + await _pipChannel.invokeMethod(method, args); + } catch (_) {} + } + + /// From the controller listener, on every tick. + void _syncPlayerSystem() { + if (!isAndroidPlatform) return; + final c = _controller; + if (c == null) return; + final v = c.value; + final playing = v.isPlaying; + if (playing != _systemPlaying) { + _systemPlaying = playing; + _audioFocusPolicy.onPlayingChanged(playing); + if (playing && !c.handlesAudioFocus) unawaited(_requestAudioFocus(c)); + _syncAutoPip(); + if (_isPip || _autoPipArmed) unawaited(_refreshPipActions()); + } + _syncMediaSession(c); + } + + Future _requestAudioFocus(PlayerController c) async { + final granted = await _systemInvoke('requestAudioFocus'); + // Refused during a phone call: playing over it is what this prevents. + if (granted == false && identical(c, _controller) && c.value.isPlaying) { + unawaited(c.pause()); + } + } + + void _syncMediaSession(PlayerController c) { + final v = c.value; + if (!v.isInitialized) return; + final serial = widget.args.isSerial; + final metaKey = '$_episodeIndex|${v.duration.inSeconds}'; + if (metaKey != _mediaMetaKey) { + _mediaMetaKey = metaKey; + final title = _episodeTitle(); + final prefix = '${widget.args.title} · '; + unawaited( + _systemInvoke('setMetadata', { + 'title': widget.args.title, + 'subtitle': serial && title.startsWith(prefix) + ? title.substring(prefix.length) + : null, + 'durationMs': _isLive ? 0 : v.duration.inMilliseconds, + 'artworkUrl': widget.args.thumbnail, + }), + ); + _mediaGate.reset(); + } + final send = _mediaGate.shouldSend( + playing: v.isPlaying, + positionMs: v.position.inMilliseconds, + speed: v.playbackSpeed, + nowMs: DateTime.now().millisecondsSinceEpoch, + ); + if (!send) return; + unawaited( + _systemInvoke('setPlaybackState', { + 'playing': v.isPlaying, + 'positionMs': v.position.inMilliseconds, + 'speed': v.playbackSpeed, + 'hasPrev': serial && _hasPrevEpisode, + 'hasNext': serial && _hasNextEpisode, + 'seekable': !_isLive, + }), + ); + } + + Future _onPlayerSystemCall(MethodCall call) async { + if (!mounted) return; + switch (call.method) { + case 'audioFocus': + _onAudioFocusEvent(audioFocusEventFromWire(call.arguments as String?)); + case 'mediaCommand': + final args = (call.arguments as Map?) ?? const {}; + _onMediaCommand( + args['action'] as String?, + (args['positionMs'] as num?)?.toInt(), + ); + } + } + + void _onAudioFocusEvent(AudioFocusEvent? event) { + final c = _controller; + if (event == null || c == null || !c.value.isInitialized) return; + // An engine that takes focus itself reacts on its own; ours, if still + // held from an earlier stream, losing it to that engine is not a reason + // to pause. + if (c.handlesAudioFocus && event != AudioFocusEvent.noisy) return; + final response = _audioFocusPolicy.on(event, playing: c.value.isPlaying); + _plog('audio focus: ${event.name}'); + if (response.volume != null) unawaited(c.setVolume(response.volume!)); + if (response.pause) { + _resumeAfterPause = false; + unawaited(c.pause()); + } + if (response.resume) unawaited(c.play()); + } + + void _onMediaCommand(String? action, int? positionMs) { + final c = _controller; + if (action == null || c == null || !c.value.isInitialized) return; + switch (action) { + case 'play': + if (!c.value.isPlaying) _togglePlay(); + case 'pause': + if (c.value.isPlaying) _togglePlay(); + case 'seek': + if (positionMs != null) _seekTo(Duration(milliseconds: positionMs)); + case 'rewind' || 'forward' || 'prev' || 'next': + unawaited(_onPipMethodCall(MethodCall('onPipAction', action))); + } + } + + /// Auto-enter PiP on Home, armed only while a video plays here. + void _syncAutoPip() { + if (!isAndroidPlatform || isTvPlatform) return; + final c = _controller; + final want = + _autoPipSetting && + c != null && + c.value.isInitialized && + c.value.isPlaying && + !_cast.isCasting && + mounted; + if (!want) { + if (!_autoPipArmed) return; + _autoPipArmed = false; + unawaited(_pipInvoke('setAutoPip', {'enabled': false})); + return; + } + _autoPipArmed = true; + final video = c.value.size; + final (num, den) = pipAspect(video); + final view = View.of(context); + final rect = pipSourceRect(view.physicalSize, video); + unawaited( + _pipInvoke('setAutoPip', { + 'enabled': true, + 'numerator': num, + 'denominator': den, + if (rect != null) + 'sourceRect': [ + rect.left.round(), + rect.top.round(), + rect.right.round(), + rect.bottom.round(), + ], + }), + ); + } + + /// The activity entered or left PiP — by our button, by Home with + /// auto-enter, or by the viewer closing the window. + void _onAndroidPipMode(Map args) { + final change = _pipTracker.onModeChanged( + inPip: args['inPip'] == true, + dismissed: args['dismissed'] == true, + ); + switch (change) { + case PipChange.none: + return; + case PipChange.entered: + _hideTimer?.cancel(); + setState(() { + _isPip = true; + _controlsVisible = false; + _panel = _SidePanel.none; + }); + _controlsAnimation.reverse(); + unawaited(_refreshPipActions()); + case PipChange.restored: + setState(() => _isPip = false); + case PipChange.dismissed: + setState(() => _isPip = false); + // Closing the window is stopping: no audio left playing behind it. + _resumeAfterPause = false; + final c = _controller; + if (c != null && c.value.isPlaying) unawaited(c.pause()); + _saveHistory(); + } + } + + void _handBackBrightness() { + if (isDesktopPlatform || _brightnessHandedBack) return; + if (!_brightnessKeeper.onBackground()) return; + _brightnessHandedBack = true; + unawaited(SystemControls.resetBrightness()); + } + + void _reapplyBrightness() { + if (isDesktopPlatform) return; + _brightnessHandedBack = false; + final level = _brightnessKeeper.onResume(); + if (level != null) unawaited(_setSystemBrightness(level)); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.toast.dart b/lib/features/detail/presentation/pages/player_page.toast.dart new file mode 100644 index 00000000..9527d9ca --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.toast.dart @@ -0,0 +1,79 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// The player's notices, drawn by the page rather than as SnackBars: those sat +/// at the bottom, over the seek bar the viewer was reaching for. +extension _PlayerToasts on _PlayerPageState { + void _toast( + String message, { + IconData icon = Icons.info_outline_rounded, + Duration duration = const Duration(seconds: 2), + String? action, + VoidCallback? onAction, + }) { + if (!mounted) return; + _toastTimer?.cancel(); + _toastNote.value = _PlayerToast( + message, + icon: icon, + action: action, + onAction: onAction, + ); + _toastTimer = Timer(duration, () { + if (mounted) _toastNote.value = null; + }); + } + + void _dismissToast() { + _toastTimer?.cancel(); + _toastNote.value = null; + } + + Widget _buildPlayerToast() { + return Positioned( + top: 0, + left: 16, + right: 16, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Center( + child: ValueListenableBuilder<_PlayerToast?>( + valueListenable: _toastNote, + builder: (context, toast, _) => AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + transitionBuilder: (child, a) => FadeTransition( + opacity: a, + child: SlideTransition( + position: Tween( + begin: const Offset(0, -0.3), + end: Offset.zero, + ).animate(a), + child: child, + ), + ), + child: toast == null + ? const SizedBox.shrink() + : ConstrainedBox( + key: ObjectKey(toast), + constraints: const BoxConstraints(maxWidth: 520), + child: IgnorePointer( + ignoring: toast.action == null, + child: _PlayerToastView( + toast: toast, + onAction: () { + _dismissToast(); + toast.onAction?.call(); + }, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.tv.dart b/lib/features/detail/presentation/pages/player_page.tv.dart index 57af7316..75c3d984 100644 --- a/lib/features/detail/presentation/pages/player_page.tv.dart +++ b/lib/features/detail/presentation/pages/player_page.tv.dart @@ -113,6 +113,11 @@ extension _PlayerTv on _PlayerPageState { /// dead until the next tap. void _tvHandOffFocusToRoot() { if (!isTvPlatform) return; + // The Up Next card keeps focus while the controls come and go; it leaves + // with the card. + if (_upNextShown && !_upNextDismissed && _tvUpNextFocus.hasPrimaryFocus) { + return; + } final current = FocusManager.instance.primaryFocus; if (current != null && current != _tvRootFocus) { // Disposition.scope hands primary focus to the enclosing scope, which is @@ -189,6 +194,11 @@ extension _PlayerTv on _PlayerPageState { _tvReveal(force: true); return; } + if (_upNextShown && !_upNextDismissed) { + _dismissUpNext(); + _tvHandOffFocusToRoot(); + return; + } if (_panel != _SidePanel.none) { // The focused panel row is about to be unmounted, so focus must be moved // deliberately rather than left to fall wherever it lands. @@ -270,6 +280,17 @@ extension _PlayerTv on _PlayerPageState { return KeyEventResult.handled; } + // The loading screen's Stop, while the ladder walks on its own. The + // handler below would otherwise read OK as play/pause. + if (_isTvSelect(k) && _tvStopSwitchFocus.hasPrimaryFocus) { + unawaited(_stopAutoSwitch()); + return KeyEventResult.handled; + } + if (_isTvSelect(k) && _tvUpNextFocus.hasPrimaryFocus) { + _playNextNow(); + return KeyEventResult.handled; + } + if (_locked) { // The lock affordance is not built on TV (see _buildControlsOverlay), so // this is only reachable if the state leaks in. Any key unlocks rather @@ -305,6 +326,13 @@ extension _PlayerTv on _PlayerPageState { return KeyEventResult.handled; } if (_isTvSelect(k)) { + // An offered skip is what OK means while it is on screen; the pill + // says so. + final skip = _activeSkip; + if (skip != null && _controlsCanShowSkip) { + unawaited(_takeSkip(skip)); + return KeyEventResult.handled; + } _togglePlay(); _tvReveal(); return KeyEventResult.handled; diff --git a/lib/features/detail/presentation/pages/player_page.upnext.dart b/lib/features/detail/presentation/pages/player_page.upnext.dart index cab343c4..1d00e9bf 100644 --- a/lib/features/detail/presentation/pages/player_page.upnext.dart +++ b/lib/features/detail/presentation/pages/player_page.upnext.dart @@ -70,6 +70,22 @@ extension _PlayerUpNext on _PlayerPageState { if (mounted) setState(() => _upNextDismissed = true); } + /// On TV the card takes focus as it appears, so OK plays now and BACK + /// cancels; focus goes back to the player when it leaves. + void _noteUpNextShown(bool shown) { + if (shown == _upNextShown) return; + _upNextShown = shown; + if (!isTvPlatform) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (shown && _tvUpNextFocus.context != null) { + _tvUpNextFocus.requestFocus(); + } else if (!shown && _tvUpNextFocus.hasFocus) { + _tvHandOffFocusToRoot(); + } + }); + } + Widget _buildUpNextPrompt() { final controller = _controller; final countdown = _automation?.upNextSeconds ?? 0; @@ -77,21 +93,23 @@ extension _PlayerUpNext on _PlayerPageState { countdown <= 0 || _initializing || _isPip || - isTvPlatform || !_willAutoAdvance || !_window.contains(_episodeIndex + 1)) { + _noteUpNextShown(false); return const SizedBox.shrink(); } final next = _episodes[_episodeIndex + 1]; return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { - if (!value.isInitialized) return const SizedBox.shrink(); - final left = NextUp.promptSecondsLeft( - position: value.position, - duration: value.duration, - countdownSeconds: countdown, - ); + final left = value.isInitialized + ? NextUp.promptSecondsLeft( + position: value.position, + duration: value.duration, + countdownSeconds: countdown, + ) + : null; + _noteUpNextShown(left != null); if (left == null) return const SizedBox.shrink(); final base = _controlsVisible ? 92.0 : 28.0; return Positioned( @@ -105,6 +123,7 @@ extension _PlayerUpNext on _PlayerPageState { countdown: countdown, onPlayNow: _playNextNow, onCancel: _dismissUpNext, + playFocus: _tvUpNextFocus, ), ); }, @@ -127,6 +146,7 @@ class _UpNextCard extends StatelessWidget { required this.countdown, required this.onPlayNow, required this.onCancel, + this.playFocus, }); final String label; @@ -134,6 +154,7 @@ class _UpNextCard extends StatelessWidget { final int countdown; final VoidCallback onPlayNow; final VoidCallback onCancel; + final FocusNode? playFocus; @override Widget build(BuildContext context) { @@ -219,15 +240,19 @@ class _UpNextCard extends StatelessWidget { child: Text('general.cancel'.tr()), ), const SizedBox(width: 6), - FilledButton.icon( - onPressed: onPlayNow, - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - visualDensity: VisualDensity.compact, + _tvRing( + radius: 20, + FilledButton.icon( + onPressed: onPlayNow, + focusNode: playFocus, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + visualDensity: VisualDensity.compact, + ), + icon: const Icon(Icons.skip_next_rounded, size: 18), + label: Text('automation.play_now'.tr()), ), - icon: const Icon(Icons.skip_next_rounded, size: 18), - label: Text('automation.play_now'.tr()), ), ], ), diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index d0c206de..3e007b68 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -149,6 +149,8 @@ class _LoadingOverlay extends StatelessWidget { required this.stage, required this.title, this.serverSwitch, + this.onStopSwitch, + this.stopFocus, }); final _LoadingStage stage; @@ -157,6 +159,10 @@ class _LoadingOverlay extends StatelessWidget { /// Non-null only while moving between mirrors, which gets its own screen. final ServerSwitch? serverSwitch; + /// Stops an automatic switch; see [_ServerSwitchOverlay.onStop]. + final VoidCallback? onStopSwitch; + final FocusNode? stopFocus; + String get _label { switch (stage) { case _LoadingStage.resolving: @@ -187,7 +193,13 @@ class _LoadingOverlay extends StatelessWidget { @override Widget build(BuildContext context) { final switching = serverSwitch; - if (switching != null) return _ServerSwitchOverlay(switch_: switching); + if (switching != null) { + return _ServerSwitchOverlay( + switch_: switching, + onStop: switching.automatic ? onStopSwitch : null, + stopFocus: stopFocus, + ); + } return SafeArea( child: Padding( @@ -257,6 +269,144 @@ class _LoadingOverlay extends StatelessWidget { } } +/// The player's own button: glass, radius 14, the primary colour for the one +/// thing to do next. Used on the error screen. +class _PlayerGlassButton extends StatelessWidget { + const _PlayerGlassButton({ + required this.onPressed, + required this.icon, + required this.label, + this.primary = false, + this.autofocus = false, + }); + + final VoidCallback onPressed; + final IconData icon; + final String label; + final bool primary; + final bool autofocus; + + static const double radius = 14; + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 11), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: primary ? Colors.white : AppColors.primaryLight, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 13.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + return _tvRing( + radius: radius, + Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(radius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + autofocus: autofocus, + borderRadius: BorderRadius.circular(radius), + child: primary + ? DecoratedBox( + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(radius), + ), + child: content, + ) + : MediaGlass( + radius: radius, + dim: Colors.white.withValues(alpha: 0.10), + child: content, + ), + ), + ), + ); + } +} + +/// The player's notices: a glass toast at the top centre, clear of the seek +/// bar and the bottom controls. One at a time. +class _PlayerToastView extends StatelessWidget { + const _PlayerToastView({required this.toast, this.onAction}); + + final _PlayerToast toast; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final action = toast.action; + return Semantics( + liveRegion: true, + child: MediaGlass( + radius: 14, + dim: Colors.black.withValues(alpha: 0.62), + child: Padding( + padding: EdgeInsets.fromLTRB(14, 10, action == null ? 16 : 6, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(toast.icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 10), + Flexible( + child: Text( + toast.message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + if (action != null) ...[ + const SizedBox(width: 6), + _tvRing( + radius: 10, + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryLight, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + child: Text(action), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + class _IconButton extends StatelessWidget { const _IconButton({ required this.icon, @@ -264,10 +414,14 @@ class _IconButton extends StatelessWidget { this.onLongPress, this.color, this.enabled = true, + this.tooltip, }); final IconData icon; final VoidCallback onTap; + /// For a button whose long press is not obvious from its icon. + final String? tooltip; + /// The long way round, for a control whose tap is a shortcut. /// /// Speed and aspect ratio both used to open a full sheet over the video for @@ -296,9 +450,10 @@ class _IconButton extends StatelessWidget { // still needed it: a bar button is a 20pt white glyph against whatever // frame is behind it, and on a bright shot 0.35 left it reading as a // smudge. Raised for legibility, not because the scrim is absent. - final fill = color != null - ? color!.withValues(alpha: 0.22) - : Colors.black.withValues(alpha: 0.45); + // + // On an iPhone that disc is clear glass with the same black laid in it + // ([MediaGlass]), so it keeps this contrast and gains the glass. + final fill = color?.withValues(alpha: 0.22); final tint = (color ?? Colors.white).withValues( alpha: enabled ? 1.0 : 0.38, ); @@ -312,7 +467,7 @@ class _IconButton extends StatelessWidget { // pitch was already 44; the gap simply moves inside the button as // transparent padding. Callers therefore drop their SizedBox(width: 6) — // keeping both would double the spacing. - return _tvRing( + final Widget button = _tvRing( circle: true, SizedBox( width: 44, @@ -325,18 +480,106 @@ class _IconButton extends StatelessWidget { onLongPress: enabled ? onLongPress : null, customBorder: const CircleBorder(), child: Center( - child: Container( - width: 38, - height: 38, - decoration: BoxDecoration(color: fill, shape: BoxShape.circle), - // 20 inside a 38pt disc. It was 18, which left 10pt of empty - // disc a side and read as a small mark in a large circle — - // half of why these looked flimsy over video. - child: Icon( - icon, - color: tint, - size: 20, - shadows: _kControlShadow, + child: MediaGlass( + dim: fill, + child: SizedBox( + width: 38, + height: 38, + // 20 inside a 38pt disc. It was 18, which left 10pt of empty + // disc a side and read as a small mark in a large circle — + // half of why these looked flimsy over video. + child: Icon( + icon, + color: tint, + size: 20, + shadows: _kControlShadow, + ), + ), + ), + ), + ), + ), + ), + ); + final tip = tooltip; + return tip == null ? button : Tooltip(message: tip, child: button); + } +} + +/// The small player's timeline: thin until the pointer is over it, and a +/// click or a drag anywhere along it seeks. The full bar's frame preview and +/// chapter marks have no room here. +class _MiniSeekBar extends StatefulWidget { + const _MiniSeekBar({required this.controller, required this.onSeek}); + + final PlayerController controller; + final ValueChanged onSeek; + + @override + State<_MiniSeekBar> createState() => _MiniSeekBarState(); +} + +class _MiniSeekBarState extends State<_MiniSeekBar> { + bool _hover = false; + + /// Where the pointer holds the bar, 0–1, while it is down. + double? _drag; + + void _at(double dx, double width) => + setState(() => _drag = width > 0 ? (dx / width).clamp(0.0, 1.0) : 0.0); + + void _commit() { + final f = _drag; + final d = widget.controller.value.duration; + if (f != null && d > Duration.zero) widget.onSeek(d * f); + setState(() => _drag = null); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, box) => MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // Claim the click for the bar. Without these the player's own tap + // (play/pause) and double tap (back to the full player) would fire + // under it as well. + onTap: () {}, + onDoubleTap: () {}, + // Raw pointer events for the seek itself, so it lands on release + // rather than after the double-tap timeout the claim above holds. + child: Listener( + onPointerDown: (e) => _at(e.localPosition.dx, box.maxWidth), + onPointerMove: (e) => _at(e.localPosition.dx, box.maxWidth), + onPointerUp: (_) => _commit(), + onPointerCancel: (_) => setState(() => _drag = null), + child: SizedBox( + height: 16, + child: Align( + alignment: Alignment.bottomCenter, + child: ListenableBuilder( + listenable: widget.controller, + builder: (_, _) { + final v = widget.controller.value; + final ms = v.duration.inMilliseconds; + final f = + _drag ?? + (ms > 0 + ? (v.position.inMilliseconds / ms).clamp(0.0, 1.0) + : 0.0); + return ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: f, + minHeight: _hover || _drag != null ? 5 : 3, + backgroundColor: Colors.white24, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ); + }, ), ), ), @@ -352,9 +595,13 @@ class _DesktopVolumeControl extends StatelessWidget { required this.volume, required this.onChanged, required this.onToggleMute, + this.max = 1.0, }); final double volume; + + /// 1.5 where the engine amplifies past 100%. + final double max; final ValueChanged onChanged; final VoidCallback onToggleMute; @@ -370,7 +617,7 @@ class _DesktopVolumeControl extends StatelessWidget { onPointerSignal: (e) { if (e is PointerScrollEvent) { final step = e.scrollDelta.dy < 0 ? 0.05 : -0.05; - onChanged((volume + step).clamp(0.0, 1.0)); + onChanged((volume + step).clamp(0.0, max)); } }, child: Row( @@ -389,7 +636,8 @@ class _DesktopVolumeControl extends StatelessWidget { overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), ), child: Slider( - value: volume.clamp(0.0, 1.0), + value: volume.clamp(0.0, max), + max: max, onChanged: onChanged, ), ), @@ -411,35 +659,37 @@ class _LangPill extends StatelessWidget { // one this pill took D-pad focus with nothing to show for it. return _tvRing( radius: 20, - Material( - // The same 0.45 disc its _IconButton neighbours wear; at 0.35 it was - // the one washed-out shape in the row. - color: Colors.black.withValues(alpha: 0.45), - shape: const StadiumBorder(), - child: InkWell( - onTap: onTap, - customBorder: const StadiumBorder(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.translate_rounded, - color: Colors.white, - size: 14, - ), - const SizedBox(width: 6), - Text( - label, - style: const TextStyle( + // The same 0.45 disc its _IconButton neighbours wear; at 0.35 it was the + // one washed-out shape in the row. + MediaGlass( + child: Material( + type: MaterialType.transparency, + shape: const StadiumBorder(), + child: InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.translate_rounded, color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w800, - letterSpacing: 0.5, + size: 14, ), - ), - ], + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + ), + ), + ], + ), ), ), ), @@ -484,35 +734,37 @@ class _CenterIconButton extends StatelessWidget { label: label, child: _tvRing( circle: true, - Material( - // These buttons sit at the vertical centre, where the scrim is fully - // transparent — 32% black left a white glyph invisible on a bright shot. - color: Colors.black.withValues(alpha: 0.45), - shape: const CircleBorder(), - child: InkWell( - onTap: enabled ? onTap : null, - focusNode: focusNode, - customBorder: const CircleBorder(), - child: SizedBox( - width: size, - height: size, - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - icon, - color: enabled ? Colors.white : Colors.white38, - size: iconSize, - shadows: _kControlShadow, - ), - if (busy) - const Positioned.fill( - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: Colors.white70, - ), + // These buttons sit at the vertical centre, where the scrim is fully + // transparent — 32% black left a white glyph invisible on a bright shot. + MediaGlass( + child: Material( + type: MaterialType.transparency, + shape: const CircleBorder(), + child: InkWell( + onTap: enabled ? onTap : null, + focusNode: focusNode, + customBorder: const CircleBorder(), + child: SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + icon, + color: enabled ? Colors.white : Colors.white38, + size: iconSize, + shadows: _kControlShadow, ), - ], + if (busy) + const Positioned.fill( + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white70, + ), + ), + ], + ), ), ), ), @@ -562,11 +814,8 @@ class _BottomTextButton extends StatelessWidget { // exactly the "thin, low quality" the icon buttons beside it had // already been given a disc to fix; this is that disc, shaped for a // label instead of a glyph, at the same 0.45. - DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(10), - ), + MediaGlass( + radius: 10, child: InkWell( onTap: enabled ? onTap : null, borderRadius: BorderRadius.circular(10), @@ -722,7 +971,7 @@ class _EpisodeRow extends StatelessWidget { @override Widget build(BuildContext context) { final label = episode.label.trim().isEmpty - ? 'player.episode_n'.tr(args: ['${episode.episode}']) + ? 'player.episode_n'.tr(args: [episode.numberLabel]) : episode.label; return InkWell( borderRadius: BorderRadius.circular(12), @@ -1071,6 +1320,15 @@ class _SettingsTile extends StatelessWidget { @override Widget build(BuildContext context) { final disabled = onTap == null; + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + leading: Icon(cupertinoIconFor(icon)), + title: label, + value: value, + accessory: IosRowAccessory.chevron, + onTap: onTap, + ); + } return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, @@ -1154,6 +1412,17 @@ class _OptionTile extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return IosSheetRow( + title: label, + subtitle: subtitle, + accessory: IosRowAccessory.check, + selected: selected, + choice: true, + onTap: onTap, + onLongPress: onLongPress, + ); + } return InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, @@ -1275,6 +1544,12 @@ class _SheetSectionLabel extends StatelessWidget { @override Widget build(BuildContext context) { + if (PlayerSheetLook.of(context)) { + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(32, 14, 32, 2), + child: Text(label.toUpperCase(), style: IosSheet.footnote), + ); + } return Padding( padding: const EdgeInsetsDirectional.fromSTEB(20, 14, 16, 6), child: Text( @@ -1768,7 +2043,6 @@ class _ScrubPreviewCard extends StatelessWidget { required this.frame, required this.width, required this.time, - required this.delta, this.segment, this.caretX, }); @@ -1778,9 +2052,6 @@ class _ScrubPreviewCard extends StatelessWidget { final double width; final String time; - /// "+1:25" / "−0:40" from the current position; empty when negligible. - final String delta; - /// "Opening" or "Ending" when the position falls inside one. final String? segment; @@ -1817,48 +2088,41 @@ class _ScrubPreviewCard extends StatelessWidget { ], ), const SizedBox(height: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - time, - style: const TextStyle( - color: Colors.white, - fontSize: 12.5, - fontWeight: FontWeight.w800, - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - if (frame == null && segment != null) ...[ - const SizedBox(width: 6), - Text( - segment!, - style: const TextStyle( - color: Colors.white70, - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - ], - if (delta.isNotEmpty) ...[ - const SizedBox(width: 6), + // "01:12:49 / 02:07:33" at a large system text size is wider than + // the card. + FittedBox( + fit: BoxFit.scaleDown, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ Text( - delta, + time, style: const TextStyle( - color: Colors.white60, - fontSize: 11, - fontWeight: FontWeight.w600, + color: Colors.white, + fontSize: 12.5, + fontWeight: FontWeight.w800, fontFeatures: [FontFeature.tabularFigures()], ), ), + if (frame == null && segment != null) ...[ + const SizedBox(width: 6), + Text( + segment!, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], ], - ], + ), ), ), if (caretX != null) @@ -1922,13 +2186,23 @@ class _CaretPainter extends CustomPainter { } class _ServerSwitchOverlay extends StatelessWidget { - const _ServerSwitchOverlay({required this.switch_}); + const _ServerSwitchOverlay({ + required this.switch_, + this.onStop, + this.stopFocus, + }); final ServerSwitch switch_; + /// Ends the automatic walk and leaves the player on its error screen. + final VoidCallback? onStop; + final FocusNode? stopFocus; + @override Widget build(BuildContext context) { final from = switch_.from; + final attempt = switch_.attempt; + final stop = onStop; return SafeArea( child: Center( @@ -1943,7 +2217,7 @@ class _ServerSwitchOverlay extends StatelessWidget { // The one being left is dimmed rather than hidden: the // journey is the information, and "from nothing to X" is // what a first load looks like. - if (from != null) ...[ + if (from != null && !switch_.sameServer) ...[ Opacity( opacity: 0.35, child: ServerBadge(name: from, size: 44), @@ -1966,13 +2240,28 @@ class _ServerSwitchOverlay extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'player.switching_server'.tr(), + switch_.sameServer + ? 'player.trying_again'.tr(args: ['${attempt ?? 1}']) + : 'player.switching_server'.tr(), style: const TextStyle( color: Colors.white60, fontSize: 13, fontWeight: FontWeight.w500, ), ), + if (attempt != null && !switch_.sameServer) ...[ + const SizedBox(height: 4), + Text( + 'player.auto_switch_attempt'.tr( + args: ['$attempt', '${switch_.maxAttempts ?? attempt}'], + ), + style: const TextStyle( + color: Colors.white38, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], const SizedBox(height: 22), SizedBox( width: 148, @@ -1989,6 +2278,25 @@ class _ServerSwitchOverlay extends StatelessWidget { ), ), ), + if (stop != null) ...[ + const SizedBox(height: 18), + _tvRing( + OutlinedButton.icon( + onPressed: stop, + focusNode: stopFocus, + // The only control on this screen; a remote needs it + // focused to reach it at all. + autofocus: isTvPlatform, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), + icon: const Icon(Icons.stop_rounded, size: 18), + label: Text('player.auto_switch_stop'.tr()), + ), + radius: 20, + ), + ], ], ), ), @@ -2054,3 +2362,55 @@ class _SwitchArrowState extends State<_SwitchArrow> ); } } + +/// Rebuilds [builder] only when [select] reads something new off +/// [listenable]: the native play button wants playing and buffering, not +/// every position tick the controller sends. +class _Selected extends StatefulWidget { + const _Selected({ + required this.listenable, + required this.select, + required this.builder, + }); + + final Listenable listenable; + final T Function() select; + final Widget Function(BuildContext context, T value) builder; + + @override + State<_Selected> createState() => _SelectedState(); +} + +class _SelectedState extends State<_Selected> { + late T _value = widget.select(); + + @override + void initState() { + super.initState(); + widget.listenable.addListener(_changed); + } + + @override + void didUpdateWidget(_Selected old) { + super.didUpdateWidget(old); + if (old.listenable != widget.listenable) { + old.listenable.removeListener(_changed); + widget.listenable.addListener(_changed); + } + _value = widget.select(); + } + + @override + void dispose() { + widget.listenable.removeListener(_changed); + super.dispose(); + } + + void _changed() { + final value = widget.select(); + if (value != _value) setState(() => _value = value); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _value); +} diff --git a/lib/features/detail/presentation/pages/player_route.dart b/lib/features/detail/presentation/pages/player_route.dart new file mode 100644 index 00000000..507c1318 --- /dev/null +++ b/lib/features/detail/presentation/pages/player_route.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/player_page.dart'; + +/// Builds the player for a key and its arguments. Injectable so the hand-off +/// between the route and the corner can be tested without a real player. +typedef PlayerPageBuilder = Widget Function(GlobalKey key, PlayerArgs args); + +Widget defaultPlayerPageBuilder(GlobalKey key, PlayerArgs args) => + PlayerPage(key: key, args: args); + +/// What /player builds. It gives the page a [GlobalKey]: that key is how a +/// page can leave this route for the desktop corner player and come back +/// with all of its state, and how a route recognises a page being handed +/// back to it. +class PlayerRoute extends StatefulWidget { + const PlayerRoute({ + super.key, + required this.args, + this.pageBuilder = defaultPlayerPageBuilder, + }); + + final PlayerArgs args; + final PlayerPageBuilder pageBuilder; + + @override + State createState() => _PlayerRouteState(); +} + +class _PlayerRouteState extends State { + final PlayerDock _dock = PlayerDock.instance; + late final GlobalKey _pageKey; + bool _ready = true; + + /// Set once the page has gone to the corner. A docked page never comes back + /// to the route it left (restoring pushes a new one), so from then on this + /// route draws nothing, whatever the dock does during its exit transition: + /// restored, replaced or closed meanwhile, building the page again here + /// would put the key in two places or pull the page into a dying route. + bool _gaveAway = false; + + @override + void initState() { + super.initState(); + final handed = _dock.takeHandBack(); + _pageKey = handed ?? GlobalKey(debugLabel: 'player'); + if (handed == null && _dock.isDocked) { + // Another title is in the corner. It goes first: its dispose saves its + // history and also clears page-wide singletons (PlayerLog, the frame + // preview service, Discord, the PiP channel handler). Disposed after + // this page's initState, it would tear down what this page had just + // set up. + _ready = false; + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _dock.close(); + if (mounted) setState(() => _ready = true); + }); + } + } + + @override + Widget build(BuildContext context) { + if (!_ready) return const ColoredBox(color: Colors.black); + return ListenableBuilder( + listenable: _dock, + // While this route animates away after docking it draws nothing: the + // page is in the corner now, under the same key, and building it here + // as well would be the same GlobalKey in two places. Empty rather than + // black, so the page underneath shows through the exit transition while + // the picture shrinks into its corner. + builder: (context, _) { + if (_dock.dockedKey == _pageKey) _gaveAway = true; + return _gaveAway + ? const SizedBox.expand() + : widget.pageBuilder(_pageKey, widget.args); + }, + ); + } +} diff --git a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart index 056a0ca3..721d6498 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -14,10 +14,12 @@ import 'package:soplay/core/content/content_mode.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/matching/title_match.dart'; import 'package:soplay/core/network/image_headers.dart'; +import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/features/detail/data/source_choice_store.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/services/alternate_source_service.dart'; +import 'package:soplay/features/detail/domain/services/auto_resolve_walk.dart'; import 'package:soplay/features/detail/presentation/widgets/source_search_sheet.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; @@ -64,6 +66,7 @@ class AlternateSourceSheet extends StatefulWidget { required this.provider, required this.category, required this.episodeNumber, + this.seasonEpisode, this.resumeAt = Duration.zero, }) : candidates = null, choices = null; @@ -81,6 +84,7 @@ class AlternateSourceSheet extends StatefulWidget { required this.provider, required this.category, required this.episodeNumber, + this.seasonEpisode, this.resumeAt = Duration.zero, required this.candidates, required this.choices, @@ -91,6 +95,10 @@ class AlternateSourceSheet extends StatefulWidget { final String category; final int? episodeNumber; + /// The episode's season and its number in that season, when the source + /// playing it lists seasons — see [AlternateSourceService.buildArgs]. + final SeasonEpisode? seasonEpisode; + /// Where the viewer is now, carried onto the source they pick. /// /// Zero when the sheet opens from a playback failure — there is nothing to @@ -110,6 +118,7 @@ class AlternateSourceSheet extends StatefulWidget { required String provider, required String category, required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) async { final picked = await _open( @@ -118,6 +127,7 @@ class AlternateSourceSheet extends StatefulWidget { provider: provider, category: category, episodeNumber: episodeNumber, + seasonEpisode: seasonEpisode, resumeAt: resumeAt, ); return picked is PlayerArgs ? picked : null; @@ -149,6 +159,7 @@ class AlternateSourceSheet extends StatefulWidget { required String provider, required String category, required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) { return showAdaptiveModal( @@ -163,6 +174,7 @@ class AlternateSourceSheet extends StatefulWidget { provider: provider, category: category, episodeNumber: episodeNumber, + seasonEpisode: seasonEpisode, resumeAt: resumeAt, ), ); @@ -212,6 +224,15 @@ class _AlternateSourceSheetState extends State { /// Play button that did nothing at all. ({String provider, String message})? _failed; + /// Non-null while Auto is going down the list looking for one that plays. + AutoResolveWalk<_Row, PlayerArgs>? _walk; + + /// How the last Auto run ended, when it ended without playing anything. + AutoWalkEnd? _autoEnd; + + /// What the current or last Auto run moved past, and why. + List> _autoSkipped = const []; + /// How the run ended, so the empty state can say which kind of empty. AlternateSearchOutcome? _outcome; @@ -220,8 +241,7 @@ class _AlternateSourceSheetState extends State { /// the same list the automatic one used. List _candidates = const []; - late final SourceChoiceStore _choices = - widget.choices ?? SourceChoiceStore(); + late final SourceChoiceStore _choices = widget.choices ?? SourceChoiceStore(); /// The viewer's corrections for this title, by provider id. final Map _corrections = {}; @@ -302,6 +322,9 @@ class _AlternateSourceSheetState extends State { @override void dispose() { + final walk = _walk; + _walk = null; + walk?.stop(); _sub?.cancel(); super.dispose(); } @@ -377,9 +400,7 @@ class _AlternateSourceSheetState extends State { (!_reads || p.id.contentMode == widget.provider.contentMode)) p, ]; - out.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); + out.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); return out; } @@ -404,7 +425,7 @@ class _AlternateSourceSheetState extends State { bool get _reads => widget.provider.contentMode != ContentMode.video; Future _pick(AlternateSource source) async { - if (_preparing != null) return; + if (_preparing != null || _walk != null) return; if (_reads) { Navigator.of(context).pop(source); return; @@ -416,6 +437,7 @@ class _AlternateSourceSheetState extends State { final args = await getIt().buildArgs( source: source, episodeNumber: widget.episodeNumber, + seasonEpisode: widget.seasonEpisode, resumeAt: widget.resumeAt, ); if (!mounted) return; @@ -436,6 +458,204 @@ class _AlternateSourceSheetState extends State { Navigator.of(context).pop(args); } + /// Auto: the first source, best match first, that can actually be played. + /// + /// The list asked the viewer to pick a row and press it, and when that + /// source had the show but not the episode, to pick the next. Auto does + /// that walk itself — corrections first, then the matches it can trust in + /// score order, taking each source as it answers rather than waiting for + /// the slowest. [AutoResolveWalk] holds the cap and the stop. + Future _runAuto() async { + if (_walk != null || _preparing != null) return; + final service = getIt(); + late final AutoResolveWalk<_Row, PlayerArgs> walk; + walk = AutoResolveWalk<_Row, PlayerArgs>( + candidates: () => _rows, + searching: () => _searching, + idOf: (r) => r.provider.id, + isEligible: (r) => r.chosen || r.source.isTrustworthy, + attempt: (r) async { + final result = await service.resolveArgs( + source: r.source, + episodeNumber: widget.episodeNumber, + seasonEpisode: widget.seasonEpisode, + resumeAt: widget.resumeAt, + ); + final args = result.args; + if (args != null) return AutoAttempt.found(args); + return AutoAttempt.skipped( + result.failure == AlternateArgsFailure.noEpisode + ? AutoSkipReason.noEpisode + : AutoSkipReason.error, + ); + }, + onChange: () { + if (mounted && identical(_walk, walk)) setState(() {}); + }, + ); + setState(() { + _walk = walk; + _autoEnd = null; + _autoSkipped = const []; + _failed = null; + }); + final result = await walk.run(); + if (!mounted) return; + if (result.end == AutoWalkEnd.found) { + Navigator.of(context).pop(result.value); + return; + } + setState(() { + _walk = null; + _autoEnd = result.end; + _autoSkipped = walk.skipped; + }); + } + + void _stopAuto() => _walk?.stop(); + + String _autoSubtitle() { + final walk = _walk; + if (walk != null) { + final current = walk.current; + if (current == null) return 'player.alt_auto_waiting'.tr(); + return 'player.alt_auto_checking'.tr( + args: [current.provider.name, '${walk.index}', '${walk.total}'], + ); + } + return switch (_autoEnd) { + AutoWalkEnd.exhausted => 'player.alt_auto_missed'.tr(), + AutoWalkEnd.capped => 'player.alt_auto_capped'.tr( + args: ['${AutoResolveWalk.maxCandidates}'], + ), + AutoWalkEnd.stopped => 'player.alt_auto_stopped'.tr(), + _ => 'player.alt_auto_hint'.tr(), + }; + } + + /// The row that offers [_runAuto], above the matches. + Widget _autoTile() { + final running = _walk != null; + final ended = _autoEnd != null; + final missed = + _autoEnd == AutoWalkEnd.exhausted || _autoEnd == AutoWalkEnd.capped; + final Widget trailing; + if (running) { + trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + const SizedBox(width: 4), + TextButton( + // The row it replaces stops being focusable once Auto runs, so a + // remote would otherwise be left with nothing focused. + autofocus: isTvPlatform, + onPressed: _stopAuto, + style: TextButton.styleFrom(foregroundColor: Colors.white), + child: Text('player.alt_auto_stop'.tr()), + ), + ], + ); + } else if (ended) { + trailing = TextButton.icon( + autofocus: isTvPlatform, + onPressed: _preparing != null ? null : _runAuto, + style: TextButton.styleFrom(foregroundColor: AppColors.primary), + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text('general.try_again'.tr()), + ); + } else { + trailing = const Icon(Icons.play_arrow_rounded, color: Colors.white70); + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + onTap: running || _preparing != null ? null : _runAuto, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.auto_awesome_rounded, + color: AppColors.primary, + size: 20, + ), + ), + title: Text( + 'player.alt_auto'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 14.5, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + _autoSubtitle(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: missed ? AppColors.error : Colors.white54, + fontSize: 12, + ), + ), + trailing: trailing, + ), + if ((running ? _walk!.skipped : _autoSkipped).isNotEmpty) + _autoSkippedList(running ? _walk!.skipped : _autoSkipped), + ], + ); + } + + /// What Auto already moved past, one short line each. + Widget _autoSkippedList(List> skipped) { + const shown = 8; + const style = TextStyle(color: Colors.white54, fontSize: 11.5); + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(72, 0, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'player.alt_auto_tried'.tr(), + style: const TextStyle( + color: Colors.white70, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + for (final s in skipped.take(shown)) + Text( + '${s.candidate.provider.name} — ${_skipLabel(s.reason)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + if (skipped.length > shown) + Text('+${skipped.length - shown}', style: style), + ], + ), + ); + } + + static String _skipLabel(AutoSkipReason reason) => switch (reason) { + AutoSkipReason.noMatch => 'player.alt_skip_no_match'.tr(), + AutoSkipReason.noEpisode => 'player.alt_skip_no_episode'.tr(), + AutoSkipReason.error => 'player.alt_skip_error'.tr(), + }; + /// Hand the question to the viewer: search this one source, take their pick. /// /// Remembered before the row is redrawn, so the correction is already on disk @@ -528,12 +748,17 @@ class _AlternateSourceSheetState extends State { ), ), const Divider(color: Colors.white12, height: 1), + // One viewport for everything, so the grid and the unmatched list + // build only the rows on screen. Still shrink-wrapped, so a short + // result keeps the sheet short. Flexible( - child: ListView( + child: CustomScrollView( shrinkWrap: true, - children: [ + slivers: [ + if (!_reads && (list.isNotEmpty || _searching)) + SliverToBoxAdapter(child: _autoTile()), if (list.isNotEmpty) _matchGrid(list), - if (list.isEmpty) _emptyBlock(), + if (list.isEmpty) SliverToBoxAdapter(child: _emptyBlock()), if (quiet.isNotEmpty) ..._silentSection(quiet), ], ), @@ -556,26 +781,31 @@ class _AlternateSourceSheetState extends State { /// Sized by the widest a tile may be rather than by a column count, so a /// phone lands on three and a tablet or a desktop window takes four or more /// without a second layout being written for them. - Widget _matchGrid(List<_Row> rows) => GridView.builder( + static const double _tileMaxWidth = 132; + + Widget _matchGrid(List<_Row> rows) => SliverPadding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: rows.length, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 132, - mainAxisSpacing: 12, - crossAxisSpacing: 10, - // The poster's own 2:3 plus room for two lines of caption, which grows - // with the text scale rather than clipping at it. - mainAxisExtent: - 132 * 1.5 + 34 + MediaQuery.textScalerOf(context).scale(12) * 1.6, + sliver: SliverGrid.builder( + itemCount: rows.length, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _tileMaxWidth, + mainAxisSpacing: 12, + crossAxisSpacing: 10, + // The poster's own 2:3 plus room for two lines of caption, which grows + // with the text scale rather than clipping at it. + mainAxisExtent: + _tileMaxWidth * 1.5 + + 34 + + MediaQuery.textScalerOf(context).scale(12) * 1.6, + ), + itemBuilder: (context, i) => _matchTile(rows[i]), ), - itemBuilder: (context, i) => _matchTile(rows[i]), ); Widget _matchTile(_Row row) { - final busy = _preparing == row.provider.id; - final enabled = _preparing == null; + final busy = + (_preparing ?? _walk?.current?.provider.id) == row.provider.id; + final enabled = _preparing == null && _walk == null; final failure = _failed?.provider == row.provider.id ? _failed!.message : null; @@ -586,151 +816,154 @@ class _AlternateSourceSheetState extends State { // escape hatch away from exactly the reader most likely to need it. return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: const Color(0xFF1C1C1C), - child: poster == null || poster.isEmpty - ? const Icon( - Icons.image_not_supported_outlined, - color: Colors.white24, - size: 22, - ) - : CachedNetworkImage( - imageUrl: poster, - httpHeaders: posterImageHeaders(poster), - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 180), - errorWidget: (_, _, _) => const Icon( - Icons.broken_image_outlined, - color: Colors.white24, - size: 22, - ), - ), - ), - // A scrim under the play mark and the badges, so both stay - // legible on a bright cover as well as a dark one. - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x99000000), Color(0x00000000)], - stops: [0, 0.55], + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox( + color: const Color(0xFF1C1C1C), + child: poster == null || poster.isEmpty + ? const Icon( + Icons.image_not_supported_outlined, + color: Colors.white24, + size: 22, + ) + : CachedNetworkImage( + imageUrl: poster, + httpHeaders: posterImageHeaders(poster), + fit: BoxFit.cover, + memCacheWidth: + (_tileMaxWidth * + MediaQuery.devicePixelRatioOf(context)) + .round(), + fadeInDuration: const Duration(milliseconds: 180), + errorWidget: (_, _, _) => const Icon( + Icons.broken_image_outlined, + color: Colors.white24, + size: 22, + ), ), - ), - ), - Center( - child: busy - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white70, - ), - ) - : Icon( - // A manga opens to be read, not played. - _reads - ? Icons.menu_book_rounded - : Icons.play_circle_fill_rounded, - color: Colors.white70, - size: 34, - ), + ), + // A scrim under the play mark and the badges, so both stay + // legible on a bright cover as well as a dark one. + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x99000000), Color(0x00000000)], + stops: [0, 0.55], ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: Semantics( - button: true, - label: - '${row.provider.name}, ${row.source.item.title}', - child: InkWell( - onTap: enabled ? () => _pick(row.source) : null, + ), + ), + Center( + child: busy + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white70, ), + ) + : Icon( + // A manga opens to be read, not played. + _reads + ? Icons.menu_book_rounded + : Icons.play_circle_fill_rounded, + color: Colors.white70, + size: 34, ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: Semantics( + button: true, + label: '${row.provider.name}, ${row.source.item.title}', + child: InkWell( + onTap: enabled ? () => _pick(row.source) : null, ), ), - PositionedDirectional( - top: 4, - start: 4, - child: Wrap(spacing: 4, children: _badge(row)), - ), - // On the tile that failed, over its own poster, where the - // finger already is. - if (failure != null) - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.82), - borderRadius: BorderRadius.circular(10), - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline_rounded, - color: Colors.white70, - size: 22, - ), - const SizedBox(height: 6), - Text( - failure, - textAlign: TextAlign.center, - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - height: 1.25, - fontWeight: FontWeight.w600, - ), - ), - ], + ), + ), + PositionedDirectional( + top: 4, + start: 4, + child: Wrap(spacing: 4, children: _badge(row)), + ), + // On the tile that failed, over its own poster, where the + // finger already is. + if (failure != null) + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(10), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline_rounded, + color: Colors.white70, + size: 22, ), - ), + const SizedBox(height: 6), + Text( + failure, + textAlign: TextAlign.center, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + height: 1.25, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), - // The escape hatch keeps its own target in the corner: the - // tile's own tap is still "play this", and a cover that - // opened a search when somebody meant to watch would be a - // worse bug than the one it is here to fix. - PositionedDirectional( - top: 0, - end: 0, - child: _wrongTitleButton(row.provider), ), - ], + ), + // The escape hatch keeps its own target in the corner: the + // tile's own tap is still "play this", and a cover that + // opened a search when somebody meant to watch would be a + // worse bug than the one it is here to fix. + PositionedDirectional( + top: 0, + end: 0, + child: _wrongTitleButton(row.provider), ), - ), - ), - const SizedBox(height: 6), - Text( - row.provider.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 12.5, - fontWeight: FontWeight.w700, - ), + ], ), - // The source's own title for the show, not ours. Two catalogues - // spell the same series differently, and seeing which one this - // source means is how the viewer tells a real match from a near - // miss before committing. - Text( - row.source.item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 6), + Text( + row.provider.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + // The source's own title for the show, not ours. Two catalogues + // spell the same series differently, and seeing which one this + // source means is how the viewer tells a real match from a near + // miss before committing. + Text( + row.source.item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white54, fontSize: 11.5), ), ], @@ -784,16 +1017,14 @@ class _AlternateSourceSheetState extends State { label: label, child: ExcludeSemantics( child: InkWell( - onTap: _preparing == null ? () => _correct(provider) : null, + onTap: _preparing == null && _walk == null + ? () => _correct(provider) + : null, customBorder: const CircleBorder(), child: const SizedBox( width: 48, height: 48, - child: Icon( - Icons.edit_outlined, - color: Colors.white70, - size: 17, - ), + child: Icon(Icons.edit_outlined, color: Colors.white70, size: 17), ), ), ), @@ -808,6 +1039,17 @@ class _AlternateSourceSheetState extends State { /// source whose automatic match failed is the single most likely place for a /// correction to be needed. List _silentSection(List quiet) { + return [ + SliverList.list(children: _silentHeader(quiet)), + if (_showSilent) + SliverList.builder( + itemCount: quiet.length, + itemBuilder: (context, i) => _silentTile(quiet[i]), + ), + ]; + } + + List _silentHeader(List quiet) { return [ const Divider(color: Colors.white12, height: 1), InkWell( @@ -838,7 +1080,7 @@ class _AlternateSourceSheetState extends State { ), ), ), - if (_showSilent) ...[ + if (_showSilent) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text( @@ -846,31 +1088,30 @@ class _AlternateSourceSheetState extends State { style: const TextStyle(color: Colors.white38, fontSize: 11), ), ), - for (final p in quiet) - ListTile( - dense: true, - enabled: _preparing == null, - onTap: () => _correct(ProviderRef.fromEntity(p)), - leading: const Icon( - Icons.search_off_rounded, - color: Colors.white30, - size: 20, - ), - title: Text( - p.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Colors.white70, fontSize: 13), - ), - subtitle: Text( - 'player.alt_search_by_hand'.tr(), - style: const TextStyle(color: Colors.white38, fontSize: 11), - ), - ), - ], ]; } + Widget _silentTile(ProviderEntity p) => ListTile( + dense: true, + enabled: _preparing == null && _walk == null, + onTap: () => _correct(ProviderRef.fromEntity(p)), + leading: const Icon( + Icons.search_off_rounded, + color: Colors.white30, + size: 20, + ), + title: Text( + p.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + subtitle: Text( + 'player.alt_search_by_hand'.tr(), + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + ); + Widget _emptyBlock() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28), diff --git a/lib/features/detail/presentation/widgets/detail_cast_tab.dart b/lib/features/detail/presentation/widgets/detail_cast_tab.dart index 08f847ff..fd6f4e7f 100644 --- a/lib/features/detail/presentation/widgets/detail_cast_tab.dart +++ b/lib/features/detail/presentation/widgets/detail_cast_tab.dart @@ -44,6 +44,7 @@ class DetailCastTab extends StatelessWidget { // 0.78 left the cell exactly as tall as its contents, so any // two-line name or a bumped system font size overflowed it. gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, @@ -194,6 +195,8 @@ class _CastAvatar extends StatelessWidget { ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, + memCacheWidth: (64 * MediaQuery.devicePixelRatioOf(context)) + .round(), fadeInDuration: const Duration(milliseconds: 160), errorWidget: (_, _, _) => _Initials(initials: _initials), placeholder: (_, _) => _Initials(initials: _initials), diff --git a/lib/features/detail/presentation/widgets/detail_hero.dart b/lib/features/detail/presentation/widgets/detail_hero.dart index 021f7330..151365c2 100644 --- a/lib/features/detail/presentation/widgets/detail_hero.dart +++ b/lib/features/detail/presentation/widgets/detail_hero.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:flutter/material.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/network/image_headers.dart'; import 'package:soplay/core/trailer/trailer_query.dart'; @@ -119,11 +120,11 @@ class DetailHeroBackground extends StatelessWidget { title.trim(), maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 26, fontWeight: FontWeight.w900, - height: 1.15, + height: lineHeightFor(context, 1.15), letterSpacing: -0.3, shadows: [ Shadow( diff --git a/lib/features/detail/presentation/widgets/detail_info.dart b/lib/features/detail/presentation/widgets/detail_info.dart index 74d8bf98..ebab4e2f 100644 --- a/lib/features/detail/presentation/widgets/detail_info.dart +++ b/lib/features/detail/presentation/widgets/detail_info.dart @@ -20,6 +20,7 @@ import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/domain/entities/history_item.dart'; import 'package:soplay/features/home/domain/entities/view_all.dart'; +import 'package:soplay/features/detail/domain/entities/source_notice.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_row.dart'; import 'package:soplay/features/recap/domain/recap.dart'; import 'package:soplay/features/recap/presentation/recap_sheet.dart'; @@ -35,6 +36,7 @@ class DetailContentHeader extends StatefulWidget { this.resolvingSource = false, this.onChangeSource, this.onFindSource, + this.unplayable, }); final DetailEntity detail; @@ -53,6 +55,14 @@ class DetailContentHeader extends StatefulWidget { /// For a catalogue title with no source yet: opens the search. final VoidCallback? onFindSource; + /// The source has said this title cannot be played on it. + /// + /// Play is then replaced rather than disabled. A greyed button says "not + /// now"; this situation is "not here, ever" — the source has moved the title + /// into its own app or never had a player for it — and the only useful + /// control is the one that goes looking elsewhere. + final SourceNotice? unplayable; + /// Queues the title for offline viewing, or opens the episode list when /// "which episodes" is a question only the user can answer. final VoidCallback? onDownload; @@ -200,6 +210,14 @@ class _DetailContentHeaderState extends State { ), ), ) + else if (widget.unplayable != null) + ItemAppear( + index: 5, + child: _UnplayableRow( + notice: widget.unplayable!, + onFindSource: widget.onFindSource, + ), + ) else ItemAppear( index: 5, @@ -891,3 +909,73 @@ class _GuessPill extends StatelessWidget { ); } } + + +/// What stands where Play stood, for a title this source will not play. +/// +/// Deliberately the same shape as the catalogue's "Find a source" button above: +/// the two say the same thing — nothing here can play this, go and look — and a +/// second visual language for it would only make the page harder to read. +class _UnplayableRow extends StatelessWidget { + const _UnplayableRow({required this.notice, this.onFindSource}); + + final SourceNotice notice; + final VoidCallback? onFindSource; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The reason, in the viewer's language, on the page itself — not only + // in the sheet that announced it, which they have already dismissed by + // the time they wonder where Play went. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: Icon( + Icons.info_outline_rounded, + size: 16, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + notice.messageKey.tr(), + style: const TextStyle( + fontSize: 12.5, + height: 1.35, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: isDesktopPlatform ? 360 : double.infinity, + height: 46, + child: OutlinedButton.icon( + onPressed: onFindSource, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white.withValues(alpha: 0.6), + side: BorderSide(color: Colors.white.withValues(alpha: 0.35)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kButtonRadius), + ), + ), + icon: const Icon(Icons.travel_explore_rounded, size: 22), + label: Text( + 'detail.find_other_sources'.tr(), + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w800), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart b/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart index c38d61e2..8ecc6673 100644 --- a/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart +++ b/lib/features/detail/presentation/widgets/detail_preview_skeleton.dart @@ -230,6 +230,7 @@ class _RelatedGridSk extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, diff --git a/lib/features/detail/presentation/widgets/detail_related.dart b/lib/features/detail/presentation/widgets/detail_related.dart index 180da584..962aa977 100644 --- a/lib/features/detail/presentation/widgets/detail_related.dart +++ b/lib/features/detail/presentation/widgets/detail_related.dart @@ -31,6 +31,7 @@ class DetailRelatedSection extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, @@ -160,18 +161,26 @@ class _RelatedThumbnail extends StatelessWidget { // Cached: these posters are the same ones the home rows already fetched, // and re-downloading them made every "Similar" strip repaint grey on the // way back to a title. - return CachedNetworkImage( - imageUrl: url!, - fit: BoxFit.cover, - fadeInDuration: const Duration(milliseconds: 180), - placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), - errorWidget: (_, _, _) => Container( - color: AppColors.surfaceVariant, - child: const Center( - child: Icon( - Icons.movie_outlined, - color: AppColors.textHint, - size: 28, + // Decoded at the width it is drawn at: thirty full-size posters decoded + // at once was a hitch every time the tab opened. + return LayoutBuilder( + builder: (context, constraints) => CachedNetworkImage( + imageUrl: url!, + fit: BoxFit.cover, + memCacheWidth: constraints.maxWidth.isFinite + ? (constraints.maxWidth * MediaQuery.devicePixelRatioOf(context)) + .round() + : null, + fadeInDuration: const Duration(milliseconds: 180), + placeholder: (_, _) => ColoredBox(color: AppColors.surfaceVariant), + errorWidget: (_, _, _) => Container( + color: AppColors.surfaceVariant, + child: const Center( + child: Icon( + Icons.movie_outlined, + color: AppColors.textHint, + size: 28, + ), ), ), ), diff --git a/lib/features/detail/presentation/widgets/detail_relations_tab.dart b/lib/features/detail/presentation/widgets/detail_relations_tab.dart index 8e441070..c4f38c9b 100644 Binary files a/lib/features/detail/presentation/widgets/detail_relations_tab.dart and b/lib/features/detail/presentation/widgets/detail_relations_tab.dart differ diff --git a/lib/features/detail/presentation/widgets/detail_screenshots.dart b/lib/features/detail/presentation/widgets/detail_screenshots.dart index ad7d01e8..c5a1f230 100644 --- a/lib/features/detail/presentation/widgets/detail_screenshots.dart +++ b/lib/features/detail/presentation/widgets/detail_screenshots.dart @@ -29,6 +29,7 @@ class DetailScreenshotsSection extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 2, crossAxisSpacing: 10, mainAxisSpacing: 10, diff --git a/lib/features/detail/presentation/widgets/detail_skeleton.dart b/lib/features/detail/presentation/widgets/detail_skeleton.dart index 4eafa28c..fe9c61ac 100644 --- a/lib/features/detail/presentation/widgets/detail_skeleton.dart +++ b/lib/features/detail/presentation/widgets/detail_skeleton.dart @@ -186,6 +186,7 @@ class _PosterGridSk extends StatelessWidget { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, crossAxisSpacing: 8, mainAxisSpacing: 14, diff --git a/lib/features/detail/presentation/widgets/detail_tab_panes.dart b/lib/features/detail/presentation/widgets/detail_tab_panes.dart new file mode 100644 index 00000000..5c097a46 --- /dev/null +++ b/lib/features/detail/presentation/widgets/detail_tab_panes.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +/// The detail page's tab contents, switched by [controller]. +/// +/// A pane is built the first time its tab opens and then kept: switching +/// away puts it offstage instead of disposing it, so Relations and Comments +/// do not refetch, and Similar does not decode thirty posters again, on every +/// swipe. Only this widget rebuilds on a tab change, not the page around it. +class DetailTabPanes extends StatefulWidget { + const DetailTabPanes({ + super.key, + required this.controller, + required this.tabs, + required this.paneBuilder, + }); + + final TabController controller; + final List tabs; + final Widget Function(BuildContext context, String tab) paneBuilder; + + static const Duration fade = Duration(milliseconds: 160); + + @override + State createState() => _DetailTabPanesState(); +} + +class _DetailTabPanesState extends State + with SingleTickerProviderStateMixin { + late int _current = widget.controller.index; + int? _previous; + + /// Each pane's widget, kept between tab switches so a switch does not + /// rebuild the panes it does not touch. Dropped when the page rebuilds. + /// Its keys are the panes built so far, a null value one to build afresh. + late final Map _panes = {_current: null}; + + late final AnimationController _fade = AnimationController( + vsync: this, + duration: DetailTabPanes.fade, + value: 1, + )..addStatusListener(_onFadeStatus); + late final Animation _in = _fade.drive( + CurveTween(curve: Curves.easeOut), + ); + late final Animation _out = ReverseAnimation( + _fade, + ).drive(CurveTween(curve: Curves.easeIn)); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onTab); + } + + @override + void didUpdateWidget(DetailTabPanes old) { + super.didUpdateWidget(old); + _panes.updateAll((_, _) => null); + if (!identical(old.controller, widget.controller)) { + old.controller.removeListener(_onTab); + widget.controller.addListener(_onTab); + _onTab(); + } + } + + void _onTab() { + final index = widget.controller.index; + if (index == _current) return; + setState(() { + _previous = _current; + _current = index; + _panes.putIfAbsent(index, () => null); + }); + _fade.forward(from: 0); + } + + void _onFadeStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && _previous != null && mounted) { + setState(() => _previous = null); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onTab); + _fade.dispose(); + super.dispose(); + } + + Widget _pane(int index) { + final tab = widget.tabs[index]; + final current = index == _current; + final live = current || index == _previous; + return KeyedSubtree( + key: ValueKey('detail-tab-$tab'), + child: Offstage( + offstage: !live, + child: TickerMode( + enabled: live, + child: IgnorePointer( + ignoring: !current, + child: FadeTransition( + opacity: current + ? _in + : live + ? _out + : kAlwaysCompleteAnimation, + child: _panes[index] ??= widget.paneBuilder(context, tab), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Offstage panes report no size, so the stack is as tall as the panes on + // screen, outgoing below incoming while they cross-fade. + return Stack( + alignment: Alignment.topCenter, + children: [ + for (final i in _panes.keys.toList()) + if (i != _current && i != _previous) _pane(i), + if (_previous != null) _pane(_previous!), + _pane(_current), + ], + ); + } +} diff --git a/lib/features/detail/presentation/widgets/native_controls_offer.dart b/lib/features/detail/presentation/widgets/native_controls_offer.dart new file mode 100644 index 00000000..71556850 --- /dev/null +++ b/lib/features/detail/presentation/widgets/native_controls_offer.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/storage/hive_service.dart'; + +/// "Try the new player?", asked once over the player. +class NativeControlsOfferDialog extends StatelessWidget { + const NativeControlsOfferDialog({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoTheme( + data: const CupertinoThemeData(brightness: Brightness.dark), + child: CupertinoAlertDialog( + title: Text('player.native_offer_title'.tr()), + content: Text('player.native_offer_body'.tr()), + actions: [ + CupertinoDialogAction( + key: const ValueKey('native-offer-not-now'), + onPressed: () => Navigator.of(context).pop(false), + child: Text('player.native_offer_not_now'.tr()), + ), + CupertinoDialogAction( + key: const ValueKey('native-offer-try'), + isDefaultAction: true, + onPressed: () => Navigator.of(context).pop(true), + child: Text('player.native_offer_try'.tr()), + ), + ], + ), + ); + } +} + +/// Marks the offer shown before it appears, so a crash never asks twice, +/// then stores the answer and returns it. Anything but "Try it" is "Not now". +Future presentNativeControlsOffer( + BuildContext context, + HiveService hive, +) async { + unawaited(hive.markNativeControlsOffered()); + final accepted = await showCupertinoDialog( + context: context, + barrierDismissible: true, + builder: (_) => const NativeControlsOfferDialog(), + ); + final style = nativeOfferOutcome(accepted); + unawaited(hive.setPlayerControlsStyle(style)); + return style; +} diff --git a/lib/features/detail/presentation/widgets/player_control_face.dart b/lib/features/detail/presentation/widgets/player_control_face.dart new file mode 100644 index 00000000..c095047a --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_control_face.dart @@ -0,0 +1,117 @@ +import 'package:flutter/widgets.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/core/native/sf_symbols.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_menus.dart'; + +/// One player control as it stands on a bar right now: what it shows, what it +/// does, whether it is on. The player builds one per control and draws it +/// either as its Flutter button or as a native glass button +/// ([toNative]), so the two can never disagree about what a control is. +/// +/// Named a face, not a spec: `PlayerControlSpec` is the catalogue's entry +/// (where a control may go), this is what it looks like where it went. +@immutable +class PlayerControlFace { + const PlayerControlFace({ + required this.id, + required this.icon, + required this.semanticLabel, + this.symbol, + this.label, + this.compactLabel, + this.compact = false, + this.pill = false, + this.tooltip, + this.onTap, + this.onLongPress, + this.active = false, + this.enabled = true, + this.menu, + this.menuPrimary = false, + this.onMenu, + }); + + final String id; + final IconData icon; + final String semanticLabel; + + /// The SF Symbol; null takes the control's own ([playerSymbol]). + final String? symbol; + + /// Words beside the glyph: the bottom row's live value, the language pill. + final String? label; + + /// Drawn instead of the glyph on a compact row. + final String? compactLabel; + final bool compact; + + /// The language pill rather than a round button. + final bool pill; + + /// The Flutter button's tooltip. + final String? tooltip; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool active; + final bool enabled; + + /// The rows a hold opens on the native button. + final List? menu; + + /// The native button opens [menu] on a tap as well ([NativeBarItem.menuPrimary]). + final bool menuPrimary; + final ValueChanged? onMenu; + + bool get labelled => label != null; + + /// This face with [menu] as its native pull-down: a tap on the native + /// button opens it. Null leaves the face as it is. + PlayerControlFace withMenu(PlayerMenu? menu) => menu == null + ? this + : PlayerControlFace( + id: id, + icon: icon, + semanticLabel: semanticLabel, + symbol: symbol, + label: label, + compactLabel: compactLabel, + compact: compact, + pill: pill, + tooltip: tooltip, + onTap: onTap, + onLongPress: onLongPress, + active: active, + enabled: enabled, + menu: menu.rows, + menuPrimary: true, + onMenu: menu.pick, + ); + + NativeBarItem toNative({int group = 0}) { + final wordsOnly = compact && compactLabel != null; + final title = pill + ? label + : wordsOnly + ? compactLabel + : compact + ? null + : label; + return NativeBarItem( + id: id, + symbol: symbol ?? playerSymbol(id, active: active), + title: title, + semanticLabel: semanticLabel, + enabled: enabled, + active: active, + hasGlyph: !wordsOnly, + fontSize: pill ? 11 : 12, + glyph: pill ? 14 : 20, + group: group, + onPressed: onTap, + onLongPress: onLongPress, + menu: menu, + menuPrimary: menuPrimary, + onMenuSelected: onMenu, + ); + } +} diff --git a/lib/features/detail/presentation/widgets/player_dock_host.dart b/lib/features/detail/presentation/widgets/player_dock_host.dart new file mode 100644 index 00000000..7f27e525 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_dock_host.dart @@ -0,0 +1,390 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/core/router/app_router.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/player_route.dart'; + +/// Where the corner player belongs to the session that started it: signing +/// out, switching profile or starting over closes it. +bool playerDockClosesOn(String path) => + path == '/login' || + path == '/splash' || + path == '/profiles' || + path == '/onboarding' || + path.startsWith('/onboarding/'); + +/// The desktop corner player's home: a layer above the app's Navigator, so +/// the player stays while the pages under it change. +/// +/// The docked PlayerPage is the same element, with the same State, that was +/// playing in /player a frame earlier — see [PlayerDock]. Here it gets a +/// rounded, shadowed box in one corner of the content area, which it can be +/// dragged out of (it snaps to the nearest corner on release) and resized +/// from the edges that face the middle of the window. +class PlayerDockHost extends StatefulWidget { + const PlayerDockHost({ + super.key, + required this.child, + this.router, + this.pageBuilder = defaultPlayerPageBuilder, + }); + + final Widget child; + + /// The app's router; [AppRouter.router] unless a test brings its own. + final GoRouter? router; + final PlayerPageBuilder pageBuilder; + + /// Wraps the docked page's box; widget tests find the corner player by it. + static const Key frameKey = ValueKey('player-dock-frame'); + + @override + State createState() => _PlayerDockHostState(); +} + +class _PlayerDockHostState extends State + with SingleTickerProviderStateMixin { + final PlayerDock _dock = PlayerDock.instance; + late final GoRouter _router = widget.router ?? AppRouter.router; + + /// Drives both moves the box makes on its own: shrinking from the full + /// window into its corner when a page docks, and gliding to the corner it + /// snapped to after a drag. + late final AnimationController _move = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + ); + + /// Where the current move started, given the area: the full area for a + /// page arriving, the drop point for a snap. Null when nothing is moving. + Rect Function(Size area)? _from; + + GlobalKey? _shown; + bool _wasDragging = false; + + /// Owns the keyboard while the viewer is using the corner player, and gives + /// it back to the page underneath on a click anywhere else. + final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'playerDock'); + + void _open(PlayerArgs args) => _router.push('/player', extra: args); + + @override + void initState() { + super.initState(); + _dock.openRoute = _open; + _dock.addListener(_onDock); + _dock.geometry.addListener(_onGeometry); + _move.addStatusListener((s) { + if (s == AnimationStatus.completed) _from = null; + }); + _router.routerDelegate.addListener(_closeOnSessionChange); + _shown = _dock.dockedKey; + } + + @override + void dispose() { + _dock.removeListener(_onDock); + _dock.geometry.removeListener(_onGeometry); + if (_dock.openRoute == _open) _dock.openRoute = null; + _router.routerDelegate.removeListener(_closeOnSessionChange); + _move.dispose(); + _scope.dispose(); + super.dispose(); + } + + /// Synchronous with the notify on purpose: the route that gave the page up + /// rebuilds in the same frame, and the GlobalKey has to land here in that + /// frame or the page is disposed. + void _onDock() { + final key = _dock.dockedKey; + if (key == _shown) return; + _shown = key; + if (key != null) { + // The Telegram move, and the visible half of the hand-off: the picture + // starts where the full player was and shrinks into its corner. + _from = (area) => Offset.zero & area; + _move.forward(from: 0); + } else { + _from = null; + _move.stop(); + } + setState(() {}); + } + + void _onGeometry() { + final dragging = _dock.dragging; + if (_wasDragging && !dragging) { + final start = _dock.lastRect; + if (start != null) { + _from = (_) => start; + _move.forward(from: 0); + } + } else if (dragging && _move.isAnimating) { + _move.stop(); + _from = null; + } + _wasDragging = dragging; + } + + void _closeOnSessionChange() { + if (!_dock.isDocked) return; + final paths = []; + try { + final config = _router.routerDelegate.currentConfiguration; + paths.add(config.uri.path); + // A push leaves `uri` at the location it was pushed from; the top + // match is where the viewer actually is. + if (config.isNotEmpty) paths.add(config.last.matchedLocation); + } catch (_) { + return; + } + if (!paths.any(playerDockClosesOn)) return; + // Not from inside the router's own notification, which can arrive while + // the tree is being built. + scheduleMicrotask(() => unawaited(_dock.close())); + } + + /// The box this frame should occupy in [area]. Called during layout only. + Rect _rectIn(Size area) { + _dock.area = area; + final target = _dock.rectIn(area); + final from = _from; + final rect = from != null && _move.isAnimating + ? Rect.lerp( + from(area), + target, + Curves.easeOutCubic.transform(_move.value), + )! + : target; + _dock.lastRect = rect; + return rect; + } + + @override + Widget build(BuildContext context) { + final key = _dock.dockedKey; + final args = _dock.dockedArgs; + return Stack( + fit: StackFit.passthrough, + children: [ + widget.child, + if (key != null && args != null) + // Empty space in this layer takes no hits, so the page underneath + // stays fully usable all around the player. + Positioned.fill( + child: CustomSingleChildLayout( + delegate: _DockLayout(this), + child: _frame(key, args), + ), + ) + else + const SizedBox.shrink(), + ], + ); + } + + /// Gives the keyboard back to whatever had it before the player took it: + /// the page the viewer is using under the corner. + void _releaseKeyboard() { + if (_scope.hasFocus) { + _scope.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + } + } + + Widget _frame(GlobalKey key, PlayerArgs args) { + return TapRegion( + onTapOutside: (_) => _releaseKeyboard(), + child: FocusScope( + node: _scope, + // The page lets Esc through while it is in the corner. Here it means + // back to the page underneath; closing the player would surprise. + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape && + _scope.hasFocus) { + _releaseKeyboard(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: DecoratedBox( + key: PlayerDockHost.frameKey, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x99000000), + blurRadius: 24, + offset: Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + _DockMedia( + dock: _dock, + // Its own messenger, so a player toast does not land on the + // page underneath. Its own Navigator, so a Tooltip or + // Navigator.of(context) inside the page finds one: the + // app's Navigator is below this layer, not above it. + child: ScaffoldMessenger( + child: HeroControllerScope.none( + child: InheritedGoRouter( + // The router is below this layer too. With it here, + // anything in the page that navigates the app does so + // rather than throwing. + goRouter: _router, + child: Navigator( + key: ValueKey(key), + // The keyboard stays with the page the viewer + // moved on to; a click on the player takes it. + requestFocus: false, + onGenerateRoute: (_) => PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => + widget.pageBuilder(key, args), + ), + ), + ), + ), + ), + ), + _ResizeGrips(dock: _dock), + ], + ), + ), + ), + ), + ); + } +} + +class _DockLayout extends SingleChildLayoutDelegate { + _DockLayout(this.host) + : super(relayout: Listenable.merge([host._dock.geometry, host._move])); + + final _PlayerDockHostState host; + + @override + Size getSize(BoxConstraints constraints) => constraints.biggest; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + BoxConstraints.tight(host._rectIn(constraints.biggest).size); + + @override + Offset getPositionForChild(Size size, Size childSize) => + host._rectIn(size).topLeft; + + @override + bool shouldRelayout(_DockLayout oldDelegate) => oldDelegate.host != host; +} + +/// What the page sees of the world while it is in the corner: a screen the +/// size of the box, and text scaled down with it. Subtitles and the page's +/// cards are sized for a full window; at a quarter of the width they read +/// like a small player's. +/// +/// From the settled size rather than the frame-by-frame one, so a drag or the +/// docking glide rebuilds nothing inside the page. +class _DockMedia extends StatelessWidget { + const _DockMedia({required this.dock, required this.child}); + + final PlayerDock dock; + final Widget child; + + /// The window width the player's text is designed for. + static const double _designWidth = 960; + + @override + Widget build(BuildContext context) { + final mq = MediaQuery.of(context); + return ListenableBuilder( + listenable: dock.geometry, + child: child, + builder: (context, child) { + final area = dock.area.isEmpty ? mq.size : dock.area; + final size = DockGeometry.sizeFor(dock.width, dock.aspect, area); + final scale = (size.width / _designWidth).clamp(0.45, 1.0); + return MediaQuery( + data: mq.copyWith( + size: size, + padding: EdgeInsets.zero, + viewPadding: EdgeInsets.zero, + viewInsets: EdgeInsets.zero, + textScaler: TextScaler.linear(mq.textScaler.scale(1) * scale), + ), + child: child!, + ); + }, + ); + } +} + +/// Resize handles on the two edges that face the middle of the window. The +/// box keeps the picture's shape, so either edge changes the width. +class _ResizeGrips extends StatelessWidget { + const _ResizeGrips({required this.dock}); + + final PlayerDock dock; + + /// Thin enough to stay clear of the corner buttons the page draws 6 px in. + static const double _thickness = 5; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: dock.geometry, + builder: (context, _) { + if (dock.dragging) return const SizedBox.shrink(); + final c = dock.corner; + final onRight = c == DockCorner.topRight || c == DockCorner.bottomRight; + final onBottom = + c == DockCorner.bottomLeft || c == DockCorner.bottomRight; + Widget grip(MouseCursor cursor, Offset Function(Offset) axis) => + MouseRegion( + cursor: cursor, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (d) => dock.resizeBy(axis(d.delta)), + onPanEnd: (_) => dock.resizeEnd(), + ), + ); + return Stack( + children: [ + Positioned( + top: 0, + bottom: 0, + width: _thickness, + left: onRight ? 0 : null, + right: onRight ? null : 0, + child: grip( + SystemMouseCursors.resizeLeftRight, + (d) => Offset(d.dx, 0), + ), + ), + Positioned( + left: 0, + right: 0, + height: _thickness, + top: onBottom ? 0 : null, + bottom: onBottom ? null : 0, + child: grip( + SystemMouseCursors.resizeUpDown, + (d) => Offset(0, d.dy), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/detail/presentation/widgets/player_menus.dart b/lib/features/detail/presentation/widgets/player_menus.dart new file mode 100644 index 00000000..58c338f1 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_menus.dart @@ -0,0 +1,203 @@ +import 'package:flutter/foundation.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; + +/// One choice of a player control's native menu: the row the menu draws and +/// what choosing it does. +@immutable +class PlayerMenuOption { + const PlayerMenuOption({ + required this.key, + required this.title, + required this.onPick, + this.subtitle, + this.checked, + this.symbol, + this.destructive = false, + }); + + /// What the row stands for ([NativeMenuRow.key]). + final Object key; + final String title; + final String? subtitle; + final bool? checked; + final String? symbol; + final bool destructive; + final VoidCallback onPick; +} + +/// A native pull-down menu for a player control (iOS 26 path): runs of +/// options with a separator between runs, each choice's action beside its +/// row so the two never drift apart. +@immutable +class PlayerMenu { + PlayerMenu(List> sections) + : options = [ + for (final run in sections.where((r) => r.isNotEmpty)) + for (final o in run) o, + ], + _breaks = _startsOf(sections); + + final List options; + final Set _breaks; + + static Set _startsOf(List> sections) { + final starts = {}; + var at = 0; + for (final run in sections.where((r) => r.isNotEmpty)) { + if (at > 0) starts.add(at); + at += run.length; + } + return starts; + } + + List get rows => [ + for (var i = 0; i < options.length; i++) + NativeMenuRow( + title: options[i].title, + subtitle: options[i].subtitle, + symbol: options[i].symbol, + checked: options[i].checked, + destructive: options[i].destructive, + breakBefore: _breaks.contains(i), + key: options[i].key, + ), + ]; + + /// Runs the choice at [index] of [rows]; an index out of range is a menu + /// that changed under the finger, and does nothing. + void pick(int index) { + if (index < 0 || index >= options.length) return; + options[index].onPick(); + } +} + +/// One value out of [values], the [current] one checked: speed, fit, +/// server, audio language. +PlayerMenu choiceMenu({ + required List values, + required bool Function(T value) isCurrent, + required String Function(T value) label, + required void Function(T value) onPick, + String? Function(T value)? subtitle, + String? Function(T value)? symbol, +}) => PlayerMenu([ + [ + for (final v in values) + PlayerMenuOption( + key: v, + title: label(v), + subtitle: subtitle?.call(v), + symbol: symbol?.call(v), + checked: isCurrent(v), + onPick: () => onPick(v), + ), + ], +]); + +/// A subtitle track as the subtitle menu lists it. +@immutable +class SubtitleMenuTrack { + const SubtitleMenuTrack({ + required this.key, + required this.label, + required this.selected, + required this.onPick, + this.subtitle, + }); + + final Object key; + final String label; + final String? subtitle; + final bool selected; + final VoidCallback onPick; +} + +/// The subtitle button's menu: Off, the stream's own tracks, the loaded +/// ones, and last the full sheet for everything a menu cannot hold — +/// search, a file, AI translation, style, sync, a second track. +/// +/// Null with no track to choose: the button opens the sheet directly, since +/// a menu of Off and "More" is one tap more for nothing. +PlayerMenu? subtitleMenu({ + required String offLabel, + required bool offSelected, + required VoidCallback onOff, + required List tracks, + required String moreLabel, + required VoidCallback onMore, +}) { + if (tracks.isEmpty) return null; + return PlayerMenu([ + [ + PlayerMenuOption( + key: 'off', + title: offLabel, + checked: offSelected, + onPick: onOff, + ), + for (final t in tracks) + PlayerMenuOption( + key: t.key, + title: t.label, + subtitle: t.subtitle, + checked: t.selected, + onPick: t.onPick, + ), + ], + [ + PlayerMenuOption( + key: 'more', + title: moreLabel, + symbol: 'ellipsis.circle', + onPick: onMore, + ), + ], + ]); +} + +/// A rendition or a file as the quality menu lists it. +@immutable +class QualityMenuEntry { + const QualityMenuEntry({ + required this.key, + required this.label, + required this.selected, + required this.onPick, + this.detail, + this.blocking = false, + }); + + final Object key; + final String label; + + /// The line under it: what Auto does, the bitrate, or the worst thing + /// about the file. + final String? detail; + final bool selected; + + /// The file will not play here (Dolby Vision, unreachable): drawn red, as + /// the sheet draws its warning. + final bool blocking; + final VoidCallback onPick; +} + +/// The quality button's menu: the stream's renditions, then the server's +/// other files under a separator. Null when there is nothing to choose. +PlayerMenu? qualityMenu({ + required List renditions, + required List files, +}) { + if (renditions.length + files.length < 2) return null; + PlayerMenuOption option(QualityMenuEntry e) => PlayerMenuOption( + key: e.key, + title: e.label, + subtitle: e.detail, + checked: e.selected, + destructive: e.blocking, + onPick: e.onPick, + ); + return PlayerMenu([ + [for (final e in renditions) option(e)], + [for (final e in files) option(e)], + ]); +} diff --git a/lib/features/detail/presentation/widgets/player_sheet_look.dart b/lib/features/detail/presentation/widgets/player_sheet_look.dart new file mode 100644 index 00000000..3065f3c7 --- /dev/null +++ b/lib/features/detail/presentation/widgets/player_sheet_look.dart @@ -0,0 +1,502 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// Whether the player's sheets under it wear iOS 26's look: the native +/// controls' path on an iPhone. Everywhere else they stay as they were. +class PlayerSheetLook extends InheritedWidget { + const PlayerSheetLook({super.key, required this.ios, required super.child}); + + final bool ios; + + static bool of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.ios ?? + false; + + @override + bool updateShouldNotify(PlayerSheetLook old) => old.ios != ios; +} + +/// iOS 26's numbers for a sheet and its grouped list, dark. +abstract final class IosSheet { + static const double radius = 38; + static const double groupRadius = 26; + static const double gutter = 16; + static const double rowHeight = 52; + + /// The sheet's material over the blurred video, and with nothing through + /// it for Reduce Transparency. + static const Color tint = Color(0xC71C1C1E); + static const Color opaque = Color(0xFF1C1C1E); + static const double blur = 30; + + /// A group's cell, a step up from the sheet. + static const Color cell = Color(0x1FFFFFFF); + static const Color separator = Color(0x2EFFFFFF); + static const Color grabber = Color(0x5CFFFFFF); + static const Color secondary = Color(0x99EBEBF5); + static const Color tertiary = Color(0x4DEBEBF5); + static const Color pressed = Color(0x1AFFFFFF); + + static const TextStyle title = TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.43, + ); + static const TextStyle body = TextStyle( + color: Colors.white, + fontSize: 17, + letterSpacing: -0.43, + ); + static const TextStyle footnote = TextStyle( + color: secondary, + fontSize: 13, + letterSpacing: -0.08, + ); +} + +/// Opens one of the player's sheets: on the iOS path ([ios]) the phone's own +/// shape — a grabber, deep continuous corners, a frosted material over the +/// video — and otherwise exactly as [showAdaptiveModal] always drew it. +Future showPlayerSheet({ + required BuildContext context, + required bool ios, + required WidgetBuilder builder, + double desktopMaxWidth = 460, +}) { + if (!ios) { + return showAdaptiveModal( + context: context, + backgroundColor: const Color(0xFF111111), + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + desktopMaxWidth: desktopMaxWidth, + builder: builder, + ); + } + // An iPad keeps its centred dialog, with the rows dressed the same way. + if (MediaQuery.sizeOf(context).shortestSide >= 600) { + return showAdaptiveModal( + context: context, + backgroundColor: IosSheet.opaque, + isScrollControlled: true, + desktopMaxWidth: desktopMaxWidth, + builder: (ctx) => PlayerSheetLook(ios: true, child: builder(ctx)), + ); + } + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + elevation: 0, + barrierColor: Colors.black.withValues(alpha: 0.3), + sheetAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + reverseDuration: const Duration(milliseconds: 220), + reverseCurve: Curves.easeInCubic, + ), + builder: (ctx) => + PlayerSheetLook(ios: true, child: IosSheetFrame(child: builder(ctx))), + ); +} + +/// The sheet itself: the grabber over [child], on the frosted material, its +/// top corners the phone's own. +class IosSheetFrame extends StatelessWidget { + const IosSheetFrame({super.key, required this.child}); + + final Widget child; + + static const ShapeBorder shape = RoundedSuperellipseBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(IosSheet.radius)), + ); + + @override + Widget build(BuildContext context) { + final opaque = GlassSense.reduceTransparencyOf(context); + final body = Material( + type: MaterialType.transparency, + child: ListTileTheme( + data: const ListTileThemeData( + iconColor: Colors.white, + textColor: Colors.white, + minVerticalPadding: 10, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const IosSheetGrabber(), + Flexible(child: child), + ], + ), + ), + ); + return ClipPath( + clipper: const ShapeBorderClipper(shape: shape), + child: opaque + ? ColoredBox(color: IosSheet.opaque, child: body) + : BackdropFilter( + filter: ImageFilter.blur( + sigmaX: IosSheet.blur, + sigmaY: IosSheet.blur, + ), + child: ColoredBox(color: IosSheet.tint, child: body), + ), + ); + } +} + +class IosSheetGrabber extends StatelessWidget { + const IosSheetGrabber({super.key}); + + @override + Widget build(BuildContext context) => ExcludeSemantics( + child: Container( + width: 36, + height: 5, + margin: const EdgeInsets.only(top: 5, bottom: 3), + decoration: const ShapeDecoration( + color: IosSheet.grabber, + shape: StadiumBorder(), + ), + ), + ); +} + +/// The sheet's title, centred, with the close button at the trailing edge. +class IosSheetHeader extends StatelessWidget { + const IosSheetHeader({super.key, required this.title, this.onClose}); + + final String title; + + /// Null pops the sheet's route. + final VoidCallback? onClose; + + @override + Widget build(BuildContext context) { + final close = MaterialLocalizations.of(context).closeButtonLabel; + void dismiss() => + onClose != null ? onClose!() : Navigator.of(context).maybePop(); + return SizedBox( + height: 48, + child: Row( + children: [ + const SizedBox(width: 60), + Expanded( + child: Semantics( + header: true, + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: IosSheet.title, + ), + ), + ), + SizedBox( + width: 60, + child: Center( + child: Semantics( + button: true, + label: close, + excludeSemantics: true, + onTap: dismiss, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: const SizedBox.square( + dimension: 44, + child: Center( + child: DecoratedBox( + decoration: ShapeDecoration( + color: IosSheet.cell, + shape: CircleBorder(), + ), + child: SizedBox.square( + dimension: 30, + child: Icon( + CupertinoIcons.xmark, + size: 14, + color: IosSheet.secondary, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// An inset group: [children] on one rounded cell, a hairline between each +/// two, starting [separatorInset] in from the leading edge. +class IosSheetGroup extends StatelessWidget { + const IosSheetGroup({ + super.key, + required this.children, + this.separatorInset = IosSheet.gutter, + this.footer, + }); + + final List children; + final double separatorInset; + + /// A note under the group, in the footnote style. + final String? footer; + + @override + Widget build(BuildContext context) { + if (children.isEmpty) return const SizedBox.shrink(); + final hair = 1 / MediaQuery.devicePixelRatioOf(context); + return Padding( + padding: const EdgeInsets.fromLTRB( + IosSheet.gutter, + 6, + IosSheet.gutter, + 10, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Material( + color: IosSheet.cell, + clipBehavior: Clip.antiAlias, + shape: const RoundedSuperellipseBorder( + borderRadius: BorderRadius.all( + Radius.circular(IosSheet.groupRadius), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) + Padding( + padding: EdgeInsetsDirectional.only( + start: separatorInset, + ), + child: SizedBox( + height: hair, + child: const ColoredBox(color: IosSheet.separator), + ), + ), + children[i], + ], + ], + ), + ), + if (footer != null) + Padding( + padding: const EdgeInsets.fromLTRB( + IosSheet.gutter, + 6, + IosSheet.gutter, + 0, + ), + child: Text(footer!, style: IosSheet.footnote), + ), + ], + ), + ); + } +} + +/// What a row shows at its trailing edge. +enum IosRowAccessory { none, check, chevron } + +/// One row of an inset group: a leading glyph, the title and a line under +/// it, a value, and a check or a chevron — the press a grey wash, not a +/// ripple. +class IosSheetRow extends StatelessWidget { + const IosSheetRow({ + super.key, + required this.title, + this.leading, + this.subtitle, + this.subtitleColor, + this.value, + this.accessory = IosRowAccessory.none, + this.selected = false, + this.choice = false, + this.titleColor, + this.onTap, + this.onLongPress, + this.autofocus = false, + }); + + final String title; + final Widget? leading; + final String? subtitle; + final Color? subtitleColor; + final String? value; + final IosRowAccessory accessory; + + /// Drawn with the check, and read out as selected. + final bool selected; + + /// One of a set of which one is on: VoiceOver says so. + final bool choice; + final Color? titleColor; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final bool autofocus; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final dim = enabled ? 1.0 : 0.38; + final sub = subtitle; + final shown = value; + final row = ConstrainedBox( + constraints: const BoxConstraints(minHeight: IosSheet.rowHeight), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + IosSheet.gutter, + 10, + IosSheet.gutter - 2, + 10, + ), + child: Row( + children: [ + if (leading != null) ...[ + IconTheme.merge( + data: IconThemeData( + color: Colors.white.withValues(alpha: dim), + size: 22, + ), + child: SizedBox(width: 28, child: Center(child: leading)), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: IosSheet.body.copyWith( + color: (titleColor ?? Colors.white).withValues( + alpha: dim, + ), + ), + ), + if (sub != null && sub.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + sub, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: IosSheet.footnote.copyWith( + color: subtitleColor ?? IosSheet.secondary, + ), + ), + ], + ], + ), + ), + if (shown != null && shown.isNotEmpty) ...[ + const SizedBox(width: 12), + Flexible( + child: Text( + shown, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: IosSheet.body.copyWith( + color: enabled ? IosSheet.secondary : IosSheet.tertiary, + ), + ), + ), + ], + if (accessory == IosRowAccessory.check) + Padding( + padding: const EdgeInsetsDirectional.only(start: 12), + child: SizedBox( + width: 22, + child: selected + ? Icon( + CupertinoIcons.checkmark_alt, + size: 22, + color: AppColors.primary, + ) + : null, + ), + ), + if (accessory == IosRowAccessory.chevron && enabled) + const Padding( + padding: EdgeInsetsDirectional.only(start: 8), + child: Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: IosSheet.tertiary, + ), + ), + ], + ), + ), + ); + return Semantics( + button: true, + enabled: enabled, + selected: choice ? selected : null, + inMutuallyExclusiveGroup: choice ? true : null, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + autofocus: autofocus, + splashFactory: NoSplash.splashFactory, + highlightColor: IosSheet.pressed, + child: row, + ), + ); + } +} + +/// The Cupertino glyph for a Material one the player's sheets use, so a row +/// on the iOS path draws the phone's own icon; one not listed keeps its own. +IconData cupertinoIconFor(IconData icon) => _cupertino[icon] ?? icon; + +final Map _cupertino = { + Icons.swap_horiz_rounded: CupertinoIcons.arrow_right_arrow_left, + Icons.download_rounded: CupertinoIcons.arrow_down_circle, + Icons.aspect_ratio_rounded: CupertinoIcons.crop, + Icons.play_circle_outline: CupertinoIcons.play_circle, + Icons.tune_rounded: CupertinoIcons.slider_horizontal_3, + Icons.photo_camera_outlined: CupertinoIcons.camera, + Icons.save_alt_rounded: CupertinoIcons.square_arrow_down, + Icons.auto_awesome_rounded: CupertinoIcons.sparkles, + Icons.visibility_off_outlined: CupertinoIcons.eye_slash, + Icons.cast_rounded: CupertinoIcons.tv, + Icons.cast_connected_rounded: CupertinoIcons.tv_fill, + Icons.bedtime_outlined: CupertinoIcons.moon_zzz, + Icons.translate_rounded: CupertinoIcons.globe, + Icons.text_fields_rounded: CupertinoIcons.textformat_size, + Icons.audiotrack_outlined: CupertinoIcons.speaker_2, + Icons.open_in_new_rounded: CupertinoIcons.arrow_up_right_square, + Icons.info_outline_rounded: CupertinoIcons.info_circle, + Icons.dashboard_customize_outlined: CupertinoIcons.square_grid_2x2, + Icons.bug_report_outlined: CupertinoIcons.ant, + Icons.travel_explore_rounded: CupertinoIcons.search, + Icons.folder_open_rounded: CupertinoIcons.folder, + Icons.av_timer_rounded: CupertinoIcons.timer, +}; diff --git a/lib/features/detail/presentation/widgets/source_notice_sheet.dart b/lib/features/detail/presentation/widgets/source_notice_sheet.dart new file mode 100644 index 00000000..e446de7d --- /dev/null +++ b/lib/features/detail/presentation/widgets/source_notice_sheet.dart @@ -0,0 +1,134 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/detail/domain/entities/source_notice.dart'; + +/// What the source said about this title, and the one thing worth doing next. +/// +/// The failure this replaces was a snackbar reading "Video URL aniqlanmadi": +/// true in the narrowest sense, useless to the person holding the phone, and +/// identical for every reason a title will not play. A viewer who has been told +/// that the source moved this film into its own app knows the title is not +/// broken and that Sozo is not broken — and the sheet then offers the step they +/// would otherwise have had to work out themselves, which is to look for the +/// same title on another source. +/// +/// The offer leans on machinery that already exists: `AlternateSourceSheet` +/// searches every other installed source for the title and hands back something +/// playable. Nothing new is built here. +Future showSourceNoticeSheet( + BuildContext context, { + required SourceNotice notice, + required VoidCallback onFindOther, +}) { + return showAdaptiveModal( + context: context, + backgroundColor: AppColors.background, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (_) => _SourceNoticeSheet(notice: notice, onFindOther: onFindOther), + ); +} + +class _SourceNoticeSheet extends StatelessWidget { + const _SourceNoticeSheet({required this.notice, required this.onFindOther}); + + final SourceNotice notice; + final VoidCallback onFindOther; + + @override + Widget build(BuildContext context) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.textSecondary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + notice == SourceNotice.isSeries + ? Icons.playlist_play_rounded + : Icons.movie_filter_outlined, + color: AppColors.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'detail.notice_title'.tr(), + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + notice.messageKey.tr(), + style: const TextStyle( + fontSize: 14, + height: 1.45, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + // Closed first: the alternate-source search opens its own + // sheet, and two stacked over the page leaves the viewer + // dismissing this one after they have already moved on. + Navigator.of(context).pop(); + onFindOther(); + }, + icon: const Icon(Icons.travel_explore_rounded, size: 20), + label: Text('detail.find_other_sources'.tr()), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'general.close'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + ), + ), + ], + ), + ), + ); + } +} 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 d0ee5b5d..1c1f8735 100644 --- a/lib/features/download/data/datasources/download_transfer_data_source.dart +++ b/lib/features/download/data/datasources/download_transfer_data_source.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data' show BytesBuilder; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:soplay/core/player/hls_variants.dart'; import 'package:soplay/core/network/http_headers.dart'; +import 'package:soplay/core/network/web_page_sniff.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'; @@ -112,13 +114,6 @@ class DownloadTransferDataSource { /// has, this many times, before the download is called failed. static const int _fileResumes = 3; - /// Content types that mean "this is not the file you asked for". - static const List _nonMediaTypes = [ - 'text/html', - 'application/xhtml', - 'text/plain', - ]; - Future run({ required String id, required String dirPath, @@ -308,7 +303,12 @@ class DownloadTransferDataSource { ), ); - _rejectNonMedia(response.headers.value('content-type')); + // Checked before the partial is touched: a web page now must not cost the + // bytes an earlier attempt already saved. + final body = await _mediaBody( + response.data!.stream, + response.headers.value('content-type'), + ); // 206 means the server honoured the range; anything else means starting // over, and appending to the old bytes would corrupt the file silently. @@ -331,7 +331,7 @@ class DownloadTransferDataSource { mode: append ? FileMode.append : FileMode.write, ); try { - await for (final chunk in response.data!.stream) { + await for (final chunk in body) { if (cancel.isCancelled) break; sink.add(chunk); written += chunk.length; @@ -911,9 +911,12 @@ class DownloadTransferDataSource { responseType: ResponseType.stream, ), ); - if (checkMedia) { - _rejectNonMedia(response.headers.value('content-type')); - } + final body = checkMedia + ? await _mediaBody( + response.data!.stream, + response.headers.value('content-type'), + ) + : response.data!.stream; // A server that ignores the Range header sends the whole file. The // slice is cut out of it rather than saving every segment as a full // copy of the one file they all live in. @@ -924,7 +927,7 @@ class DownloadTransferDataSource { var written = 0; final sink = part.openWrite(); try { - await for (var chunk in response.data!.stream) { + await for (var chunk in body) { if (cancel.isCancelled) break; if (skip > 0) { if (chunk.length <= skip) { @@ -1006,13 +1009,55 @@ class DownloadTransferDataSource { ); } - void _rejectNonMedia(String? contentType) { - final type = contentType?.toLowerCase() ?? ''; - if (type.isEmpty) return; - for (final bad in _nonMediaTypes) { - if (type.startsWith(bad)) { - throw _NotMediaException('the server answered with $type'); + /// [body], refused before a byte is written if it opens as a web page. + /// + /// A challenge or error page served as 200 used to be saved under a video's + /// name. The Content-Type was the test, and the HLS CDNs behind vidapi and + /// anikoto label real MPEG-TS segments `text/html` — every one of their + /// episodes failed on its first disguised segment while the same stream + /// played. The first bytes decide now (see [looksLikeWebPage]); the type is + /// only quoted in the message. + static Future> _mediaBody( + Stream body, + String? contentType, + ) async { + final it = StreamIterator(body); + final head = BytesBuilder(copy: false); + try { + while (head.length < webPageSniffBytes && await it.moveNext()) { + head.add(it.current); } + } catch (_) { + await it.cancel(); + rethrow; + } + final first = head.takeBytes(); + if (looksLikeWebPage( + first.length > webPageSniffBytes + ? Uint8List.sublistView(first, 0, webPageSniffBytes) + : first, + )) { + await it.cancel(); + throw _NotMediaException( + 'not media: the server answered with a web page ' + '(${contentType ?? 'no type'})', + ); + } + return _replay(first, it); + } + + /// The bytes [_mediaBody] read ahead, then the rest of the body. + static Stream _replay( + Uint8List first, + StreamIterator rest, + ) async* { + try { + if (first.isNotEmpty) yield first; + while (await rest.moveNext()) { + yield rest.current; + } + } finally { + await rest.cancel(); } } diff --git a/lib/features/download/data/repositories/download_repository_impl.dart b/lib/features/download/data/repositories/download_repository_impl.dart index d27395dc..0e5df980 100644 --- a/lib/features/download/data/repositories/download_repository_impl.dart +++ b/lib/features/download/data/repositories/download_repository_impl.dart @@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart'; import 'package:soplay/features/download/data/subtitle_sidecar.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/network/external_dio.dart'; +import 'package:soplay/core/network/media_request_headers.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/detail/domain/playback/wakelock_holds.dart'; @@ -507,6 +508,26 @@ class DownloadRepositoryImpl implements DownloadRepository { await _startInProcess(current); } + /// The headers a video transfer goes out with: the ones the player sends + /// for the same url (see [mediaRequestHeaders]), the item's own on top. + /// + /// The item keeps only what the source declared, and that is usually a + /// Referer and nothing else. Sent as it was, the Android service fetched + /// under `Dalvik/2.1.0` and the in-process engine under `dart:io`'s agent, + /// and a host that answers a bare client with a page instead of a status + /// failed the download as "a web page" while the same episode played. Added + /// here, at start, so a row queued before this rule gets it on its retry. + /// + /// Chapters keep their own: their images carry per-page headers, merged by + /// the transfer. + @visibleForTesting + static Map transferHeaders(DownloadItem item) { + if (item.kind == DownloadKind.manga) return item.headers; + final uri = Uri.tryParse(item.sourceUrl); + if (uri == null || !uri.hasScheme) return item.headers; + return mediaRequestHeaders(uri, item.headers, provider: item.provider); + } + Future _startNative(DownloadItem item) async { // Asked for, not required. Declining the Android 13 prompt used to stop // the download entirely, with nothing on screen saying why. @@ -518,7 +539,7 @@ class DownloadRepositoryImpl implements DownloadRepository { title: item.title, url: item.sourceUrl, artefactPath: _storage.absoluteOf(item.relativePath), - headers: item.headers, + headers: transferHeaders(item), kind: item.kind.id, pageUrls: item.pageUrls, imageHeaders: item.imageHeaders, @@ -555,7 +576,7 @@ class DownloadRepositoryImpl implements DownloadRepository { kind: item.kind, sourceUrl: item.sourceUrl, videoHeight: item.videoHeight, - headers: item.headers, + headers: transferHeaders(item), pageUrls: item.pageUrls, chapterHtml: item.chapterHtml, imageHeaders: item.imageHeaders, diff --git a/lib/features/download/data/subtitle_sidecar.dart b/lib/features/download/data/subtitle_sidecar.dart index 9a6dff40..1de03b5c 100644 --- a/lib/features/download/data/subtitle_sidecar.dart +++ b/lib/features/download/data/subtitle_sidecar.dart @@ -79,8 +79,12 @@ class SubtitleSidecar { if (bytes == null || bytes.isEmpty || bytes.length > maxBytes) { continue; } + // Kept decrypted: the manifest does not carry the key. + final cipher = t.cipher; + if (cipher != null) bytes = await cipher.decryptBytes(bytes); if (!await dir.exists()) await dir.create(recursive: true); - final name = '${kept.length}.${_extensionOf(t.file)}'; + final ext = t.cipher != null ? 'srt' : _extensionOf(t.file); + final name = '${kept.length}.$ext'; await File('${dir.path}/$name').writeAsBytes(bytes, flush: true); kept.add({'label': t.label, 'file': name}); } catch (e) { diff --git a/lib/features/download/domain/entities/download_failure.dart b/lib/features/download/domain/entities/download_failure.dart index 2193c2ea..bb93ed26 100644 --- a/lib/features/download/domain/entities/download_failure.dart +++ b/lib/features/download/domain/entities/download_failure.dart @@ -79,8 +79,12 @@ enum DownloadFailureKind { l.contains('network')) { return DownloadFailureKind.network; } - if (l.contains('not media') || - l.contains('html') || + // The engines say "not media" when they mean it. A bare "html" also + // matched segment urls inside a network error — vidapi's `page-3.html`, + // anikoto's `seg-2-f1-v1-a1.html` — and labelled a dropped connection + // "the source sent a web page". + if (l.startsWith('not media') || + l.contains('web page') || l.contains('content-type')) { return DownloadFailureKind.notMedia; } diff --git a/lib/features/download/domain/usecases/download_request_builder.dart b/lib/features/download/domain/usecases/download_request_builder.dart index 09e3bdd1..8db0dadd 100644 --- a/lib/features/download/domain/usecases/download_request_builder.dart +++ b/lib/features/download/domain/usecases/download_request_builder.dart @@ -35,6 +35,11 @@ enum DownloadBuildFailure { /// The chapter's pages could not be listed. pagesFailed, + + /// The source answered the url with a web page, not the video: a challenge, + /// an error or a home page served as success. Queued, it failed minutes + /// later in the list; another server is the way out. + webPage, } /// A resolved episode ready for a quality pick, or why there is none. @@ -68,10 +73,12 @@ class DownloadRequestBuilder { required GetPagesUseCase getPages, Future Function(MediaResolveEntity)? resolveEmbed, Future Function(MediaResolveEntity)? canDownload, + Future Function(MediaResolveEntity)? servesWebPage, }) : _resolve = resolve, _getPages = getPages, _resolveEmbed = resolveEmbed, - _canDownload = canDownload; + _canDownload = canDownload, + _servesWebPage = servesWebPage; final ResolveMediaUseCase _resolve; final GetPagesUseCase _getPages; @@ -79,6 +86,10 @@ class DownloadRequestBuilder { final Future Function(MediaResolveEntity)? _canDownload; + /// True only for a definite web page (see `DownloadMediaProbe`): a probe + /// that got no answer says nothing about the file and must not block it. + final Future Function(MediaResolveEntity)? _servesWebPage; + /// The stream URL does not exist until the provider is asked for it, so a /// video download always starts with the same resolve the player makes. Future resolveVideo( @@ -111,7 +122,20 @@ class DownloadRequestBuilder { hasDirective: false, ), ); - if (!hasEmbeds) return VideoResolution.resolved(media); + if (!hasEmbeds) { + // A direct url is still only the provider's word. One that answers with + // a web page used to be queued anyway and fail minutes later as "a web + // page"; 2 KB says so before anything is queued. The mirror asked is + // the one a download takes by default, not whatever was listed first. + final pick = quietPick(media); + final probed = MediaResolveEntity( + videoUrl: pick.url, + headers: pick.headers, + type: pick.type, + ); + if (!await _isWebPage(probed)) return VideoResolution.resolved(media); + return _withoutWebPages(media, pick.url); + } final candidates = []; if (direct(media)) candidates.add(media); @@ -134,6 +158,10 @@ class DownloadRequestBuilder { headers: source.headers.isEmpty ? media.headers : source.headers, subtitles: media.subtitles, videoSources: [source], + // A sibling of unknown shape under a directive is an embed page like + // the top-level one. Without the directive it counted as direct, was + // probed, found to be HTML and skipped, instead of being sniffed. + extractor: source.type == null ? media.extractor : null, ), ); } @@ -171,6 +199,75 @@ class DownloadRequestBuilder { return const VideoResolution.failed(DownloadBuildFailure.needsPlayback); } + /// The default mirror served a page, but another mirror may not: the + /// episode list fails only when every mirror does, and otherwise keeps the + /// ones that did not, so neither the quiet pick nor the sheet lands on it. + Future _withoutWebPages( + MediaResolveEntity media, + String refused, + ) async { + final sources = media.videoSources; + final pages = await Future.wait([ + for (final s in sources) + s.videoUrl == refused + ? Future.value(true) + : _isWebPage( + MediaResolveEntity( + videoUrl: s.videoUrl, + headers: s.headers.isEmpty ? media.headers : s.headers, + type: s.type, + ), + ), + ]); + final kept = [ + for (var i = 0; i < sources.length; i++) + if (!pages[i]) sources[i], + ]; + final top = media.videoUrl; + final topIsPage = + top == refused || + (!kept.any((s) => s.videoUrl == top) && + (sources.any((s) => s.videoUrl == top) || + await _isWebPage( + MediaResolveEntity( + videoUrl: top, + headers: media.headers, + type: media.type, + ), + ))); + if (topIsPage && kept.isEmpty) { + return const VideoResolution.failed(DownloadBuildFailure.webPage); + } + final first = topIsPage ? kept.first : null; + return VideoResolution.resolved( + MediaResolveEntity( + videoUrl: first?.videoUrl ?? top, + type: first != null ? first.type : media.type, + headers: first != null && first.headers.isNotEmpty + ? first.headers + : media.headers, + videoSources: kept, + languagesAvailable: media.languagesAvailable, + activeLang: media.activeLang, + subtitles: media.subtitles, + thumbnails: media.thumbnails, + extractor: media.extractor, + live: media.live, + ), + ); + } + + /// A probe that throws is no answer, and no answer never blocks a download. + Future _isWebPage(MediaResolveEntity media) async { + final probe = _servesWebPage; + if (probe == null) return false; + try { + return await probe(media); + } catch (_) { + return false; + } + } + /// The mirror a download takes when nobody is asked. /// /// `media.videoUrl` is whatever the provider listed first, often its lowest diff --git a/lib/features/download/presentation/download_messages.dart b/lib/features/download/presentation/download_messages.dart index bcf2ea8b..adbb6d96 100644 --- a/lib/features/download/presentation/download_messages.dart +++ b/lib/features/download/presentation/download_messages.dart @@ -1,10 +1,12 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:soplay/features/download/domain/entities/download_failure.dart'; import 'package:soplay/features/download/domain/entities/download_item.dart'; import 'package:soplay/features/download/domain/entities/download_kind.dart'; import 'package:soplay/features/download/domain/entities/download_location.dart'; import 'package:soplay/features/download/domain/entities/download_status.dart'; import 'package:soplay/features/download/domain/repositories/download_repository.dart'; +import 'package:soplay/features/download/domain/usecases/download_request_builder.dart'; /// What to say after asking for a download. /// @@ -21,6 +23,20 @@ String downloadOutcomeMessage(EnqueueOutcome outcome) => switch (outcome) { EnqueueOutcome.refused => 'detail.download_needs_permission'.tr(), }; +/// What to say when an episode could not be turned into a download. +/// +/// A web page gets the same sentence a row that failed on one shows, at tap +/// time instead of minutes later in the list. +String downloadBuildFailureMessage(DownloadBuildFailure failure) => + switch (failure) { + DownloadBuildFailure.needsPlayback => + 'detail.download_needs_playback'.tr(), + DownloadBuildFailure.webPage => + DownloadFailureKind.notMedia.messageKey.tr(), + DownloadBuildFailure.resolveFailed || + DownloadBuildFailure.pagesFailed => 'detail.download_resolve_failed'.tr(), + }; + /// The one-line state of a row, in the viewer's language. String downloadStatusLabel(DownloadItem item) => switch (item.status) { DownloadStatus.pending => 'downloads.pending'.tr(), diff --git a/lib/features/download/presentation/widgets/relink_source_sheet.dart b/lib/features/download/presentation/widgets/relink_source_sheet.dart index a61c4d9f..ae554aea 100644 --- a/lib/features/download/presentation/widgets/relink_source_sheet.dart +++ b/lib/features/download/presentation/widgets/relink_source_sheet.dart @@ -397,6 +397,9 @@ class _SourceRow extends StatelessWidget { imageUrl: poster, httpHeaders: posterImageHeaders(poster), fit: BoxFit.cover, + memCacheWidth: + (52 * MediaQuery.devicePixelRatioOf(context)) + .round(), errorWidget: (_, _, _) => const Icon( Icons.broken_image_outlined, color: AppColors.textHint, diff --git a/lib/features/extensions/data/extension_repo_defaults.dart b/lib/features/extensions/data/extension_repo_defaults.dart index d14ca1d0..2f64510c 100644 --- a/lib/features/extensions/data/extension_repo_defaults.dart +++ b/lib/features/extensions/data/extension_repo_defaults.dart @@ -26,6 +26,17 @@ class ExtensionRepoDefaults { const ExtensionRepoDefaults._(); static const List all = [ + // --- Zangetsu --------------------------------------------------------- + ExtensionRepoEntity( + kind: ExtensionRepoKind.zangetsu, + name: 'Zangetsu Sources', + description: 'Anime, movies and live TV · 90 sources', + url: + 'https://raw.githubusercontent.com/Spyhell/zangetsu-experiment-repo/main/index.json', + badge: '90', + order: 0, + ), + // --- CloudStream ------------------------------------------------------ // CloudStream's own, and the only entry marked `verified` in the project's // repo database (recloudstream/cs-repos). Small — five providers — but diff --git a/lib/features/extensions/data/mangayomi_bridge.dart b/lib/features/extensions/data/mangayomi_bridge.dart index 47c7df13..40a9203a 100644 --- a/lib/features/extensions/data/mangayomi_bridge.dart +++ b/lib/features/extensions/data/mangayomi_bridge.dart @@ -74,7 +74,7 @@ class MangayomiBridge { List> listProviders({bool includeNsfw = false}) { final preferred = getIt().getProviderLanguages(); final picked = {}; - for (final s in store.sources()) { + for (final s in store.enabledSources()) { if (s.isNsfw && !includeNsfw) continue; // Languages the user asked for do not compete for a name — they are all // kept, each under its own key, and the picker labels them apart. Only diff --git a/lib/features/extensions/data/mangayomi_repo_store.dart b/lib/features/extensions/data/mangayomi_repo_store.dart index d782846c..d135fba7 100644 --- a/lib/features/extensions/data/mangayomi_repo_store.dart +++ b/lib/features/extensions/data/mangayomi_repo_store.dart @@ -33,6 +33,9 @@ class MangayomiRepoStore { static const _sourcesKey = 'mangayomi_sources'; static const _codePrefix = 'mangayomi_code:'; static const _prefsPrefix = 'mangayomi_prefs:'; + static const _excludedKey = 'mangayomi_excluded'; + static const _disabledKey = 'mangayomi_disabled'; + static const _updatesKey = 'mangayomi_updates'; Box get _box => Hive.box(AppConstants.settingsBox); Object? _sourcesRaw; @@ -110,15 +113,9 @@ class MangayomiRepoStore { // --- install / remove ---------------------------------------------------- - /// Fetches [indexUrl] and merges its JavaScript sources into the installed set. - /// - /// Returns `{added, total, skippedDart}`. `skippedDart` is reported rather - /// than hidden: several large repos are majority-Dart, and a user who - /// installs one and sees a third of the advertised count needs to know why. - Future> addRepo(String indexUrl) async { + /// Fetches and parses [indexUrl] without installing anything. + Future fetchIndex(String indexUrl) async { final url = indexUrl.trim(); - if (url.isEmpty) return const {'added': 0, 'total': 0, 'skippedDart': 0}; - final resp = await _net.get(url); if (resp.statusCode == null || resp.statusCode! < 200 || @@ -133,22 +130,13 @@ class MangayomiRepoStore { final decoded = jsonDecode(body); if (decoded is! List) throw Exception('Unexpected index format'); - final existing = sources(); - // Replace this repository's snapshot; a successful refresh can retire IDs. - // Keep other repositories until their own index has been fetched successfully. - final byId = { - for (final s in existing.where((s) => s.repoUrl != url)) s.id: s, - }; - final oldIds = existing.map((s) => s.id).toSet(); - var added = 0; - var skippedDart = 0; - var validEntries = 0; - // Two index shapes, told apart by what the entries carry rather than by // where they came from — so a mirror, a fork or a local copy of either one // still works. See [MangayomiSource.looksLikeLnReaderIndex]. final lnReader = MangayomiSource.looksLikeLnReaderIndex(decoded); - + final sources = []; + var skippedDart = 0; + var validEntries = 0; for (final entry in decoded.whereType()) { final json = Map.from(entry); final src = lnReader @@ -160,8 +148,7 @@ class MangayomiRepoStore { skippedDart++; continue; } - if (!oldIds.contains(src.id)) added++; - byId[src.id] = src; + sources.add(src); } if (decoded.isNotEmpty && validEntries == 0) { @@ -169,50 +156,324 @@ class MangayomiRepoStore { 'Repository contains no valid source entries', ); } + return MangayomiRepoIndex( + url: url, + sources: sources, + skippedDart: skippedDart, + ); + } - await _saveSources(byId.values.toList()); - final list = repos(); - if (!list.contains(url)) { - await _saveRepos([...list, url]); + /// Fetches [indexUrl] and installs every JavaScript source in it, including + /// ones the user removed earlier. + /// + /// Returns `{added, total, skippedDart}`. `skippedDart` is reported rather + /// than hidden: several large repos are majority-Dart, and a user who + /// installs one and sees a third of the advertised count needs to know why. + Future> addRepo(String indexUrl) async { + final url = indexUrl.trim(); + if (url.isEmpty) return const {'added': 0, 'total': 0, 'skippedDart': 0}; + final index = await fetchIndex(url); + + final existing = sources(); + // Replace this repository's snapshot; a successful refresh can retire IDs. + // Keep other repositories until their own index has been fetched successfully. + final byId = { + for (final s in existing.where((s) => s.repoUrl != url)) s.id: s, + }; + final oldIds = existing.map((s) => s.id).toSet(); + var added = 0; + for (final src in index.sources) { + if (!oldIds.contains(src.id)) added++; + byId[src.id] = src; } + final retired = existing + .where((s) => s.repoUrl == url && !byId.containsKey(s.id)) + .map((s) => s.id) + .toList(); + + await _saveSources(byId.values.toList()); + await _forget(retired); + await _setExcluded(url, const {}); + await _dropUpdates(index.sources.map((s) => s.id)); + await _addRepoUrl(url); return { 'added': added, 'total': byId.values.where((s) => s.repoUrl == url).length, - 'skippedDart': skippedDart, + 'skippedDart': index.skippedDart, }; } + /// Installs only the sources of [index] whose ids are in [ids]. + /// + /// Sources of this repo the user did not pick are remembered as excluded, so + /// a later refresh does not install them behind the user's back. Picking one + /// here lifts its exclusion. + Future> installSources( + MangayomiRepoIndex index, + Set ids, + ) async { + final url = index.url; + final existing = sources(); + final byId = {for (final s in existing) s.id: s}; + final installedHere = existing + .where((s) => s.repoUrl == url) + .map((s) => s.id) + .toSet(); + var added = 0; + var total = 0; + for (final src in index.sources) { + if (!ids.contains(src.id)) continue; + if (!byId.containsKey(src.id)) added++; + total++; + byId[src.id] = src; + } + final excluded = { + ...excludedIds(url), + ...index.sources + .map((s) => s.id) + .where((id) => !ids.contains(id) && !installedHere.contains(id)), + }..removeAll(ids); + + await _saveSources(byId.values.toList()); + await _setExcluded(url, excluded); + await _dropUpdates(ids); + await _addRepoUrl(url); + return {'added': added, 'total': total, 'skippedDart': index.skippedDart}; + } + + Future _addRepoUrl(String url) async { + final list = repos(); + if (!list.contains(url)) await _saveRepos([...list, url]); + } + + /// Uninstalls one source and keeps it out of later refreshes of its repo. + Future removeSource(String id) async { + final bare = id.startsWith('my:') ? id.substring(3) : id; + final source = sourceById(bare); + if (source == null) return; + final all = sources(); + await _saveSources(all.where((s) => s.id != bare).toList()); + if (source.repoUrl.isNotEmpty) { + await _setExcluded(source.repoUrl, { + ...excludedIds(source.repoUrl), + bare, + }); + } + await _forget([bare]); + } + Future removeRepo(String indexUrl) async { final url = indexUrl.trim(); - final kept = sources().where((s) => s.repoUrl != url).toList(); - await _saveSources(kept); + final all = sources(); + final dropped = all.where((s) => s.repoUrl == url).map((s) => s.id); + await _saveSources(all.where((s) => s.repoUrl != url).toList()); await _saveRepos(repos().where((r) => r != url).toList()); - // Cached extension code for the dropped sources is left behind on purpose: - // it is a few KB per source and re-adding the repo is instant with it warm. - // It is cleaned up by [clearCode] when the user asks explicitly. + await _setExcluded(url, const {}); + await _forget(dropped.toList()); } - /// Re-fetches every installed repo index, replacing the stored metadata and - /// dropping cached code for sources whose version changed. Returns how many - /// sources were updated. - Future checkUpdates() async { - final before = {for (final s in sources()) s.id: s.runtimeIdentity}; + /// Drops everything stored for sources that are no longer installed. + Future _forget(List ids) async { + if (ids.isEmpty) return; + await _box.deleteAll([ + for (final id in ids) ...['$_codePrefix$id', '$_prefsPrefix$id'], + ]); + await _dropUpdates(ids); + final disabled = disabledIds(); + if (disabled.any(ids.contains)) { + await _saveDisabled(disabled..removeAll(ids)); + } + } + + /// Re-fetches every installed repo index and records which installed + /// sources have a newer build, without applying them. + /// + /// Sources that left their index are retired and new, non-excluded ones are + /// installed. A repo that cannot be fetched keeps its previous snapshot and + /// is reported in [MangayomiUpdateCheck.failures]. + Future checkUpdates() async { + final failures = {}; + var added = 0; for (final repo in repos()) { try { - await addRepo(repo); - } catch (_) { - // One dead repo must not abort the rest. + added += await _refresh(await fetchIndex(repo)); + } catch (e) { + failures[repo] = '$e'; } } - var updated = 0; - for (final s in sources()) { - final old = before[s.id]; - if (old != null && old != s.runtimeIdentity) { - updated++; - await _box.delete('$_codePrefix${s.id}'); + return MangayomiUpdateCheck( + updates: availableUpdates(), + failures: failures, + added: added, + ); + } + + Future _refresh(MangayomiRepoIndex index) async { + final url = index.url; + final existing = sources(); + final byId = {for (final s in existing) s.id: s}; + final latest = {for (final s in index.sources) s.id: s}; + final excluded = excludedIds(url); + final updates = _readUpdates(); + final retired = []; + var added = 0; + + for (final s in existing.where((s) => s.repoUrl == url)) { + final next = latest[s.id]; + if (next == null) { + byId.remove(s.id); + retired.add(s.id); + } else if (next.runtimeIdentity != s.runtimeIdentity) { + updates[s.id] = next; + } else { + updates.remove(s.id); } } - return updated; + for (final s in index.sources) { + if (byId.containsKey(s.id) || excluded.contains(s.id)) continue; + byId[s.id] = s; + added++; + } + + await _saveSources(byId.values.toList()); + await _saveUpdates(updates); + await _forget(retired); + return added; + } + + // --- updates ------------------------------------------------------------- + + /// Newer builds found by [checkUpdates] that the user has not applied yet. + Map availableUpdates() { + final out = {}; + _readSources(); + for (final entry in _readUpdates().entries) { + final installed = _sourcesById[entry.key]; + if (installed == null || installed.repoUrl != entry.value.repoUrl) { + continue; + } + if (installed.runtimeIdentity == entry.value.runtimeIdentity) continue; + out[entry.key] = entry.value; + } + return out; + } + + /// Applies the pending updates for [ids], or all of them when null. + /// Returns the ids that were updated. + Future> applyUpdates([Iterable? ids]) async { + final pending = availableUpdates(); + final wanted = ids?.toSet() ?? pending.keys.toSet(); + final byId = {for (final s in sources()) s.id: s}; + final applied = []; + for (final id in wanted) { + final next = pending[id]; + if (next == null) continue; + byId[id] = next; + applied.add(id); + await _box.delete('$_codePrefix$id'); + } + if (applied.isEmpty) return applied; + await _saveSources(byId.values.toList()); + await _dropUpdates(applied); + return applied; + } + + Map _readUpdates() { + final raw = _box.get(_updatesKey); + if (raw is! String || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return {}; + return { + for (final e in decoded.whereType()) + e['id'].toString(): MangayomiSource.fromJson( + Map.from(e), + ), + }; + } catch (_) { + return {}; + } + } + + Future _saveUpdates(Map updates) => _box.put( + _updatesKey, + jsonEncode(updates.values.map((e) => e.toJson()).toList()), + ); + + Future _dropUpdates(Iterable ids) async { + final updates = _readUpdates(); + final before = updates.length; + for (final id in ids) { + updates.remove(id); + } + if (updates.length != before) await _saveUpdates(updates); + } + + // --- exclusions ---------------------------------------------------------- + + /// Ids from [repoUrl] the user removed or chose not to install. + Set excludedIds(String repoUrl) => + (_readExcluded()[repoUrl] ?? const {}).toSet(); + + Map> _readExcluded() { + final raw = _box.get(_excludedKey); + if (raw is! String || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return { + for (final e in decoded.entries) + if (e.value is List) + e.key.toString(): (e.value as List).map((v) => '$v').toSet(), + }; + } catch (_) { + return {}; + } + } + + Future _setExcluded(String repoUrl, Set ids) async { + final all = _readExcluded(); + if (ids.isEmpty) { + if (all.remove(repoUrl) == null) return; + } else { + all[repoUrl] = ids; + } + await _box.put( + _excludedKey, + jsonEncode({for (final e in all.entries) e.key: e.value.toList()}), + ); + } + + // --- enabled / disabled -------------------------------------------------- + + /// Installed sources the user switched off. They stay installed, with their + /// settings, but are left out of the provider list. + Set disabledIds() { + final raw = _box.get(_disabledKey); + if (raw is! String || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + return decoded is List ? decoded.map((e) => '$e').toSet() : {}; + } catch (_) { + return {}; + } + } + + bool isEnabled(String id) => !disabledIds().contains(id); + + Future setEnabled(String id, bool enabled) async { + final disabled = disabledIds(); + final changed = enabled ? disabled.remove(id) : disabled.add(id); + if (changed) await _saveDisabled(disabled); + } + + Future _saveDisabled(Set ids) => + _box.put(_disabledKey, jsonEncode(ids.toList())); + + /// Installed sources that are switched on. + List enabledSources() { + final disabled = disabledIds(); + return sources().where((s) => !disabled.contains(s.id)).toList(); } // --- extension code ------------------------------------------------------ @@ -278,3 +539,34 @@ class MangayomiRepoStore { Future savePrefs(String sourceId, Map values) => _box.put('$_prefsPrefix$sourceId', jsonEncode(values)); } + +/// A parsed repository index. Only JavaScript sources are kept; the Dart ones +/// are counted in [skippedDart]. +class MangayomiRepoIndex { + const MangayomiRepoIndex({ + required this.url, + required this.sources, + this.skippedDart = 0, + }); + + final String url; + final List sources; + final int skippedDart; +} + +class MangayomiUpdateCheck { + const MangayomiUpdateCheck({ + required this.updates, + this.failures = const {}, + this.added = 0, + }); + + /// Newer builds keyed by source id. + final Map updates; + + /// Repo url to error, for indexes that could not be fetched. + final Map failures; + + /// New sources that appeared in an index and were installed. + final int added; +} diff --git a/lib/features/extensions/data/source_installer.dart b/lib/features/extensions/data/source_installer.dart index 6af01232..7fe23a84 100644 --- a/lib/features/extensions/data/source_installer.dart +++ b/lib/features/extensions/data/source_installer.dart @@ -60,11 +60,30 @@ Future installCatalogSource( result = await MangaChannel.addRepo(url); countKey = 'sourceCount'; case ExtensionRepoKind.mangayomi: - result = await store.addRepo(url); + // A catalog row names one source; only rows without an identity (the + // recommended repos) install the whole index. + if (source.externalId.isEmpty) { + result = await store.addRepo(url); + } else { + final index = await store.fetchIndex(url); + final ids = index.sources + .where((s) => mangayomiCatalogMatch(s.id, source.externalId)) + .map((s) => s.id) + .toSet(); + if (ids.isEmpty) throw StateError('Source not found in repository'); + result = await store.installSources(index, ids); + } countKey = 'total'; + case ExtensionRepoKind.zangetsu: + throw UnsupportedError('Zangetsu sources install from their own page'); } if (result['error'] case final String error when error.isNotEmpty) { throw StateError(error); } return (result[countKey] as num?)?.toInt() ?? 0; } + +/// The catalog stores the index's own id; LNReader plugins are prefixed with +/// `ln.` once installed so they cannot collide with a Mangayomi id. +bool mangayomiCatalogMatch(String installedId, String externalId) => + installedId == externalId || installedId == 'ln.$externalId'; diff --git a/lib/features/extensions/data/zangetsu_repo_store.dart b/lib/features/extensions/data/zangetsu_repo_store.dart new file mode 100644 index 00000000..d7211359 --- /dev/null +++ b/lib/features/extensions/data/zangetsu_repo_store.dart @@ -0,0 +1,321 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/js/extractor_cache.dart'; +import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; +import 'package:soplay/features/profile/data/models/provider_model.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; +import 'package:soplay/features/sources/domain/source_scope.dart'; + +enum ZangetsuRepoError { invalidUrl, unreachable, invalidIndex } + +class ZangetsuRepoException implements Exception { + const ZangetsuRepoException(this.error, [this.detail]); + + final ZangetsuRepoError error; + final String? detail; + + @override + String toString() => detail == null ? error.name : '${error.name}: $detail'; +} + +/// External Zangetsu repos and the sources installed from them. +/// +/// The backend reads the repo's `index.json` and composes each source into a +/// device bundle (`/extractors/zangetsu-ext`); the device only keeps which +/// repos were added, their last manifest, and which sources are installed. +class ZangetsuRepoStore { + ZangetsuRepoStore({required this.dio, required this.cache}); + + final Dio dio; + final ExtractorCache cache; + + static const allowedHosts = { + 'raw.githubusercontent.com', + 'gist.githubusercontent.com', + }; + static const externalPrefix = 'zx-'; + + static const _reposKey = 'zangetsu_repos'; + static const _installedKey = 'zangetsu_installed'; + static const _manifestPrefix = 'zangetsu_manifest:'; + + Box get _box => Hive.box(AppConstants.settingsBox); + + static bool isAllowedUrl(String raw) { + final uri = Uri.tryParse(raw.trim()); + return uri != null && + uri.scheme == 'https' && + allowedHosts.contains(uri.host.toLowerCase()) && + uri.userInfo.isEmpty && + uri.pathSegments.where((s) => s.isNotEmpty).isNotEmpty; + } + + static bool isExternalId(String id) => id.startsWith(externalPrefix); + + /// A source's version string as the int extractor caches key on: + /// `1.0.3` → 10003. Never below 1, which the caches treat as "unknown". + static int versionCode(String version) { + final parts = RegExp(r'\d+') + .allMatches(version) + .take(3) + .map((m) => int.tryParse(m.group(0)!) ?? 0) + .toList(); + while (parts.length < 3) { + parts.add(0); + } + final code = + parts[0].clamp(0, 99999) * 10000 + + parts[1].clamp(0, 99) * 100 + + parts[2].clamp(0, 99); + return code < 1 ? 1 : code; + } + + // --- repos --------------------------------------------------------------- + + List repos() => _readList(_reposKey).whereType().toList(); + + /// The repo's sources as last fetched, adult ones included. + List manifest(String repoUrl) => + _decodeSources(_readList('$_manifestPrefix${repoUrl.trim()}')); + + /// Adds [url] (or refreshes it) and returns its sources. + Future> addRepo(String url) async { + final repo = url.trim(); + if (!isAllowedUrl(repo)) { + throw const ZangetsuRepoException(ZangetsuRepoError.invalidUrl); + } + final sources = await _fetch(repo); + await _box.put( + '$_manifestPrefix$repo', + jsonEncode(sources.map((s) => s.toJson()).toList()), + ); + final list = repos(); + if (!list.contains(repo)) { + await _box.put(_reposKey, jsonEncode([...list, repo])); + } + // A source Sozo starts shipping stops registering from the repo. + final byId = {for (final s in sources) s.providerId: s}; + var changed = false; + final installed = this.installed().map((s) { + final fresh = s.repoUrl == repo ? byId[s.providerId] : null; + if (fresh == null || fresh.builtIn == s.builtIn) return s; + changed = true; + return s.copyWith(builtIn: fresh.builtIn); + }).toList(); + if (changed) await _saveInstalled(installed); + return sources; + } + + Future removeRepo(String url) async { + final repo = url.trim(); + final all = installed(); + for (final s in all.where((s) => s.repoUrl == repo)) { + await _clearCode(s.providerId); + } + await _saveInstalled(all.where((s) => s.repoUrl != repo).toList()); + await _box.delete('$_manifestPrefix$repo'); + await _box.put( + _reposKey, + jsonEncode(repos().where((r) => r != repo).toList()), + ); + } + + /// Re-reads every repo. One that fails keeps its last manifest. + Future refreshAll() async { + for (final repo in repos()) { + try { + await addRepo(repo); + } catch (_) {} + } + } + + Future> _fetch(String repo) async { + final Response resp; + try { + resp = await dio.get( + '/extension-repos/zangetsu', + queryParameters: {'url': repo}, + ); + } on DioException catch (e) { + final data = e.response?.data; + final code = data is Map ? data['code'] : null; + final message = data is Map ? data['message']?.toString() : null; + throw ZangetsuRepoException(switch (code) { + 'REPO_URL_INVALID' => ZangetsuRepoError.invalidUrl, + 'REPO_INDEX_INVALID' => ZangetsuRepoError.invalidIndex, + _ => ZangetsuRepoError.unreachable, + }, message ?? e.message); + } + final data = resp.data is String ? _tryDecode(resp.data) : resp.data; + final raw = data is Map ? data['sources'] : null; + if (raw is! List) { + throw const ZangetsuRepoException(ZangetsuRepoError.invalidIndex); + } + return [ + for (final e in raw.whereType()) + ?ZangetsuSource.fromJson(Map.from(e), repoUrl: repo), + ]; + } + + // --- installed sources --------------------------------------------------- + + List installed() => _decodeSources(_readList(_installedKey)); + + ZangetsuSource? installedById(String providerId) { + for (final s in installed()) { + if (s.providerId == providerId) return s; + } + return null; + } + + Future install(ZangetsuSource source) async { + if (source.builtIn) return; + final current = installedById(source.providerId); + final kept = installed() + .where((s) => s.providerId != source.providerId) + .toList(); + await _saveInstalled([ + ...kept, + source.copyWith(enabled: current?.enabled ?? true), + ]); + } + + Future remove(String providerId) async { + await _clearCode(providerId); + await _saveInstalled( + installed().where((s) => s.providerId != providerId).toList(), + ); + } + + Future setEnabled(String providerId, bool enabled) async { + await _saveInstalled([ + for (final s in installed()) + s.providerId == providerId ? s.copyWith(enabled: enabled) : s, + ]); + } + + /// The manifest entry that would replace [source], when its version differs. + ZangetsuSource? updateFor(ZangetsuSource source) { + for (final m in manifest(source.repoUrl)) { + if (m.providerId != source.providerId) continue; + return m.version != source.version || m.file != source.file ? m : null; + } + return null; + } + + List updates() => [ + for (final s in installed()) + if (updateFor(s) != null) s, + ]; + + /// Moves [providerId] to its repo's current version. + Future update(String providerId) async { + final current = installedById(providerId); + final next = current == null ? null : updateFor(current); + if (current == null || next == null) return false; + await _clearCode(providerId); + await install(next.copyWith(enabled: current.enabled)); + return true; + } + + Future updateAll() async { + var count = 0; + for (final s in updates()) { + if (await update(s.providerId)) count++; + } + return count; + } + + // --- providers ----------------------------------------------------------- + + /// Installed, enabled sources as providers run on the device. + List providers({required bool allowAdult}) => [ + for (final s in installed()) + if (s.enabled && !s.builtIn && (allowAdult || !s.nsfw)) toProvider(s), + ]; + + ProviderModel? providerById(String id, {required bool allowAdult}) { + if (!isExternalId(id)) return null; + for (final p in providers(allowAdult: allowAdult)) { + if (p.id == id) return p; + } + return null; + } + + static ProviderModel toProvider(ZangetsuSource s) { + final url = + '/extractors/zangetsu-ext?src=${Uri.encodeQueryComponent(s.file)}' + '&id=${Uri.encodeQueryComponent(s.providerId)}' + '${s.isLive ? '&live=1' : ''}'; + return ProviderModel( + id: s.providerId, + name: s.name, + image: s.icon ?? '', + url: '', + description: 'Zangetsu · ${repoLabel(s.repoUrl)}', + repo: s.repoUrl, + domains: const [], + mode: 'client', + category: s.isAnime ? 'anime' : 'movies', + lang: s.lang, + nsfw: s.nsfw, + extractor: ExtractorRef( + name: s.providerId, + version: versionCode(s.version), + scope: 'all', + url: url, + ), + ); + } + + /// [builtIn] plus the [external] ones whose id is not taken. Sozo's own + /// providers always win, under either the `zx-` id or the plain one. + static List merge( + List builtIn, + List external, + ) { + final taken = {for (final p in builtIn) p.id}; + return [ + ...builtIn, + for (final p in external) + if (!taken.contains(p.id) && + !(isExternalId(p.id) && + taken.contains(p.id.substring(externalPrefix.length)))) + p, + ]; + } + + // --- storage ------------------------------------------------------------- + + Future _clearCode(String providerId) => cache.clear(providerId); + + Future _saveInstalled(List list) => _box.put( + _installedKey, + jsonEncode(list.map((s) => s.toJson()).toList()), + ); + + List _readList(String key) { + final raw = _box.get(key); + if (raw is! String || raw.isEmpty) return const []; + final decoded = _tryDecode(raw); + return decoded is List ? decoded : const []; + } + + static Object? _tryDecode(Object? raw) { + if (raw is! String) return null; + try { + return jsonDecode(raw); + } catch (_) { + return null; + } + } + + static List _decodeSources(List raw) => [ + for (final e in raw.whereType()) + ?ZangetsuSource.fromJson(Map.from(e)), + ]; +} diff --git a/lib/features/extensions/domain/entities/extension_repo_entity.dart b/lib/features/extensions/domain/entities/extension_repo_entity.dart index 39e5756d..5cc14caf 100644 --- a/lib/features/extensions/domain/entities/extension_repo_entity.dart +++ b/lib/features/extensions/domain/entities/extension_repo_entity.dart @@ -4,13 +4,15 @@ enum ExtensionRepoKind { cloudstream, aniyomi, manga, - mangayomi; + mangayomi, + zangetsu; static ExtensionRepoKind? parse(String? raw) => switch (raw?.trim()) { 'cloudstream' => ExtensionRepoKind.cloudstream, 'aniyomi' => ExtensionRepoKind.aniyomi, 'manga' => ExtensionRepoKind.manga, 'mangayomi' => ExtensionRepoKind.mangayomi, + 'zangetsu' => ExtensionRepoKind.zangetsu, _ => null, }; diff --git a/lib/features/extensions/domain/entities/zangetsu_source.dart b/lib/features/extensions/domain/entities/zangetsu_source.dart new file mode 100644 index 00000000..8c5fe670 --- /dev/null +++ b/lib/features/extensions/domain/entities/zangetsu_source.dart @@ -0,0 +1,100 @@ +/// One source of an external Zangetsu repo, as `/extension-repos/zangetsu` +/// lists it, plus what the device keeps once it is installed. +class ZangetsuSource { + const ZangetsuSource({ + required this.id, + required this.providerId, + required this.name, + required this.repoUrl, + required this.file, + this.lang = 'en', + this.type = 'movie', + this.version = '0', + this.icon, + this.nsfw = false, + this.builtIn = false, + this.enabled = true, + }); + + final String id; + + /// `zx-`: namespaced so it can never take over a built-in provider. + final String providerId; + final String name; + final String repoUrl; + final String file; + final String lang; + final String type; + final String version; + final String? icon; + final bool nsfw; + + /// Sozo already ships this source; its own provider is the one used. + final bool builtIn; + final bool enabled; + + bool get isAnime => type == 'anime'; + + bool get isLive => type == 'live' || type == 'tv'; + + static ZangetsuSource? fromJson(Map json, {String? repoUrl}) { + final id = (json['id'] as String?)?.trim() ?? ''; + final file = (json['file'] as String?)?.trim() ?? ''; + final repo = repoUrl ?? (json['repoUrl'] as String?)?.trim() ?? ''; + if (id.isEmpty || file.isEmpty || repo.isEmpty) return null; + final providerId = (json['providerId'] as String?)?.trim(); + final icon = (json['icon'] as String?)?.trim(); + return ZangetsuSource( + id: id, + providerId: providerId == null || providerId.isEmpty + ? 'zx-$id' + : providerId, + name: ((json['name'] as String?)?.trim().isNotEmpty ?? false) + ? (json['name'] as String).trim() + : id, + repoUrl: repo, + file: file, + lang: ((json['lang'] as String?)?.trim().isNotEmpty ?? false) + ? (json['lang'] as String).trim().toLowerCase() + : 'en', + type: ((json['type'] as String?)?.trim().isNotEmpty ?? false) + ? (json['type'] as String).trim().toLowerCase() + : 'movie', + version: json['version']?.toString().trim() ?? '0', + icon: icon == null || icon.isEmpty ? null : icon, + nsfw: json['nsfw'] == true, + builtIn: json['builtIn'] == true, + enabled: json['enabled'] != false, + ); + } + + Map toJson() => { + 'id': id, + 'providerId': providerId, + 'name': name, + 'repoUrl': repoUrl, + 'file': file, + 'lang': lang, + 'type': type, + 'version': version, + if (icon != null) 'icon': icon, + 'nsfw': nsfw, + 'builtIn': builtIn, + 'enabled': enabled, + }; + + ZangetsuSource copyWith({bool? enabled, bool? builtIn}) => ZangetsuSource( + id: id, + providerId: providerId, + name: name, + repoUrl: repoUrl, + file: file, + lang: lang, + type: type, + version: version, + icon: icon, + nsfw: nsfw, + builtIn: builtIn ?? this.builtIn, + enabled: enabled ?? this.enabled, + ); +} diff --git a/lib/features/extensions/domain/mangayomi_source_filter.dart b/lib/features/extensions/domain/mangayomi_source_filter.dart new file mode 100644 index 00000000..0597c9d0 --- /dev/null +++ b/lib/features/extensions/domain/mangayomi_source_filter.dart @@ -0,0 +1,60 @@ +import 'package:soplay/core/extensions/source_language.dart' as srclang; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; + +/// The filters shared by the installed list and the source picker. +class MangayomiSourceFilter { + const MangayomiSourceFilter({ + this.query = '', + this.type, + this.languages = const [], + this.includeNsfw = true, + }); + + final String query; + + /// Null means every type. + final MangayomiItemType? type; + + /// Empty means every language. Sources tagged `all` match any selection. + final List languages; + final bool includeNsfw; + + bool matches(MangayomiSource s) { + if (!includeNsfw && s.isNsfw) return false; + if (type != null && s.itemType != type) return false; + if (!srclang.langMatches(s.lang, languages)) return false; + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + return s.name.toLowerCase().contains(q) || + s.baseUrl.toLowerCase().contains(q); + } + + /// Matching sources sorted by name, then language. + List apply(Iterable sources) => + sources.where(matches).toList()..sort(compareByName); + + static int compareByName(MangayomiSource a, MangayomiSource b) { + final byName = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + if (byName != 0) return byName; + return a.lang.compareTo(b.lang); + } + + /// How many of [sources] carry each normalised language code. + static Map languageCounts(Iterable sources) { + final out = {}; + for (final s in sources) { + final key = srclang.normalizeLang(s.lang); + if (key.isEmpty) continue; + out.update(key, (n) => n + 1, ifAbsent: () => 1); + } + return out; + } + + /// The selection after the user taps [code]; `all` clears it. + static List toggleLanguage(List selected, String code) { + if (code == srclang.kAllLanguages) return const []; + final next = List.from(selected); + if (!next.remove(code)) next.add(code); + return next; + } +} diff --git a/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart new file mode 100644 index 00000000..cf7500b1 --- /dev/null +++ b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart @@ -0,0 +1,264 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/extensions/data/mangayomi_repo_store.dart'; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; +import 'package:soplay/features/extensions/domain/mangayomi_source_filter.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.dart'; + +/// Lets the user pick which sources of one or more fetched indexes to install. +/// +/// Pops with the chosen ids keyed by index url, or null when cancelled. +class MangayomiSourcePickerPage extends StatefulWidget { + const MangayomiSourcePickerPage({ + super.key, + required this.indexes, + required this.installedIds, + this.initialLanguages = const [], + this.includeNsfw = false, + }); + + final List indexes; + final Set installedIds; + final List initialLanguages; + final bool includeNsfw; + + static Future>?> show( + BuildContext context, { + required List indexes, + required Set installedIds, + List initialLanguages = const [], + bool includeNsfw = false, + }) => Navigator.of(context).push>>( + MaterialPageRoute( + builder: (_) => MangayomiSourcePickerPage( + indexes: indexes, + installedIds: installedIds, + initialLanguages: initialLanguages, + includeNsfw: includeNsfw, + ), + ), + ); + + @override + State createState() => + _MangayomiSourcePickerPageState(); +} + +class _MangayomiSourcePickerPageState extends State { + static const Color _accent = Color(0xFFE5484D); + + final _search = TextEditingController(); + final _chosen = {}; + String _query = ''; + MangayomiItemType? _type; + late List _languages = widget.initialLanguages; + + late final List _all = [ + for (final index in widget.indexes) + ...index.sources.where((s) => widget.includeNsfw || !s.isNsfw), + ]; + + static String _key(MangayomiSource s) => '${s.repoUrl}\u0000${s.id}'; + + bool _installed(MangayomiSource s) => widget.installedIds.contains(s.id); + + MangayomiSourceFilter get _filter => MangayomiSourceFilter( + query: _query, + type: _type, + languages: _languages, + includeNsfw: widget.includeNsfw, + ); + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + void _setAll(List visible, bool selected) { + setState(() { + final keys = visible.where((s) => !_installed(s)).map(_key); + if (selected) { + _chosen.addAll(keys); + } else { + _chosen.removeAll(keys); + } + }); + } + + void _submit() { + final out = >{}; + for (final s in _all) { + if (!_chosen.contains(_key(s))) continue; + out.putIfAbsent(s.repoUrl, () => {}).add(s.id); + } + Navigator.of(context).pop(out); + } + + @override + Widget build(BuildContext context) { + final visible = _filter.apply(_all); + final langScope = MangayomiSourceFilter( + type: _type, + includeNsfw: widget.includeNsfw, + ).apply(_all); + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('ext.choose_sources'.tr()), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), + child: TextField( + controller: _search, + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'general.search'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint), + prefixIcon: const Icon(Icons.search, color: AppColors.textHint), + isDense: true, + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + MangayomiTypeFilterRow( + selected: _type, + onSelected: (t) => setState(() => _type = t), + ), + MangayomiLanguageFilterRow( + counts: MangayomiSourceFilter.languageCounts(langScope), + selected: _languages, + onToggle: (code) => setState( + () => _languages = MangayomiSourceFilter.toggleLanguage( + _languages, + code, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Flexible( + child: TextButton( + onPressed: () => _setAll(visible, true), + child: Text( + 'ext.select_all'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Flexible( + child: TextButton( + onPressed: () => _setAll(visible, false), + child: Text( + 'ext.select_none'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: Text( + '${visible.length}', + style: const TextStyle(color: AppColors.textHint), + ), + ), + ], + ), + ), + Expanded( + child: visible.isEmpty + ? Center( + child: Text( + 'ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 16), + itemCount: visible.length, + itemBuilder: (_, i) => _tile(visible[i]), + ), + ), + ], + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SizedBox( + height: 46, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: _accent, + foregroundColor: Colors.white, + ), + onPressed: _chosen.isEmpty ? null : _submit, + icon: const Icon(Icons.download_rounded), + label: Text( + 'ext.install_selected'.tr(args: ['${_chosen.length}']), + ), + ), + ), + ), + ), + ); + } + + Widget _tile(MangayomiSource s) { + final installed = _installed(s); + final type = switch (s.itemType) { + MangayomiItemType.anime => 'ext.type_anime'.tr(), + MangayomiItemType.novel => 'ext.type_novel'.tr(), + MangayomiItemType.manga => 'ext.type_manga'.tr(), + }; + return CheckboxListTile( + value: installed || _chosen.contains(_key(s)), + onChanged: installed + ? null + : (v) => setState(() { + if (v == true) { + _chosen.add(_key(s)); + } else { + _chosen.remove(_key(s)); + } + }), + activeColor: _accent, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + s.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13.5), + ), + subtitle: Text( + [ + s.lang.toUpperCase(), + type, + 'v${s.version}', + if (s.isNsfw) '18+', + if (installed) 'ext.already_installed'.tr(), + ].join(' · '), + style: const TextStyle(color: AppColors.textHint, fontSize: 11), + ), + ); + } +} diff --git a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart index a26198e7..be5ecb21 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart @@ -9,7 +9,10 @@ import 'package:soplay/features/extensions/data/mangayomi_repo_store.dart'; import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; +import 'package:soplay/features/extensions/domain/mangayomi_source_filter.dart'; +import 'package:soplay/features/extensions/presentation/pages/mangayomi_source_picker_page.dart'; import 'package:soplay/features/extensions/presentation/widgets/recommended_repos_section.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.dart'; import 'package:soplay/features/extensions/presentation/widgets/source_settings_sheet.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; @@ -39,22 +42,15 @@ class _MangayomiSourcesPageState extends State { bool _busy = false; String? _status; bool _statusError = false; + MangayomiItemType? _type; + List _languages = const []; List get _repos => _store.repos(); - List get _sources { - // `.toList()` on BOTH branches. `store.sources()` returns a const empty - // list when nothing is installed, and `List.sort` on an unmodifiable list - // throws "Cannot modify an unmodifiable list" regardless of length — so - // sorting the branch that passed the original list straight through blew up - // the whole page for any user with adult sources enabled and no sources yet. - final all = _store.sources(); - final visible = _hive.showAdultContent - ? all.toList() - : all.where((s) => !s.isNsfw).toList(); - visible.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - return visible; - } + /// Installed sources the user may see, before the type and language filters. + List get _installed => + MangayomiSourceFilter(includeNsfw: _hive.showAdultContent) + .apply(_store.sources()); @override void dispose() { @@ -70,7 +66,8 @@ class _MangayomiSourcesPageState extends State { Future _add() => _install([_controller.text.trim()]); - /// Installs one or more sibling indexes (manga / anime / novel) as one unit. + /// Fetches one or more sibling indexes (manga / anime / novel), lets the + /// user pick sources from them, and installs only those. Future _install(List urls) async { final list = urls.where((u) => u.trim().isNotEmpty).toList(); if (list.isEmpty || _busy) return; @@ -78,23 +75,63 @@ class _MangayomiSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'ext.installing_extensions'.tr(); + _status = 'ext.loading_repo'.tr(); }); - var added = 0; - var skippedDart = 0; + final indexes = []; final failures = []; for (final url in list) { try { - final res = await _store.addRepo(url); - added += res['added'] ?? 0; - skippedDart += res['skippedDart'] ?? 0; + indexes.add(await _store.fetchIndex(url)); } catch (e) { // A repo publishing only some of the three sibling indexes is normal — // record the miss and keep going rather than failing the whole install. failures.add('${url.split('/').last}: $e'); } } + if (!mounted) return; + + final skippedDart = indexes.fold(0, (n, i) => n + i.skippedDart); + if (indexes.every((i) => i.sources.isEmpty)) { + setState(() { + _busy = false; + _statusError = true; + _status = indexes.isEmpty && failures.isNotEmpty + ? 'ext.could_not_install'.tr(args: [failures.first]) + : _withDartNote('ext.none_found_js'.tr(), skippedDart); + }); + return; + } + + setState(() { + _busy = false; + _status = null; + }); + final chosen = await MangayomiSourcePickerPage.show( + context, + indexes: indexes, + installedIds: _store.sources().map((s) => s.id).toSet(), + initialLanguages: _hive.getProviderLanguages(), + includeNsfw: _hive.showAdultContent, + ); + if (!mounted || chosen == null || chosen.isEmpty) return; + + setState(() { + _busy = true; + _status = 'ext.installing_extensions'.tr(); + }); + var added = 0; + String? error; + try { + for (final index in indexes) { + final ids = chosen[index.url]; + if (ids == null || ids.isEmpty) continue; + final res = await _store.installSources(index, ids); + added += res['added'] ?? 0; + } + } catch (e) { + error = '$e'; + } if (!mounted) return; if (added > 0) { @@ -103,23 +140,50 @@ class _MangayomiSourcesPageState extends State { } setState(() { _busy = false; - _statusError = added == 0; - if (added == 0) { - _status = failures.isEmpty - ? 'ext.none_found_js'.tr() - : 'ext.could_not_install'.tr(args: [failures.first]); - } else { - _status = 'ext.added_sources'.tr(args: ['$added']); - if (skippedDart > 0) { - _status = '$_status ' - '${'ext.skipped_dart'.tr(args: ['$skippedDart'])}'; - } - } + _statusError = error != null; + _status = error != null + ? 'ext.could_not_install'.tr(args: [error]) + : _withDartNote( + 'ext.added_sources'.tr(args: ['$added']), + skippedDart, + ); }); } + static String _withDartNote(String status, int skippedDart) => + skippedDart > 0 + ? '$status ${'ext.skipped_dart'.tr(args: ['$skippedDart'])}' + : status; + + Future _confirm(String message) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + content: Text(message), + actions: [ + TextButton( + autofocus: true, + onPressed: () => Navigator.of(ctx).pop(false), + child: Text('general.cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + 'ext.remove'.tr(), + style: const TextStyle(color: AppColors.error), + ), + ), + ], + ), + ); + return ok == true; + } + Future _remove(String url) async { + if (!await _confirm('ext.remove_repo_confirm'.tr())) return; await _store.removeRepo(url); + getIt().invalidate(); if (!mounted) return; _reloadProviders(); setState(() { @@ -128,6 +192,27 @@ class _MangayomiSourcesPageState extends State { }); } + Future _removeSource(MangayomiSource s) async { + if (!await _confirm('ext.remove_source_confirm'.tr(args: [s.name]))) { + return; + } + await _store.removeSource(s.id); + getIt().invalidate(s.id); + if (!mounted) return; + _reloadProviders(); + setState(() { + _statusError = false; + _status = 'ext.source_removed'.tr(); + }); + } + + Future _setEnabled(MangayomiSource s, bool enabled) async { + await _store.setEnabled(s.id, enabled); + if (!mounted) return; + _reloadProviders(); + setState(() {}); + } + Future _checkUpdates() async { if (_busy) return; setState(() { @@ -136,17 +221,53 @@ class _MangayomiSourcesPageState extends State { _status = 'ext.checking_extension_updates'.tr(); }); try { - final count = await _store.checkUpdates(); - // Extension code is cached per version; a bump invalidates it, but the - // runtime also holds one loaded in memory. - getIt().invalidate(); + final result = await _store.checkUpdates(); + if (!mounted) return; + if (result.added > 0) _reloadProviders(); + final lines = [ + if (result.updates.isNotEmpty) + 'ext.updates_available'.tr(args: ['${result.updates.length}']) + else if (result.failures.isEmpty) + 'ext.all_up_to_date'.tr(), + if (result.added > 0) 'ext.added_sources'.tr(args: ['${result.added}']), + for (final e in result.failures.entries) + 'ext.repo_check_failed'.tr(args: [e.key.split('/').last, e.value]), + ]; + setState(() { + _statusError = result.failures.isNotEmpty; + _status = lines.join('\n'); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _statusError = true; + _status = 'ext.update_check_failed'.tr(args: ['$e']); + }); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Applies pending updates for [ids], or every pending one when null. + Future _applyUpdates([List? ids]) async { + if (_busy) return; + setState(() { + _busy = true; + _statusError = false; + _status = 'ext.installing_extensions'.tr(); + }); + try { + final applied = await _store.applyUpdates(ids); + // Extension code is cached per version, but the runtime also holds the + // compiled instance in memory. + final runtime = getIt(); + for (final id in applied) { + runtime.invalidate(id); + } if (!mounted) return; - if (count > 0) _reloadProviders(); + if (applied.isNotEmpty) _reloadProviders(); setState(() { - _statusError = false; - _status = count == 0 - ? 'ext.all_up_to_date'.tr() - : 'ext.updated_count'.tr(args: ['$count']); + _status = 'ext.updated_count'.tr(args: ['${applied.length}']); }); } catch (e) { if (!mounted) return; @@ -163,13 +284,32 @@ class _MangayomiSourcesPageState extends State { Widget build(BuildContext context) { if (!MangayomiRuntime.isSupported) { return Scaffold( - appBar: AppBar(title: Text('ext.mangayomi_title'.tr())), + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text('ext.mangayomi_title'.tr()), + ), body: Center( - child: Text('ext.needs_webview'.tr()), + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'ext.needs_webview'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), ), ); } - final sources = _sources; + final installed = _installed; + final byType = MangayomiSourceFilter(type: _type).apply(installed); + final sources = MangayomiSourceFilter( + type: _type, + languages: _languages, + ).apply(installed); + final updates = _store.availableUpdates(); + final disabled = _store.disabledIds(); final repos = _repos; return Scaffold( backgroundColor: AppColors.background, @@ -221,7 +361,7 @@ class _MangayomiSourcesPageState extends State { ), const SizedBox(height: 8), if (repos.isEmpty) _empty() else ...repos.map(_repoTile), - if (sources.isNotEmpty) ...[ + if (installed.isNotEmpty) ...[ const SizedBox(height: 24), Row( children: [ @@ -231,12 +371,47 @@ class _MangayomiSourcesPageState extends State { style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppColors.textHint, letterSpacing: 1)), const Spacer(), - Text('${sources.length}', + Text( + sources.length == installed.length + ? '${installed.length}' + : '${sources.length} / ${installed.length}', style: const TextStyle(color: AppColors.textHint)), ], ), + if (updates.isNotEmpty) ...[ + const SizedBox(height: 8), + _updatesBanner(updates.length), + ], + const SizedBox(height: 4), + MangayomiTypeFilterRow( + padding: EdgeInsets.zero, + selected: _type, + onSelected: (t) => setState(() => _type = t), + ), + MangayomiLanguageFilterRow( + padding: EdgeInsets.zero, + counts: MangayomiSourceFilter.languageCounts(byType), + selected: _languages, + onToggle: (code) => setState( + () => _languages = + MangayomiSourceFilter.toggleLanguage(_languages, code), + ), + ), const SizedBox(height: 8), - ...sources.map(_sourceTile), + if (sources.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint)), + ), + ) + else + ...sources.map((s) => _sourceTile( + s, + update: updates[s.id], + enabled: !disabled.contains(s.id), + )), ], ], ), @@ -255,9 +430,9 @@ class _MangayomiSourcesPageState extends State { errorBuilder: (_, _, _) => Container( width: 44, height: 44, - color: Colors.white10, + color: AppColors.surfaceVariant, child: const Icon(Icons.javascript_outlined, - color: Colors.white54), + color: AppColors.textHint), )), ), const SizedBox(width: 12), @@ -330,7 +505,7 @@ class _MangayomiSourcesPageState extends State { ); Widget _statusBanner() { - final color = _statusError ? Colors.redAccent : Colors.green; + final color = _statusError ? AppColors.error : AppColors.success; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( @@ -398,15 +573,60 @@ class _MangayomiSourcesPageState extends State { overflow: TextOverflow.ellipsis, style: const TextStyle(color: AppColors.textHint, fontSize: 11)), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), - onPressed: _busy ? null : () => _remove(url), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'ext.add_sources'.tr(), + icon: const Icon(Icons.playlist_add_rounded, + color: AppColors.textSecondary), + onPressed: _busy ? null : () => _install([url]), + ), + IconButton( + tooltip: 'ext.remove'.tr(), + icon: const Icon(Icons.delete_outline, + color: AppColors.error), + onPressed: _busy ? null : () => _remove(url), + ), + ], ), ), ), ); - Widget _sourceTile(MangayomiSource s) => Padding( + Widget _updatesBanner(int count) => Container( + padding: const EdgeInsetsDirectional.fromSTEB(12, 6, 6, 6), + decoration: BoxDecoration( + color: _accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _accent.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.system_update_alt_rounded, + size: 16, color: _accent), + const SizedBox(width: 8), + Expanded( + child: Text( + 'ext.updates_available'.tr(args: ['$count']), + style: const TextStyle(color: Colors.white, fontSize: 12.5), + ), + ), + TextButton( + onPressed: _busy ? null : () => _applyUpdates(), + child: Text('ext.update_all'.tr(), + style: const TextStyle(color: _accent)), + ), + ], + ), + ); + + Widget _sourceTile( + MangayomiSource s, { + MangayomiSource? update, + bool enabled = true, + }) => + Padding( padding: const EdgeInsets.only(bottom: 6), child: Container( decoration: BoxDecoration( @@ -416,72 +636,122 @@ class _MangayomiSourcesPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(7), - child: Image.network( - s.iconUrl.isEmpty ? _logo : s.iconUrl, - width: 26, - height: 26, - cacheWidth: 78, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - width: 26, - height: 26, - color: Colors.white10, - child: const Icon(Icons.public, - size: 14, color: Colors.white54), - ), - ), - ), - const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text(s.name, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network( + s.iconUrl.isEmpty ? _logo : s.iconUrl, + width: 26, + height: 26, + cacheWidth: 78, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 26, + height: 26, + color: AppColors.surfaceVariant, + child: const Icon(Icons.public, + size: 14, color: AppColors.textHint), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text(s.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontSize: 13)), + ), + if (s.isNsfw) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text('18+', + style: TextStyle( + color: AppColors.error, + fontSize: 9, + fontWeight: FontWeight.w700)), + ), + ], + ], + ), + Text( + [ + s.lang.toUpperCase(), + _typeLabel(s.itemType), + update == null + ? 'v${s.version}' + : 'v${s.version} → v${update.version}', + if (!enabled) 'ext.source_disabled'.tr(), + ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, fontSize: 13)), - ), - if (s.isNsfw) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), + color: AppColors.textHint, fontSize: 10.5), ), - child: const Text('18+', - style: TextStyle( - color: Colors.redAccent, - fontSize: 9, - fontWeight: FontWeight.w700)), - ), - ], - ], - ), - Text( - '${s.lang.toUpperCase()} · ${_typeLabel(s.itemType)} · v${s.version}', - style: const TextStyle( - color: AppColors.textHint, fontSize: 10.5), - ), - ], + ], + ), + ), + ], + ), ), ), + if (update != null) + TextButton( + onPressed: _busy ? null : () => _applyUpdates([s.id]), + style: TextButton.styleFrom( + foregroundColor: _accent, + visualDensity: VisualDensity.compact, + ), + child: Text('ext.update'.tr()), + ), // The source's own settings — its domain, preferred quality, // what it hides. IconButton( tooltip: 'ext.settings'.tr(), visualDensity: VisualDensity.compact, icon: const Icon(Icons.tune_rounded, - size: 19, color: Colors.white70), + size: 19, color: AppColors.textSecondary), onPressed: () => SourceSettingsSheet.show(context, s), ), + PopupMenuButton<_SourceAction>( + enabled: !_busy, + icon: const Icon(Icons.more_vert_rounded, + size: 19, color: AppColors.textSecondary), + color: AppColors.surface, + onSelected: (a) => switch (a) { + _SourceAction.toggle => _setEnabled(s, !enabled), + _SourceAction.remove => _removeSource(s), + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: _SourceAction.toggle, + child: Text(enabled + ? 'ext.disable_source'.tr() + : 'ext.enable_source'.tr()), + ), + PopupMenuItem( + value: _SourceAction.remove, + child: Text('ext.remove_source'.tr(), + style: const TextStyle(color: AppColors.error)), + ), + ], + ), ], ), ), @@ -493,3 +763,5 @@ class _MangayomiSourcesPageState extends State { MangayomiItemType.manga => 'ext.type_manga'.tr(), }; } + +enum _SourceAction { toggle, remove } diff --git a/lib/features/extensions/presentation/pages/source_catalog_page.dart b/lib/features/extensions/presentation/pages/source_catalog_page.dart index 4568f5d7..3670ccf9 100644 --- a/lib/features/extensions/presentation/pages/source_catalog_page.dart +++ b/lib/features/extensions/presentation/pages/source_catalog_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/content/content_mode.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; @@ -93,6 +94,7 @@ class _SourceCatalogPageState extends State { ExtensionRepoKind.manga => MangaChannel.isSupported, ExtensionRepoKind.mangayomi => source.jsRuntime && MangayomiRuntime.isSupported, + ExtensionRepoKind.zangetsu => false, }; ProviderEntity? _installed(CatalogSourceEntity source) { @@ -103,6 +105,7 @@ class _SourceCatalogPageState extends State { ExtensionRepoKind.aniyomi => 'an:', ExtensionRepoKind.manga => 'mn:', ExtensionRepoKind.mangayomi => 'my:', + ExtensionRepoKind.zangetsu => 'zx-', }; for (final provider in state.providers) { if (!provider.id.startsWith(prefix)) continue; @@ -577,8 +580,10 @@ class _TypeFilterRow extends StatelessWidget { itemBuilder: (context, i) { final (type, key) = _options[i]; final active = type == selected; - return GestureDetector( + return HoverTap( onTap: () => onSelected(type), + scale: 1.03, + borderRadius: 17, child: AnimatedContainer( duration: Duration( milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, @@ -647,8 +652,10 @@ class _CatalogLanguageRow extends StatelessWidget { final active = code == 'all' ? selected.isEmpty : selected.any((c) => srclang.normalizeLang(c) == code); - return GestureDetector( + return HoverTap( onTap: () => onToggle(code), + scale: 1.03, + borderRadius: 17, child: AnimatedContainer( duration: Duration( milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, diff --git a/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart new file mode 100644 index 00000000..568fd2c7 --- /dev/null +++ b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart @@ -0,0 +1,649 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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/extensions/data/zangetsu_repo_store.dart'; +import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; +import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; +import 'package:soplay/features/extensions/presentation/widgets/recommended_repos_section.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.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/sources/domain/source_scope.dart'; + +/// Sources from external Zangetsu repos, run on the device. Sources Sozo +/// already ships are listed as built in and keep using Sozo's own copy. +class ZangetsuSourcesPage extends StatefulWidget { + const ZangetsuSourcesPage({super.key}); + + @override + State createState() => _ZangetsuSourcesPageState(); +} + +class _ZangetsuSourcesPageState extends State { + static const Color _accent = Color(0xFF7C5CFF); + + late final ZangetsuRepoStore _store = getIt(); + late final HiveService _hive = getIt(); + + final _urlController = TextEditingController(); + final _searchController = TextEditingController(); + bool _busy = false; + String? _status; + bool _statusError = false; + String _type = 'all'; + String _lang = 'all'; + + @override + void initState() { + super.initState(); + if (_store.repos().isNotEmpty) _refresh(silent: true); + } + + @override + void dispose() { + _urlController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + bool get _adult => _hive.showAdultContent; + + List get _allSources { + final out = []; + for (final repo in _store.repos()) { + out.addAll(_store.manifest(repo).where((s) => _adult || !s.nsfw)); + } + return out; + } + + List _visible(List all) { + final q = _searchController.text.trim().toLowerCase(); + final list = all.where((s) { + if (_type == 'anime' && !s.isAnime) return false; + if (_type == 'movies' && s.isAnime) return false; + if (_lang != 'all' && s.lang != _lang) return false; + return q.isEmpty || + s.name.toLowerCase().contains(q) || + s.id.contains(q); + }).toList(); + list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return list; + } + + void _reloadProviders() { + try { + context.read().add(const ProviderLoad()); + } catch (_) {} + } + + void _setStatus(String text, {bool error = false}) { + if (!mounted) return; + setState(() { + _status = text; + _statusError = error; + }); + } + + String _errorText(Object e) { + final kind = e is ZangetsuRepoException + ? e.error + : ZangetsuRepoError.unreachable; + return switch (kind) { + ZangetsuRepoError.invalidUrl => 'zangetsu.error_invalid_url'.tr(), + ZangetsuRepoError.unreachable => 'zangetsu.error_unreachable'.tr(), + ZangetsuRepoError.invalidIndex => 'zangetsu.error_invalid_index'.tr(), + }; + } + + Future _addRepo(String raw) async { + final url = raw.trim(); + if (url.isEmpty || _busy) return; + FocusScope.of(context).unfocus(); + if (!ZangetsuRepoStore.isAllowedUrl(url)) { + _setStatus('zangetsu.error_invalid_url'.tr(), error: true); + return; + } + setState(() { + _busy = true; + _status = 'zangetsu.loading_repo'.tr(); + _statusError = false; + }); + try { + final sources = await _store.addRepo(url); + if (!mounted) return; + _urlController.clear(); + final count = sources.where((s) => _adult || !s.nsfw).length; + _setStatus('zangetsu.repo_added'.tr(args: ['$count'])); + } catch (e) { + _setStatus(_errorText(e), error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _removeRepo(String url) async { + await _store.removeRepo(url); + _reloadProviders(); + _setStatus('ext.repo_removed'.tr()); + } + + Future _refresh({bool silent = false}) async { + if (_busy) return; + setState(() { + _busy = true; + if (!silent) { + _status = 'zangetsu.checking'.tr(); + _statusError = false; + } + }); + await _store.refreshAll(); + if (!mounted) return; + setState(() => _busy = false); + if (silent) return; + final pending = _store.updates().length; + _setStatus( + pending == 0 + ? 'ext.all_up_to_date'.tr() + : 'zangetsu.updates_available'.tr(args: ['$pending']), + ); + } + + Future _install(ZangetsuSource s) async { + await _store.install(s); + _reloadProviders(); + _setStatus('zangetsu.installed_one'.tr(args: [s.name])); + } + + Future _remove(ZangetsuSource s) async { + await _store.remove(s.providerId); + _reloadProviders(); + _setStatus('zangetsu.removed_one'.tr(args: [s.name])); + } + + Future _toggle(ZangetsuSource s, bool enabled) async { + await _store.setEnabled(s.providerId, enabled); + _reloadProviders(); + if (mounted) setState(() {}); + } + + Future _update(ZangetsuSource s) async { + if (await _store.update(s.providerId)) { + _reloadProviders(); + _setStatus('zangetsu.updated'.tr(args: ['1'])); + } + } + + Future _updateAll() async { + if (_busy) return; + setState(() => _busy = true); + final count = await _store.updateAll(); + if (!mounted) return; + setState(() => _busy = false); + if (count > 0) _reloadProviders(); + _setStatus( + count == 0 + ? 'ext.all_up_to_date'.tr() + : 'zangetsu.updated'.tr(args: ['$count']), + ); + } + + @override + Widget build(BuildContext context) { + final repos = _store.repos(); + final all = _allSources; + final langs = {for (final s in all) s.lang}.toList()..sort(); + if (_lang != 'all' && !langs.contains(_lang)) _lang = 'all'; + final visible = _visible(all); + final installed = {for (final s in _store.installed()) s.providerId: s}; + final updates = _store.updates(); + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('zangetsu.title'.tr()), + actions: [ + IconButton( + tooltip: 'ext.check_updates'.tr(), + icon: const Icon(Icons.refresh_rounded), + onPressed: (_busy || repos.isEmpty) ? null : _refresh, + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + Text( + 'zangetsu.desc'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12.5, + height: 1.35, + ), + ), + const SizedBox(height: 16), + _addCard(), + if (_status != null) ...[ + const SizedBox(height: 12), + _statusBanner(), + ], + const SizedBox(height: 24), + RecommendedReposSection( + kind: ExtensionRepoKind.zangetsu, + installedUrls: repos.toSet(), + busy: _busy, + accent: _accent, + fallbackIcon: '', + nsfwAllowed: _adult, + onInstall: (repo) => _addRepo(repo.url), + ), + const SizedBox(height: 24), + _label('ext.installed_repos'.tr(), repos.length), + const SizedBox(height: 8), + if (repos.isEmpty) _empty() else ...repos.map(_repoTile), + if (all.isNotEmpty) ...[ + const SizedBox(height: 24), + Row( + children: [ + Expanded(child: _label('ext.sources_heading'.tr(), all.length)), + if (updates.isNotEmpty) + TextButton.icon( + onPressed: _busy ? null : _updateAll, + icon: const Icon(Icons.system_update_alt_rounded, size: 16), + label: Text( + 'zangetsu.update_all'.tr(args: ['${updates.length}']), + ), + ), + ], + ), + const SizedBox(height: 8), + _filters(langs), + const SizedBox(height: 8), + if (visible.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text( + 'ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint), + ), + ), + ) + else + ...visible.map((s) => _sourceTile(s, installed[s.providerId])), + ], + ], + ), + ); + } + + Widget _label(String text, int count) => Row( + children: [ + Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppColors.textHint, + letterSpacing: 1, + ), + ), + const Spacer(), + if (count > 0) + Text('$count', style: const TextStyle(color: AppColors.textHint)), + ], + ); + + Widget _addCard() => Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _urlController, + enabled: !_busy, + cursorColor: _accent, + keyboardType: TextInputType.url, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration( + label: 'ext.index_url'.tr(), + hint: 'https://raw.githubusercontent.com/…/index.json', + ), + onSubmitted: _addRepo, + ), + const SizedBox(height: 12), + SizedBox( + height: 46, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: _accent, + foregroundColor: Colors.white, + ), + onPressed: _busy ? null : () => _addRepo(_urlController.text), + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.add), + label: Text('ext.add_repo'.tr()), + ), + ), + ], + ), + ); + + InputDecoration _inputDecoration({ + String? label, + required String hint, + Widget? prefix, + }) => InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColors.textHint), + floatingLabelStyle: const TextStyle(color: _accent), + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textHint), + prefixIcon: prefix, + isDense: true, + filled: true, + fillColor: AppColors.background, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: _accent.withValues(alpha: 0.6), width: 1.4), + ), + ); + + Widget _statusBanner() { + final color = _statusError ? AppColors.error : AppColors.success; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + if (_busy) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + _statusError ? Icons.error_outline : Icons.check_circle_outline, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _status!, + style: TextStyle(color: color, fontSize: 12.5, height: 1.3), + ), + ), + ], + ), + ); + } + + Widget _empty() => Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + 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), + ), + ], + ), + ); + + Widget _repoTile(String url) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: const Icon(Icons.folder_outlined, color: _accent), + title: Text( + repoLabel(url), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 11), + ), + trailing: IconButton( + tooltip: 'general.remove'.tr(), + icon: const Icon(Icons.delete_outline, color: AppColors.error), + onPressed: _busy ? null : () => _removeRepo(url), + ), + ), + ), + ); + + Widget _filters(List langs) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _searchController, + cursorColor: _accent, + style: const TextStyle(color: Colors.white), + onChanged: (_) => setState(() {}), + decoration: _inputDecoration( + hint: 'zangetsu.search'.tr(), + prefix: const Icon(Icons.search, color: AppColors.textHint), + ), + ), + const SizedBox(height: 4), + // The same chip rows as the Mangayomi page and the catalog. + SourceFilterChipRow( + padding: EdgeInsets.zero, + options: [ + for (final (value, label) in [ + ('all', 'zangetsu.all_types'.tr()), + ('anime', 'ext.type_anime'.tr()), + ('movies', 'zangetsu.type_movies'.tr()), + ]) + SourceFilterOption( + label: label, + active: _type == value, + onTap: () => setState(() => _type = value), + ), + ], + ), + if (langs.length > 1) + SourceFilterChipRow( + padding: EdgeInsets.zero, + options: [ + for (final lang in ['all', ...langs]) + SourceFilterOption( + label: lang == 'all' + ? 'zangetsu.all_languages'.tr() + : lang.toUpperCase(), + active: _lang == lang, + onTap: () => setState(() => _lang = lang), + ), + ], + ), + ], + ); + + Widget _sourceTile(ZangetsuSource s, ZangetsuSource? installed) { + final update = installed == null ? null : _store.updateFor(installed); + final shown = installed ?? s; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + _icon(s.icon), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + s.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + ), + ), + ), + if (s.nsfw) ...[ + const SizedBox(width: 6), + const _Badge(text: '18+', color: AppColors.error), + ], + ], + ), + Text( + '${s.lang.toUpperCase()} · ' + '${s.isAnime ? 'ext.type_anime'.tr() : 'zangetsu.type_movies'.tr()}' + ' · v${shown.version}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), + ), + ], + ), + ), + ..._actions(s, installed, update), + ], + ), + ), + ); + } + + List _actions( + ZangetsuSource s, + ZangetsuSource? installed, + ZangetsuSource? update, + ) { + if (s.builtIn) { + return [_Badge(text: 'zangetsu.built_in'.tr(), color: AppColors.success)]; + } + if (installed == null) { + return [ + TextButton( + onPressed: _busy ? null : () => _install(s), + child: Text('general.install'.tr()), + ), + ]; + } + return [ + if (update != null) + IconButton( + tooltip: 'zangetsu.update_to'.tr(args: [update.version]), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.system_update_alt_rounded, color: _accent), + onPressed: _busy ? null : () => _update(installed), + ), + Switch( + value: installed.enabled, + activeThumbColor: Colors.white, + activeTrackColor: _accent, + onChanged: _busy ? null : (v) => _toggle(installed, v), + ), + IconButton( + tooltip: 'general.remove'.tr(), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.delete_outline, color: AppColors.error), + onPressed: _busy ? null : () => _remove(installed), + ), + ]; + } + + Widget _icon(String? url) { + final fallback = Container( + width: 26, + height: 26, + color: AppColors.surfaceVariant, + child: const Icon(Icons.bolt_rounded, size: 14, color: AppColors.textHint), + ); + return ClipRRect( + borderRadius: BorderRadius.circular(7), + child: url == null || url.isEmpty + ? fallback + : Image.network( + url, + width: 26, + height: 26, + cacheWidth: 78, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + ), + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.text, required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + text, + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w700), + ), + ); +} diff --git a/lib/features/extensions/presentation/widgets/source_filter_chips.dart b/lib/features/extensions/presentation/widgets/source_filter_chips.dart new file mode 100644 index 00000000..4f74cf6a --- /dev/null +++ b/lib/features/extensions/presentation/widgets/source_filter_chips.dart @@ -0,0 +1,179 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/extensions/source_language.dart' as srclang; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/tv/tv.dart'; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; + +/// One chip in a [SourceFilterChipRow]. +class SourceFilterOption { + const SourceFilterOption({ + required this.label, + required this.active, + required this.onTap, + this.count, + }); + + final String label; + final bool active; + final VoidCallback onTap; + final int? count; +} + +/// A horizontal row of filter chips, styled like the catalog's type and +/// language rows and reachable with a D-pad on TV. +class SourceFilterChipRow extends StatelessWidget { + const SourceFilterChipRow({ + super.key, + required this.options, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + final List options; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: padding, + itemCount: options.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (context, i) => Center(child: _chip(context, options[i])), + ), + ); + } + + Widget _chip(BuildContext context, SourceFilterOption o) { + return Semantics( + button: true, + selected: o.active, + child: TvFocusable( + onPressed: o.onTap, + borderRadius: 17, + ensureVisibleAlignment: 0.5, + child: AnimatedContainer( + duration: Duration( + milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, + ), + curve: Curves.easeOutCubic, + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: o.active ? AppColors.primary : AppColors.surface, + borderRadius: BorderRadius.circular(17), + border: Border.all( + color: o.active + ? AppColors.primary.withValues(alpha: 0.55) + : Colors.white.withValues(alpha: 0.07), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + o.label, + style: TextStyle( + color: o.active ? Colors.white : AppColors.textSecondary, + fontSize: 12.5, + fontWeight: o.active ? FontWeight.w700 : FontWeight.w600, + ), + ), + if (o.count != null) ...[ + const SizedBox(width: 5), + Text( + '${o.count}', + style: TextStyle( + color: o.active + ? AppColors.primaryLight.withValues(alpha: 0.75) + : AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// All / anime / manga / novel. +class MangayomiTypeFilterRow extends StatelessWidget { + const MangayomiTypeFilterRow({ + super.key, + required this.selected, + required this.onSelected, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + final MangayomiItemType? selected; + final ValueChanged onSelected; + final EdgeInsetsGeometry padding; + + static const _options = <(MangayomiItemType?, String)>[ + (null, 'catalog.type_all'), + (MangayomiItemType.anime, 'catalog.type_anime'), + (MangayomiItemType.manga, 'catalog.type_manga'), + (MangayomiItemType.novel, 'catalog.type_novel'), + ]; + + @override + Widget build(BuildContext context) => SourceFilterChipRow( + padding: padding, + options: [ + for (final (type, key) in _options) + SourceFilterOption( + label: key.tr(), + active: type == selected, + onTap: () => onSelected(type), + ), + ], + ); +} + +/// Language chips built from the languages the listed sources carry. +class MangayomiLanguageFilterRow extends StatelessWidget { + const MangayomiLanguageFilterRow({ + super.key, + required this.counts, + required this.selected, + required this.onToggle, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + final Map counts; + final List selected; + final ValueChanged onToggle; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final ordered = srclang + .orderedLanguages(counts.keys, selected) + .where((c) => c != srclang.kAllLanguages); + return SourceFilterChipRow( + padding: padding, + options: [ + SourceFilterOption( + label: srclang.labelFor(srclang.kAllLanguages), + active: selected.isEmpty, + onTap: () => onToggle(srclang.kAllLanguages), + ), + for (final code in ordered) + SourceFilterOption( + label: srclang.labelFor(code), + count: counts[code], + active: selected.any((c) => srclang.normalizeLang(c) == code), + onTap: () => onToggle(code), + ), + ], + ); + } +} diff --git a/lib/features/history/data/history_service.dart b/lib/features/history/data/history_service.dart index 3528a633..19cabc4e 100644 --- a/lib/features/history/data/history_service.dart +++ b/lib/features/history/data/history_service.dart @@ -15,21 +15,85 @@ import 'package:soplay/features/my_list/data/private_list_service.dart'; class HistoryService { final ValueNotifier revision = ValueNotifier(0); - Box get _box => Hive.box(ProfileScope.box(AppConstants.historyBox)); + /// Decoded rows, keyed by box key and reused while the stored string is the + /// same instance. Hive keeps values in memory, so an unchanged row hands back + /// the identical string and its decode can be skipped. + final Map _decoded = {}; + + /// The last [getAll] result, dropped on any change to the box. + List? _sorted; + Box? _watched; + StreamSubscription? _watch; + + Box get _box { + final box = Hive.box(ProfileScope.box(AppConstants.historyBox)); + if (!identical(box, _watched)) { + // A profile switch or a reopened box: nothing cached describes it. + _watch?.cancel(); + _watched = box; + _invalidate(); + _watch = box.watch().listen((event) { + _decoded.remove(event.key); + _sorted = null; + }); + } + return box; + } + + void _invalidate([Object? key]) { + if (key == null) { + _decoded.clear(); + } else { + _decoded.remove(key); + } + _sorted = null; + } + + /// Decode count, for tests. + @visibleForTesting + int decodes = 0; + + HistoryItem? _decode(Object? key, Object? raw) { + if (raw is! String) return null; + final hit = _decoded[key]; + if (hit != null && identical(hit.$1, raw)) return hit.$2; + try { + decodes++; + final item = HistoryItem.fromJson(jsonDecode(raw) as Map); + _decoded[key] = (raw, item); + return item; + } catch (_) { + return null; + } + } + + /// How many rows are stored, without decoding any of them. The box also + /// holds the manga reader's chapter marks, which are not rows. + int get count { + final box = _box; + final cached = _sorted; + if (cached != null) return cached.length; + var n = 0; + for (final key in box.keys) { + if (key is String && key.startsWith(_chapterMarkPrefix)) continue; + n++; + } + return n; + } + + static const _chapterMarkPrefix = 'chapters_read:'; List getAll() { + final box = _box; + final cached = _sorted; + if (cached != null) return List.of(cached); final items = []; - for (final key in _box.keys) { - try { - final raw = _box.get(key); - if (raw is String) { - items.add( - HistoryItem.fromJson(jsonDecode(raw) as Map), - ); - } - } catch (_) {} + for (final key in box.keys) { + final item = _decode(key, box.get(key)); + if (item != null) items.add(item); } items.sort((a, b) => b.watchedAt.compareTo(a.watchedAt)); + _sorted = List.unmodifiable(items); return items; } @@ -40,12 +104,10 @@ class HistoryService { episodeIndex: episodeIndex, episodeNumber: episodeNumber, ); - final raw = _box.get(key) ?? _box.get(contentUrl); - if (raw is String) { - try { - return HistoryItem.fromJson(jsonDecode(raw) as Map); - } catch (_) {} - } + final box = _box; + final direct = + _decode(key, box.get(key)) ?? _decode(contentUrl, box.get(contentUrl)); + if (direct != null) return direct; if (episodeIndex == null && episodeNumber == null) { return _getLatestForContent(contentUrl); } @@ -55,18 +117,14 @@ class HistoryService { HistoryItem? _getLatestForContent(String contentUrl) { final prefix = '$contentUrl::episode::'; HistoryItem? latest; - for (final key in _box.keys) { + final box = _box; + for (final key in box.keys) { if (key is! String || !key.startsWith(prefix)) continue; - try { - final raw = _box.get(key); - if (raw is! String) continue; - final item = HistoryItem.fromJson( - jsonDecode(raw) as Map, - ); - if (latest == null || item.watchedAt > latest.watchedAt) { - latest = item; - } - } catch (_) {} + final item = _decode(key, box.get(key)); + if (item == null) continue; + if (latest == null || item.watchedAt > latest.watchedAt) { + latest = item; + } } return latest; } @@ -106,7 +164,9 @@ class HistoryService { getIt().contains(item.contentUrl)) { return; } + _invalidate(item.storageKey); await _box.put(item.storageKey, jsonEncode(item.toJson())); + _invalidate(item.storageKey); await _trimIfNeeded(); if (notify) { _quietPending = false; @@ -118,15 +178,7 @@ class HistoryService { /// Reads a row back so a delete can be described to the server before the /// local copy is gone. - HistoryItem? _read(String key) { - final raw = _box.get(key); - if (raw is! String) return null; - try { - return HistoryItem.fromJson(jsonDecode(raw) as Map); - } catch (_) { - return null; - } - } + HistoryItem? _read(String key) => _decode(key, _box.get(key)); /// Deleting locally is only half of it: the row also exists on the account, /// and until the server is told, signing out and back in simply downloads it @@ -147,6 +199,7 @@ class HistoryService { final item = _read(key); if (item != null) await _rememberDeleted([item]); await _box.delete(key); + _invalidate(key); revision.value++; } @@ -160,6 +213,7 @@ class HistoryService { ); for (final k in keys) { await _box.delete(k); + _invalidate(k); } if (keys.isNotEmpty) revision.value++; } @@ -172,6 +226,7 @@ class HistoryService { /// for each one into whichever account signs in next. Future clearLocalOnly() async { await _box.clear(); + _invalidate(); revision.value++; } @@ -182,6 +237,7 @@ class HistoryService { unawaited(sync.sync()); } await _box.clear(); + _invalidate(); revision.value++; } @@ -203,6 +259,7 @@ class HistoryService { ]); for (final key in doomed) { await _box.delete(key); + _invalidate(key); } } } diff --git a/lib/features/history/domain/entities/history_item.dart b/lib/features/history/domain/entities/history_item.dart index 70d918f5..6c9a04f9 100644 --- a/lib/features/history/domain/entities/history_item.dart +++ b/lib/features/history/domain/entities/history_item.dart @@ -102,12 +102,12 @@ class HistoryItem { title: json['title'] as String? ?? '', thumbnail: json['thumbnail'] as String?, isSerial: json['isSerial'] as bool? ?? false, - episodeIndex: json['episodeIndex'] as int?, - episodeNumber: json['episodeNumber'] as int?, + episodeIndex: (json['episodeIndex'] as num?)?.toInt(), + episodeNumber: (json['episodeNumber'] as num?)?.toInt(), episodeLabel: json['episodeLabel'] as String?, - positionMs: json['positionMs'] as int? ?? 0, - durationMs: json['durationMs'] as int? ?? 0, - watchedAt: json['watchedAt'] as int? ?? 0, + positionMs: (json['positionMs'] as num?)?.toInt() ?? 0, + durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, + watchedAt: (json['watchedAt'] as num?)?.toInt() ?? 0, mediaType: json['mediaType'] as String?, isFinale: json['finale'] == true, ); diff --git a/lib/features/history/presentation/pages/history_page.dart b/lib/features/history/presentation/pages/history_page.dart index e9cedcd1..76c38f66 100644 --- a/lib/features/history/presentation/pages/history_page.dart +++ b/lib/features/history/presentation/pages/history_page.dart @@ -41,6 +41,13 @@ class _HistoryPageState extends State { super.dispose(); } + /// Pull-to-refresh: waits out a sync already running, then runs one. + Future _refresh() async { + await _syncService.settle(); + await _syncService.sync(); + _reload(); + } + void _reload() { final items = _historyService.getAll(); if (!mounted) return; @@ -120,79 +127,83 @@ class _HistoryPageState extends State { return Scaffold( backgroundColor: AppColors.background, - body: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // Pinned: "Clear all" and the back button are the only controls on - // this screen, and a long history scrolled them out of reach. - SliverAppBar( - pinned: true, - automaticallyImplyLeading: false, - backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - titleSpacing: 16, - title: Row( - children: [ - _CircleBackButton( - onTap: () => context.canPop() - ? context.pop() - : context.go('/main'), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'profile.watch_history'.tr(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 22, - fontWeight: FontWeight.w800, - ), - ), - ), - if (_items.isNotEmpty) - _PillButton( - label: 'history.clear_all'.tr(), - onTap: _clearHistory, + body: RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _refresh, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Pinned: "Clear all" and the back button are the only controls on + // this screen, and a long history scrolled them out of reach. + SliverAppBar( + pinned: true, + automaticallyImplyLeading: false, + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + titleSpacing: 16, + title: Row( + children: [ + _CircleBackButton( + onTap: () => + context.canPop() ? context.pop() : context.go('/main'), ), - ], - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16)), - if (_items.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: _EmptyState(), - ) - else - SliverList.separated( - itemCount: _items.length, - separatorBuilder: (_, _) => - Divider(color: AppColors.divider, height: 1, indent: 82), - itemBuilder: (_, i) { - final item = _items[i]; - return _HistoryRow( - item: item, - onTap: () { - context.push( - '/detail', - extra: DetailArgs( - contentUrl: item.contentUrl, - autoPlay: true, - resumeEpisodeIndex: item.episodeIndex, - provider: item.provider, + const SizedBox(width: 12), + Expanded( + child: Text( + 'profile.watch_history'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 22, + fontWeight: FontWeight.w800, ), - ); - }, - onDismissed: () => _removeItem(item), - ); - }, + ), + ), + if (_items.isNotEmpty) + _PillButton( + label: 'history.clear_all'.tr(), + onTap: _clearHistory, + ), + ], + ), ), - SliverToBoxAdapter(child: SizedBox(height: bottomPad + 24)), - ], + const SliverToBoxAdapter(child: SizedBox(height: 16)), + if (_items.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: _EmptyState(), + ) + else + SliverList.separated( + itemCount: _items.length, + separatorBuilder: (_, _) => + Divider(color: AppColors.divider, height: 1, indent: 82), + itemBuilder: (_, i) { + final item = _items[i]; + return _HistoryRow( + item: item, + onTap: () { + context.push( + '/detail', + extra: DetailArgs( + contentUrl: item.contentUrl, + autoPlay: true, + resumeEpisodeIndex: item.episodeIndex, + provider: item.provider, + ), + ); + }, + onDismissed: () => _removeItem(item), + ); + }, + ), + SliverToBoxAdapter(child: SizedBox(height: bottomPad + 24)), + ], + ), ), ); } @@ -330,125 +341,125 @@ class _HistoryRow extends StatelessWidget { @override Widget build(BuildContext context) { final row = InkWell( - onTap: onTap, - onLongPress: isDesktopPlatform ? null : () => _showActions(context), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 54, - height: 76, - child: Stack( - fit: StackFit.expand, - children: [ - HomeNetworkImage( - url: item.thumbnail, - borderRadius: BorderRadius.zero, - placeholderIcon: Icons.movie_outlined, - ), - if (item.progress > 0) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: LinearProgressIndicator( - value: item.progress, - minHeight: 3, - backgroundColor: Colors.black45, - valueColor: AlwaysStoppedAnimation( - AppColors.primary, - ), + onTap: onTap, + onLongPress: isDesktopPlatform ? null : () => _showActions(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 54, + height: 76, + child: Stack( + fit: StackFit.expand, + children: [ + HomeNetworkImage( + url: item.thumbnail, + borderRadius: BorderRadius.zero, + placeholderIcon: Icons.movie_outlined, + ), + if (item.progress > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: item.progress, + minHeight: 3, + backgroundColor: Colors.black45, + valueColor: AlwaysStoppedAnimation( + AppColors.primary, ), ), - ], - ), + ), + ], ), ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - height: 1.3, - ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.3, ), - const SizedBox(height: 4), - Row( - children: [ - if (item.isSerial && item.episodeNumber != null) ...[ - Text( - 'EP ${item.episodeNumber}', - style: TextStyle( - color: AppColors.primary, - fontSize: 11, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 4), + Row( + children: [ + if (item.isSerial && item.episodeNumber != null) ...[ + Text( + 'EP ${item.episodeNumber}', + style: TextStyle( + color: AppColors.primary, + fontSize: 11, + fontWeight: FontWeight.w700, ), - if (item.episodeLabel != null && - item.episodeLabel!.trim().isNotEmpty) - Expanded( - child: Text( - ' · ${item.episodeLabel}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 11, - ), + ), + if (item.episodeLabel != null && + item.episodeLabel!.trim().isNotEmpty) + Expanded( + child: Text( + ' · ${item.episodeLabel}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, ), ), - ], - if (!item.isSerial && item.durationMs > 0) - Text( - _formatProgress(item), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 11, - ), ), ], + if (!item.isSerial && item.durationMs > 0) + Text( + _formatProgress(item), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + _watchedAgo(context, item.watchedAt), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10, ), - const SizedBox(height: 2), - Text( - _watchedAgo(context, item.watchedAt), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 10, - ), - ), - ], - ), + ), + ], ), - const SizedBox(width: 8), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.15), - shape: BoxShape.circle, - ), - child: Icon( - Icons.play_arrow_rounded, - color: AppColors.primary, - size: 20, - ), + ), + const SizedBox(width: 8), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, ), - ], - ), + child: Icon( + Icons.play_arrow_rounded, + color: AppColors.primary, + size: 20, + ), + ), + ], ), - ); + ), + ); if (isDesktopPlatform) { return Row( @@ -456,8 +467,10 @@ class _HistoryRow extends StatelessWidget { Expanded(child: row), IconButton( tooltip: 'history.remove'.tr(), - icon: const Icon(Icons.delete_outline_rounded, - color: AppColors.textHint), + icon: const Icon( + Icons.delete_outline_rounded, + color: AppColors.textHint, + ), onPressed: onDismissed, ), const SizedBox(width: 8), @@ -516,7 +529,11 @@ class _SwipeToRemoveBackground extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.delete_outline_rounded, color: Colors.white, size: 22), + const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + size: 22, + ), const SizedBox(width: 8), Text( label, @@ -541,8 +558,11 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.history_rounded, - color: AppColors.textHint, size: 52), + const Icon( + Icons.history_rounded, + color: AppColors.textHint, + size: 52, + ), const SizedBox(height: 14), Text( 'history.empty_title'.tr(), diff --git a/lib/features/home/data/models/home_data_model.dart b/lib/features/home/data/models/home_data_model.dart index bc5676e1..23955edf 100644 --- a/lib/features/home/data/models/home_data_model.dart +++ b/lib/features/home/data/models/home_data_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/domain/entities/home_data_entity.dart'; import 'content_filter_model.dart'; @@ -20,18 +21,14 @@ class HomeDataModel extends HomeDataEntity { }) { return HomeDataModel( provider: json['provider'] as String? ?? '', - banner: (json['banner'] as List? ?? []) - .map((e) => MovieModel.fromJson(e as Map)) - .toList(), - sections: (json['sections'] as List? ?? []) - .map((e) => HomeSectionModel.fromJson(e as Map)) - .toList(), - categories: categories - .map((e) => ContentFilterModel.fromJson(e as Map)) - .toList(), - genres: genres - .map((e) => ContentFilterModel.fromJson(e as Map)) - .toList(), + banner: jsonMaps(json['banner']).map(MovieModel.fromJson).toList(), + sections: jsonMaps( + json['sections'], + ).map(HomeSectionModel.fromJson).toList(), + categories: jsonMaps( + categories, + ).map(ContentFilterModel.fromJson).toList(), + genres: jsonMaps(genres).map(ContentFilterModel.fromJson).toList(), ); } } diff --git a/lib/features/home/data/models/home_section_model.dart b/lib/features/home/data/models/home_section_model.dart index 8af51d56..33b9fc3b 100644 --- a/lib/features/home/data/models/home_section_model.dart +++ b/lib/features/home/data/models/home_section_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/data/models/view_all_model.dart'; import 'package:soplay/features/home/domain/entities/home_section_entity.dart'; @@ -15,12 +16,11 @@ class HomeSectionModel extends HomeSectionEntity { return HomeSectionModel( key: json['key'] as String? ?? '', label: json['label'] as String? ?? '', - viewAll: json['viewAll'] is Map - ? ViewAllModel.fromJson(json['viewAll'] as Map) - : ViewAllModel(slug: '', type: ''), - items: (json['items'] as List? ?? []) - .map((e) => MovieModel.fromJson(e as Map)) - .toList(), + viewAll: switch (jsonMap(json['viewAll'])) { + final Map v => ViewAllModel.fromJson(v), + null => ViewAllModel(slug: '', type: ''), + }, + items: jsonMaps(json['items']).map(MovieModel.fromJson).toList(), ); } } diff --git a/lib/features/home/data/models/movie_model.dart b/lib/features/home/data/models/movie_model.dart index 10983427..311748f2 100644 --- a/lib/features/home/data/models/movie_model.dart +++ b/lib/features/home/data/models/movie_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; class MovieModel extends MovieEntity { @@ -27,11 +28,12 @@ class MovieModel extends MovieEntity { provider: json['provider'] as String? ?? '', thumbnail: json['thumbnail'] as String?, banner: json['banner'] as String?, - year: (json['year'] as num?)?.toInt(), - rating: (json['rating'] as num?)?.toInt(), - qualities: json['qualities'] != null - ? List.from(json['qualities'] as List) - : null, + year: jsonInt(json['year']), + rating: jsonInt(json['rating']), + qualities: switch (json['qualities']) { + final List q => [for (final e in q) ?jsonText(e)], + _ => null, + }, category: json['category'] as String? ?? '', altTitles: [ for (final n in (json['altTitles'] as List?) ?? const []) diff --git a/lib/features/home/data/models/view_all_paging_model.dart b/lib/features/home/data/models/view_all_paging_model.dart index 9d30f369..4e2bdd40 100644 --- a/lib/features/home/data/models/view_all_paging_model.dart +++ b/lib/features/home/data/models/view_all_paging_model.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/data/models/movie_model.dart'; import '../../domain/entities/view_all_paging_entity.dart'; @@ -12,12 +13,10 @@ class ViewAllPagingModel extends ViewAllPagingEntity { factory ViewAllPagingModel.fromJson(Map json) { return ViewAllPagingModel( - page: json['page'], - totalPages: json['totalPages'], - provider: json['provider'], - items: (json['items'] as List) - .map((e) => MovieModel.fromJson(e)) - .toList(), + page: jsonInt(json['page']) ?? 1, + totalPages: jsonInt(json['totalPages']) ?? 1, + provider: jsonText(json['provider']) ?? '', + items: jsonMaps(json['items']).map(MovieModel.fromJson).toList(), ); } } diff --git a/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart b/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart index f5ba3575..20221dca 100644 --- a/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart +++ b/lib/features/home/presentation/bloc/view_all/view_all_bloc.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:math' as math; + import 'package:flutter_bloc/flutter_bloc.dart' show Bloc, Emitter; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/home/domain/entities/view_all_paging_entity.dart'; @@ -9,10 +11,28 @@ import '../../../domain/usecase/view_all_usecase.dart'; class ViewAllBloc extends Bloc { final ViewAllUseCase useCase; + final DateTime Function() _now; String _key = ''; String _slug = ''; - ViewAllBloc({required this.useCase}) : super(ViewAllInitial()) { + /// Consecutive failed next-page requests, and when the scroll may try again. + int _failures = 0; + DateTime? _retryAfter; + + static const Duration firstBackoff = Duration(seconds: 2); + static const Duration maxBackoff = Duration(seconds: 60); + + static Duration backoffFor(int failures) { + if (failures <= 0) return Duration.zero; + final ms = firstBackoff.inMilliseconds * math.pow(2, failures - 1); + return Duration( + milliseconds: math.min(ms.toInt(), maxBackoff.inMilliseconds), + ); + } + + ViewAllBloc({required this.useCase, DateTime Function()? now}) + : _now = now ?? DateTime.now, + super(ViewAllInitial()) { on(_onLoad); on(_onLoadMore); } @@ -20,6 +40,8 @@ class ViewAllBloc extends Bloc { Future _onLoad(ViewAllLoad event, Emitter emit) async { _key = event.key; _slug = event.slug ?? ''; + _failures = 0; + _retryAfter = null; emit(ViewAllLoading()); final Result result = await useCase(_key, _slug, 1); @@ -38,6 +60,11 @@ class ViewAllBloc extends Bloc { Future _onLoadMore(ViewAllLoadMore event, Emitter emit) async { final current = state; if (current is! ViewAllLoaded || !current.hasMore || current.isLoadingMore) return; + // The scroll listener fires on every frame near the bottom. After a + // failure those are refused until the backoff runs out; the footer's + // retry is not. + final wait = _retryAfter; + if (!event.retry && wait != null && _now().isBefore(wait)) return; emit(ViewAllLoaded( items: current.items, @@ -48,6 +75,8 @@ class ViewAllBloc extends Bloc { final result = await useCase(_key, _slug, current.currentPage + 1); if (result.isSuccess) { + _failures = 0; + _retryAfter = null; final data = result.getOrNull()!; emit(ViewAllLoaded( items: [...current.items, ...data.items], @@ -55,13 +84,15 @@ class ViewAllBloc extends Bloc { totalPages: data.totalPages, )); } else { - // A failed *pagination* append must not wipe the already-loaded grid and - // scroll position — just clear the bottom spinner and keep what we have. + // A failed append keeps the grid and scroll position; the footer says + // what happened instead of a spinner that just disappears. + _failures++; + _retryAfter = _now().add(backoffFor(_failures)); emit(ViewAllLoaded( items: current.items, currentPage: current.currentPage, totalPages: current.totalPages, - isLoadingMore: false, + loadMoreFailed: true, )); } } diff --git a/lib/features/home/presentation/bloc/view_all/view_all_event.dart b/lib/features/home/presentation/bloc/view_all/view_all_event.dart index f38b8ee9..1c441438 100644 --- a/lib/features/home/presentation/bloc/view_all/view_all_event.dart +++ b/lib/features/home/presentation/bloc/view_all/view_all_event.dart @@ -5,7 +5,16 @@ class ViewAllEvent extends Equatable { List get props => []; } -class ViewAllLoadMore extends ViewAllEvent {} +class ViewAllLoadMore extends ViewAllEvent { + /// From the retry footer: skips the backoff a failed page imposes on the + /// scroll-driven requests. + final bool retry; + + ViewAllLoadMore({this.retry = false}); + + @override + List get props => [retry]; +} class ViewAllLoad extends ViewAllEvent { final String? slug; diff --git a/lib/features/home/presentation/bloc/view_all/view_all_state.dart b/lib/features/home/presentation/bloc/view_all/view_all_state.dart index bcc0ac06..a3fc3a9a 100644 --- a/lib/features/home/presentation/bloc/view_all/view_all_state.dart +++ b/lib/features/home/presentation/bloc/view_all/view_all_state.dart @@ -26,6 +26,9 @@ class ViewAllLoaded extends ViewAllState { final int totalPages; final bool isLoadingMore; + /// The last next-page request failed; the grid shows a retry footer. + final bool loadMoreFailed; + bool get hasMore => currentPage < totalPages; ViewAllLoaded({ @@ -33,8 +36,15 @@ class ViewAllLoaded extends ViewAllState { required this.currentPage, required this.totalPages, this.isLoadingMore = false, + this.loadMoreFailed = false, }); @override - List get props => [items, currentPage, totalPages, isLoadingMore]; + List get props => [ + items, + currentPage, + totalPages, + isLoadingMore, + loadMoreFailed, + ]; } diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 3965da73..bb660c30 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/navigation/app_tab.dart'; +import 'package:soplay/core/widgets/star_prompt_banner.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/features/home/domain/catalogue_status.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; @@ -103,6 +105,7 @@ class _HomePageState extends State { // Collapses to nothing while the backend is healthy, so the bar // keeps its exact previous geometry in the normal case. const BackendOutageBanner(), + const StarPromptBanner(tab: TabId.home), ], ), ), diff --git a/lib/features/home/presentation/widgets/genre_card.dart b/lib/features/home/presentation/widgets/genre_card.dart index 4a0ff997..b0801825 100644 --- a/lib/features/home/presentation/widgets/genre_card.dart +++ b/lib/features/home/presentation/widgets/genre_card.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/localization/line_height.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -81,7 +82,7 @@ class GenreCard extends StatelessWidget { color: AppColors.textPrimary, fontSize: desktop ? 14 : 12, fontWeight: FontWeight.w700, - height: 1.15, + height: lineHeightFor(context, 1.15), letterSpacing: -0.2, shadows: const [ Shadow(color: Colors.black87, blurRadius: 6), diff --git a/lib/features/home/presentation/widgets/home_banner.dart b/lib/features/home/presentation/widgets/home_banner.dart index eb7a8d18..f8ab6969 100644 --- a/lib/features/home/presentation/widgets/home_banner.dart +++ b/lib/features/home/presentation/widgets/home_banner.dart @@ -6,6 +6,7 @@ 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/localization/line_height.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/system/platform_utils.dart'; @@ -371,11 +372,11 @@ class _MovieInfo extends StatelessWidget { movieTitle(movie), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 26, fontWeight: FontWeight.w900, - height: 1.08, + height: lineHeightFor(context, 1.08), letterSpacing: -0.3, shadows: [ Shadow( @@ -392,11 +393,11 @@ class _MovieInfo extends StatelessWidget { description, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textSecondary, fontSize: 14, fontWeight: FontWeight.w500, - height: 1.08, + height: lineHeightFor(context, 1.08), letterSpacing: -0.3, ), ), @@ -420,11 +421,11 @@ class _BannerInfo extends StatelessWidget { banner.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 26, fontWeight: FontWeight.w900, - height: 1.08, + height: lineHeightFor(context, 1.08), letterSpacing: -0.3, shadows: [ Shadow( @@ -441,11 +442,11 @@ class _BannerInfo extends StatelessWidget { banner.subtitle!, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textSecondary, fontSize: 14, fontWeight: FontWeight.w500, - height: 1.08, + height: lineHeightFor(context, 1.08), letterSpacing: -0.3, ), ), @@ -788,11 +789,11 @@ class _DesktopSlideState extends State<_DesktopSlide> movieTitle(movie), maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 34, fontWeight: FontWeight.w900, - height: 1.12, + height: lineHeightFor(context, 1.12), letterSpacing: -0.5, shadows: [ Shadow( @@ -845,11 +846,11 @@ class _DesktopSlideState extends State<_DesktopSlide> banner.title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 34, fontWeight: FontWeight.w900, - height: 1.12, + height: lineHeightFor(context, 1.12), letterSpacing: -0.5, shadows: [ Shadow( diff --git a/lib/features/home/presentation/widgets/home_content.dart b/lib/features/home/presentation/widgets/home_content.dart index 69a87844..ab0a3121 100644 --- a/lib/features/home/presentation/widgets/home_content.dart +++ b/lib/features/home/presentation/widgets/home_content.dart @@ -119,10 +119,12 @@ class _HomeContentState extends State { /// sat at the front of Watch's Home, and a film at the front of Manga's. void _loadHistory() { final mode = ContentMode.fromId(getIt().getContentMode()); - final items = [ - for (final item in _historyService.getAll()) - if (item.provider.contentMode == mode) item, - ]; + final items = _historyService.count == 0 + ? const [] + : [ + for (final item in _historyService.getAll()) + if (item.provider.contentMode == mode) item, + ]; if (!mounted) return; setState(() => _historyItems = items); } diff --git a/lib/features/home/presentation/widgets/home_movie_section.dart b/lib/features/home/presentation/widgets/home_movie_section.dart index 767a3eef..36900e81 100644 --- a/lib/features/home/presentation/widgets/home_movie_section.dart +++ b/lib/features/home/presentation/widgets/home_movie_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/widgets/item_appear.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -72,11 +73,11 @@ class MovieSection extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), diff --git a/lib/features/home/presentation/widgets/home_shared_widgets.dart b/lib/features/home/presentation/widgets/home_shared_widgets.dart index ec756210..0ece14aa 100644 --- a/lib/features/home/presentation/widgets/home_shared_widgets.dart +++ b/lib/features/home/presentation/widgets/home_shared_widgets.dart @@ -57,13 +57,25 @@ class HomeNetworkImage extends StatelessWidget { : File(imageUrl); return ClipRRect( borderRadius: borderRadius, - child: Image.file( - file, - fit: fit, - width: double.infinity, - height: double.infinity, - errorBuilder: (_, _, _) => - HomeImagePlaceholder(icon: placeholderIcon), + child: LayoutBuilder( + builder: (context, constraints) { + final w = + memCacheWidth ?? + (constraints.maxWidth.isFinite + ? (constraints.maxWidth * + MediaQuery.devicePixelRatioOf(context)) + .round() + : null); + return Image.file( + file, + fit: fit, + width: double.infinity, + height: double.infinity, + cacheWidth: (w != null && w > 0) ? w : null, + errorBuilder: (_, _, _) => + HomeImagePlaceholder(icon: placeholderIcon), + ); + }, ), ); } diff --git a/lib/features/home/presentation/widgets/home_top_bar.dart b/lib/features/home/presentation/widgets/home_top_bar.dart index 5736e006..73388165 100644 --- a/lib/features/home/presentation/widgets/home_top_bar.dart +++ b/lib/features/home/presentation/widgets/home_top_bar.dart @@ -1,7 +1,6 @@ import 'package:soplay/core/content/catalogue.dart'; import 'package:soplay/core/content/catalogue_logo.dart'; import 'dart:async'; -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -10,6 +9,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/core/storage/hive_service.dart'; +import 'package:soplay/core/system/foreground_poller.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/download/domain/entities/download_status.dart'; @@ -20,6 +20,7 @@ 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/core/glass/reducible_blur.dart'; class HomeTopBar extends StatelessWidget { const HomeTopBar({super.key, required this.blurProgress}); @@ -110,8 +111,9 @@ class _TopBarBackground extends StatelessWidget { } return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 14 * progress, sigmaY: 14 * progress), + child: ReducibleBlur( + sigma: 14 * progress, + reducedFill: AppColors.navBackground.withValues(alpha: 0.2 * progress), child: DecoratedBox( decoration: BoxDecoration( color: AppColors.navBackground.withValues(alpha: 0.72 * progress), @@ -229,7 +231,12 @@ class _NotificationsIndicatorState extends State<_NotificationsIndicator> final NotificationsRepository _repo = getIt(); final HiveService _hive = getIt(); - Timer? _timer; + + /// Off while the app is in the background. + late final ForegroundPoller _poller = ForegroundPoller( + period: const Duration(seconds: 60), + onTick: _refresh, + ); int _count = 0; @override @@ -238,19 +245,19 @@ class _NotificationsIndicatorState extends State<_NotificationsIndicator> WidgetsBinding.instance.addObserver(this); _count = _cachedCount; _refresh(); - _timer = Timer.periodic(const Duration(seconds: 60), (_) => _refresh()); + _poller.start(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _timer?.cancel(); + _poller.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) _refresh(); + _poller.handleLifecycle(state); } /// [force] skips the cool-down (but never the in-flight coalescing) — used diff --git a/lib/features/home/presentation/widgets/view_all_widgets.dart b/lib/features/home/presentation/widgets/view_all_widgets.dart index 5310d870..30d99e19 100644 --- a/lib/features/home/presentation/widgets/view_all_widgets.dart +++ b/lib/features/home/presentation/widgets/view_all_widgets.dart @@ -1,18 +1,23 @@ -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/widgets/item_appear.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/sources/domain/source_failure.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_bloc.dart'; +import 'package:soplay/features/home/presentation/bloc/view_all/view_all_event.dart'; import 'package:soplay/features/home/presentation/bloc/view_all/view_all_state.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/home/presentation/widgets/home_ui_helpers.dart'; import 'package:soplay/core/widgets/poster_hero.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; +import 'package:soplay/core/widgets/load_more_failed_footer.dart'; class ViewAllAppBar extends StatelessWidget { const ViewAllAppBar({ @@ -59,11 +64,11 @@ class ViewAllAppBar extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w700, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), @@ -74,8 +79,9 @@ class ViewAllAppBar extends StatelessWidget { if (progress < 0.01) return content; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), + child: ReducibleBlur( + sigma: 20 * progress, + reducedFill: AppColors.background.withValues(alpha: 0.08 * progress), child: content, ), ); @@ -146,6 +152,7 @@ class ViewAllGrid extends StatelessWidget { childCount: state.items.length, ), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, // 14 read as a gap between unrelated blocks rather than the // seam between two rows of one grid. @@ -171,6 +178,14 @@ class ViewAllGrid extends StatelessWidget { ), ), ), + if (state.loadMoreFailed) + SliverToBoxAdapter( + child: LoadMoreFailedFooter( + onRetry: () => context.read().add( + ViewAllLoadMore(retry: true), + ), + ), + ), SliverToBoxAdapter(child: SizedBox(height: bottomPad + 16)), ], ); @@ -353,6 +368,7 @@ class ViewAllSkeleton extends StatelessWidget { childCount: 15, ), gridDelegate: responsiveGridDelegate( + context: context, mobileCrossAxisCount: 3, mainAxisSpacing: 10, crossAxisSpacing: 8, diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index 14be2cfc..10ec6d14 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -1,6 +1,6 @@ import 'dart:async'; import '../widgets/scroll_compact_navigation.dart'; -import 'dart:ui'; +import 'dart:ui' as ui show TextDirection; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; @@ -11,6 +11,7 @@ import 'package:adaptive_platform_ui/adaptive_platform_ui.dart' 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:liquid_glass_widgets/liquid_glass_widgets.dart'; import 'package:soplay/core/deeplink/deeplink_opt_in.dart'; import 'package:soplay/core/di/injection.dart'; @@ -26,10 +27,12 @@ import 'package:soplay/features/home/presentation/bloc/home/home_state.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/search/presentation/blocs/search_bloc.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; import 'package:showcaseview/showcaseview.dart'; import '../../../../core/navigation/app_tab.dart'; import '../../../../core/navigation/nav_controller.dart'; +import 'package:soplay/core/glass/reducible_blur.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 — @@ -117,6 +120,7 @@ class _MainPageState extends State with WidgetsBindingObserver { ); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + unawaited(openPendingParty(GoRouter.of(context))); if (!isDesktopPlatform) { // An update is never held back — only queued, so nothing stacks on it. StartupPrompts.run( @@ -501,7 +505,8 @@ class _MainPageState extends State with WidgetsBindingObserver { }, child: isTvPlatform ? _buildTvShell(tabs, defs) - : isDesktopPlatform + // A tablet or unfolded foldable in landscape gets the rail. + : isDesktopPlatform || SozoWidth.of(context).isExpanded ? _buildDesktopShell(tabs, defs) : Scaffold( backgroundColor: AppColors.background, @@ -652,6 +657,20 @@ class _MainPageState extends State with WidgetsBindingObserver { /// refresh Showcase highlights the whole capsule (Shorts is the central tab), /// and double-tap-to-refresh becomes re-tapping the already-active Shorts tab /// (see [_MainPageState._handleTabTap]). +/// The capsule bar on its own, solid so a test needs no shaders. +@visibleForTesting +Widget glassCapsuleForTest({ + required int index, + required List items, + required ValueChanged onTabSelected, +}) => _SozoGlassCapsule( + index: index, + items: items, + glass: false, + shortsShowcaseKey: GlobalKey(), + onTabSelected: onTabSelected, +); + class _SozoGlassCapsule extends StatelessWidget { const _SozoGlassCapsule({ required this.index, @@ -740,76 +759,89 @@ class _SozoGlassCapsule extends StatelessWidget { Widget build(BuildContext context) { final density = NavigationDensity.of(context); final barHeight = _barHeight - 8 * density; - final bar = GlassTabBar.bottom( - tabs: [ - for (final it in items) - GlassTab( - label: it.labelKey.tr(), - icon: Icon(it.icon, size: 24 - 2 * density), - activeIcon: Icon(it.activeIcon, size: 24 - 2 * density), - ), - ], - selectedIndex: index, - onTabSelected: onTabSelected, - // Outer Positioned(left:16,right:16) owns the float inset; the bar expands - // to fill it (tabWidth null) and its own padding is zeroed. - tabWidth: null, - horizontalPadding: 0, - verticalPadding: 0, - barHeight: barHeight, - iconSize: 24 - 2 * density, - barBorderRadius: barHeight / 2, // full capsule - iconLabelSpacing: 4 - density, - magnification: glass - ? 1.12 - .10 * density - : 1.0, // subtle iOS-26 lens on the selected tab - indicatorPinchStrength: glass ? .3 * (1 - density) : 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: EdgeInsets.symmetric( - horizontal: 12 - 6 * density, - vertical: 2 * (1 - density), + // In Arabic the package's tab Row follows the ambient direction and lays + // the tabs out right to left, but its tap, drag and indicator maths always + // count from the left edge. So each tap landed on the mirror-image tab: + // Home opened Profile, Search opened Downloads. The bar is therefore built + // left to right in every locale, and in RTL it is handed the tabs reversed + // with the index and the callback mirrored, which puts Home on the right + // where Arabic readers expect it. liquid_glass_widgets fixed this the same + // way upstream after 0.18. + final rtl = Directionality.of(context) == ui.TextDirection.rtl; + final last = items.length - 1; + final bar = Directionality( + textDirection: ui.TextDirection.ltr, + child: GlassTabBar.bottom( + tabs: [ + for (final it in rtl ? items.reversed : items) + GlassTab( + label: it.labelKey.tr(), + icon: Icon(it.icon, size: 24 - 2 * density), + activeIcon: Icon(it.activeIcon, size: 24 - 2 * density), + ), + ], + selectedIndex: rtl ? last - index : index, + onTabSelected: rtl ? (i) => onTabSelected(last - i) : onTabSelected, + // Outer Positioned(left:16,right:16) owns the float inset; the bar expands + // to fill it (tabWidth null) and its own padding is zeroed. + tabWidth: null, + horizontalPadding: 0, + verticalPadding: 0, + barHeight: barHeight, + iconSize: 24 - 2 * density, + barBorderRadius: barHeight / 2, // full capsule + iconLabelSpacing: 4 - density, + magnification: glass + ? 1.12 - .10 * density + : 1.0, // subtle iOS-26 lens on the selected tab + indicatorPinchStrength: glass ? .3 * (1 - density) : 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: EdgeInsets.symmetric( + horizontal: 12 - 6 * density, + vertical: 2 * (1 - density), + ), + // 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), + // `standard`, not `null`. Passing null does NOT mean "let the adaptive + // scope decide" — the package resolves it against a `premium` fallback, + // and the adaptive scope also *starts* at premium and only re-decides + // after 270 rastered frames. So every cold start ran the heaviest shader + // path, on every device, through the first tab switch and the first + // Shorts video — and premium is the tier the package's own source flags + // as the Mali/Impeller crash path. + // + // Standard fixes a second thing for free: `backerColor` (the near-opaque + // capsule pad below) is silently dropped on the premium grouped path and + // honoured on the lightweight one, so the capsule only now renders the + // design that was already written here. It is also the shader + // `LiquidGlassWidgets.initialize()` actually pre-warms. + quality: glass ? GlassQuality.standard : GlassQuality.minimal, + 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, + // #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, + 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).scale(12), ), - // 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), - // `standard`, not `null`. Passing null does NOT mean "let the adaptive - // scope decide" — the package resolves it against a `premium` fallback, - // and the adaptive scope also *starts* at premium and only re-decides - // after 270 rastered frames. So every cold start ran the heaviest shader - // path, on every device, through the first tab switch and the first - // Shorts video — and premium is the tier the package's own source flags - // as the Mali/Impeller crash path. - // - // Standard fixes a second thing for free: `backerColor` (the near-opaque - // capsule pad below) is silently dropped on the premium grouped path and - // honoured on the lightweight one, so the capsule only now renders the - // design that was already written here. It is also the shader - // `LiquidGlassWidgets.initialize()` actually pre-warms. - quality: glass ? GlassQuality.standard : GlassQuality.minimal, - 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, - // #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, - 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).scale(12), ); // The package drop shadow is light-mode only, so paint our own soft capsule @@ -1177,8 +1209,9 @@ class _SozoClassicBar extends StatelessWidget { return ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(22)), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: ReducibleBlur( + sigma: 22, + reducedFill: AppColors.surface.withValues(alpha: 0.6), child: Container( height: 68 + bottomPad, decoration: BoxDecoration( @@ -1384,8 +1417,9 @@ class _SozoFloatingNav extends StatelessWidget { ), child: ClipRRect( borderRadius: radius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: ReducibleBlur( + sigma: 22, + reducedFill: AppColors.surface.withValues(alpha: 0.6), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/features/manga/data/page_translator.dart b/lib/features/manga/data/page_translator.dart new file mode 100644 index 00000000..36a0a8bf --- /dev/null +++ b/lib/features/manga/data/page_translator.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:soplay/core/subtitles/subtitle_translation_service.dart'; +import 'package:soplay/features/manga/domain/entities/manga_page_entity.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; + +/// Reads the lettering off a manga page on the device and has the backend +/// translate it, one request per page. +/// +/// Android and iOS only: ML Kit has no desktop build. [isSupported] is what +/// the reader branches on before showing anything. +class PageTranslator { + PageTranslator({SubtitleTranslationService service = + const SubtitleTranslationService()}) + : _service = service; + + final SubtitleTranslationService _service; + TextRecognizer? _recognizer; + + /// The real host, not [defaultTargetPlatform]: widget tests report Android + /// there and have no ML Kit. + static bool get isSupported => + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + /// A translation made before, from disk. Costs nothing. + Future cached({ + required String chapterUrl, + required int pageIndex, + required String targetLang, + }) async { + try { + final file = await _cacheFile(chapterUrl, pageIndex, targetLang); + if (!await file.exists()) return null; + return PageTranslation.tryFromJson(jsonDecode(await file.readAsString())); + } catch (_) { + return null; + } + } + + /// Recognises, merges and translates [page]. Throws [PageTranslateException]. + Future translate({ + required MangaPageEntity page, + required Map headers, + required String chapterUrl, + required int pageIndex, + required String targetLang, + }) async { + final path = await _imagePath(page, headers); + final size = await _imageSize(path); + + final recognizer = _recognizer ??= TextRecognizer( + script: TextRecognitionScript.latin, + ); + final RecognizedText found; + try { + found = await recognizer.processImage(InputImage.fromFilePath(path)); + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + + final blocks = mergeBlocks([ + for (final b in found.blocks) + OcrBlock( + rect: b.boundingBox, + text: b.lines.map((l) => l.text).join('\n'), + lineHeight: b.lines.isEmpty + ? b.boundingBox.height + : b.boundingBox.height / b.lines.length, + ), + ]); + final texts = [for (final b in blocks) normalizeOcrText(b.text)]; + if (texts.every((t) => t.isEmpty)) { + throw const PageTranslateException(PageTranslateFailure.noText); + } + + final List out; + try { + out = await _service.translateLines( + lines: texts, + targetLang: targetLang, + docKey: pageTranslateDocKey(chapterUrl, pageIndex), + count: true, + ); + } on SubtitleDailyLimitReached catch (e) { + throw PageTranslateException(PageTranslateFailure.dailyLimit, e.message); + } on TranslationOffline { + throw const PageTranslateException(PageTranslateFailure.offline); + } on TranslationNeedsSignIn { + throw const PageTranslateException(PageTranslateFailure.signIn); + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + + final translation = PageTranslation( + imageSize: size, + targetLang: targetLang, + blocks: [ + for (var i = 0; i < blocks.length; i++) + if (texts[i].isNotEmpty) + TranslatedBlock(rect: blocks[i].rect, source: texts[i], text: out[i]), + ], + ); + try { + final file = await _cacheFile(chapterUrl, pageIndex, targetLang); + await file.parent.create(recursive: true); + await file.writeAsString(jsonEncode(translation.toJson())); + } catch (_) {} + return translation; + } + + Future dispose() async { + await _recognizer?.close(); + _recognizer = null; + } + + /// The page as a file: a downloaded page already is one, and a network page + /// is in the image cache once it has been on screen. + Future _imagePath( + MangaPageEntity page, + Map headers, + ) async { + final url = page.imageUrl; + if (!url.startsWith('http')) return url; + final key = page.cacheKey ?? url; + try { + final hit = await DefaultCacheManager().getFileFromCache(key); + if (hit != null) return hit.file.path; + final cookie = page.cookie; + final file = await DefaultCacheManager().getSingleFile( + url, + key: key, + headers: { + ...headers, + ...page.headers, + if (cookie != null && cookie.isNotEmpty) 'Cookie': cookie, + }, + ); + return file.path; + } on SocketException { + throw const PageTranslateException(PageTranslateFailure.offline); + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + } + + /// Width and height from the file header, without decoding the page. + Future _imageSize(String path) async { + try { + final buffer = await ui.ImmutableBuffer.fromFilePath(path); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + final size = ui.Size( + descriptor.width.toDouble(), + descriptor.height.toDouble(), + ); + descriptor.dispose(); + buffer.dispose(); + return size; + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + } + + Future _cacheFile(String chapterUrl, int pageIndex, String lang) async { + final dir = await getApplicationSupportDirectory(); + final name = sha1 + .convert(utf8.encode('$chapterUrl\n$pageIndex\n$lang')) + .toString(); + return File('${dir.path}/manga_translations/$name.json'); + } +} diff --git a/lib/features/manga/domain/translate/page_translation.dart b/lib/features/manga/domain/translate/page_translation.dart new file mode 100644 index 00000000..8e38e75d --- /dev/null +++ b/lib/features/manga/domain/translate/page_translation.dart @@ -0,0 +1,373 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' show Rect, Size; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +/// One piece of text found on a page, in the image's own pixels. +class OcrBlock { + const OcrBlock({ + required this.rect, + required this.text, + required this.lineHeight, + }); + + final Rect rect; + final String text; + + /// Average height of one recognised line, which is what "close together" + /// is measured in: lettering size varies by page far more than by bubble. + final double lineHeight; +} + +/// A bubble's worth of text and what it says in the target language. +class TranslatedBlock { + const TranslatedBlock({ + required this.rect, + required this.source, + required this.text, + }); + + final Rect rect; + final String source; + final String text; + + Map toJson() => { + 'l': rect.left, + 't': rect.top, + 'r': rect.right, + 'b': rect.bottom, + 's': source, + 'x': text, + }; + + static TranslatedBlock fromJson(Map m) => TranslatedBlock( + rect: Rect.fromLTRB( + (m['l'] as num).toDouble(), + (m['t'] as num).toDouble(), + (m['r'] as num).toDouble(), + (m['b'] as num).toDouble(), + ), + source: '${m['s'] ?? ''}', + text: '${m['x'] ?? ''}', + ); +} + +/// A translated page: the boxes to cover, in the coordinates of an image of +/// [imageSize]. +class PageTranslation { + const PageTranslation({ + required this.imageSize, + required this.targetLang, + required this.blocks, + }); + + final Size imageSize; + final String targetLang; + final List blocks; + + Map toJson() => { + 'w': imageSize.width, + 'h': imageSize.height, + 'lang': targetLang, + 'blocks': [for (final b in blocks) b.toJson()], + }; + + static PageTranslation? tryFromJson(Object? raw) { + if (raw is! Map) return null; + try { + return PageTranslation( + imageSize: Size( + (raw['w'] as num).toDouble(), + (raw['h'] as num).toDouble(), + ), + targetLang: '${raw['lang']}', + blocks: [ + for (final b in raw['blocks'] as List) + TranslatedBlock.fromJson(b as Map), + ], + ); + } catch (_) { + return null; + } + } +} + +/// The server keeps 200 characters of a docKey. A long chapter url cut there +/// would lose the page number and make every page of the chapter one document. +const int kDocKeyLimit = 200; + +/// The quota key for one page: the backend charges once per key per day. +String pageTranslateDocKey(String chapterUrl, int pageIndex) { + final plain = 'manga:$chapterUrl:$pageIndex'; + if (plain.length <= kDocKeyLimit) return plain; + final digest = sha1.convert(utf8.encode(chapterUrl)).toString(); + return 'manga:$digest:$pageIndex'; +} + +/// Languages written right to left, among those a page can be translated into. +const Set kRtlLanguages = {'ar', 'fa', 'he', 'ur', 'ckb', 'ps', 'yi'}; + +bool isRtlLanguage(String lang) => + kRtlLanguages.contains(lang.toLowerCase().split(RegExp('[-_]')).first); + +/// Joins blocks whose boxes come within [gapFactor] line heights of each +/// other, so a bubble the recogniser split in two is translated as one +/// sentence. Blocks with no letters (page numbers, stray marks) are dropped. +/// +/// Reading order is top to bottom, then left to right, both for the blocks +/// returned and for the lines inside a merged one. +List mergeBlocks(List blocks, {double gapFactor = 0.8}) { + final kept = [ + for (final b in blocks) + if (_hasLetters(b.text) && !b.rect.isEmpty) b, + ]; + final n = kept.length; + final parent = List.generate(n, (i) => i); + int find(int i) { + while (parent[i] != i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + } + + for (var i = 0; i < n; i++) { + for (var j = i + 1; j < n; j++) { + if (_close(kept[i], kept[j], gapFactor)) { + parent[find(i)] = find(j); + } + } + } + + final groups = >{}; + for (var i = 0; i < n; i++) { + groups.putIfAbsent(find(i), () => []).add(kept[i]); + } + + final merged = [ + for (final group in groups.values) + if (group.length == 1) group.single else _union(group), + ]; + merged.sort(_readingOrder); + return merged; +} + +bool _close(OcrBlock a, OcrBlock b, double gapFactor) { + final gap = gapFactor * math.min(a.lineHeight, b.lineHeight); + final dx = math.max( + 0.0, + math.max(a.rect.left, b.rect.left) - math.min(a.rect.right, b.rect.right), + ); + final dy = math.max( + 0.0, + math.max(a.rect.top, b.rect.top) - math.min(a.rect.bottom, b.rect.bottom), + ); + if (dx > gap || dy > gap) return false; + // Stacked lines of one bubble share a column. Two bubbles side by side + // touch at the edges but barely overlap horizontally. + if (dy > 0) { + final overlap = + math.min(a.rect.right, b.rect.right) - + math.max(a.rect.left, b.rect.left); + return overlap >= 0.3 * math.min(a.rect.width, b.rect.width); + } + return true; +} + +OcrBlock _union(List group) { + group.sort(_readingOrder); + var rect = group.first.rect; + var lines = 0.0; + for (final b in group) { + rect = rect.expandToInclude(b.rect); + lines += b.lineHeight; + } + return OcrBlock( + rect: rect, + text: group.map((b) => b.text).join('\n'), + lineHeight: lines / group.length, + ); +} + +int _readingOrder(OcrBlock a, OcrBlock b) { + // Within half a line counts as the same row, so a slightly lower block to + // the left still reads first. + final tolerance = 0.5 * math.min(a.lineHeight, b.lineHeight); + if ((a.rect.top - b.rect.top).abs() > tolerance) { + return a.rect.top.compareTo(b.rect.top); + } + return a.rect.left.compareTo(b.rect.left); +} + +bool _hasLetters(String text) => RegExp(r'\p{L}', unicode: true).hasMatch(text); + +/// Prepares recognised text for the translator: line breaks become spaces, +/// a word split by a hyphen at a line end is joined again, and ALL-CAPS +/// lettering becomes sentence case, which translators handle far better. +String normalizeOcrText(String text) { + var out = text.replaceAllMapped( + RegExp(r'(\p{L})-\s*\n\s*(\p{L})', unicode: true), + (m) => '${m[1]}${m[2]}', + ); + out = out.replaceAll(RegExp(r'\s+'), ' ').trim(); + final letters = out.replaceAll(RegExp(r'[^\p{L}]', unicode: true), ''); + if (letters.length > 1 && letters == letters.toUpperCase()) { + out = _sentenceCase(out.toLowerCase()); + } + return out; +} + +String _sentenceCase(String lower) { + final buffer = StringBuffer(); + var capitalize = true; + for (final rune in lower.runes) { + final ch = String.fromCharCode(rune); + if (capitalize && RegExp(r'\p{L}', unicode: true).hasMatch(ch)) { + buffer.write(ch.toUpperCase()); + capitalize = false; + } else { + buffer.write(ch); + } + if ('.!?'.contains(ch)) capitalize = true; + } + // The pronoun survives lower-casing as "i". + return buffer.toString().replaceAllMapped( + RegExp(r"\bi\b(?!\.)"), + (_) => 'I', + ); +} + +/// The largest font size between [min] and [max] at which [measure] says the +/// text fits [box], to within [precision]. [measure] lays the text out at a +/// font size, wrapped to the box width, and returns the size it took. +/// +/// Returns [min] when nothing fits; the caller lets that overflow rather than +/// draw text nobody can read. +double fitFontSize({ + required Size box, + required Size Function(double fontSize) measure, + double min = 6, + double max = 32, + double precision = 0.5, +}) { + bool fits(double size) { + final used = measure(size); + return used.width <= box.width + 0.01 && used.height <= box.height + 0.01; + } + + if (box.isEmpty) return min; + if (fits(max)) return max; + if (!fits(min)) return min; + var lo = min; + var hi = max; + while (hi - lo > precision) { + final mid = (lo + hi) / 2; + if (fits(mid)) { + lo = mid; + } else { + hi = mid; + } + } + return lo; +} + +/// What a page shows, kept per chapter and page while the reader is open. +class PageOverlayEntry { + const PageOverlayEntry({ + required this.translation, + this.applied = false, + this.showOriginal = false, + }); + + final PageTranslation translation; + final bool applied; + final bool showOriginal; + + PageOverlayEntry copyWith({bool? applied, bool? showOriginal}) => + PageOverlayEntry( + translation: translation, + applied: applied ?? this.applied, + showOriginal: showOriginal ?? this.showOriginal, + ); +} + +/// Overlay state for every translated page of the open reader. +/// +/// A translation is stored before it is shown: the preview offers it, and +/// only Apply puts it over the page. Cancel leaves whatever was there. +class PageOverlays extends ChangeNotifier { + final Map _entries = {}; + + static String keyFor(String chapterUrl, int pageIndex) => + '$chapterUrl#$pageIndex'; + + PageOverlayEntry? entry(String key) => _entries[key]; + + /// The stored translation for [key], if it was made into [lang]. + PageTranslation? translationFor(String key, String lang) { + final t = _entries[key]?.translation; + return t != null && t.targetLang == lang ? t : null; + } + + /// What to draw over the page, or null for the original. + PageTranslation? visible(String key) { + final e = _entries[key]; + if (e == null || !e.applied || e.showOriginal) return null; + return e.translation; + } + + bool isApplied(String key) => _entries[key]?.applied ?? false; + + /// Keeps [translation] without showing it, unless it replaces one that was + /// already applied in the same language. + void store(String key, PageTranslation translation) { + final old = _entries[key]; + final keepApplied = + old != null && + old.applied && + old.translation.targetLang == translation.targetLang; + _entries[key] = PageOverlayEntry( + translation: translation, + applied: keepApplied, + showOriginal: keepApplied && old.showOriginal, + ); + notifyListeners(); + } + + /// Puts the stored translation over the page; [showOriginal] keeps it + /// hidden but one toggle away. + void apply(String key, {bool showOriginal = false}) { + final e = _entries[key]; + if (e == null) return; + _entries[key] = e.copyWith(applied: true, showOriginal: showOriginal); + notifyListeners(); + } + + void toggleOriginal(String key) { + final e = _entries[key]; + if (e == null || !e.applied) return; + _entries[key] = e.copyWith(showOriginal: !e.showOriginal); + notifyListeners(); + } + + /// Takes the overlay off the page and keeps the translation for later. + void remove(String key) { + final e = _entries[key]; + if (e == null || !e.applied) return; + _entries[key] = e.copyWith(applied: false, showOriginal: false); + notifyListeners(); + } +} + +/// Why a page could not be translated, for the message the reader shows. +enum PageTranslateFailure { noText, dailyLimit, offline, signIn, failed } + +class PageTranslateException implements Exception { + const PageTranslateException(this.reason, [this.message]); + final PageTranslateFailure reason; + final String? message; + @override + String toString() => message ?? reason.name; +} diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index 7b05a08a..073e596d 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -44,6 +44,11 @@ import 'package:soplay/features/manga/data/chapter_read_store.dart'; import 'package:soplay/features/manga/data/novel_highlight_store.dart'; import 'package:soplay/features/manga/domain/reading/novel_highlight.dart'; import 'package:soplay/features/manga/data/page_tiles.dart'; +import 'package:soplay/features/manga/data/page_translator.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; +import 'package:soplay/features/manga/presentation/widgets/page_translation_overlay.dart'; +import 'package:soplay/core/subtitles/subtitle_languages.dart'; +import 'package:soplay/features/manga/presentation/widgets/pinch_zoom_scroll.dart'; import 'package:soplay/features/manga/presentation/widgets/tiled_zoom.dart'; import 'package:soplay/features/manga/domain/reading/chapter_progress.dart'; import 'package:soplay/features/manga/domain/reading/tts_language.dart'; @@ -238,6 +243,23 @@ class _ReaderPageState extends State { /// read off a chapter in this file went unchecked. List get _chapters => widget.args.chapters; + /// Page translation: recognised on the device, translated by the backend. + /// Nothing here is touched where [PageTranslator.isSupported] is false. + final PageOverlays _overlays = PageOverlays(); + PageTranslator? _translator; + final Set _translating = {}; + bool _autoTranslate = false; + Timer? _autoTranslateTimer; + + bool get _canTranslate => + PageTranslator.isSupported && _html == null && _pages.isNotEmpty; + + PageOverlays? get _pageOverlays => + PageTranslator.isSupported ? _overlays : null; + + String _overlayKey(int page) => + PageOverlays.keyFor(_chapters[_chapterIndex].mediaRef, page); + @override void initState() { super.initState(); @@ -258,6 +280,10 @@ class _ReaderPageState extends State { _novelMargin = _hive.getNovelMargin(); _itemPositionsListener.itemPositions.addListener(_onItemPositions); _page.addListener(_maybePrefetchNextChapter); + if (PageTranslator.isSupported) { + _autoTranslate = _hive.mangaAutoTranslate; + _page.addListener(_scheduleAutoTranslate); + } _novelProgress.addListener(_maybePrefetchNextChapter); _novelScrollController.addListener(_onNovelScroll); if (!isDesktopPlatform) { @@ -330,6 +356,9 @@ class _ReaderPageState extends State { // Reading stops with the reader: nothing else on screen shows what is // being read or offers a way to stop it. _ttsLifecycle?.dispose(); + _autoTranslateTimer?.cancel(); + _overlays.dispose(); + unawaited(_translator?.dispose()); _tts?.removeListener(_onTtsChanged); _tts?.dispose(); _spreadController.dispose(); @@ -423,6 +452,7 @@ class _ReaderPageState extends State { _loading = false; }); _scheduleSave(); + _scheduleAutoTranslate(); return; } _localChapter = false; @@ -452,6 +482,7 @@ class _ReaderPageState extends State { }); if (isText) _afterTextLoaded(); _scheduleSave(); + _scheduleAutoTranslate(); case Failure(:final error): _pendingJump = null; setState(() { @@ -1519,22 +1550,28 @@ class _ReaderPageState extends State { Widget _verticalReader() { return GestureDetector( onTapUp: _handleTapZone, - child: ScrollablePositionedList.builder( - key: ValueKey('v_$_chapterIndex'), - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - initialScrollIndex: _initialIndex.clamp(0, _pageCount), - minCacheExtent: 2000, - itemCount: _pages.length + 1, - itemBuilder: (context, i) { - if (i == _pages.length) return _chapterFooter(); - return _PageImage( - key: ValueKey('v_${_chapterIndex}_$i'), - page: _pages[i], - headers: _headers, - zoomable: false, - ); - }, + child: PinchZoomScroll( + key: ValueKey('zoom_$_chapterIndex'), + builder: (context, physics) => ScrollablePositionedList.builder( + key: ValueKey('v_$_chapterIndex'), + physics: physics, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + initialScrollIndex: _initialIndex.clamp(0, _pageCount), + minCacheExtent: 2000, + itemCount: _pages.length + 1, + itemBuilder: (context, i) { + if (i == _pages.length) return _chapterFooter(); + return _PageImage( + key: ValueKey('v_${_chapterIndex}_$i'), + page: _pages[i], + headers: _headers, + zoomable: false, + overlays: _pageOverlays, + overlayKey: _overlayKey(i), + ); + }, + ), ), ); } @@ -1583,6 +1620,8 @@ class _ReaderPageState extends State { page: _pages[i], headers: _headers, zoomable: true, + overlays: _pageOverlays, + overlayKey: _overlayKey(i), ), ), ), @@ -1633,6 +1672,8 @@ class _ReaderPageState extends State { // Not zoomable in a spread: a pinch would zoom one half // out from under the other. zoomable: false, + overlays: _pageOverlays, + overlayKey: _overlayKey(i), ), ), ], @@ -2159,6 +2200,7 @@ class _ReaderPageState extends State { ? () => _tts!.stop() : _startTts, ), + if (_canTranslate) _translateButton(), _downloadButton(ch), // Only when there is somewhere to go. A provider with no web page // for this chapter gets no button rather than a button that @@ -2200,6 +2242,183 @@ class _ReaderPageState extends State { ); } + Widget _translateButton() => ListenableBuilder( + listenable: Listenable.merge([_page, _overlays]), + builder: (context, _) { + final key = _overlayKey(_currentPage); + if (_translating.contains(key)) { + return const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ); + } + final shown = _overlays.visible(key) != null; + return IconButton( + tooltip: 'manga.translate_page'.tr(), + icon: Icon( + Icons.translate_rounded, + color: shown ? _accent : Colors.white, + ), + onPressed: () => _translatePage(_currentPage), + ); + }, + ); + + void _scheduleAutoTranslate() { + _autoTranslateTimer?.cancel(); + if (!_autoTranslate) return; + // Only a page that stays on screen: flicking past ten pages should not + // spend ten translations. + _autoTranslateTimer = Timer(const Duration(milliseconds: 700), () { + if (!mounted || !_autoTranslate || !_canTranslate || _loading) return; + final page = _currentPage; + if (_overlays.entry(_overlayKey(page)) != null) return; + _translatePage(page, auto: true); + }); + } + + /// Translates [page] and offers it in a preview, or, for [auto], lays it + /// straight over the page. + Future _translatePage(int page, {bool auto = false}) async { + if (page < 0 || page >= _pages.length) return; + final chapterUrl = _chapters[_chapterIndex].mediaRef; + final key = _overlayKey(page); + final lang = _hive.getMangaTranslateLang(); + var translation = _overlays.translationFor(key, lang); + if (translation == null) { + if (_translating.contains(key)) return; + setState(() => _translating.add(key)); + final translator = _translator ??= PageTranslator(); + try { + translation = + await translator.cached( + chapterUrl: chapterUrl, + pageIndex: page, + targetLang: lang, + ) ?? + await translator.translate( + page: _pages[page], + headers: _headers, + chapterUrl: chapterUrl, + pageIndex: page, + targetLang: lang, + ); + _overlays.store(key, translation); + } on PageTranslateException catch (e) { + if (!mounted) return; + if (auto) { + if (e.reason == PageTranslateFailure.noText) return; + // One message, not one per page turned while the problem lasts. + _autoTranslate = false; + } + _translateSnack(e.reason); + return; + } finally { + if (mounted) setState(() => _translating.remove(key)); + } + } + if (!mounted) return; + if (auto) { + _overlays.apply(key); + return; + } + final ready = translation; + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => PageTranslatePreview( + image: _imageProviderFor(_pages[page]), + translation: ready, + showOriginal: _overlays.entry(key)?.showOriginal ?? false, + ), + ), + ); + if (result == null || !mounted) return; + _overlays.apply(key, showOriginal: result.showOriginal); + } + + ImageProvider _imageProviderFor(MangaPageEntity page) { + final url = page.imageUrl; + if (!url.startsWith('http')) return FileImage(File(url)); + final cookie = page.cookie; + return CachedNetworkImageProvider( + url, + cacheKey: page.cacheKey, + headers: mergeHttpHeaders([ + _headers, + page.headers, + if (cookie != null && cookie.isNotEmpty) {'Cookie': cookie}, + ]), + ); + } + + void _translateSnack(PageTranslateFailure reason) { + final message = switch (reason) { + PageTranslateFailure.noText => 'manga.translate_no_text'.tr(), + PageTranslateFailure.dailyLimit => 'manga.translate_daily_limit'.tr(), + PageTranslateFailure.offline => 'manga.translate_offline'.tr(), + PageTranslateFailure.signIn => 'manga.translate_sign_in'.tr(), + PageTranslateFailure.failed => 'manga.translate_failed'.tr(), + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + Widget _translateSettings(StateSetter setSheet) { + final lang = _hive.getMangaTranslateLang(); + final known = kSubtitleTranslateLanguages.any((l) => l.$1 == lang); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sheetLabel('manga.translate_language'), + const SizedBox(height: 4), + DropdownButton( + value: known ? lang : null, + isExpanded: true, + dropdownColor: const Color(0xFF1E1E1E), + style: const TextStyle(color: Colors.white, fontSize: 13.5), + underline: const SizedBox.shrink(), + items: [ + for (final l in kSubtitleTranslateLanguages) + DropdownMenuItem(value: l.$1, child: Text(l.$2)), + ], + onChanged: (v) { + if (v == null) return; + _hive.setMangaTranslateLang(v); + setSheet(() {}); + }, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + activeTrackColor: _accent, + value: _autoTranslate, + title: Text( + 'manga.translate_auto'.tr(), + style: const TextStyle(color: Colors.white, fontSize: 13.5), + ), + subtitle: Text( + 'manga.translate_auto_desc'.tr(), + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + onChanged: (v) { + _hive.setMangaAutoTranslate(v); + setState(() => _autoTranslate = v); + setSheet(() {}); + if (v) _scheduleAutoTranslate(); + }, + ), + ], + ); + } + /// Prose has more to offer than fits along the bar on a phone. Widget _novelMenu() { final site = _chapterOnSite; @@ -2787,6 +3006,10 @@ class _ReaderPageState extends State { setSheet(() {}); }, ), + if (_canTranslate) ...[ + const SizedBox(height: 18), + _translateSettings(setSheet), + ], ], if (!isDesktopPlatform) ...[ const SizedBox(height: 18), @@ -3084,12 +3307,18 @@ class _PageImage extends StatefulWidget { required this.page, required this.headers, required this.zoomable, + this.overlays, + this.overlayKey = '', }); final MangaPageEntity page; final Map headers; final bool zoomable; + /// Translated lettering to lay over this page, under [overlayKey]. + final PageOverlays? overlays; + final String overlayKey; + @override State<_PageImage> createState() => _PageImageState(); } @@ -3203,7 +3432,27 @@ class _PageImageState extends State<_PageImage> { errorWidget: (_, _, _) => _errorTile(width), ); - if (!widget.zoomable) return img; + final overlays = widget.overlays; + final Widget drawn = overlays == null + ? img + : Stack( + children: [ + img, + Positioned.fill( + child: ListenableBuilder( + listenable: overlays, + builder: (_, _) { + final t = overlays.visible(widget.overlayKey); + return t == null + ? const SizedBox.shrink() + : PageTranslationOverlay(translation: t); + }, + ), + ), + ], + ); + + if (!widget.zoomable) return drawn; // Zoomed, the image above is a bitmap decoded at column width being // magnified — which is exactly when a dense page or small lettering turns // to mush. [TiledZoom] re-reads the part that is on screen from the file @@ -3212,7 +3461,7 @@ class _PageImageState extends State<_PageImage> { return TiledZoom( sourcePath: _sourcePath, maxScale: 4, - child: SizedBox(width: width, child: img), + child: SizedBox(width: width, child: drawn), ); }, ); diff --git a/lib/features/manga/presentation/widgets/page_translation_overlay.dart b/lib/features/manga/presentation/widgets/page_translation_overlay.dart new file mode 100644 index 00000000..5e11f619 --- /dev/null +++ b/lib/features/manga/presentation/widgets/page_translation_overlay.dart @@ -0,0 +1,245 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart' hide TextDirection; +import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; + +/// The translated lettering, laid over a page drawn at any size. +/// +/// Sized by its parent, which is the page image: block rectangles are in the +/// image's pixels and are scaled to whatever the page is drawn at. +class PageTranslationOverlay extends StatelessWidget { + const PageTranslationOverlay({super.key, required this.translation}); + + final PageTranslation translation; + + @override + Widget build(BuildContext context) { + final rtl = isRtlLanguage(translation.targetLang); + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + final image = translation.imageSize; + if (image.isEmpty || !constraints.maxWidth.isFinite) { + return const SizedBox.shrink(); + } + final sx = constraints.maxWidth / image.width; + final sy = constraints.maxHeight.isFinite + ? constraints.maxHeight / image.height + : sx; + return Stack( + children: [ + for (final block in translation.blocks) + _box(block, sx, sy, constraints.biggest, rtl), + ], + ); + }, + ), + ); + } + + Widget _box(TranslatedBlock block, double sx, double sy, Size area, bool rtl) { + final r = block.rect; + // A little wider than the letters, so their edges do not peek out. + final pad = math.max(3.0, 0.15 * math.min(r.width * sx, r.height * sy)); + final left = math.max(0.0, r.left * sx - pad); + final top = math.max(0.0, r.top * sy - pad); + final right = area.width.isFinite + ? math.min(area.width, r.right * sx + pad) + : r.right * sx + pad; + final bottom = area.height.isFinite + ? math.min(area.height, r.bottom * sy + pad) + : r.bottom * sy + pad; + return Positioned( + left: left, + top: top, + width: math.max(0.0, right - left), + height: math.max(0.0, bottom - top), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.97), + borderRadius: BorderRadius.circular( + math.min(12.0, 0.3 * math.min(right - left, bottom - top)), + ), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: FittedLettering( + text: block.text, + direction: rtl ? TextDirection.rtl : TextDirection.ltr, + ), + ), + ), + ); + } +} + +/// Text at the largest size that fits its box. +class FittedLettering extends StatelessWidget { + const FittedLettering({ + super.key, + required this.text, + required this.direction, + }); + + final String text; + final TextDirection direction; + + static TextStyle styleAt(double size) => TextStyle( + color: Colors.black, + fontSize: size, + height: 1.15, + fontWeight: FontWeight.w600, + ); + + @override + Widget build(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + final box = constraints.biggest; + final size = fitFontSize( + box: box, + max: math.max(6.0, math.min(40.0, box.height)), + measure: (fontSize) { + final painter = TextPainter( + text: TextSpan(text: text, style: styleAt(fontSize)), + textAlign: TextAlign.center, + textDirection: direction, + textScaler: scaler, + )..layout(maxWidth: box.width); + final used = Size( + math.max(painter.width, painter.minIntrinsicWidth), + painter.height, + ); + painter.dispose(); + return used; + }, + ); + return Center( + child: Text( + text, + textAlign: TextAlign.center, + textDirection: direction, + overflow: TextOverflow.clip, + style: styleAt(size), + ), + ); + }, + ); + } +} + +/// What the person chose in the preview. Null from the route means Cancel. +class PageTranslatePreviewResult { + const PageTranslatePreviewResult({required this.showOriginal}); + final bool showOriginal; +} + +/// The page with its translation over it, before anything changes in the +/// reader: Apply, Show original, Cancel. +class PageTranslatePreview extends StatefulWidget { + const PageTranslatePreview({ + super.key, + required this.image, + required this.translation, + this.showOriginal = false, + }); + + final ImageProvider image; + final PageTranslation translation; + final bool showOriginal; + + @override + State createState() => _PageTranslatePreviewState(); +} + +class _PageTranslatePreviewState extends State { + late bool _original = widget.showOriginal; + + @override + Widget build(BuildContext context) { + final accent = AppColors.primary; + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text( + 'manga.translate_preview'.tr(), + style: const TextStyle(fontSize: 16), + ), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Stack( + children: [ + Image( + image: widget.image, + fit: BoxFit.fitWidth, + width: double.infinity, + ), + if (!_original) + Positioned.fill( + child: PageTranslationOverlay( + translation: widget.translation, + ), + ), + ], + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + // Wraps rather than overflows when the labels are long and the + // phone is narrow. + child: SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'general.cancel'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilterChip( + selected: _original, + onSelected: (v) => setState(() => _original = v), + label: Text('manga.translate_show_original'.tr()), + selectedColor: accent.withValues(alpha: 0.3), + ), + const SizedBox(width: 10), + FilledButton( + autofocus: isTvPlatform, + style: FilledButton.styleFrom(backgroundColor: accent), + onPressed: () => Navigator.of(context).pop( + PageTranslatePreviewResult(showOriginal: _original), + ), + child: Text('manga.translate_apply'.tr()), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/manga/presentation/widgets/pinch_zoom_scroll.dart b/lib/features/manga/presentation/widgets/pinch_zoom_scroll.dart new file mode 100644 index 00000000..516f1ecf --- /dev/null +++ b/lib/features/manga/presentation/widgets/pinch_zoom_scroll.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +/// Pinch zoom over a scrolling list of pages, for the continuous reader. +/// +/// One finger still scrolls: a vertical drag belongs to the list and a sideways +/// one pans the zoomed page. Two fingers must belong to the zoom, and a list +/// that can still drag takes a pinch whose fingers move up or down for a scroll, +/// so while two are down the list is told not to scroll at all. +class PinchZoomScroll extends StatefulWidget { + const PinchZoomScroll({super.key, required this.builder, this.maxScale = 4}); + + /// The list, given the physics it has to use right now. + final Widget Function(BuildContext context, ScrollPhysics? physics) builder; + final double maxScale; + + @override + State createState() => _PinchZoomScrollState(); +} + +class _PinchZoomScrollState extends State { + final ValueNotifier _pinching = ValueNotifier(false); + final TransformationController _transform = TransformationController(); + int _pointers = 0; + + void _track(int change) { + _pointers = (_pointers + change).clamp(0, 10); + _pinching.value = _pointers >= 2; + } + + @override + void dispose() { + _pinching.dispose(); + _transform.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _track(1), + onPointerUp: (_) => _track(-1), + onPointerCancel: (_) => _track(-1), + child: InteractiveViewer( + transformationController: _transform, + maxScale: widget.maxScale, + child: ValueListenableBuilder( + valueListenable: _pinching, + builder: (context, pinching, _) => widget.builder( + context, + pinching ? const NeverScrollableScrollPhysics() : null, + ), + ), + ), + ); + } +} diff --git a/lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart b/lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart deleted file mode 100644 index 80328b77..00000000 --- a/lib/features/my_list/my_list/data/datasources/my_list_remote_data_source.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:soplay/features/my_list/data/models/favorite_model.dart'; - -class MyListRemoteDataSource { - const MyListRemoteDataSource({required this.dio}); - - final Dio dio; - - Future> getFavorites() async { - final response = await dio.get('/auth/favorites'); - final data = response.data; - final items = data is Map ? data['items'] : null; - if (items is! List) return const []; - return items - .whereType() - .map((e) => FavoriteModel.fromJson(e.cast())) - .where((e) => e.contentUrl.trim().isNotEmpty) - .toList(growable: false); - } - - Future addFavorite({ - required String provider, - required String contentUrl, - required String title, - required String thumbnail, - }) async { - await dio.post( - '/auth/favorites', - data: { - 'provider': provider, - 'contentUrl': contentUrl, - 'title': title, - 'thumbnail': thumbnail, - }, - ); - } - - Future removeFavorite(String contentUrl) async { - await dio.delete('/auth/favorites', data: {'contentUrl': contentUrl}); - } -} diff --git a/lib/features/my_list/my_list/data/models/favorite_model.dart b/lib/features/my_list/my_list/data/models/favorite_model.dart deleted file mode 100644 index be3d43f6..00000000 --- a/lib/features/my_list/my_list/data/models/favorite_model.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -class FavoriteModel extends FavoriteEntity { - const FavoriteModel({ - required super.provider, - required super.contentUrl, - required super.title, - required super.thumbnail, - super.description, - }); - - factory FavoriteModel.fromJson(Map json) => FavoriteModel( - provider: json['provider'] as String? ?? '', - contentUrl: json['contentUrl'] as String? ?? '', - title: json['title'] as String? ?? '', - thumbnail: json['thumbnail'] as String? ?? '', - description: json['description'] as String? ?? '', - ); -} diff --git a/lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart b/lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart deleted file mode 100644 index 2afd0ea2..00000000 --- a/lib/features/my_list/my_list/data/repositories/my_list_repository_impl.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/data/datasources/my_list_remote_data_source.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/entities/my_list_failure.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class MyListRepositoryImpl implements MyListRepository { - const MyListRepositoryImpl(this.dataSource); - - final MyListRemoteDataSource dataSource; - - @override - Future>> getFavorites() async { - try { - return Success(await dataSource.getFavorites()); - } on DioException catch (e) { - if (e.response?.statusCode == 401) { - return const Failure(MyListUnauthorizedException()); - } - return Failure(Exception(_messageFrom(e))); - } catch (e) { - return Failure(Exception(e.toString())); - } - } - - @override - Future> addFavorite(FavoriteEntity entity) async { - try { - await dataSource.addFavorite( - provider: entity.provider, - contentUrl: entity.contentUrl, - title: entity.title, - thumbnail: entity.thumbnail, - ); - return Success(null); - } on DioException catch (e) { - return Failure(Exception(_messageFrom(e))); - } catch (e) { - return Failure(Exception(e.toString())); - } - } - - @override - Future> removeFavorite(String contentUrl) async { - try { - await dataSource.removeFavorite(contentUrl); - return Success(null); - } on DioException catch (e) { - return Failure(Exception(_messageFrom(e))); - } catch (e) { - return Failure(Exception(e.toString())); - } - } - - @override - Future syncAfterLogin() async {} - - String _messageFrom(DioException e) { - final data = e.response?.data; - if (data is Map) { - final message = data['message']; - if (message is String && message.trim().isNotEmpty) return message; - if (message is List && message.isNotEmpty) { - return message.first.toString(); - } - } - return e.message ?? 'Request failed'; - } -} diff --git a/lib/features/my_list/my_list/domain/entities/favorite_entity.dart b/lib/features/my_list/my_list/domain/entities/favorite_entity.dart deleted file mode 100644 index 6de72c7d..00000000 --- a/lib/features/my_list/my_list/domain/entities/favorite_entity.dart +++ /dev/null @@ -1,15 +0,0 @@ -class FavoriteEntity { - final String provider; - final String contentUrl; - final String title; - final String thumbnail; - final String description; - - const FavoriteEntity({ - required this.provider, - required this.contentUrl, - required this.title, - required this.thumbnail, - this.description = '', - }); -} diff --git a/lib/features/my_list/my_list/domain/entities/my_list_failure.dart b/lib/features/my_list/my_list/domain/entities/my_list_failure.dart deleted file mode 100644 index 61a6dd09..00000000 --- a/lib/features/my_list/my_list/domain/entities/my_list_failure.dart +++ /dev/null @@ -1,6 +0,0 @@ -class MyListUnauthorizedException implements Exception { - const MyListUnauthorizedException(); - - @override - String toString() => 'Token kerak'; -} diff --git a/lib/features/my_list/my_list/domain/repositories/my_list_repository.dart b/lib/features/my_list/my_list/domain/repositories/my_list_repository.dart deleted file mode 100644 index 1d9c0df7..00000000 --- a/lib/features/my_list/my_list/domain/repositories/my_list_repository.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -abstract class MyListRepository { - Future>> getFavorites(); - Future> addFavorite(FavoriteEntity entity); - Future> removeFavorite(String contentUrl); -} diff --git a/lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart b/lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart deleted file mode 100644 index a98c1e51..00000000 --- a/lib/features/my_list/my_list/domain/usecases/add_favorite_usecase.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class AddFavoriteUseCase { - const AddFavoriteUseCase(this.repository); - - final MyListRepository repository; - - Future> call(FavoriteEntity entity) => - repository.addFavorite(entity); -} diff --git a/lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart b/lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart deleted file mode 100644 index 18844db7..00000000 --- a/lib/features/my_list/my_list/domain/usecases/get_favorites_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class GetFavoritesUseCase { - final MyListRepository repository; - - const GetFavoritesUseCase(this.repository); - - Future>> call() => repository.getFavorites(); -} diff --git a/lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart b/lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart deleted file mode 100644 index cc95d870..00000000 --- a/lib/features/my_list/my_list/domain/usecases/remove_favorite_usecase.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/repositories/my_list_repository.dart'; - -class RemoveFavoriteUseCase { - const RemoveFavoriteUseCase(this.repository); - - final MyListRepository repository; - - Future> call(String contentUrl) => - repository.removeFavorite(contentUrl); -} diff --git a/lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart b/lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart deleted file mode 100644 index ef1d898a..00000000 --- a/lib/features/my_list/my_list/presentation/bloc/my_list_bloc.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:soplay/core/error/result.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/entities/my_list_failure.dart'; -import 'package:soplay/features/my_list/domain/usecases/get_favorites_usecase.dart'; - -import 'my_list_event.dart'; -import 'my_list_state.dart'; - -class MyListBloc extends Bloc { - final GetFavoritesUseCase useCase; - - MyListBloc({required this.useCase}) : super(const MyListInitial()) { - on(_onLoad); - on(_onRefresh); - } - - Future _onLoad(MyListLoad event, Emitter emit) async { - emit(const MyListLoading()); - await _load(emit); - } - - Future _onRefresh( - MyListRefresh event, - Emitter emit, - ) async { - final current = state; - if (current is MyListLoaded) { - emit(current.copyWith(refreshing: true)); - } else { - emit(const MyListLoading()); - } - await _load(emit); - } - - Future _load(Emitter emit) async { - final result = await useCase(); - switch (result) { - case Success>(:final value): - emit(MyListLoaded(items: value)); - case Failure>(:final error): - if (error is MyListUnauthorizedException) { - emit(const MyListUnauthorized()); - return; - } - emit(MyListError(_friendlyError(error))); - } - } - - String _friendlyError(Object error) { - return error.toString().replaceFirst('Exception: ', ''); - } -} diff --git a/lib/features/my_list/my_list/presentation/bloc/my_list_event.dart b/lib/features/my_list/my_list/presentation/bloc/my_list_event.dart deleted file mode 100644 index 8be88255..00000000 --- a/lib/features/my_list/my_list/presentation/bloc/my_list_event.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:equatable/equatable.dart'; - -sealed class MyListEvent extends Equatable { - const MyListEvent(); - - @override - List get props => []; -} - -class MyListLoad extends MyListEvent { - const MyListLoad(); -} - -class MyListRefresh extends MyListEvent { - const MyListRefresh(); -} diff --git a/lib/features/my_list/my_list/presentation/bloc/my_list_state.dart b/lib/features/my_list/my_list/presentation/bloc/my_list_state.dart deleted file mode 100644 index 614f012e..00000000 --- a/lib/features/my_list/my_list/presentation/bloc/my_list_state.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -sealed class MyListState extends Equatable { - const MyListState(); - - @override - List get props => []; -} - -class MyListInitial extends MyListState { - const MyListInitial(); -} - -class MyListLoading extends MyListState { - const MyListLoading(); -} - -class MyListLoaded extends MyListState { - final List items; - final bool refreshing; - - const MyListLoaded({required this.items, this.refreshing = false}); - - MyListLoaded copyWith({List? items, bool? refreshing}) { - return MyListLoaded( - items: items ?? this.items, - refreshing: refreshing ?? this.refreshing, - ); - } - - @override - List get props => [items, refreshing]; -} - -class MyListUnauthorized extends MyListState { - const MyListUnauthorized(); -} - -class MyListError extends MyListState { - final String message; - - const MyListError(this.message); - - @override - List get props => [message]; -} diff --git a/lib/features/my_list/my_list/presentation/pages/my_list_page.dart b/lib/features/my_list/my_list/presentation/pages/my_list_page.dart deleted file mode 100644 index 730ef7f3..00000000 --- a/lib/features/my_list/my_list/presentation/pages/my_list_page.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:async'; - -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/navigation/nav_controller.dart'; -import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/features/detail/domain/entities/detail_args.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/my_list/domain/usecases/get_favorites_usecase.dart'; -import 'package:soplay/features/my_list/presentation/bloc/my_list_bloc.dart'; -import 'package:soplay/features/my_list/presentation/bloc/my_list_event.dart'; -import 'package:soplay/features/my_list/presentation/bloc/my_list_state.dart'; -import 'package:soplay/features/my_list/presentation/widgets/favorite_card.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_background.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_header.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_skeleton.dart'; -import 'package:soplay/features/my_list/presentation/widgets/my_list_state_views.dart'; - -class MyListPage extends StatelessWidget { - const MyListPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => - MyListBloc(useCase: getIt()) - ..add(const MyListLoad()), - child: const _MyListView(), - ); - } -} - -class _MyListView extends StatefulWidget { - const _MyListView(); - - @override - State<_MyListView> createState() => _MyListViewState(); -} - -class _MyListViewState extends State<_MyListView> - with AutomaticKeepAliveClientMixin { - static const _tabIndex = 3; - - final _scrollController = ScrollController(); - final _headerBlur = ValueNotifier(0.0); - late final NavController _navController; - - @override - bool get wantKeepAlive => true; - - @override - void initState() { - super.initState(); - _navController = getIt(); - _navController.index.addListener(_onNavChange); - _scrollController.addListener(_onScroll); - } - - @override - void dispose() { - _navController.index.removeListener(_onNavChange); - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); - _headerBlur.dispose(); - super.dispose(); - } - - void _onNavChange() { - if (_navController.index.value != _tabIndex) return; - final bloc = context.read(); - if (bloc.state is MyListLoading) return; - bloc.add(const MyListLoad()); - } - - void _onScroll() { - if (!_scrollController.hasClients) return; - final next = (_scrollController.offset / 80.0).clamp(0.0, 1.0); - if ((next - _headerBlur.value).abs() > 0.01) { - _headerBlur.value = next; - } - } - - Future _refresh() async { - final bloc = context.read(); - final done = bloc.stream.firstWhere(_isSettled); - bloc.add(const MyListRefresh()); - await done.timeout( - const Duration(seconds: 20), - onTimeout: () => bloc.state, - ); - } - - bool _isSettled(MyListState state) { - return switch (state) { - MyListLoaded(:final refreshing) => !refreshing, - MyListUnauthorized() || MyListError() => true, - _ => false, - }; - } - - void _openDetail(FavoriteEntity item) { - context.push('/detail', extra: DetailArgs(contentUrl: item.contentUrl)); - } - - @override - Widget build(BuildContext context) { - super.build(context); - final topPad = MediaQuery.paddingOf(context).top; - final bottomPad = MediaQuery.paddingOf(context).bottom; - final headerH = topPad + MyListHeader.contentHeight; - - return Scaffold( - backgroundColor: AppColors.background, - body: Stack( - children: [ - const MyListBackground(), - RefreshIndicator( - onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, - edgeOffset: headerH, - child: BlocBuilder( - builder: (context, state) { - return CustomScrollView( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: SizedBox(height: headerH + 16)), - _buildBody(state, bottomPad), - ], - ); - }, - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: ValueListenableBuilder( - valueListenable: _headerBlur, - builder: (_, blur, _) => - MyListHeader(topPad: topPad, blurProgress: blur), - ), - ), - ], - ), - ); - } - - Widget _buildBody(MyListState state, double bottomPad) { - return switch (state) { - MyListInitial() || MyListLoading() => const MyListSkeleton(), - MyListLoaded(:final items) => - items.isEmpty - ? const SliverFillRemaining( - hasScrollBody: false, - child: MyListEmptyView(), - ) - : SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, bottomPad + 96), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 142, - mainAxisSpacing: 16, - crossAxisSpacing: 10, - childAspectRatio: 0.56, - ), - delegate: SliverChildBuilderDelegate( - (_, i) => FavoriteCard( - item: items[i], - onTap: () => _openDetail(items[i]), - ), - childCount: items.length, - ), - ), - ), - MyListUnauthorized() => SliverFillRemaining( - hasScrollBody: false, - child: MyListUnauthorizedView(onLogin: () => context.push('/login')), - ), - MyListError(:final message) => SliverFillRemaining( - hasScrollBody: false, - child: MyListErrorView( - message: message, - onRetry: () => context.read().add(const MyListLoad()), - ), - ), - }; - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart b/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart deleted file mode 100644 index 3e4273dd..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; -import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; - -class FavoriteCard extends StatelessWidget { - const FavoriteCard({super.key, required this.item, required this.onTap}); - - final FavoriteEntity item; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final title = item.title.trim().isEmpty ? 'Untitled' : item.title.trim(); - final description = item.description.trim(); - final meta = description.isNotEmpty ? description : item.provider.trim(); - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - HomeNetworkImage( - url: item.thumbnail, - borderRadius: BorderRadius.zero, - placeholderIcon: Icons.movie_outlined, - ), - const Positioned( - left: 0, - right: 0, - bottom: 0, - child: SizedBox( - height: 44, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Color(0xB0000000), Color(0x00000000)], - ), - ), - ), - ), - ), - Positioned( - top: 6, - right: 6, - child: Container( - width: 26, - height: 26, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.52), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.bookmark_rounded, - color: Colors.white, - size: 16, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 7), - Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, - height: 1.18, - ), - ), - if (meta.isNotEmpty) ...[ - const SizedBox(height: 3), - Text( - meta, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 10.5, - fontWeight: FontWeight.w500, - height: 1.2, - ), - ), - ], - ], - ), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_background.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_background.dart deleted file mode 100644 index 4c65367c..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_background.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; - -class MyListBackground extends StatelessWidget { - const MyListBackground({super.key}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [const Color(0xFF161616), AppColors.background, const Color(0xFF101010)], - stops: const [0, 0.42, 1], - ), - ), - child: const SizedBox.expand(), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_header.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_header.dart deleted file mode 100644 index f74cfa45..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_header.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'dart:ui'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; - -class MyListHeader extends StatelessWidget { - const MyListHeader({ - super.key, - required this.topPad, - required this.blurProgress, - }); - - final double topPad; - final double blurProgress; - - static const double contentHeight = 58.0; - - @override - Widget build(BuildContext context) { - final progress = blurProgress.clamp(0.0, 1.0); - - final content = Container( - padding: EdgeInsetsDirectional.fromSTEB(20, topPad + 14, 16, 14), - decoration: BoxDecoration( - color: AppColors.navBackground.withValues(alpha: 0.78 * progress), - border: progress > 0.05 - ? Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.07 * progress), - width: 0.5, - ), - ) - : null, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - 'home.my_list'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 28, - fontWeight: FontWeight.w900, - height: 1.05, - ), - ), - ), - ], - ), - ); - - if (progress < 0.01) return content; - - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), - child: content, - ), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart deleted file mode 100644 index 7441d404..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_skeleton.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; - -class MyListSkeleton extends StatelessWidget { - const MyListSkeleton({super.key}); - - @override - Widget build(BuildContext context) { - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 142, - mainAxisSpacing: 16, - crossAxisSpacing: 10, - childAspectRatio: 0.56, - ), - delegate: SliverChildBuilderDelegate( - (_, _) => const _SkeletonCard(), - childCount: 9, - ), - ), - ); - } -} - -class _SkeletonCard extends StatelessWidget { - const _SkeletonCard(); - - @override - Widget build(BuildContext context) { - return const ShimmerWrapper( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: HomeSkeletonBox( - width: double.infinity, - height: 1, - radius: 10, - ), - ), - SizedBox(height: 7), - HomeSkeletonBox(width: double.infinity, height: 12, radius: 4), - SizedBox(height: 5), - HomeSkeletonBox(width: 72, height: 10, radius: 4), - ], - ), - ); - } -} diff --git a/lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart b/lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart deleted file mode 100644 index 28bc53b8..00000000 --- a/lib/features/my_list/my_list/presentation/widgets/my_list_state_views.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:soplay/core/theme/app_colors.dart'; - -class MyListEmptyView extends StatelessWidget { - const MyListEmptyView({super.key}); - - @override - Widget build(BuildContext context) { - return const MyListStateView( - icon: Icons.bookmark_border_rounded, - title: 'Your list is empty', - message: 'Tap the + button on any title to save it here.', - ); - } -} - -class MyListUnauthorizedView extends StatelessWidget { - const MyListUnauthorizedView({super.key, required this.onLogin}); - - final VoidCallback onLogin; - - @override - Widget build(BuildContext context) { - return MyListStateView( - icon: Icons.lock_outline_rounded, - title: 'Sign in to view your list', - message: 'Your saved titles will appear here once you sign in.', - actionLabel: 'Sign In', - onAction: onLogin, - ); - } -} - -class MyListErrorView extends StatelessWidget { - const MyListErrorView({ - super.key, - required this.message, - required this.onRetry, - }); - - final String message; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return MyListStateView( - icon: Icons.wifi_off_rounded, - title: 'Could not load list', - message: message, - actionLabel: 'Try Again', - onAction: onRetry, - ); - } -} - -class MyListStateView extends StatelessWidget { - const MyListStateView({ - super.key, - required this.icon, - required this.title, - required this.message, - this.actionLabel, - this.onAction, - }); - - final IconData icon; - final String title; - final String message; - final String? actionLabel; - final VoidCallback? onAction; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 120), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.09)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _IconCircle(icon: icon), - const SizedBox(height: 18), - Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w800, - height: 1.2, - ), - ), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 13, - height: 1.5, - ), - ), - if (actionLabel != null && onAction != null) ...[ - const SizedBox(height: 22), - SizedBox( - height: 46, - width: double.infinity, - child: ElevatedButton( - onPressed: onAction, - child: Text(actionLabel!), - ), - ), - ], - ], - ), - ), - ), - ), - ), - ); - } -} - -class _IconCircle extends StatelessWidget { - const _IconCircle({required this.icon}); - - final IconData icon; - - @override - Widget build(BuildContext context) { - return Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - AppColors.primary.withValues(alpha: 0.22), - AppColors.primary.withValues(alpha: 0.06), - ], - ), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.22)), - ), - child: Icon(icon, color: AppColors.primaryLight, size: 30), - ); - } -} diff --git a/lib/features/my_list/presentation/pages/my_list_page.dart b/lib/features/my_list/presentation/pages/my_list_page.dart index a643b47c..854ae0f4 100644 --- a/lib/features/my_list/presentation/pages/my_list_page.dart +++ b/lib/features/my_list/presentation/pages/my_list_page.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/widgets/item_appear.dart'; import 'package:soplay/core/navigation/app_tab.dart'; @@ -29,11 +31,10 @@ class MyListPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - MyListBloc( - useCase: getIt(), - private: getIt(), - )..add(const MyListLoad()), + create: (_) => MyListBloc( + useCase: getIt(), + private: getIt(), + )..add(const MyListLoad()), child: const _MyListView(), ); } @@ -54,6 +55,28 @@ class _MyListViewState extends State<_MyListView> late final MyListLocalDataSource _localFavorites; late final PrivateListService _privateFavorites; + static const _sortKey = 'my_list_sort'; + MyListSort _sort = MyListSort.parse(_settings?.get(_sortKey)); + + static Box? get _settings => Hive.isBoxOpen(AppConstants.settingsBox) + ? Hive.box(AppConstants.settingsBox) + : null; + + void _setSort(MyListSort sort) { + setState(() => _sort = sort); + unawaited(_settings?.put(_sortKey, sort.name)); + } + + /// [items] in the picked order. They arrive newest-added first. + List _sorted(List items) => switch (_sort) { + MyListSort.added => items, + MyListSort.oldest => items.reversed.toList(), + MyListSort.title => + items.toList()..sort( + (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), + ), + }; + @override bool get wantKeepAlive => true; @@ -128,7 +151,10 @@ class _MyListViewState extends State<_MyListView> } void _openDetail(FavoriteEntity item) { - context.push('/detail', extra: DetailArgs(contentUrl: item.contentUrl, provider: item.provider)); + context.push( + '/detail', + extra: DetailArgs(contentUrl: item.contentUrl, provider: item.provider), + ); } @override @@ -171,6 +197,8 @@ class _MyListViewState extends State<_MyListView> topPad: topPad, blurProgress: blur, onRefresh: _refresh, + sort: _sort, + onSort: _setSort, ), ), ), @@ -182,23 +210,24 @@ class _MyListViewState extends State<_MyListView> Widget _buildBody(MyListState state, double bottomPad) { return switch (state) { MyListInitial() || MyListLoading() => const MyListSkeleton(), - MyListLoaded(:final items) => - items.isEmpty - ? const SliverFillRemaining( - hasScrollBody: false, - child: MyListEmptyView(), - ) - : SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, bottomPad + 96), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 142, - mainAxisSpacing: 16, - crossAxisSpacing: 10, - childAspectRatio: 0.56, - ), - delegate: SliverChildBuilderDelegate( - (context, i) { + MyListLoaded(items: final loaded) => switch (_sorted(loaded)) { + final items => + items.isEmpty + ? const SliverFillRemaining( + hasScrollBody: false, + child: MyListEmptyView(), + ) + : SliverPadding( + padding: EdgeInsets.fromLTRB(16, 0, 16, bottomPad + 96), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 142, + mainAxisSpacing: 16, + crossAxisSpacing: 10, + childAspectRatio: 0.56, + ), + delegate: SliverChildBuilderDelegate((context, i) { final item = items[i]; return ItemAppear( index: i, @@ -216,11 +245,10 @@ class _MyListViewState extends State<_MyListView> onTap: () => _openDetail(item), ), ); - }, - childCount: items.length, + }, childCount: items.length), ), ), - ), + }, MyListUnauthorized() => SliverFillRemaining( hasScrollBody: false, child: MyListUnauthorizedView(onLogin: () => context.push('/login')), diff --git a/lib/features/my_list/presentation/widgets/favorite_card.dart b/lib/features/my_list/presentation/widgets/favorite_card.dart index 42625efe..55f6f1c9 100644 --- a/lib/features/my_list/presentation/widgets/favorite_card.dart +++ b/lib/features/my_list/presentation/widgets/favorite_card.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/content/catalogue.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; @@ -29,7 +30,9 @@ class FavoriteCard extends StatelessWidget { final title = item.title.trim().isEmpty ? 'my_list.untitled'.tr() : item.title.trim(); final description = item.description.trim(); - final meta = description.isNotEmpty ? description : item.provider.trim(); + final meta = description.isNotEmpty + ? description + : Catalogue.displayName(item.provider.trim()); return HoverTap( onTap: onTap, diff --git a/lib/features/my_list/presentation/widgets/my_list_header.dart b/lib/features/my_list/presentation/widgets/my_list_header.dart index d7971af7..0d19d4bf 100644 --- a/lib/features/my_list/presentation/widgets/my_list_header.dart +++ b/lib/features/my_list/presentation/widgets/my_list_header.dart @@ -1,10 +1,25 @@ -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; + +/// The order My List is shown in. Remembered on the device. +enum MyListSort { + added('my_list.sort_added'), + oldest('my_list.sort_oldest'), + title('my_list.sort_title'); + + const MyListSort(this.labelKey); + final String labelKey; + + static MyListSort parse(Object? raw) => MyListSort.values.firstWhere( + (s) => s.name == raw, + orElse: () => MyListSort.added, + ); +} class MyListHeader extends StatelessWidget { const MyListHeader({ @@ -12,12 +27,16 @@ class MyListHeader extends StatelessWidget { required this.topPad, required this.blurProgress, this.onRefresh, + this.sort = MyListSort.added, + this.onSort, }); final double topPad; final double blurProgress; final VoidCallback? onRefresh; + final MyListSort sort; + final ValueChanged? onSort; static const double contentHeight = 58.0; @@ -52,6 +71,24 @@ class MyListHeader extends StatelessWidget { ), ), ), + if (onSort != null) + PopupMenuButton( + tooltip: 'my_list.sort'.tr(), + icon: const Icon( + Icons.sort_rounded, + color: AppColors.textSecondary, + ), + initialValue: sort, + onSelected: onSort, + itemBuilder: (_) => [ + for (final s in MyListSort.values) + CheckedPopupMenuItem( + value: s, + checked: s == sort, + child: Text(s.labelKey.tr()), + ), + ], + ), // Entry point to the curated lists (Watch Later / Watched). They live // on their own page rather than as modes of this one — see // UserListsPage — so this is the only way in. @@ -75,8 +112,9 @@ class MyListHeader extends StatelessWidget { if (progress < 0.01) return content; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), + child: ReducibleBlur( + sigma: 20 * progress, + reducedFill: AppColors.navBackground.withValues(alpha: 0.15 * progress), child: content, ), ); diff --git a/lib/features/notifications/presentation/widgets/notification_priming.dart b/lib/features/notifications/presentation/widgets/notification_priming.dart index a9e38c7b..57d970be 100644 --- a/lib/features/notifications/presentation/widgets/notification_priming.dart +++ b/lib/features/notifications/presentation/widgets/notification_priming.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/core/widgets/app_buttons.dart'; @@ -29,6 +30,7 @@ import 'package:soplay/features/tracker/data/follow_service.dart'; Future showNotificationPriming( BuildContext context, { String? titleHint, + bool forDownloads = false, }) async { if (!Platform.isAndroid && !Platform.isIOS) return false; final service = getIt(); @@ -44,6 +46,7 @@ Future showNotificationPriming( titleHint: titleHint?.trim().isEmpty ?? true ? null : titleHint!.trim(), blocked: blocked, service: service, + forDownloads: forDownloads, ), ); if (result == true) return true; @@ -51,6 +54,31 @@ Future showNotificationPriming( return service.permissionGranted; } +bool _downloadPrimingAsked = false; + +/// Once, on the first download started on Android without notification +/// permission: the progress and "done" notices need it from Android 13 on. +Future primeNotificationsForFirstDownload( + BuildContext context, { + String? titleHint, +}) async { + if (!Platform.isAndroid || _downloadPrimingAsked) return; + final hive = getIt(); + if (hive.downloadPrimingShown) return; + _downloadPrimingAsked = true; + if (await getIt().permissionGranted) { + _downloadPrimingAsked = false; + return; + } + await hive.markDownloadPrimingShown(); + if (!context.mounted) return; + await showNotificationPriming( + context, + titleHint: titleHint, + forDownloads: true, + ); +} + enum _Phase { ask, blocked, done } class _PrimingSheet extends StatefulWidget { @@ -58,11 +86,13 @@ class _PrimingSheet extends StatefulWidget { required this.titleHint, required this.blocked, required this.service, + this.forDownloads = false, }); final String? titleHint; final bool blocked; final NotificationService service; + final bool forDownloads; @override State<_PrimingSheet> createState() => _PrimingSheetState(); @@ -178,9 +208,13 @@ class _PrimingSheetState extends State<_PrimingSheet> ? 'release_notify.priming_enabled'.tr() : blocked ? 'release_notify.priming_blocked_title'.tr() + : widget.forDownloads + ? 'release_notify.priming_download_title'.tr() : 'release_notify.priming_title'.tr(); final body = blocked ? 'release_notify.priming_blocked_body'.tr() + : widget.forDownloads + ? 'release_notify.priming_download_body'.tr() : hint != null ? 'release_notify.priming_body_title'.tr(args: [hint]) : 'release_notify.priming_body'.tr(); @@ -394,7 +428,11 @@ class _Illustration extends StatelessWidget { ], ), child: done - ? const Icon(Icons.check_rounded, color: Colors.white, size: 32) + ? const Icon( + Icons.check_rounded, + color: Colors.white, + size: 32, + ) : AnimatedBell( state: BellState.on, color: AppColors.onPrimary, @@ -529,11 +567,15 @@ class _MockCard extends StatelessWidget { Row( children: [ Flexible( - child: _MockAction(label: 'release_notify.action_watch'.tr()), + child: _MockAction( + label: 'release_notify.action_watch'.tr(), + ), ), const SizedBox(width: 14), Flexible( - child: _MockAction(label: 'release_notify.action_seen'.tr()), + child: _MockAction( + label: 'release_notify.action_seen'.tr(), + ), ), ], ), @@ -552,8 +594,10 @@ class _MockCard extends StatelessWidget { : CachedNetworkImage( imageUrl: poster!, fit: BoxFit.cover, - errorWidget: (_, _, _) => _PosterPlaceholder(compact: compact), - placeholder: (_, _) => _PosterPlaceholder(compact: compact), + errorWidget: (_, _, _) => + _PosterPlaceholder(compact: compact), + placeholder: (_, _) => + _PosterPlaceholder(compact: compact), ), ), ), @@ -593,7 +637,10 @@ class _PosterPlaceholder extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [AppColors.primary.withValues(alpha: 0.85), AppColors.primaryDark], + colors: [ + AppColors.primary.withValues(alpha: 0.85), + AppColors.primaryDark, + ], ), ), child: Center( diff --git a/lib/features/onboarding/presentation/onboarding_navigation.dart b/lib/features/onboarding/presentation/onboarding_navigation.dart index 148bc776..8428a8b6 100644 --- a/lib/features/onboarding/presentation/onboarding_navigation.dart +++ b/lib/features/onboarding/presentation/onboarding_navigation.dart @@ -20,6 +20,7 @@ import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; OnboardingController get _controller => getIt(); @@ -146,6 +147,7 @@ Future _goAfterAuth(BuildContext context) async { final c = _controller; if (!c.active || c.flow != OnboardingFlow.firstRun) { router.go('/main'); + await openPendingParty(router); return; } const beforeAuth = {OnboardingStep.welcome, OnboardingStep.account}; diff --git a/lib/features/onboarding/presentation/pages/onboarding_kinds_page.dart b/lib/features/onboarding/presentation/pages/onboarding_kinds_page.dart index 5c6fd5e9..68e70897 100644 --- a/lib/features/onboarding/presentation/pages/onboarding_kinds_page.dart +++ b/lib/features/onboarding/presentation/pages/onboarding_kinds_page.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -341,7 +342,7 @@ class _CardFace extends StatelessWidget { color: AppColors.textPrimary, fontSize: OnbType.label + 2, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), diff --git a/lib/features/profile/data/backup_service.dart b/lib/features/profile/data/backup_service.dart index 58102c13..5aa758be 100644 --- a/lib/features/profile/data/backup_service.dart +++ b/lib/features/profile/data/backup_service.dart @@ -107,6 +107,9 @@ class BackupService { // Which household profile this device acts as; the account decides that. ProfileSession.cacheKey, ProfileSession.activeKey, + // Names this device to the XP counter; a copy on a second device would + // make the two share one watch-time total. + 'progress_device_id', }; /// The file always holds one person's data under the plain names, so it diff --git a/lib/features/profile/data/contributors_service.dart b/lib/features/profile/data/contributors_service.dart new file mode 100644 index 00000000..f9fc5a8a --- /dev/null +++ b/lib/features/profile/data/contributors_service.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/features/profile/domain/entities/contributor.dart'; + +/// The About page's contributor list: the last one seen, from Hive, at once, +/// then whatever `/contributors` says. A failed refresh keeps the old list. +class ContributorsService { + ContributorsService({required Dio dio}) : _dio = dio { + list.value = _readCache(); + } + + final Dio _dio; + + /// Null until something is known, from the cache or the network. + final ValueNotifier?> list = ValueNotifier(null); + + /// True once a refresh has failed with nothing cached to show. + final ValueNotifier failed = ValueNotifier(false); + + DateTime? _fetchedAt; + bool _inFlight = false; + + static const Duration _minRefresh = Duration(minutes: 30); + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + List? _readCache() { + try { + final raw = _box?.get(AppConstants.contributorsCacheKey); + if (raw is String && raw.isNotEmpty) { + final rows = Contributor.listFrom(jsonDecode(raw)); + if (rows.isNotEmpty) return rows; + } + } catch (_) {} + return null; + } + + Future refresh() async { + if (_inFlight) return; + final at = _fetchedAt; + if (at != null && DateTime.now().difference(at) < _minRefresh) return; + _inFlight = true; + failed.value = false; + try { + final res = await _dio.get( + '/contributors', + options: Options(extra: const {'skipAuthInterceptor': true}), + ); + final rows = Contributor.listFrom(res.data); + if (rows.isEmpty) throw StateError('empty contributors'); + _fetchedAt = DateTime.now(); + list.value = rows; + try { + await _box?.put( + AppConstants.contributorsCacheKey, + jsonEncode([for (final c in rows) c.toJson()]), + ); + } catch (_) {} + } catch (e) { + debugPrint('[contributors] refresh failed: $e'); + if (list.value == null) failed.value = true; + } finally { + _inFlight = false; + } + } +} diff --git a/lib/features/profile/domain/entities/contributor.dart b/lib/features/profile/domain/entities/contributor.dart new file mode 100644 index 00000000..0aa3ae60 --- /dev/null +++ b/lib/features/profile/domain/entities/contributor.dart @@ -0,0 +1,60 @@ +/// Someone who has committed to one of the open-source Sozo repositories. +class Contributor { + const Contributor({ + required this.login, + required this.avatarUrl, + required this.htmlUrl, + required this.contributions, + }); + + final String login; + final String avatarUrl; + final String htmlUrl; + final int contributions; + + /// Null for anything without a login. A profile link that is not on + /// github.com is replaced, so a tap can only ever open GitHub. + static Contributor? fromJson(Object? raw) { + if (raw is! Map) return null; + final login = raw['login']; + if (login is! String || login.trim().isEmpty) return null; + final name = login.trim(); + final html = raw['htmlUrl']; + final avatar = raw['avatarUrl']; + final count = raw['contributions']; + return Contributor( + login: name, + avatarUrl: avatar is String && avatar.startsWith('https://') + ? avatar + : '', + htmlUrl: html is String && html.startsWith('https://github.com/') + ? html + : 'https://github.com/$name', + contributions: count is num ? count.toInt() : 0, + ); + } + + /// Reads `{contributors: [...]}` or a bare list, skipping bad rows. + static List listFrom(Object? data) { + final rows = data is Map ? data['contributors'] : data; + if (rows is! List) return const []; + return [for (final row in rows) ?Contributor.fromJson(row)]; + } + + /// GitHub serves an avatar at any size through `s`; the default is 460 px. + static String sizedAvatar(String url, int px) { + if (url.isEmpty) return url; + final uri = Uri.tryParse(url); + if (uri == null) return url; + return uri + .replace(queryParameters: {...uri.queryParameters, 's': '$px'}) + .toString(); + } + + Map toJson() => { + 'login': login, + 'avatarUrl': avatarUrl, + 'htmlUrl': htmlUrl, + 'contributions': contributions, + }; +} diff --git a/lib/features/profile/presentation/bloc/provider_bloc.dart b/lib/features/profile/presentation/bloc/provider_bloc.dart index 79993115..d10b94a5 100644 --- a/lib/features/profile/presentation/bloc/provider_bloc.dart +++ b/lib/features/profile/presentation/bloc/provider_bloc.dart @@ -7,8 +7,10 @@ import 'package:soplay/core/manga/manga_channel.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/extensions/data/mangayomi_bridge.dart'; +import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_bridge.dart'; import 'package:soplay/core/extractor/provider_manager.dart'; +import 'package:soplay/core/js/js_runtime_service.dart'; import 'package:soplay/core/js/provider_registry.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/profile/data/models/provider_model.dart'; @@ -90,7 +92,11 @@ class ProviderBloc extends Bloc { Success( ProvidersSnapshot( providers: previous.providers - .where((p) => !p.isServerIndependent) + .where( + (p) => + !p.isServerIndependent && + !ZangetsuRepoStore.isExternalId(p.id), + ) .toList(), fromCache: previous.offline, cachedAt: previous.cachedAt, @@ -134,6 +140,13 @@ class ProviderBloc extends Bloc { ..addAll(localManga) ..addAll(localMangayomi) ..addAll(localJellyfin); + final withZangetsu = ZangetsuRepoStore.merge( + providers, + _zangetsuProviders(), + ); + providers + ..clear() + ..addAll(withZangetsu); if (providers.isEmpty) { if (previous is! ProviderLoaded) { @@ -353,6 +366,20 @@ class ProviderBloc extends Bloc { } catch (_) {} } + /// Sources installed from external Zangetsu repos. They run in the same + /// WebView runtime as Sozo's own device extractors, whose bundles the backend + /// composes, so they are not server-independent. + List _zangetsuProviders() { + if (!JsRuntimeService.isSupported) return const []; + try { + return getIt().providers( + allowAdult: hiveService.showAdultContent, + ); + } catch (_) { + return const []; + } + } + /// Signed-in Jellyfin servers, one source each. Read from the local store, /// so they list with the backend down and on every platform. Future _appendJellyfinProviders(List into) async { diff --git a/lib/features/profile/presentation/pages/about_page.dart b/lib/features/profile/presentation/pages/about_page.dart index a9daa18a..4dd30e1c 100644 --- a/lib/features/profile/presentation/pages/about_page.dart +++ b/lib/features/profile/presentation/pages/about_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/presentation/widgets/contributors_section.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -203,6 +204,8 @@ class AboutSection extends StatelessWidget { ), ], ), + const SizedBox(height: 24), + const ContributorsSection(), ], ); } diff --git a/lib/features/profile/presentation/pages/activity_page.dart b/lib/features/profile/presentation/pages/activity_page.dart index 8f984f45..42077338 100644 --- a/lib/features/profile/presentation/pages/activity_page.dart +++ b/lib/features/profile/presentation/pages/activity_page.dart @@ -57,7 +57,7 @@ class _ActivityPageState extends State { SettingsNavTile( icon: Icons.history_rounded, title: 'profile.watch_history'.tr(), - value: countLabel(_history.getAll().length), + value: countLabel(_history.count), onTap: () => context.push('/history'), ), const SettingsDivider(), diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index b135eff7..633ee229 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -6,17 +6,22 @@ import 'package:flutter/material.dart'; import 'package:soplay/features/detail/presentation/pages/player_controls_page.dart'; import 'package:soplay/features/detail/presentation/widgets/player_info_fields_sheet.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; import 'package:soplay/core/player/media_controller.dart' show warmUpPlayerEngine; import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/core/player/quality_preference.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/core/subtitles/subtitle_languages.dart'; import 'package:soplay/features/detail/domain/entities/subtitle_style.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/subtitle_size_control.dart'; +import 'package:soplay/features/profile/presentation/widgets/player_controls_style_section.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; +import 'package:soplay/features/comments/data/social_playback_prefs.dart'; /// Settings → Player. Every control here seeds a knob that already exists /// inside the fullscreen player. @@ -57,13 +62,19 @@ class _PlayerSettingsPageState extends State { late double _boost; late bool _brightnessGesture; late bool _volumeGesture; + late bool _autoPip; late bool _heroTrailer; late bool _keepScreenOn; late bool _incognito; late bool _autoSkipIntro; + final SocialPlaybackPrefs _social = const SocialPlaybackPrefs(); + late bool _reactionsOverlay; + late bool _hideSpoilers; late SubtitleStyle _subtitle; late bool _autoTranslate; late String _translateLang; + late PlayerControlsStyle _controlsStyle; + NativeGlassCapability _glass = NativeGlassCapability.absent; static const List _speedChoices = [ 0.5, @@ -96,15 +107,24 @@ class _PlayerSettingsPageState extends State { _startPaused = _hive.startPaused; _incognito = _hive.isIncognito; _autoSkipIntro = _hive.autoSkipIntro; + _reactionsOverlay = _social.reactionsOverlay; + _hideSpoilers = _social.hideUnwatchedSpoilers; _seekSeconds = _hive.getDoubleTapSeekSeconds(); _boost = _hive.getLongPressBoost(); _brightnessGesture = _hive.brightnessGestureEnabled; _volumeGesture = _hive.volumeGestureEnabled; + _autoPip = _hive.autoPipEnabled; _heroTrailer = _hive.heroTrailerAutoplay; _keepScreenOn = _hive.keepScreenOn; _subtitle = _hive.getSubtitleStyle(); _autoTranslate = _hive.getSubtitleAutoTranslate(); _translateLang = _hive.getSubtitleTranslateLang(); + _controlsStyle = _hive.playerControlsStyle; + if (PlayerControlsStyleSection.shown) { + NativeGlass.capability().then((c) { + if (mounted) setState(() => _glass = c); + }); + } if (kDebugMode) { final readMs = _openWatch.elapsedMilliseconds; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -206,6 +226,18 @@ class _PlayerSettingsPageState extends State { const SizedBox(height: 22), ], + if (PlayerControlsStyleSection.shown) ...[ + PlayerControlsStyleSection( + chosen: _controlsStyle, + capability: _glass, + onChanged: (v) { + setState(() => _controlsStyle = v); + _hive.setPlayerControlsStyle(v); + }, + ), + const SizedBox(height: 22), + ], + SettingsLabel('profile.playback_section'.tr()), SettingsCard( children: [ @@ -292,6 +324,28 @@ class _PlayerSettingsPageState extends State { }, ), const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.add_reaction_outlined, + title: 'profile.reactions_overlay'.tr(), + subtitle: 'profile.reactions_overlay_desc'.tr(), + value: _reactionsOverlay, + onChanged: (v) { + setState(() => _reactionsOverlay = v); + _social.setReactionsOverlay(v); + }, + ), + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.speaker_notes_off_outlined, + title: 'profile.hide_spoilers'.tr(), + subtitle: 'profile.hide_spoilers_desc'.tr(), + value: _hideSpoilers, + onChanged: (v) { + setState(() => _hideSpoilers = v); + _social.setHideUnwatchedSpoilers(v); + }, + ), + const SettingsDivider(), SettingsSwitchTile( icon: Icons.visibility_off_outlined, title: 'profile.incognito'.tr(), @@ -377,6 +431,19 @@ class _PlayerSettingsPageState extends State { _hive.setVolumeGestureEnabled(v); }, ), + if (isAndroidPlatform && !isTvPlatform) ...[ + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.picture_in_picture_alt_rounded, + title: 'profile.auto_pip'.tr(), + subtitle: 'profile.auto_pip_desc'.tr(), + value: _autoPip, + onChanged: (v) { + setState(() => _autoPip = v); + _hive.setAutoPipEnabled(v); + }, + ), + ], const SettingsDivider(), // A video that starts on its own, on a page somebody opened to // read — worth offering, worth being able to refuse. Turning it diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 0b65e0a8..f83a08aa 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -8,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.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'; @@ -49,6 +49,10 @@ import 'package:soplay/core/storage/profile_scope.dart'; import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/achievements/presentation/widgets/achievements_entry_card.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; // Split into parts the way player_page.dart is: the page was 2721 lines // across nine sections, which is past the point where anyone can find @@ -293,10 +297,10 @@ class _ProfileViewState extends State<_ProfileView> { ); if (progress < 0.01) return content; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 20 * progress, - sigmaY: 20 * progress, + child: ReducibleBlur( + sigma: 20 * progress, + reducedFill: AppColors.navBackground.withValues( + alpha: 0.15 * progress, ), child: content, ), @@ -505,33 +509,36 @@ class _SettingsNavItemState extends State<_SettingsNavItem> { cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hover = true), onExit: (_) => setState(() => _hover = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 140), - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: active - ? AppColors.primary.withValues(alpha: 0.12) - : (_hover - ? Colors.white.withValues(alpha: 0.05) - : Colors.transparent), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - Icon(widget.icon, color: color, size: 18), - const SizedBox(width: 12), - Text( - widget.label, - style: TextStyle( - color: color, - fontSize: 14, - fontWeight: active ? FontWeight.w700 : FontWeight.w500, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: HoverTap( + onTap: widget.onTap, + scale: 1, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.12) + : (_hover + ? Colors.white.withValues(alpha: 0.05) + : Colors.transparent), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(widget.icon, color: color, size: 18), + const SizedBox(width: 12), + Text( + widget.label, + style: TextStyle( + color: color, + fontSize: 14, + fontWeight: active ? FontWeight.w700 : FontWeight.w500, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/profile/presentation/pages/profile_page.header.dart b/lib/features/profile/presentation/pages/profile_page.header.dart index 90c1c83b..227a2244 100644 --- a/lib/features/profile/presentation/pages/profile_page.header.dart +++ b/lib/features/profile/presentation/pages/profile_page.header.dart @@ -186,11 +186,23 @@ class _UserContent extends StatelessWidget { if (user.authProvider != 'local') 'Google', ].join(' · '); - return Padding( + return ValueListenableBuilder( + valueListenable: getIt.isRegistered() + ? getIt().state + : _noProgress, + builder: (context, progress, _) => Padding( padding: const EdgeInsets.all(16), child: Row( children: [ - _Avatar(user: user), + GestureDetector( + onTap: progress == null ? null : () => context.push('/progress'), + child: FramedAvatar( + size: 72, + radius: 20, + frame: progress?.frame, + child: _Avatar(user: user), + ), + ), const SizedBox(width: 14), Expanded( child: Column( @@ -198,11 +210,13 @@ class _UserContent extends StatelessWidget { children: [ Text( name, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: + Cosmetics.nameColor(progress?.nameColor) ?? + AppColors.textPrimary, fontSize: 19, fontWeight: FontWeight.w800, - height: 1.15, + height: lineHeightFor(context, 1.15), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -229,6 +243,10 @@ class _UserContent extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ], + if (progress != null) ...[ + const SizedBox(height: 8), + _LevelLink(view: progress), + ], ], ), ), @@ -236,6 +254,53 @@ class _UserContent extends StatelessWidget { _EditProfileButton(user: user), ], ), + ), + ); + } +} + +final ValueNotifier _noProgress = ValueNotifier(null); + +/// The level under the name; the way into the progress page. +class _LevelLink extends StatelessWidget { + const _LevelLink({required this.view}); + + final ProgressView view; + + @override + Widget build(BuildContext context) { + final p = view.progress; + final title = Cosmetics.titleText(view.title); + return HoverTap( + onTap: () => context.push('/progress'), + borderRadius: 8, + scale: 1.02, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LevelChip(level: p.level, fontSize: 11), + const SizedBox(width: 8), + Flexible( + child: Text( + title ?? 'progress.to_next_short'.tr(args: ['${p.toNext}']), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppColors.textHint, + ), + ], + ), + ), ); } } diff --git a/lib/features/profile/presentation/pages/profile_page.sections.dart b/lib/features/profile/presentation/pages/profile_page.sections.dart index 244e79a7..057e0c6b 100644 --- a/lib/features/profile/presentation/pages/profile_page.sections.dart +++ b/lib/features/profile/presentation/pages/profile_page.sections.dart @@ -55,7 +55,7 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { void _reload() { if (!mounted) return; setState(() { - _historyCount = _historyService.getAll().length; + _historyCount = _historyService.count; _downloadCount = _downloadService().length; }); } diff --git a/lib/features/profile/presentation/pages/sources_page.dart b/lib/features/profile/presentation/pages/sources_page.dart index ce4b6a88..e1094ae4 100644 --- a/lib/features/profile/presentation/pages/sources_page.dart +++ b/lib/features/profile/presentation/pages/sources_page.dart @@ -14,6 +14,8 @@ import 'package:soplay/features/cloudstream/presentation/pages/cloudstream_sourc 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/extensions/presentation/pages/zangetsu_sources_page.dart'; +import 'package:soplay/core/js/js_runtime_service.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_bridge.dart'; import 'package:soplay/features/jellyfin/presentation/pages/jellyfin_servers_page.dart'; import 'package:soplay/features/manga/presentation/pages/manga_sources_page.dart'; @@ -89,6 +91,15 @@ class SourcesPage extends StatelessWidget { MaterialPageRoute(builder: (_) => const MangayomiSourcesPage()), ), ), + if (JsRuntimeService.isSupported) + SettingsNavTile( + icon: Icons.bolt_rounded, + title: 'zangetsu.title'.tr(), + subtitle: 'zangetsu.row_subtitle'.tr(), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ZangetsuSourcesPage()), + ), + ), // Talks to the server directly, so it works on every platform. SettingsNavTile( leading: const SettingsTileLogo( diff --git a/lib/features/profile/presentation/widgets/contributors_section.dart b/lib/features/profile/presentation/widgets/contributors_section.dart new file mode 100644 index 00000000..9dc67952 --- /dev/null +++ b/lib/features/profile/presentation/widgets/contributors_section.dart @@ -0,0 +1,225 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:soplay/core/constants/app_constants.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/profile/data/contributors_service.dart'; +import 'package:soplay/features/profile/domain/entities/contributor.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; + +/// Everyone who has committed to Sozo, and a way in for the next one. +class ContributorsSection extends StatefulWidget { + const ContributorsSection({super.key}); + + static const String contributingUrl = + '${AppConstants.sozoRepoUrl}/blob/master/CONTRIBUTING.md'; + + @override + State createState() => _ContributorsSectionState(); +} + +class _ContributorsSectionState extends State { + final ContributorsService _service = getIt(); + + @override + void initState() { + super.initState(); + _service.refresh(); + } + + static Future _open(String url) async { + try { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder?>( + valueListenable: _service.list, + builder: (context, people, _) { + final title = 'profile.contributors'.tr(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsLabel( + people == null ? title : '$title · ${people.length}', + ), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 12), + child: people != null + ? _Grid(people: people, onOpen: _open) + : ValueListenableBuilder( + valueListenable: _service.failed, + builder: (_, failed, _) => failed + ? _QuietError(onRetry: _service.refresh) + : const _Skeleton(), + ), + ), + const SettingsDivider(), + SettingsNavTile( + icon: Icons.volunteer_activism_outlined, + title: 'profile.become_contributor'.tr(), + subtitle: 'profile.become_contributor_body'.tr(), + onTap: () => _open(ContributorsSection.contributingUrl), + ), + ], + ), + ], + ); + }, + ); + } +} + +const double _avatar = 44; +const double _cell = 64; + +class _Grid extends StatelessWidget { + const _Grid({required this.people, required this.onOpen}); + + final List people; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final px = (_avatar * MediaQuery.devicePixelRatioOf(context)).round(); + return Wrap( + spacing: 6, + runSpacing: 12, + children: [ + for (final c in people) + HoverTap( + borderRadius: 12, + onTap: () => onOpen(c.htmlUrl), + child: Semantics( + label: c.login, + excludeSemantics: true, + child: SizedBox( + width: _cell, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipOval( + child: c.avatarUrl.isEmpty + ? const _AvatarFallback() + : CachedNetworkImage( + imageUrl: Contributor.sizedAvatar( + c.avatarUrl, + px, + ), + width: _avatar, + height: _avatar, + memCacheWidth: px, + fit: BoxFit.cover, + placeholder: (_, _) => const _AvatarFallback(), + errorWidget: (_, _, _) => const _AvatarFallback(), + ), + ), + const SizedBox(height: 5), + Text( + c.login, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _AvatarFallback extends StatelessWidget { + const _AvatarFallback(); + + @override + Widget build(BuildContext context) { + return Container( + width: _avatar, + height: _avatar, + color: AppColors.surfaceVariant, + child: const Icon( + Icons.person_rounded, + color: AppColors.textHint, + size: 22, + ), + ); + } +} + +class _Skeleton extends StatelessWidget { + const _Skeleton(); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 6, + runSpacing: 12, + children: [ + for (var i = 0; i < 6; i++) + SizedBox( + width: _cell, + child: Column( + children: [ + Container( + width: _avatar, + height: _avatar, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + shape: BoxShape.circle, + ), + ), + const SizedBox(height: 7), + Container( + width: 40, + height: 8, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _QuietError extends StatelessWidget { + const _QuietError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.cloud_off_rounded, color: AppColors.textHint, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + 'profile.contributors_error'.tr(), + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ), + TextButton(onPressed: onRetry, child: Text('general.retry'.tr())), + ], + ); + } +} diff --git a/lib/features/profile/presentation/widgets/player_controls_style_section.dart b/lib/features/profile/presentation/widgets/player_controls_style_section.dart new file mode 100644 index 00000000..269b81f0 --- /dev/null +++ b/lib/features/profile/presentation/widgets/player_controls_style_section.dart @@ -0,0 +1,95 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; + +/// Settings → Player → Controls style: iPhone and iPad only. +class PlayerControlsStyleSection extends StatelessWidget { + const PlayerControlsStyleSection({ + super.key, + required this.chosen, + required this.capability, + required this.onChanged, + }); + + final PlayerControlsStyle chosen; + + /// What the phone said it can draw; the styles it cannot are disabled. + final NativeGlassCapability capability; + + final ValueChanged onChanged; + + static bool get shown => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + static IconData _icon(PlayerControlsStyle s) => switch (s) { + PlayerControlsStyle.native => Icons.phone_iphone_rounded, + PlayerControlsStyle.systemGlass => Icons.blur_on_rounded, + PlayerControlsStyle.classicGlass => Icons.blur_circular_rounded, + }; + + static String _title(PlayerControlsStyle s) => switch (s) { + PlayerControlsStyle.native => 'profile.controls_style_native'.tr(), + PlayerControlsStyle.systemGlass => 'profile.controls_style_system'.tr(), + PlayerControlsStyle.classicGlass => 'profile.controls_style_classic'.tr(), + }; + + static String _desc(PlayerControlsStyle s) => switch (s) { + PlayerControlsStyle.native => 'profile.controls_style_native_desc'.tr(), + PlayerControlsStyle.systemGlass => + 'profile.controls_style_system_desc'.tr(), + PlayerControlsStyle.classicGlass => + 'profile.controls_style_classic_desc'.tr(), + }; + + @override + Widget build(BuildContext context) { + if (!shown) return const SizedBox.shrink(); + // What the player will draw: a choice this phone cannot have shows as + // the classic glass it falls back to. + final current = playerControlsStyleAvailable(chosen, capability) + ? chosen + : PlayerControlsStyle.classicGlass; + final styles = PlayerControlsStyle.values; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingsLabel('profile.controls_style'.tr()), + SettingsCard( + children: [ + for (var i = 0; i < styles.length; i++) ...[ + if (i > 0) const SettingsDivider(), + _row(styles[i], current), + ], + ], + ), + SettingsFootnote('profile.controls_style_desc'.tr()), + ], + ); + } + + Widget _row(PlayerControlsStyle style, PlayerControlsStyle current) { + final available = playerControlsStyleAvailable(style, capability); + final selected = style == current; + return SettingsNavTile( + key: ValueKey('controls-style-${style.name}'), + icon: _icon(style), + title: _title(style), + subtitle: available + ? _desc(style) + : 'profile.controls_style_needs_ios26'.tr(), + enabled: available, + trailing: Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + size: 20, + color: selected ? AppColors.primary : AppColors.textHint, + ), + onTap: available && !selected ? () => onChanged(style) : null, + ); + } +} diff --git a/lib/features/profile/presentation/widgets/provider_quick_switch.dart b/lib/features/profile/presentation/widgets/provider_quick_switch.dart index 8342c0a3..eb5d0272 100644 --- a/lib/features/profile/presentation/widgets/provider_quick_switch.dart +++ b/lib/features/profile/presentation/widgets/provider_quick_switch.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/features/home/data/datasources/home_data_source.dart'; import 'package:soplay/features/sources/domain/source_ecosystem.dart'; import 'package:soplay/features/sources/domain/source_scope.dart'; +import 'package:soplay/features/sources/domain/source_verdict_policy.dart'; import 'package:soplay/features/search/data/source_health_store.dart'; import 'package:soplay/features/sources/presentation/widgets/source_health_badge.dart'; import 'package:soplay/features/sources/presentation/widgets/source_scope_menu.dart'; @@ -480,18 +481,43 @@ class ProviderQuickSwitchSheetState extends State { if (mounted) setState(() {}); } + /// The badge this row is entitled to, after [shownVerdict] has dropped a + /// server verdict that was never about this phone. + /// + /// The Sources screen asks the same question through the same function. This + /// sheet is where somebody goes to get OUT of a broken source, so showing a + /// red Down on a source that works here is at its most expensive: it steers + /// the reader away from the one that would have played. + RemoteVerdict? _badgeFor(ProviderEntity p) => + shownVerdict(p.id, _health.badgeOf(p.id, key: p.healthKey)); + + bool _isDown(ProviderEntity p) => _badgeFor(p)?.state == RemoteHealth.dead; + /// Down sources go last, or away when the user hid them; the one in use /// always stays. - List _arrange(List rows) => _health.sinkDown( + /// + /// [partitionDown] rather than the store's own sink, so the ordering, the + /// hiding and the count below all come from one pass over one list and + /// agree with the badges actually drawn. + List _arrange(List rows) => partitionDown( rows, - (p) => p.id, - keyOf: (p) => p.healthKey, - hide: _health.hideDown, + isDown: _isDown, keep: (p) => p.id == widget.currentProviderId, - ); + hide: _health.hideDown, + ).rows; - int get _downCount => - widget.all.where((p) => _health.isDown(p.id, key: p.healthKey)).length; + /// How many rows hiding would take away — counted over the rows this sheet + /// is showing, not over every source installed. + /// + /// `widget.all` was the wrong list: it includes the other content modes and + /// the rows the filter has already removed, so the number promised more than + /// hiding could deliver. The source in use is excluded too, because it is + /// never hidden. + int _downCountOf(List rows) => partitionDown( + rows, + isDown: _isDown, + keep: (p) => p.id == widget.currentProviderId, + ).hidable; @override void dispose() { @@ -639,7 +665,10 @@ class ProviderQuickSwitchSheetState extends State { Widget _body(BuildContext context, ScrollController controller) { final favorites = _arrange(_shownFavorites); final items = [...favorites, ..._arrange(_rest)]; - final downCount = _downCount; + // Counted over the rows this sheet is showing, so "Hide down sources · N" + // takes away N rows. + final downCount = + _downCountOf(_shownFavorites) + _downCountOf(_rest); final catalogues = Catalogue.forMode(widget.mode); final searchExtent = _SearchBarHeader.extentFor(context); _alignToCurrent(controller, items); @@ -844,10 +873,7 @@ class ProviderQuickSwitchSheetState extends State { favorite: index < favorites.length, available: !widget.unavailableIds .contains(items[index].id), - health: _health.badgeOf( - items[index].id, - key: items[index].healthKey, - ), + health: _badgeFor(items[index]), ), ), ); diff --git a/lib/features/profiles/data/profile_pin_store.dart b/lib/features/profiles/data/profile_pin_store.dart new file mode 100644 index 00000000..c010b400 --- /dev/null +++ b/lib/features/profiles/data/profile_pin_store.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; + +/// Lets a profile PIN be checked while the server can't be asked. +/// +/// After the server accepts a profile's PIN on this device, a salted PBKDF2 +/// hash of it is kept in secure storage, per account and profile, with its +/// own wrong-guess backoff and the biometric choice for that profile. The PIN +/// itself is never stored. +class ProfilePinStore { + ProfilePinStore({ + FlutterSecureStorage? secureStorage, + DeviceBiometrics? biometrics, + DateTime Function()? clock, + this.iterations = PinHash.iterations, + }) : _secure = + secureStorage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ), + _biometrics = biometrics ?? DeviceBiometrics(), + _now = clock ?? DateTime.now; + + /// One secure entry for every profile, so sign-out can clear them all + /// without knowing which account they belonged to. + static const String storageKey = 'profile_pin_verifiers'; + + final FlutterSecureStorage _secure; + final DeviceBiometrics _biometrics; + final DateTime Function() _now; + final int iterations; + + Future _queue = Future.value(); + + /// Runs [run] after every earlier read-modify-write has finished. + Future _locked(Future Function() run) { + final next = _queue.then((_) => run()); + _queue = next.then((_) {}, onError: (_) {}); + return next; + } + + static String _key(String account, String profile) => '$account:$profile'; + + Future>> _readAll() async { + try { + final raw = await _secure.read(key: storageKey); + if (raw == null || raw.isEmpty) return {}; + final data = jsonDecode(raw); + if (data is! Map) return {}; + return { + for (final e in data.entries) + if (e.value is Map) + e.key.toString(): (e.value as Map).cast(), + }; + } catch (_) { + return {}; + } + } + + Future _writeAll(Map> all) async { + try { + if (all.isEmpty) { + await _secure.delete(key: storageKey); + } else { + await _secure.write(key: storageKey, value: jsonEncode(all)); + } + } catch (_) {} + } + + Future _edit( + T Function(Map> all) change, + ) => _locked(() async { + final all = await _readAll(); + final out = change(all); + await _writeAll(all); + return out; + }); + + Future?> _entry(String account, String profile) async => + (await _readAll())[_key(account, profile)]; + + /// Keeps a verifier for [pin], replacing any older one. The biometric + /// choice survives a PIN change; the wrong-guess count does not. + Future remember(String account, String profile, String pin) async { + final salt = PinHash.newSalt(); + final hash = await PinHash.hash(pin, salt, iterations); + await _edit((all) { + final key = _key(account, profile); + final old = all[key] ?? const {}; + all[key] = { + 's': salt, + 'h': hash, + if (old['b'] == true) 'b': true, + if (old['o'] == true) 'o': true, + }; + }); + } + + Future has(String account, String profile) async { + final e = await _entry(account, profile); + return e != null && e['s'] is String && e['h'] is String; + } + + /// [PinCheck.unavailable] when no PIN has been accepted here for it. + Future check( + String account, + String profile, + String pin, + ) async { + final key = _key(account, profile); + final e = await _entry(account, profile); + final salt = e?['s']; + final hash = e?['h']; + if (salt is! String || hash is! String) { + return const PinCheckResult.unavailable(); + } + final until = _lockedUntil(e!); + if (until != null) return PinCheckResult.lockedOut(until); + + final ok = await PinHash.verify(pin, salt, hash); + return _edit((all) { + final current = all[key]; + if (current == null) return const PinCheckResult.unavailable(); + if (ok) { + current + ..remove('f') + ..remove('u'); + return const PinCheckResult.ok(); + } + final failures = ((current['f'] as num?)?.toInt() ?? 0) + 1; + final result = PinBackoff.afterFailure(failures, _now()); + current['f'] = failures; + final retryAt = result.retryAt; + if (retryAt != null) current['u'] = retryAt.millisecondsSinceEpoch; + return result; + }); + } + + DateTime? _lockedUntil(Map e) { + final ms = (e['u'] as num?)?.toInt(); + if (ms == null) return null; + final until = DateTime.fromMillisecondsSinceEpoch(ms); + return until.isAfter(_now()) ? until : null; + } + + Future forget(String account, String profile) => + _edit((all) => all.remove(_key(account, profile))); + + /// Drops [account]'s verifiers for every profile not in [withPin]: deleted + /// elsewhere, or no longer protected. + Future retain(String account, Set withPin) => _edit((all) { + all.removeWhere((key, _) { + if (!key.startsWith('$account:')) return false; + return !withPin.contains(key.substring(account.length + 1)); + }); + }); + + /// Sign-out. + Future clear() => _locked(() => _writeAll({})); + + Future biometricsAvailable() => _biometrics.isAvailable(); + + Future biometricEnabled(String account, String profile) async { + final e = await _entry(account, profile); + return e != null && e['b'] == true && e['h'] is String; + } + + /// Only for a profile that already has a verifier here. + Future setBiometric(String account, String profile, bool on) => + _edit((all) { + final e = all[_key(account, profile)]; + if (e == null) return; + if (on) { + e['b'] = true; + } else { + e.remove('b'); + } + e['o'] = true; + }); + + /// Whether to suggest biometric unlock for [profile]: a PIN was accepted + /// here, it isn't on yet, it hasn't been suggested before, and the device + /// can do it. + Future shouldOfferBiometric(String account, String profile) async { + final e = await _entry(account, profile); + if (e == null || e['h'] is! String) return false; + if (e['b'] == true || e['o'] == true) return false; + return _biometrics.isAvailable(); + } + + Future markBiometricOffered(String account, String profile) => + _edit((all) => all[_key(account, profile)]?['o'] = true); + + Future canUnlockWithBiometric(String account, String profile) async { + if (!await biometricEnabled(account, profile)) return false; + return _biometrics.isAvailable(); + } + + Future authenticate(String reason) => _biometrics.authenticate(reason); + + Future unlockWithBiometric( + String account, + String profile, + String reason, + ) async { + if (!await biometricEnabled(account, profile)) return false; + final ok = await _biometrics.authenticate(reason); + if (ok) { + await _edit((all) { + all[_key(account, profile)] + ?..remove('f') + ..remove('u'); + }); + } + return ok; + } +} diff --git a/lib/features/profiles/data/profile_session.dart b/lib/features/profiles/data/profile_session.dart index 1c7363ee..8d4e749e 100644 --- a/lib/features/profiles/data/profile_session.dart +++ b/lib/features/profiles/data/profile_session.dart @@ -6,6 +6,8 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/storage/profile_scope.dart'; import 'package:soplay/core/storage/profile_storage.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; +import 'package:soplay/features/profiles/data/profile_pin_store.dart'; import 'package:soplay/features/profiles/data/profiles_remote_data_source.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; @@ -21,8 +23,12 @@ class ProfileSession extends ChangeNotifier { Future Function()? beforeSwitch, Future Function({required bool resync})? onScopeChanged, Box? settings, + ProfilePinStore? pins, + String? Function()? accountId, }) : _remote = remote, _isLoggedIn = isLoggedIn, + _pins = pins, + _accountId = accountId, _beforeSwitch = beforeSwitch, _onScopeChanged = onScopeChanged, _settingsOverride = settings { @@ -31,12 +37,17 @@ class ProfileSession extends ChangeNotifier { static const String cacheKey = 'household_profiles'; static const String activeKey = 'household_active'; + static const String askOnLaunchKey = 'household_ask_on_launch'; final ProfilesRemoteDataSource _remote; final bool Function() _isLoggedIn; final Future Function()? _beforeSwitch; final Future Function({required bool resync})? _onScopeChanged; final Box? _settingsOverride; + final ProfilePinStore? _pins; + final String? Function()? _accountId; + + String get _account => _accountId?.call() ?? ''; Box get _settings => _settingsOverride ?? Hive.box(AppConstants.settingsBox); @@ -46,6 +57,13 @@ class ProfileSession extends ChangeNotifier { bool _chosenThisRun = false; bool _loaded = false; + /// The active profile came from the previous run rather than from a choice + /// or a sign-in in this one. + bool _restored = false; + + /// Profiles whose PIN was entered (or biometrics passed) in this run. + final Set _unlocked = {}; + /// Bumped when the app should show the picker without being asked: the /// active profile was deleted elsewhere, or a sign-in found several. final ValueNotifier pickRequests = ValueNotifier(0); @@ -68,7 +86,35 @@ class ProfileSession extends ChangeNotifier { bool get signedIn => _isLoggedIn(); bool get shouldPick => - _isLoggedIn() && _profiles.length > 1 && !_chosenThisRun; + _isLoggedIn() && + _profiles.length > 1 && + !_chosenThisRun && + !opensLastProfile( + askOnLaunch: askOnLaunch, + restored: _restored ? _active : null, + ); + + /// "Ask who's watching on launch". Off, a launch goes straight into the + /// profile the previous run ended in. + bool get askOnLaunch => _settings.get(askOnLaunchKey) != false; + + Future setAskOnLaunch(bool value) async { + await _settings.put(askOnLaunchKey, value); + notifyListeners(); + } + + /// Whether a launch skips the picker for the profile the last run ended in. + /// Never for one with a PIN: the picker is where that PIN is asked. + static bool opensLastProfile({ + required bool askOnLaunch, + required HouseholdProfile? restored, + }) => !askOnLaunch && restored != null && !restored.hasPin; + + /// Whether opening [profile] has to ask for its PIN. Not for the active + /// profile once it has been unlocked in this run. + bool needsPin(HouseholdProfile profile) => + profile.hasPin && + !(profile.id == _active?.id && _unlocked.contains(profile.id)); HouseholdProfile? get defaultProfile { for (final p in _profiles) { @@ -122,6 +168,7 @@ class ProfileSession extends ChangeNotifier { _active = active != null && active.id == ProfileScope.remoteId ? active : null; + _restored = _active != null; } catch (_) { _profiles = const []; _active = null; @@ -186,10 +233,16 @@ class ProfileSession extends ChangeNotifier { } else if (fresh == null || listing.activeProfileId == null) { await _switchTo(defaultProfile, resync: true); _chosenThisRun = false; + _restored = false; if (shouldPick) pickRequests.value++; } else if (fresh != current) { await _adopt(fresh); } + _unlocked.removeWhere((id) => !(byId(id)?.hasPin ?? false)); + await _pins?.retain(_account, { + for (final p in _profiles) + if (p.hasPin) p.id, + }); await _persist(); notifyListeners(); return true; @@ -239,8 +292,84 @@ class ProfileSession extends ChangeNotifier { if (kidsChanged) await _onScopeChanged?.call(resync: false); } - Future verifyPin(String id, String pin) => - _remote.verifyPin(id, pin); + /// Checks [pin] with the server, or, only when the server can't be + /// reached, with what this device kept from the last time it was accepted. + Future verifyPin(String id, String pin) async { + try { + final profile = await _remote.verifyPin(id, pin); + _unlocked.add(id); + await _remember(id, pin); + return profile; + } on ProfileException catch (e) { + final pins = _pins; + final known = byId(id); + if (!e.isUnreachable || pins == null || known == null) rethrow; + final result = await pins.check(_account, id, pin); + switch (result.outcome) { + case PinCheck.ok: + _unlocked.add(id); + return known; + case PinCheck.wrong: + throw const ProfileException('Wrong PIN', code: 'PIN_INVALID'); + case PinCheck.lockedOut: + throw ProfileException( + 'Too many attempts', + code: 'PIN_LOCKED', + retryAt: result.retryAt, + ); + case PinCheck.unavailable: + throw const ProfileException( + 'PIN not known offline', + code: 'PIN_NOT_ON_DEVICE', + ); + } + } + } + + Future _remember(String id, String pin) async { + try { + await _pins?.remember(_account, id, pin); + } catch (_) {} + } + + /// Whether [id] can be opened with a fingerprint or face on this device. + Future canUnlockWithBiometric(String id) async => + await _pins?.canUnlockWithBiometric(_account, id) ?? false; + + Future unlockWithBiometric(String id, String reason) async { + final ok = + await _pins?.unlockWithBiometric(_account, id, reason) ?? false; + if (ok) _unlocked.add(id); + return ok; + } + + Future shouldOfferBiometric(String id) async => + await _pins?.shouldOfferBiometric(_account, id) ?? false; + + Future markBiometricOffered(String id) async { + await _pins?.markBiometricOffered(_account, id); + } + + /// Whether [id]'s PIN has been accepted here, so biometrics can stand in. + Future hasLocalPin(String id) async => + await _pins?.has(_account, id) ?? false; + + Future biometricEnabled(String id) async => + await _pins?.biometricEnabled(_account, id) ?? false; + + Future biometricsAvailable() async => + await _pins?.biometricsAvailable() ?? false; + + /// Turning it on asks for a fingerprint or face first, so it is the person + /// holding the device who is enrolled. Returns whether it is now on. + Future setBiometric(String id, bool on, {String? reason}) async { + final pins = _pins; + if (pins == null) return false; + if (on && !await pins.authenticate(reason ?? '')) return false; + await pins.setBiometric(_account, id, on); + notifyListeners(); + return on; + } Future create({ required String name, @@ -256,6 +385,7 @@ class ProfileSession extends ChangeNotifier { isKids: isKids, pin: pin, ); + if (pin != null) await _remember(created.id, pin); _profiles = List.unmodifiable([..._profiles, created]); await _persist(); notifyListeners(); @@ -268,6 +398,15 @@ class ProfileSession extends ChangeNotifier { String? currentPin, }) async { final updated = await _remote.update(id, changes, currentPin: currentPin); + if (changes.containsKey('pin')) { + final pin = changes['pin']; + if (pin is String) { + await _remember(id, pin); + } else { + _unlocked.remove(id); + await _pins?.forget(_account, id); + } + } _profiles = List.unmodifiable([ for (final p in _profiles) p.id == id ? updated : p, ]); @@ -280,9 +419,12 @@ class ProfileSession extends ChangeNotifier { Future delete(String id, {String? currentPin}) async { final target = byId(id); await _remote.delete(id, currentPin: currentPin); + _unlocked.remove(id); + await _pins?.forget(_account, id); if (_active?.id == id) { await _switchTo(defaultProfile, resync: true); _chosenThisRun = false; + _restored = false; } _profiles = List.unmodifiable(_profiles.where((p) => p.id != id)); final ns = target?.namespace ?? id; @@ -304,6 +446,9 @@ class ProfileSession extends ChangeNotifier { _active = null; _chosenThisRun = false; _loaded = false; + _restored = false; + _unlocked.clear(); + await _pins?.clear(); await ProfileStorage.deleteAll(_settings, known: known); await _settings.delete(cacheKey); await _settings.delete(activeKey); diff --git a/lib/features/profiles/domain/household_profile.dart b/lib/features/profiles/domain/household_profile.dart index c2e5954d..c5a4f7dd 100644 --- a/lib/features/profiles/domain/household_profile.dart +++ b/lib/features/profiles/domain/household_profile.dart @@ -64,15 +64,29 @@ class HouseholdProfile { } /// What the profiles endpoints refuse with. [code] is the server's machine -/// code (`PIN_INVALID`, `PROFILE_LIMIT`, ...); null for network failures. +/// code (`PIN_INVALID`, `PROFILE_LIMIT`, ...), or one the device checked the +/// PIN with (`PIN_LOCKED`, `PIN_NOT_ON_DEVICE`); null for network failures. class ProfileException implements Exception { - const ProfileException(this.message, {this.code, this.status}); + const ProfileException( + this.message, { + this.code, + this.status, + this.retryAt, + }); final String message; final String? code; final int? status; - bool get isOffline => status == null; + /// When the next guess is allowed, after too many wrong ones on the device. + final DateTime? retryAt; + + bool get isOffline => status == null && code == null; + + /// The server could not be asked at all, or its gateway said so. A refusal + /// (a wrong PIN, a rate limit) is an answer and never counts. + bool get isUnreachable => + isOffline || status == 502 || status == 503 || status == 504; @override String toString() => message; diff --git a/lib/features/profiles/presentation/pages/household_profile_edit_page.dart b/lib/features/profiles/presentation/pages/household_profile_edit_page.dart index 9a8771db..ffbb8f6d 100644 --- a/lib/features/profiles/presentation/pages/household_profile_edit_page.dart +++ b/lib/features/profiles/presentation/pages/household_profile_edit_page.dart @@ -137,6 +137,7 @@ class _HouseholdProfileEditPageState extends State { original, title: 'profiles.pin_current_title'.tr(), subtitle: 'profiles.pin_current_subtitle'.tr(), + allowBiometric: false, ); Future _save() async { diff --git a/lib/features/profiles/presentation/pages/manage_profiles_page.dart b/lib/features/profiles/presentation/pages/manage_profiles_page.dart index 6e9e9946..73b6b14b 100644 --- a/lib/features/profiles/presentation/pages/manage_profiles_page.dart +++ b/lib/features/profiles/presentation/pages/manage_profiles_page.dart @@ -7,6 +7,7 @@ import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; +import 'package:soplay/features/profiles/presentation/profile_flows.dart'; import 'package:soplay/features/profiles/presentation/widgets/profile_avatar.dart'; class ManageProfilesPage extends StatefulWidget { @@ -24,6 +25,10 @@ class _ManageProfilesPageState extends State { bool _loading = false; bool _failed = false; + /// Profiles whose PIN this device knows, and whether each opens with + /// biometrics. Empty where biometrics aren't available. + Map _biometric = const {}; + @override void initState() { super.initState(); @@ -52,6 +57,37 @@ class _ManageProfilesPageState extends State { _loading = false; _failed = !ok; }); + await _loadBiometric(); + } + + Future _loadBiometric() async { + final out = {}; + if (await _session.biometricsAvailable()) { + for (final p in _session.profiles) { + if (p.hasPin && await _session.hasLocalPin(p.id)) { + out[p.id] = await _session.biometricEnabled(p.id); + } + } + } + if (mounted) setState(() => _biometric = out); + } + + Future _toggleBiometric(HouseholdProfile p, bool on) async { + if (on) { + final pin = await askProfilePin( + context, + _session, + p, + allowBiometric: false, + ); + if (pin == null || !mounted) return; + } + await _session.setBiometric( + p.id, + on, + reason: 'app_lock.biometric_enable_reason'.tr(), + ); + await _loadBiometric(); } String? _tags(HouseholdProfile p) { @@ -120,6 +156,29 @@ class _ManageProfilesPageState extends State { ], ), SettingsFootnote('profiles.limit'.tr(args: ['${_session.max}'])), + const SizedBox(height: 20), + SettingsLabel('profiles.on_this_device'.tr()), + SettingsCard( + children: [ + SettingsSwitchTile( + icon: Icons.switch_account_rounded, + title: 'profiles.ask_on_launch'.tr(), + subtitle: 'profiles.ask_on_launch_desc'.tr(), + value: _session.askOnLaunch, + onChanged: _session.setAskOnLaunch, + ), + for (final p in profiles) + if (_biometric.containsKey(p.id)) ...[ + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.fingerprint_rounded, + title: 'profiles.biometric_toggle'.tr(args: [p.name]), + value: _biometric[p.id]!, + onChanged: (on) => _toggleBiometric(p, on), + ), + ], + ], + ), ], ], ); diff --git a/lib/features/profiles/presentation/pages/profile_picker_page.dart b/lib/features/profiles/presentation/pages/profile_picker_page.dart index 258a8e80..239d9084 100644 --- a/lib/features/profiles/presentation/pages/profile_picker_page.dart +++ b/lib/features/profiles/presentation/pages/profile_picker_page.dart @@ -101,7 +101,7 @@ class _ProfilePickerPageState extends State { Future _open(HouseholdProfile profile) async { if (_opening != null) return; - if (profile.hasPin) { + if (_session.needsPin(profile)) { final pin = await askProfilePin(context, _session, profile); if (pin == null || !mounted) return; } diff --git a/lib/features/profiles/presentation/profile_flows.dart b/lib/features/profiles/presentation/profile_flows.dart index 98cfa2e2..b737233f 100644 --- a/lib/features/profiles/presentation/profile_flows.dart +++ b/lib/features/profiles/presentation/profile_flows.dart @@ -10,6 +10,10 @@ String profileErrorText(Object error, {int max = 5}) { switch (error.code) { case 'PIN_INVALID': return 'profiles.pin_wrong'.tr(); + case 'PIN_LOCKED': + return _lockedText(error.retryAt); + case 'PIN_NOT_ON_DEVICE': + return 'profiles.pin_not_on_device'.tr(); case 'PROFILE_LIMIT': return 'profiles.limit'.tr(args: ['$max']); case 'PROFILE_NAME_TAKEN': @@ -25,28 +29,92 @@ String profileErrorText(Object error, {int max = 5}) { return 'profiles.error_generic'.tr(); } -/// Asks for [profile]'s PIN and checks it with the server. Returns the PIN, -/// or null if the viewer gave up. +String _lockedText(DateTime? at) { + if (at == null) return 'profiles.pin_too_many'.tr(); + final hh = at.hour.toString().padLeft(2, '0'); + final mm = at.minute.toString().padLeft(2, '0'); + return 'profiles.pin_locked_until'.tr(args: ['$hh:$mm']); +} + +/// Asks for [profile]'s PIN and checks it (with the server, or on the device +/// when offline). Returns the PIN, an empty string when biometrics unlocked +/// it instead, or null if the viewer gave up. +/// +/// [allowBiometric] is false where the PIN itself is needed afterwards. Future askProfilePin( BuildContext context, ProfileSession session, HouseholdProfile profile, { String? title, String? subtitle, -}) => ProfilePinPage.verify( - context, - title: title ?? 'profiles.pin_enter_title'.tr(), - subtitle: subtitle ?? 'profiles.pin_enter_subtitle'.tr(args: [profile.name]), - header: ProfileAvatar.of(profile, size: 84, showLock: false), - onSubmit: (pin) async { - try { - await session.verifyPin(profile.id, pin); - return null; - } catch (e) { - return profileErrorText(e); - } - }, -); + bool allowBiometric = true, +}) async { + final biometric = + allowBiometric && await session.canUnlockWithBiometric(profile.id); + if (!context.mounted) return null; + final pin = await ProfilePinPage.verify( + context, + title: title ?? 'profiles.pin_enter_title'.tr(), + subtitle: + subtitle ?? 'profiles.pin_enter_subtitle'.tr(args: [profile.name]), + header: ProfileAvatar.of(profile, size: 84, showLock: false), + onSubmit: (pin) async { + try { + await session.verifyPin(profile.id, pin); + return null; + } catch (e) { + return profileErrorText(e); + } + }, + onBiometric: biometric + ? () => session.unlockWithBiometric( + profile.id, + 'profiles.biometric_reason'.tr(args: [profile.name]), + ) + : null, + ); + if (allowBiometric && pin != null && pin.isNotEmpty && context.mounted) { + await offerProfileBiometric(context, session, profile); + } + return pin; +} + +/// Once per profile and device, after its PIN was accepted: offer to open it +/// with a fingerprint or face from now on. +Future offerProfileBiometric( + BuildContext context, + ProfileSession session, + HouseholdProfile profile, +) async { + if (!await session.shouldOfferBiometric(profile.id)) return; + if (!context.mounted) return; + await session.markBiometricOffered(profile.id); + if (!context.mounted) return; + final yes = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('profiles.biometric_offer_title'.tr()), + content: Text('profiles.biometric_offer_body'.tr(args: [profile.name])), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text('profiles.biometric_offer_no'.tr()), + ), + TextButton( + autofocus: true, + onPressed: () => Navigator.of(ctx).pop(true), + child: Text('profiles.biometric_offer_yes'.tr()), + ), + ], + ), + ); + if (yes != true) return; + await session.setBiometric( + profile.id, + true, + reason: 'app_lock.biometric_enable_reason'.tr(), + ); +} /// Whether changing [target]'s PIN or kids flag, or deleting it, needs its /// current PIN: the server waives it only for the main profile acting on diff --git a/lib/features/profiles/presentation/widgets/profile_pin_page.dart b/lib/features/profiles/presentation/widgets/profile_pin_page.dart index 55598b6d..8ed009fc 100644 --- a/lib/features/profiles/presentation/widgets/profile_pin_page.dart +++ b/lib/features/profiles/presentation/widgets/profile_pin_page.dart @@ -12,6 +12,7 @@ class ProfilePinPage extends StatefulWidget { required this.subtitle, this.header, this.onSubmit, + this.onBiometric, }); static const int length = 4; @@ -24,13 +25,18 @@ class ProfilePinPage extends StatefulWidget { /// Checks a PIN; returns an error message, or null when it was accepted. final Future Function(String pin)? onSubmit; - /// Returns the accepted PIN, or null if the page was closed. + /// Unlocks without the PIN; offered on the keypad and tried once on open. + final Future Function()? onBiometric; + + /// Returns the accepted PIN, an empty string when [onBiometric] unlocked + /// it, or null if the page was closed. static Future verify( BuildContext context, { required String title, required String subtitle, Widget? header, required Future Function(String pin) onSubmit, + Future Function()? onBiometric, }) => Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, @@ -40,6 +46,7 @@ class ProfilePinPage extends StatefulWidget { subtitle: subtitle, header: header, onSubmit: onSubmit, + onBiometric: onBiometric, ), ), ); @@ -71,6 +78,24 @@ class _ProfilePinPageState extends State { bool get _confirming => widget.creating && _first != null; + @override + void initState() { + super.initState(); + if (widget.onBiometric != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _biometric()); + } + } + + Future _biometric() async { + final unlock = widget.onBiometric; + if (unlock == null || _busy || !mounted) return; + setState(() => _busy = true); + final ok = await unlock(); + if (!mounted) return; + setState(() => _busy = false); + if (ok) Navigator.of(context).pop(''); + } + void _digit(String d) { if (_busy || _entered.length >= ProfilePinPage.length) return; setState(() { @@ -222,7 +247,13 @@ class _ProfilePinPageState extends State { ), ), const Spacer(), - PinKeypad(onDigit: _digit, onBackspace: _backspace), + PinKeypad( + onDigit: _digit, + onBackspace: _backspace, + onBiometric: widget.onBiometric == null + ? null + : _biometric, + ), const SizedBox(height: 24), ], ), diff --git a/lib/features/progress/data/progress_remote_data_source.dart b/lib/features/progress/data/progress_remote_data_source.dart new file mode 100644 index 00000000..19d50c45 --- /dev/null +++ b/lib/features/progress/data/progress_remote_data_source.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; + +class ProgressRemoteDataSource { + const ProgressRemoteDataSource({required this.dio}); + + final Dio dio; + + Map _body(Response res) { + final data = res.data; + return data is Map ? data.cast() : const {}; + } + + Future> me() async => + _body(await dio.get('/progress/me')); + + /// [seconds] is this device's running total for local [day], not a delta: + /// the server credits only the rise, so a retry adds nothing. + Future> watchTime({ + required String day, + required int seconds, + required String deviceId, + }) async => _body( + await dio.post( + '/progress/watch-time', + data: {'day': day, 'seconds': seconds, 'deviceId': deviceId}, + ), + ); + + Future> equip(Map slots) async => + _body(await dio.put('/progress/cosmetics', data: slots)); +} diff --git a/lib/features/progress/data/progress_service.dart b/lib/features/progress/data/progress_service.dart new file mode 100644 index 00000000..0a229497 --- /dev/null +++ b/lib/features/progress/data/progress_service.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; + +/// A level reached since the viewer last saw their level, and what it opened. +class LevelUp { + const LevelUp({required this.from, required this.to, this.unlocked = const []}); + + final int from; + final int to; + final List unlocked; + + /// What reaching [level] means against the [seen] level, if anything. The + /// first level ever seen is a baseline, not a celebration. + static LevelUp? between(int? seen, ProgressView view) { + final level = view.level; + if (seen == null || level <= seen) return null; + return LevelUp( + from: seen, + to: level, + unlocked: [ + for (final c in view.catalog) + if (c.level != null && c.level! > seen && c.level! <= level) c, + ], + ); + } +} + +/// The active profile's XP, level and cosmetics. +/// +/// Null [state] means nothing is known — signed out, or a server without +/// `/progress` yet — and every decoration hides. +class ProgressService { + ProgressService({ + required ProgressRemoteDataSource remote, + required HiveService hive, + }) : _remote = remote, + _hive = hive { + state.value = _readCache(); + } + + final ProgressRemoteDataSource _remote; + final HiveService _hive; + + final ValueNotifier state = ValueNotifier(null); + final ValueNotifier pendingLevelUp = ValueNotifier(null); + + static const String viewKey = 'progress_view'; + static const String seenLevelKey = 'progress_seen_level'; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + ProgressView? _readCache() { + try { + final raw = _box?.get(ProfileScope.key(viewKey)); + if (raw is String && raw.isNotEmpty) { + return ProgressView.fromJson( + (jsonDecode(raw) as Map).cast(), + ); + } + } catch (_) {} + return null; + } + + /// Takes a progress body from any `/progress` answer. + Future apply(Map raw) async { + if (raw['xp'] is! num) return; + final view = ProgressView.fromJson(raw); + state.value = view; + final box = _box; + try { + await box?.put(ProfileScope.key(viewKey), jsonEncode(raw)); + final seen = box?.get(ProfileScope.key(seenLevelKey)); + final up = LevelUp.between(seen is int ? seen : null, view); + if (up != null) { + final had = pendingLevelUp.value; + pendingLevelUp.value = had == null + ? up + : LevelUp( + from: had.from, + to: up.to, + unlocked: [...had.unlocked, ...up.unlocked], + ); + } + if (seen is! int || view.level > seen) { + await box?.put(ProfileScope.key(seenLevelKey), view.level); + } + } catch (_) {} + } + + bool _inFlight = false; + + Future refresh() async { + if (!_hive.isLoggedIn || _inFlight) return; + _inFlight = true; + try { + await apply(await _remote.me()); + } catch (e) { + debugPrint('[progress] refresh failed: $e'); + } finally { + _inFlight = false; + } + } + + /// Puts [id] on [slot], or takes the slot off with null. Throws on failure + /// so the locker can say so. + Future equip(CosmeticSlot slot, String? id) async { + await apply(await _remote.equip({slot.name: id})); + } + + LevelUp? takeLevelUp() { + final out = pendingLevelUp.value; + pendingLevelUp.value = null; + return out; + } + + /// A profile switch: what is on screen belonged to someone else. + void reload() { + pendingLevelUp.value = null; + state.value = _readCache(); + unawaited(refresh()); + } + + /// Sign-out: nothing of the account stays on the device. + Future reset() async { + pendingLevelUp.value = null; + state.value = null; + try { + await _box?.delete(ProfileScope.key(viewKey)); + await _box?.delete(ProfileScope.key(seenLevelKey)); + } catch (_) {} + } +} diff --git a/lib/features/progress/data/watch_time_uploader.dart b/lib/features/progress/data/watch_time_uploader.dart new file mode 100644 index 00000000..3d377eb4 --- /dev/null +++ b/lib/features/progress/data/watch_time_uploader.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/stats/data/watch_stats_store.dart'; + +/// Which days' watch time still has to reach the server. +/// +/// The server is sent each day's running total, and it credits the rise since +/// the last total it saw. So what is pending is just the gap between +/// [WatchStatsStore.byDay] and what was last sent — it survives being offline +/// with nothing extra to keep, and a retry after a lost answer adds nothing. +class WatchTimeBatch { + WatchTimeBatch._(); + + static const Duration interval = Duration(minutes: 10); + + /// Today and yesterday: the server takes nothing older. + static List> due({ + required Map byDay, + required Map sent, + required DateTime now, + }) { + final days = [dayKey(now.subtract(const Duration(days: 1))), dayKey(now)]; + return [ + for (final d in days) + if ((byDay[d] ?? 0) > (sent[d] ?? 0)) MapEntry(d, byDay[d]!), + ]; + } + + /// [sent] without the days that can no longer be sent. + static Map pruned(Map sent, DateTime now) { + final oldest = dayKey(now.subtract(const Duration(days: 7))); + return { + for (final e in sent.entries) + if (e.key.compareTo(oldest) >= 0) e.key: e.value, + }; + } + + /// Whether a tick while watching should send now. + static bool shouldFlush(DateTime? last, DateTime now) => + last == null || now.difference(last) >= interval; + + static String dayKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +/// Sends watch time for XP: every ten minutes while something plays, and when +/// the app goes to the background. +class WatchTimeUploader with WidgetsBindingObserver { + WatchTimeUploader({ + required ProgressRemoteDataSource remote, + required HiveService hive, + required ProgressService progress, + WatchStatsStore? stats, + DateTime Function()? clock, + }) : _remote = remote, + _hive = hive, + _progress = progress, + _stats = stats ?? WatchStatsStore(), + _clock = clock ?? DateTime.now; + + final ProgressRemoteDataSource _remote; + final HiveService _hive; + final ProgressService _progress; + final WatchStatsStore _stats; + final DateTime Function() _clock; + + static const String sentKey = 'progress_watch_sent'; + static const String _deviceKey = 'progress_device_id'; + + DateTime? _lastFlush; + Future? _running; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + String get _deviceId { + final box = _box; + final had = box?.get(_deviceKey); + if (had is String && had.isNotEmpty) return had; + final rnd = math.Random.secure(); + final id = List.generate( + 16, + (_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + unawaited(box?.put(_deviceKey, id)); + return id; + } + + Map _readSent() { + final raw = _box?.get(ProfileScope.key(sentKey)); + if (raw is! Map) return {}; + return { + for (final e in raw.entries) + if (e.value is num) e.key.toString(): (e.value as num).toInt(), + }; + } + + void start() { + WidgetsBinding.instance.addObserver(this); + _lastFlush = _clock(); + unawaited(flush()); + unawaited(_progress.refresh()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + unawaited(flush()); + case AppLifecycleState.resumed: + unawaited(_progress.refresh()); + default: + break; + } + } + + /// A tick of watching was just recorded. + void onWatched() { + if (WatchTimeBatch.shouldFlush(_lastFlush, _clock())) unawaited(flush()); + } + + /// Sends whatever is pending. A failure leaves it pending for next time. + Future flush() => _running ??= _flush().whenComplete(() { + _running = null; + }); + + Future _flush() async { + _lastFlush = _clock(); + if (!_hive.isLoggedIn) return; + final now = _clock(); + var sent = WatchTimeBatch.pruned(_readSent(), now); + final due = WatchTimeBatch.due( + byDay: _stats.byDay, + sent: sent, + now: now, + ); + if (due.isEmpty) return; + final device = _deviceId; + final key = ProfileScope.key(sentKey); + for (final entry in due) { + try { + final res = await _remote.watchTime( + day: entry.key, + seconds: entry.value, + deviceId: device, + ); + sent = {...sent, entry.key: entry.value}; + await _box?.put(key, sent); + final progress = res['progress']; + if (progress is Map) { + await _progress.apply(progress.cast()); + } + } on DioException catch (e) { + // A day the server will never take would block every day after it. + if (e.response?.statusCode == 400) { + sent = {...sent, entry.key: entry.value}; + await _box?.put(key, sent); + continue; + } + debugPrint('[progress] watch-time upload failed: $e'); + return; + } catch (e) { + debugPrint('[progress] watch-time upload failed: $e'); + return; + } + } + } + + /// Sign-out: the totals this was measured against are gone too. + Future reset() async { + try { + await _box?.delete(ProfileScope.key(sentKey)); + } catch (_) {} + } +} diff --git a/lib/features/progress/domain/progress.dart b/lib/features/progress/domain/progress.dart new file mode 100644 index 00000000..5c3137d9 --- /dev/null +++ b/lib/features/progress/domain/progress.dart @@ -0,0 +1,221 @@ +/// XP, levels and cosmetics, as `/progress` describes them. Parsing is +/// lenient for the same reason as everywhere else: an old app keeps talking to +/// a newer server. +library; + +import 'dart:math' as math; + +/// Cumulative XP level [n] needs: 100·n^1.5, the server's own curve. +int xpForLevel(int n) => n <= 0 ? 0 : (100 * math.pow(n, 1.5)).floor(); + +/// The level [xp] cumulative XP reaches. +int levelFor(int xp) { + if (xp <= 0) return 0; + var n = math.pow(xp / 100, 2 / 3).floor(); + while (xpForLevel(n + 1) <= xp) { + n++; + } + while (n > 0 && xpForLevel(n) > xp) { + n--; + } + return n; +} + +/// Where an XP total sits inside its level, for a ring or a bar. +class LevelProgress { + const LevelProgress._(this.level, this.xp, this.floor, this.next); + + factory LevelProgress.of(int xp) { + final x = math.max(0, xp); + final level = levelFor(x); + return LevelProgress._(level, x, xpForLevel(level), xpForLevel(level + 1)); + } + + final int level; + final int xp; + + /// XP the current level started at. + final int floor; + + /// XP the next level starts at. + final int next; + + int get intoLevel => xp - floor; + int get span => next - floor; + int get toNext => next - xp; + double get fraction => + span <= 0 ? 0 : (intoLevel / span).clamp(0.0, 1.0).toDouble(); +} + +int _int(Object? v) => v is num ? v.toInt() : 0; + +String? _str(Object? v) { + if (v is! String) return null; + return v.isEmpty ? null : v; +} + +Map _map(Object? v) => + v is Map ? v.cast() : const {}; + +/// What others see beside a name. Null wherever the server sent no level, +/// which is also what a server without this feature sends. +class UserDecoration { + const UserDecoration({ + required this.level, + this.frame, + this.title, + this.nameColor, + }); + + final int level; + final String? frame; + final String? title; + final String? nameColor; + + static UserDecoration? fromJson(Map json) { + final level = json['level']; + if (level is! num) return null; + return UserDecoration( + level: level.toInt(), + frame: _str(json['frame']), + title: _str(json['title']), + nameColor: _str(json['nameColor']), + ); + } +} + +enum CosmeticSlot { + frame, + title, + nameColor; + + static CosmeticSlot? parse(String raw) => switch (raw) { + 'frame' => frame, + 'title' => title, + 'nameColor' => nameColor, + _ => null, + }; +} + +/// One frame, title or colour, and what unlocks it. +class CosmeticItem { + const CosmeticItem({ + required this.slot, + required this.id, + required this.unlocked, + this.level, + this.badge, + this.tier = 1, + }); + + final CosmeticSlot slot; + final String id; + final bool unlocked; + + /// The level that unlocks it, or null when a badge does. + final int? level; + final String? badge; + final int tier; + + factory CosmeticItem.fromJson(CosmeticSlot slot, Map json) { + final unlock = _map(json['unlock']); + return CosmeticItem( + slot: slot, + id: _str(json['id']) ?? '', + unlocked: json['unlocked'] == true, + level: unlock['level'] is num ? _int(unlock['level']) : null, + badge: _str(unlock['badge']), + tier: unlock['tier'] is num ? _int(unlock['tier']) : 1, + ); + } +} + +class XpGain { + const XpGain({required this.reason, required this.xp, this.at}); + + final String reason; + final int xp; + final DateTime? at; + + factory XpGain.fromJson(Map json) => XpGain( + reason: _str(json['reason']) ?? '', + xp: _int(json['xp']), + at: json['at'] is String + ? DateTime.tryParse(json['at'] as String)?.toLocal() + : null, + ); +} + +class ProgressView { + const ProgressView({ + required this.xp, + this.watchSeconds = 0, + this.commentCount = 0, + this.frame, + this.title, + this.nameColor, + this.recent = const [], + this.catalog = const [], + this.watchXpToday = 0, + this.watchCap = 300, + }); + + final int xp; + final int watchSeconds; + final int commentCount; + final String? frame; + final String? title; + final String? nameColor; + final List recent; + final List catalog; + final int watchXpToday; + final int watchCap; + + LevelProgress get progress => LevelProgress.of(xp); + int get level => progress.level; + + UserDecoration get decoration => UserDecoration( + level: level, + frame: frame, + title: title, + nameColor: nameColor, + ); + + List slot(CosmeticSlot s) => + catalog.where((c) => c.slot == s && c.id.isNotEmpty).toList(); + + String? equipped(CosmeticSlot s) => switch (s) { + CosmeticSlot.frame => frame, + CosmeticSlot.title => title, + CosmeticSlot.nameColor => nameColor, + }; + + factory ProgressView.fromJson(Map json) { + final equipped = _map(json['equipped']); + final today = _map(json['today']); + final catalog = []; + for (final e in _map(json['catalog']).entries) { + final slot = CosmeticSlot.parse(e.key); + if (slot == null || e.value is! List) continue; + for (final item in (e.value as List).whereType()) { + catalog.add(CosmeticItem.fromJson(slot, item.cast())); + } + } + return ProgressView( + xp: _int(json['xp']), + watchSeconds: _int(json['watchSeconds']), + commentCount: _int(json['commentCount']), + frame: _str(equipped['frame']), + title: _str(equipped['title']), + nameColor: _str(equipped['nameColor']), + recent: [ + if (json['recent'] is List) + for (final g in (json['recent'] as List).whereType()) + XpGain.fromJson(g.cast()), + ], + catalog: catalog, + watchXpToday: _int(today['watchXp']), + watchCap: today['watchCap'] is num ? _int(today['watchCap']) : 300, + ); + } +} diff --git a/lib/features/progress/presentation/dialogs/level_up_dialog.dart b/lib/features/progress/presentation/dialogs/level_up_dialog.dart new file mode 100644 index 00000000..a48026e1 --- /dev/null +++ b/lib/features/progress/presentation/dialogs/level_up_dialog.dart @@ -0,0 +1,156 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; + +/// "Level 12": the new level, and what it opened in the locker. +class LevelUpDialog extends StatelessWidget { + const LevelUpDialog({super.key, required this.levelUp, this.onOpen}); + + final LevelUp levelUp; + final VoidCallback? onOpen; + + static Future show( + BuildContext context, + LevelUp levelUp, { + VoidCallback? onOpen, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black.withValues(alpha: 0.8), + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, _, _) => LevelUpDialog(levelUp: levelUp, onOpen: onOpen), + transitionBuilder: (_, anim, _, child) { + final t = Curves.easeOutBack.transform(anim.value.clamp(0.0, 1.0)); + return Opacity( + opacity: anim.value, + child: Transform.scale(scale: 0.85 + 0.15 * t, child: child), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final unlocked = levelUp.unlocked + .where((c) => Cosmetics.isKnown(c.slot, c.id)) + .take(4) + .toList(); + return Material( + type: MaterialType.transparency, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 28), + constraints: const BoxConstraints(maxWidth: 360), + padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.surface, AppColors.background], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + width: 0.8, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 34, + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level_up'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.8, + ), + ), + const SizedBox(height: 18), + LevelRing(level: levelUp.to, fraction: 1, size: 132), + const SizedBox(height: 16), + Text( + 'progress.level_reached'.tr(args: ['${levelUp.to}']), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + if (unlocked.isNotEmpty) ...[ + const SizedBox(height: 14), + Text( + 'progress.unlocked_new'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 6, + runSpacing: 6, + children: [ + for (final c in unlocked) + Chip( + label: Text(Cosmetics.label(c.slot, c.id)), + visualDensity: VisualDensity.compact, + backgroundColor: Colors.white.withValues(alpha: 0.06), + side: BorderSide.none, + labelStyle: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ], + const SizedBox(height: 18), + Row( + children: [ + if (onOpen != null) + Expanded( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + onOpen!(); + }, + child: Text('progress.open_locker'.tr()), + ), + ), + if (onOpen != null) const SizedBox(width: 8), + Expanded( + child: FilledButton( + autofocus: isTvPlatform, + onPressed: () => Navigator.of(context).pop(), + child: Text('achievements.great'.tr()), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/progress/presentation/pages/progress_page.dart b/lib/features/progress/presentation/pages/progress_page.dart new file mode 100644 index 00000000..996eff86 --- /dev/null +++ b/lib/features/progress/presentation/pages/progress_page.dart @@ -0,0 +1,619 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; +import 'package:soplay/features/streak/presentation/widgets/streak_badge.dart'; + +/// The level, how it was earned, and the locker of what it unlocks. +class ProgressPage extends StatefulWidget { + const ProgressPage({super.key}); + + @override + State createState() => _ProgressPageState(); +} + +class _ProgressPageState extends State { + final ProgressService _service = getIt(); + bool _loading = true; + String? _saving; + + @override + void initState() { + super.initState(); + _service.state.addListener(_onChange); + _service.refresh().whenComplete(() { + if (mounted) setState(() => _loading = false); + }); + } + + Future _retry() async { + setState(() => _loading = true); + await _service.refresh(); + if (mounted) setState(() => _loading = false); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _service.state.removeListener(_onChange); + super.dispose(); + } + + Future _equip(CosmeticItem item, bool equipped) async { + if (_saving != null) return; + setState(() => _saving = '${item.slot.name}:${item.id}'); + try { + await _service.equip(item.slot, equipped ? null : item.id); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('achievements.save_failed'.tr())), + ); + } + } finally { + if (mounted) setState(() => _saving = null); + } + } + + @override + Widget build(BuildContext context) { + final view = _service.state.value; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text('progress.title'.tr()), + actions: const [StreakBadge(), SizedBox(width: 8)], + ), + body: view == null + ? Center( + child: _loading + ? const CircularProgressIndicator() + : Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_rounded, + size: 40, + color: AppColors.textHint, + ), + const SizedBox(height: 12), + Text( + 'progress.unavailable'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 16), + TextButton( + autofocus: true, + onPressed: _retry, + child: Text('general.retry'.tr()), + ), + ], + ), + ), + ) + : RefreshIndicator( + onRefresh: _service.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + _LevelCard(view: view), + const SizedBox(height: 16), + _SectionTitle('progress.how_to_earn'.tr()), + const _Rules(), + if (view.recent.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionTitle('progress.recent'.tr()), + _Recent(gains: view.recent.take(8).toList()), + ], + const SizedBox(height: 16), + _SectionTitle('progress.locker'.tr()), + for (final slot in CosmeticSlot.values) + _LockerSlot( + slot: slot, + view: view, + saving: _saving, + onTap: _equip, + ), + ], + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + text, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +BoxDecoration _card() => BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.06), width: 0.5), +); + +class _LevelCard extends StatelessWidget { + const _LevelCard({required this.view}); + + final ProgressView view; + + @override + Widget build(BuildContext context) { + final p = view.progress; + final user = getIt().getUser(); + final display = user?.displayName?.trim(); + final name = display != null && display.isNotEmpty + ? display + : user?.displayIdentifier ?? ''; + final photo = user?.photoURL; + return Container( + padding: const EdgeInsets.all(18), + decoration: _card(), + child: Column( + children: [ + Row( + children: [ + LevelRing(level: p.level, fraction: p.fraction, size: 112), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'progress.xp_total'.tr(args: ['${p.xp}']), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + 'progress.to_next'.tr( + args: ['${p.toNext}', '${p.level + 1}'], + ), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: view.watchCap <= 0 + ? 0.0 + : (view.watchXpToday / view.watchCap).clamp( + 0.0, + 1.0, + ), + minHeight: 5, + backgroundColor: Colors.white.withValues(alpha: 0.08), + ), + ), + const SizedBox(height: 4), + Text( + 'progress.watch_today'.tr( + args: ['${view.watchXpToday}', '${view.watchCap}'], + ), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + ), + ), + ], + ), + ), + ], + ), + if (name.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + FramedAvatar( + size: 40, + frame: view.frame, + child: CircleAvatar( + radius: 20, + backgroundColor: AppColors.surfaceVariant, + backgroundImage: photo != null && photo.isNotEmpty + ? NetworkImage(photo) + : null, + child: photo != null && photo.isNotEmpty + ? null + : Text(name.characters.first.toUpperCase()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: UserBadgeLine( + name: name, + decoration: view.decoration, + showTitle: true, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _Rules extends StatelessWidget { + const _Rules(); + + @override + Widget build(BuildContext context) { + final rules = [ + (Icons.play_circle_outline_rounded, 'progress.rule_watch'.tr()), + (Icons.chat_bubble_outline_rounded, 'progress.rule_comment'.tr()), + (Icons.favorite_border_rounded, 'progress.rule_like'.tr()), + (Icons.local_fire_department_rounded, 'progress.rule_streak'.tr()), + (Icons.emoji_events_outlined, 'progress.rule_achievement'.tr()), + ]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: _card(), + child: Column( + children: [ + for (final (icon, text) in rules) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + height: 1.3, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _reasonKey(String reason) => switch (reason) { + 'watch' => 'progress.reason_watch', + 'comment' => 'progress.reason_comment', + 'like' => 'progress.reason_like', + 'streak' => 'progress.reason_streak', + 'achievement' => 'progress.reason_achievement', + _ => 'progress.reason_other', +}; + +class _Recent extends StatelessWidget { + const _Recent({required this.gains}); + + final List gains; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: _card(), + child: Column( + children: [ + for (final g in gains) + Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Expanded( + child: Text( + _reasonKey(g.reason).tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13.5, + ), + ), + ), + if (g.at != null) + Padding( + padding: const EdgeInsetsDirectional.only(end: 10), + child: Text( + DateFormat.MMMd( + context.locale.toString(), + ).format(g.at!), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + ), + ), + ), + Text( + 'progress.xp_gain'.tr(args: ['${g.xp}']), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _slotKey(CosmeticSlot slot) => switch (slot) { + CosmeticSlot.frame => 'progress.slot_frame', + CosmeticSlot.title => 'progress.slot_title', + CosmeticSlot.nameColor => 'progress.slot_color', +}; + +/// How to get a locked item: a level, or a badge at a tier. +String unlockHint(CosmeticItem item) { + final badge = item.badge; + if (item.level != null || badge == null) { + return 'progress.unlock_level'.tr(args: ['${item.level ?? 0}']); + } + final def = AchievementDef.of(badge); + final name = def.nameKey.tr(); + if (def.isSingle) return 'progress.unlock_badge'.tr(args: [name]); + return 'progress.unlock_badge'.tr( + args: ['$name · ${MedalTier.ofLevel(item.tier).labelKey.tr()}'], + ); +} + +class _LockerSlot extends StatelessWidget { + const _LockerSlot({ + required this.slot, + required this.view, + required this.saving, + required this.onTap, + }); + + final CosmeticSlot slot; + final ProgressView view; + final String? saving; + final Future Function(CosmeticItem item, bool equipped) onTap; + + @override + Widget build(BuildContext context) { + final items = view + .slot(slot) + .where((c) => Cosmetics.isKnown(slot, c.id)) + .toList(); + if (items.isEmpty) return const SizedBox.shrink(); + final current = view.equipped(slot); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Container( + padding: const EdgeInsets.all(12), + decoration: _card(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text( + _slotKey(slot).tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final item in items) + _LockerItem( + item: item, + equipped: current == item.id, + busy: saving == '${slot.name}:${item.id}', + onTap: item.unlocked + ? () => onTap(item, current == item.id) + : null, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _LockerItem extends StatelessWidget { + const _LockerItem({ + required this.item, + required this.equipped, + required this.busy, + required this.onTap, + }); + + final CosmeticItem item; + final bool equipped; + final bool busy; + final VoidCallback? onTap; + + Widget _preview() { + switch (item.slot) { + case CosmeticSlot.frame: + return FramedAvatar( + size: 34, + frame: item.id, + child: CircleAvatar( + radius: 17, + backgroundColor: AppColors.surfaceVariant, + child: const Icon( + Icons.person_rounded, + size: 20, + color: AppColors.textHint, + ), + ), + ); + case CosmeticSlot.nameColor: + return Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: Cosmetics.nameColor(item.id), + shape: BoxShape.circle, + ), + ); + case CosmeticSlot.title: + return const Icon( + Icons.workspace_premium_outlined, + size: 24, + color: AppColors.textSecondary, + ); + } + } + + @override + Widget build(BuildContext context) { + final locked = !item.unlocked; + final label = Cosmetics.label(item.slot, item.id); + return Tooltip( + message: locked ? unlockHint(item) : label, + child: Semantics( + selected: equipped, + child: HoverTap( + onTap: onTap, + borderRadius: 12, + scale: 1.03, + child: Container( + width: 100, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + decoration: BoxDecoration( + color: equipped + ? AppColors.primary.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: equipped + ? AppColors.primary.withValues(alpha: 0.6) + : Colors.white.withValues(alpha: 0.06), + ), + ), + child: Opacity( + opacity: locked ? 0.5 : 1, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: Center( + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _preview(), + ), + ), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: item.slot == CosmeticSlot.nameColor + ? Cosmetics.nameColor(item.id) + : AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (locked) ...[ + const Icon( + Icons.lock_outline_rounded, + size: 11, + color: AppColors.textHint, + ), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + locked + ? unlockHint(item) + : equipped + ? 'progress.equipped'.tr() + : 'progress.equip'.tr(), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/progress/presentation/widgets/level_ring.dart b/lib/features/progress/presentation/widgets/level_ring.dart new file mode 100644 index 00000000..8f01f191 --- /dev/null +++ b/lib/features/progress/presentation/widgets/level_ring.dart @@ -0,0 +1,119 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; + +/// The level in the middle, the way through it around the edge. +class LevelRing extends StatelessWidget { + const LevelRing({ + super.key, + required this.level, + required this.fraction, + this.size = 120, + }); + + final int level; + final double fraction; + final double size; + + @override + Widget build(BuildContext context) { + final still = MediaQuery.disableAnimationsOf(context); + return Semantics( + label: 'progress.level_short'.tr(args: ['$level']), + excludeSemantics: true, + child: SizedBox( + width: size, + height: size, + child: TweenAnimationBuilder( + tween: Tween(begin: still ? fraction : 0, end: fraction), + duration: still ? Duration.zero : const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, t, child) => CustomPaint( + painter: _RingPainter( + fraction: t, + color: AppColors.primary, + track: Colors.white.withValues(alpha: 0.08), + stroke: size * 0.075, + ), + child: child, + ), + // Scales down rather than spilling out of the ring at large text + // sizes. + child: Padding( + padding: EdgeInsets.all(size * 0.14), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontSize: size * 0.09, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + Text( + '$level', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.32, + fontWeight: FontWeight.w900, + height: 1.05, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({ + required this.fraction, + required this.color, + required this.track, + required this.stroke, + }); + + final double fraction; + final Color color; + final Color track; + final double stroke; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final arc = rect.deflate(stroke / 2); + final base = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track; + canvas.drawArc(arc, 0, math.pi * 2, false, base); + if (fraction <= 0) return; + final fill = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..shader = SweepGradient( + startAngle: -math.pi / 2, + endAngle: math.pi * 1.5, + colors: [color.withValues(alpha: 0.6), color], + transform: const GradientRotation(-math.pi / 2), + ).createShader(rect); + canvas.drawArc(arc, -math.pi / 2, math.pi * 2 * fraction, false, fill); + } + + @override + bool shouldRepaint(_RingPainter old) => + old.fraction != fraction || old.color != color || old.stroke != stroke; +} diff --git a/lib/features/progress/presentation/widgets/user_badge_line.dart b/lib/features/progress/presentation/widgets/user_badge_line.dart new file mode 100644 index 00000000..511eaa0b --- /dev/null +++ b/lib/features/progress/presentation/widgets/user_badge_line.dart @@ -0,0 +1,237 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; + +/// How each cosmetic id looks. The server only stores ids; an id this build +/// does not know draws as nothing rather than as a broken frame. +class Cosmetics { + Cosmetics._(); + + static const Map> frames = { + 'bronze': [Color(0xFFE0A374), Color(0xFF9C5B2E)], + 'silver': [Color(0xFFF2F2F2), Color(0xFF9DA3AB)], + 'gold': [Color(0xFFFFE58A), Color(0xFFD4A017)], + 'neon': [Color(0xFF3DF5FF), Color(0xFFB44DFF)], + 'sakura': [Color(0xFFFFC6DD), Color(0xFFFF6FA5)], + 'ember': [Color(0xFFFFC078), Color(0xFFEF4A1A)], + 'flame': [Color(0xFFFFE066), Color(0xFFFF7A1A), Color(0xFFE5243B)], + 'moon': [Color(0xFFDDE6FF), Color(0xFF4C5BD4)], + }; + + static const Map nameColors = { + 'sky': Color(0xFF6EC1FF), + 'mint': Color(0xFF5EE6B0), + 'gold': Color(0xFFFFD45E), + 'rose': Color(0xFFFF7FA8), + 'violet': Color(0xFFB98CFF), + 'crimson': Color(0xFFFF5A5F), + 'aurora': Color(0xFF7CF2D8), + 'ember': Color(0xFFFF9F43), + }; + + static const Set titles = { + 'newcomer', + 'regular', + 'veteran', + 'legend', + 'binger', + 'cinephile', + 'otaku', + 'bookworm', + 'night_owl', + 'host', + }; + + static List? frame(String? id) => id == null ? null : frames[id]; + + static Color? nameColor(String? id) => id == null ? null : nameColors[id]; + + /// The words for a title, or null for one this build has none for. + static String? titleText(String? id) => + id != null && titles.contains(id) ? 'progress.title_$id'.tr() : null; + + static bool isKnown(CosmeticSlot slot, String id) => switch (slot) { + CosmeticSlot.frame => frames.containsKey(id), + CosmeticSlot.title => titles.contains(id), + CosmeticSlot.nameColor => nameColors.containsKey(id), + }; + + /// The name a locker shows for any cosmetic. + static String label(CosmeticSlot slot, String id) => switch (slot) { + CosmeticSlot.frame => 'progress.frame_$id'.tr(), + CosmeticSlot.title => 'progress.title_$id'.tr(), + CosmeticSlot.nameColor => 'progress.color_$id'.tr(), + }; +} + +/// An avatar inside the frame its owner equipped; the avatar alone without +/// one. +/// +/// The frame is painted outside the avatar's box rather than added to it, so a +/// framed avatar takes the same space as a plain one and a list of names stays +/// aligned whoever has a frame. +class FramedAvatar extends StatelessWidget { + const FramedAvatar({ + super.key, + required this.size, + required this.child, + this.frame, + this.radius, + }); + + /// The avatar's own size; the frame is drawn around it, not into it. + final double size; + final Widget child; + final String? frame; + + /// Corner radius for a square avatar; null for a circle. + final double? radius; + + static double ringFor(double size) => + (size * 0.07).clamp(2.0, 5.0).toDouble(); + + @override + Widget build(BuildContext context) { + final colors = Cosmetics.frame(frame); + if (colors == null) return child; + final ring = ringFor(size); + final shape = radius == null + ? const BoxDecoration(shape: BoxShape.circle) + : BoxDecoration(borderRadius: BorderRadius.circular(radius! + ring)); + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -ring, + top: -ring, + right: -ring, + bottom: -ring, + child: DecoratedBox( + decoration: shape.copyWith( + gradient: SweepGradient(colors: [...colors, colors.first]), + boxShadow: [ + BoxShadow( + color: colors.last.withValues(alpha: 0.35), + blurRadius: ring * 2.5, + ), + ], + ), + ), + ), + Positioned.fill(child: child), + ], + ), + ); + } +} + +/// "Lv 12" as a small pill. +class LevelChip extends StatelessWidget { + const LevelChip({super.key, required this.level, this.fontSize = 10}); + + final int level; + final double fontSize; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: fontSize * 0.55, + vertical: fontSize * 0.15, + ), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(fontSize), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.45), + width: 0.6, + ), + ), + child: Text( + 'progress.level_short'.tr(args: ['$level']), + style: TextStyle( + color: AppColors.textPrimary, + fontSize: fontSize, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + ); + } +} + +/// A name in its owner's colour, their level beside it and, when asked, their +/// title under it. With no [decoration] it is just the name. +class UserBadgeLine extends StatelessWidget { + const UserBadgeLine({ + super.key, + required this.name, + required this.decoration, + required this.style, + this.showTitle = false, + this.center = false, + this.chipSize, + }); + + final String name; + final UserDecoration? decoration; + final TextStyle style; + final bool showTitle; + final bool center; + final double? chipSize; + + @override + Widget build(BuildContext context) { + final d = decoration; + final color = Cosmetics.nameColor(d?.nameColor); + final line = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: center ? TextAlign.center : null, + style: color == null ? style : style.copyWith(color: color), + ), + ), + if (d != null && d.level > 0) ...[ + const SizedBox(width: 6), + LevelChip( + level: d.level, + fontSize: chipSize ?? ((style.fontSize ?? 13) * 0.72), + ), + ], + ], + ); + final title = showTitle ? Cosmetics.titleText(d?.title) : null; + if (title == null) return line; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: center + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + line, + const SizedBox(height: 2), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: (color ?? AppColors.textSecondary).withValues(alpha: 0.85), + fontSize: (style.fontSize ?? 13) * 0.78, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ], + ); + } +} diff --git a/lib/features/search/data/model/search_model.dart b/lib/features/search/data/model/search_model.dart index c576d62b..fc991319 100644 --- a/lib/features/search/data/model/search_model.dart +++ b/lib/features/search/data/model/search_model.dart @@ -1,24 +1,22 @@ +import 'package:soplay/core/js/js_value.dart'; import 'package:soplay/features/home/data/models/movie_model.dart'; import '../../domain/entities/search_entity.dart'; class SearchModel extends SearchEntity { - SearchModel({required super.provider, required super.items, - required super.page, required super.totalPages + SearchModel({ + required super.provider, + required super.items, + required super.page, + required super.totalPages, }); factory SearchModel.fromJson(Map json) { - final rawItems = json['items']; return SearchModel( - page: (json['page'] as num?)?.toInt() ?? 1, - totalPages: (json['totalPages'] as num?)?.toInt() ?? 1, - provider: json['provider'] as String? ?? '', - items: rawItems is List - ? rawItems - .whereType() - .map((e) => MovieModel.fromJson(Map.from(e))) - .toList() - : const [], + page: jsonInt(json['page']) ?? 1, + totalPages: jsonInt(json['totalPages']) ?? 1, + provider: jsonText(json['provider']) ?? '', + items: jsonMaps(json['items']).map(MovieModel.fromJson).toList(), ); } } diff --git a/lib/features/search/presentation/pages/search_page.dart b/lib/features/search/presentation/pages/search_page.dart index 18f92ec6..057f63c4 100644 --- a/lib/features/search/presentation/pages/search_page.dart +++ b/lib/features/search/presentation/pages/search_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'package:soplay/features/search/presentation/widgets/voice_search_button.dart'; import 'package:soplay/features/search/presentation/widgets/search_filter_sheet.dart'; @@ -34,6 +35,7 @@ class _SearchViewState extends State<_SearchView> { final _focus = FocusNode(); final _scrollController = ScrollController(); final _blurProgress = ValueNotifier(0); + final _voiceKey = GlobalKey(); @override void initState() { @@ -197,7 +199,7 @@ class _SearchViewState extends State<_SearchView> { final topPad = MediaQuery.paddingOf(context).top; final bottomPad = MediaQuery.paddingOf(context).bottom; - return Scaffold( + final page = Scaffold( backgroundColor: AppColors.background, body: BlocConsumer( listener: (context, state) => _maybeAutoFill(state), @@ -233,6 +235,7 @@ class _SearchViewState extends State<_SearchView> { context.read().add(SearchSubmitted(q)), onClear: _clearSearch, voiceButton: VoiceSearchButton( + key: _voiceKey, // 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. @@ -332,5 +335,20 @@ class _SearchViewState extends State<_SearchView> { ), ), ); + if (!isTvPlatform) return page; + // The remote's search or voice key starts voice search from anywhere on + // this page, the text field included. + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (_, event) { + if (event is! KeyDownEvent || !TvKeys.isVoiceSearch(event.logicalKey)) { + return KeyEventResult.ignored; + } + _voiceKey.currentState?.start(); + return KeyEventResult.handled; + }, + child: page, + ); } } diff --git a/lib/features/search/presentation/widgets/genre_tile.dart b/lib/features/search/presentation/widgets/genre_tile.dart index d6eca535..db9f3ecc 100644 --- a/lib/features/search/presentation/widgets/genre_tile.dart +++ b/lib/features/search/presentation/widgets/genre_tile.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/network/image_headers.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -112,7 +113,7 @@ class GenreTile extends StatelessWidget { color: AppColors.textPrimary, fontSize: labelSize, fontWeight: FontWeight.w700, - height: 1.15, + height: lineHeightFor(context, 1.15), letterSpacing: -0.2, shadows: [Shadow(color: Colors.black87, blurRadius: 6)], ), diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 942719f6..2efd6533 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -1,9 +1,9 @@ -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/tv/tv.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; class SearchStickyHeader extends StatelessWidget { const SearchStickyHeader({ @@ -124,8 +124,8 @@ class SearchStickyHeader extends StatelessWidget { if (!blurred) return surface; return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), + child: ReducibleBlur( + sigma: 20 * progress, child: surface, ), ); @@ -176,11 +176,11 @@ class _SearchField extends StatelessWidget { suffixIcon: ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { - // Microphone while there is nothing to clear, clear button once - // there is. They never both apply, so the two share one slot rather - // than crowding the field with a second icon. + // The microphone stays reachable with text in the field, so a + // misheard query can be spoken again without clearing it first. + final voice = voiceButton; if (value.text.isEmpty) { - return voiceButton ?? const SizedBox.shrink(); + return voice ?? const SizedBox.shrink(); } const icon = Icon( Icons.close_rounded, @@ -189,17 +189,21 @@ class _SearchField extends StatelessWidget { ); // Android TV: the clear (X) is the only way to drop a query // without a hardware keyboard, so it needs to be a focus stop. - if (isTvPlatform) { - return TvFocusable( - onPressed: onClear, - borderRadius: 9, - child: icon, - ); - } - return IconButton( - tooltip: 'general.clear'.tr(), - onPressed: onClear, - icon: icon, + final Widget clear = isTvPlatform + ? TvFocusable( + onPressed: onClear, + borderRadius: 9, + child: icon, + ) + : IconButton( + tooltip: 'general.clear'.tr(), + onPressed: onClear, + icon: icon, + ); + if (voice == null) return clear; + return Row( + mainAxisSize: MainAxisSize.min, + children: [clear, voice], ); }, ), diff --git a/lib/features/search/presentation/widgets/search_landing.dart b/lib/features/search/presentation/widgets/search_landing.dart index 415a558f..359e6f00 100644 --- a/lib/features/search/presentation/widgets/search_landing.dart +++ b/lib/features/search/presentation/widgets/search_landing.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/content/catalogue.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/tv/tv.dart'; @@ -555,11 +556,11 @@ class _SectionTitle extends StatelessWidget { text, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w800, - height: 1.15, + height: lineHeightFor(context, 1.15), // Slightly tight, which is what stops a large weight reading as shouting. letterSpacing: -0.3, ), diff --git a/lib/features/search/presentation/widgets/search_result_card.dart b/lib/features/search/presentation/widgets/search_result_card.dart index 643a7e94..2fc9442d 100644 --- a/lib/features/search/presentation/widgets/search_result_card.dart +++ b/lib/features/search/presentation/widgets/search_result_card.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; @@ -242,10 +243,10 @@ class SearchResultCard extends StatelessWidget { movie.title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 12, - height: 1.15, + height: lineHeightFor(context, 1.15), fontWeight: FontWeight.w600, ), ), diff --git a/lib/features/search/presentation/widgets/voice_search_button.dart b/lib/features/search/presentation/widgets/voice_search_button.dart index 2da9e5f3..4efe0a18 100644 --- a/lib/features/search/presentation/widgets/voice_search_button.dart +++ b/lib/features/search/presentation/widgets/voice_search_button.dart @@ -1,24 +1,23 @@ import 'dart:io' show Platform; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:speech_to_text/speech_to_text.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/search/presentation/widgets/voice_search_dialog.dart'; /// Microphone button for the search field. /// -/// Renders nothing on the platforms with no recogniser to talk to, rather than -/// showing a button that reports a failure when pressed. On Android the -/// recogniser is also invisible without the `` entry in the manifest, -/// and the failure mode there is silent — the plugin simply says no recogniser -/// is available — so the check below covers both. +/// Shown on every platform with a system recogniser. Nothing is initialised +/// until it is pressed: the dialog does that, so the permission prompt comes +/// when the user asks to speak, and a refusal or a missing recogniser is +/// explained there instead of the button quietly disappearing. /// -/// Partial results are reported as they arrive, so the field fills in while the -/// user is still speaking. That is what makes it feel like dictation rather -/// than a recording being uploaded, and it lets someone see a misheard word and -/// stop rather than waiting for a wrong search to run. +/// Partial results are reported as they arrive, so the field fills in while +/// the user is still speaking. class VoiceSearchButton extends StatefulWidget { const VoiceSearchButton({ super.key, @@ -35,101 +34,65 @@ class VoiceSearchButton extends StatefulWidget { final double size; + static bool get platformSupported => + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + @override - State createState() => _VoiceSearchButtonState(); + State createState() => VoiceSearchButtonState(); } -class _VoiceSearchButtonState extends State - with SingleTickerProviderStateMixin { +class VoiceSearchButtonState extends State { final SpeechToText _speech = SpeechToText(); + bool _open = false; - bool _available = false; - bool _listening = false; - late final AnimationController _pulse; - - /// Only these have a system recogniser worth offering. - static bool get _platformSupported => Platform.isAndroid || Platform.isIOS; - - @override - void initState() { - super.initState(); - _pulse = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - if (_platformSupported) _init(); - } - - Future _init() async { + /// Opens the listening dialog. Also called by the TV remote's search key. + Future start() async { + if (_open || !VoiceSearchButton.platformSupported) return; + _open = true; try { - final ok = await _speech.initialize( - onStatus: (s) { - if (!mounted) return; - final listening = s == 'listening'; - if (listening != _listening) { - setState(() => _listening = listening); - listening ? _pulse.repeat(reverse: true) : _pulse.stop(); - } - }, - onError: (_) { - if (!mounted) return; - setState(() => _listening = false); - _pulse.stop(); - }, + final result = await VoiceSearchDialog.show( + context, + speech: _speech, + onPartial: widget.onText, ); - if (mounted) setState(() => _available = ok); - } catch (_) { - // A device with no recogniser, or one that refuses to initialise. The - // button simply does not appear. - if (mounted) setState(() => _available = false); + final words = result?.trim() ?? ''; + if (words.isEmpty || !mounted) return; + widget.onText(words); + widget.onSubmit?.call(words); + } finally { + _open = false; } } @override void dispose() { - _pulse.dispose(); - // Not awaited: dispose cannot, and an orphaned recogniser session ends on - // its own timeout anyway. - _speech.cancel(); + // Not awaited: dispose cannot, and an orphaned session ends on its own. + if (_speech.isListening) _speech.cancel(); super.dispose(); } - /// Open the listening dialog and run whatever it comes back with. - /// - /// The session itself lives in the dialog, which owns the waveform and needs - /// the sound level anyway. This widget stays what it looks like: a button - /// that knows whether the device can listen at all. - Future _open() async { - if (_listening) return; - final result = await VoiceSearchDialog.show( - context, - speech: _speech, - onPartial: widget.onText, - ); - final words = result?.trim() ?? ''; - if (words.isEmpty) return; - widget.onText(words); - widget.onSubmit?.call(words); - } - @override Widget build(BuildContext context) { - if (!_platformSupported || !_available) return const SizedBox.shrink(); + if (!VoiceSearchButton.platformSupported) return const SizedBox.shrink(); + final icon = Icon( + Icons.mic_none_rounded, + size: widget.size, + color: AppColors.textSecondary, + ); + if (isTvPlatform) { + return Tooltip( + message: 'search.voice'.tr(), + child: TvFocusable( + onPressed: start, + borderRadius: 9, + child: icon, + ), + ); + } return IconButton( tooltip: 'search.voice'.tr(), - onPressed: _open, - icon: AnimatedBuilder( - animation: _pulse, - builder: (_, child) => Opacity( - opacity: _listening ? 0.45 + (_pulse.value * 0.55) : 1, - child: child, - ), - child: Icon( - _listening ? Icons.mic_rounded : Icons.mic_none_rounded, - size: widget.size, - color: _listening ? AppColors.primary : AppColors.textSecondary, - ), - ), + onPressed: start, + icon: icon, ); } } diff --git a/lib/features/search/presentation/widgets/voice_search_dialog.dart b/lib/features/search/presentation/widgets/voice_search_dialog.dart index ddd4eb49..35ee0d43 100644 --- a/lib/features/search/presentation/widgets/voice_search_dialog.dart +++ b/lib/features/search/presentation/widgets/voice_search_dialog.dart @@ -2,44 +2,55 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:speech_to_text/speech_recognition_error.dart'; import 'package:speech_to_text/speech_to_text.dart'; +import 'package:soplay/core/system/app_settings.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/search/presentation/widgets/voice_search_session.dart'; -/// The listening dialog: a live waveform, the words as they are heard, and one -/// way out. +/// The listening dialog: a live waveform, the words as they are heard, and a +/// plain account of what went wrong when nothing usable came back. /// /// The waveform is drawn from the recogniser's own sound level, not from a -/// timer. That distinction is the whole point of having one: a decorative -/// animation keeps dancing when the microphone has stopped hearing anything, -/// which is exactly the moment the user needs to be told. Bars that go flat -/// when someone stops talking — or when the phone is not picking them up — -/// answer "is it hearing me?" without a word of copy. +/// timer, so it goes flat when the microphone stops hearing anything. The +/// level scale differs between Android and iOS, so a running peak normalises +/// whatever arrives. /// -/// The level scale differs between Android and iOS (iOS reports decibels, -/// Android something undocumented), so nothing here assumes a range. A running -/// peak normalises whatever arrives, which also adapts to a quiet room and a -/// loud one without a calibration step. +/// The recogniser is initialised here, on the first tap, so the permission +/// prompt appears when the user asks to speak rather than when search opens. /// /// Returns the final transcript, or null if the user backed out. class VoiceSearchDialog extends StatefulWidget { - const VoiceSearchDialog._({required this.speech, required this.onPartial}); + const VoiceSearchDialog._({ + required this.speech, + required this.onPartial, + required this.appLocale, + }); final SpeechToText speech; /// Fires for every partial result so the field behind the dialog fills in - /// alongside it — closing then feels like the search was already there. + /// alongside it. final ValueChanged onPartial; + final Locale appLocale; + static Future show( BuildContext context, { required SpeechToText speech, required ValueChanged onPartial, }) { + final appLocale = context.locale; return showDialog( context: context, barrierDismissible: true, - builder: (_) => VoiceSearchDialog._(speech: speech, onPartial: onPartial), + builder: (_) => VoiceSearchDialog._( + speech: speech, + onPartial: onPartial, + appLocale: appLocale, + ), ); } @@ -49,24 +60,30 @@ class VoiceSearchDialog extends StatefulWidget { class _VoiceSearchDialogState extends State with SingleTickerProviderStateMixin { - /// How many bars the strip holds. Enough to read as a waveform, few enough - /// that each one is still wide enough to see on a phone. static const int _barCount = 34; - final List _levels = List.filled(_barCount, 0); + /// A cold recogniser on a slow device genuinely takes a second or two. + static const Duration _startDeadline = Duration(seconds: 6); - late final AnimationController _ticker; + /// Resolved once per app language; asking the recogniser is not free. + static final Map _localeCache = {}; - /// Where the newest sample is heading; bars ease toward it so a single loud - /// syllable does not make the strip flicker. + final List _levels = List.filled(_barCount, 0); + late final AnimationController _ticker; double _target = 0; - - /// Running peak used to normalise, since the platforms disagree on scale. - /// Never allowed to reach zero, which would divide the strip by nothing. double _peak = 1; - String _words = ''; - bool _done = false; + VoiceSearchState _state = VoiceSearchState.initial; + + /// Bumped on every attempt so callbacks from an abandoned one are ignored. + int _attempt = 0; + + /// Until the recogniser has started, `isListening` is false for an ordinary + /// reason and must not be read as "it stopped". + bool _everListened = false; + + String? _localeId; + bool _localeDropped = false; @override void initState() { @@ -75,98 +92,151 @@ class _VoiceSearchDialogState extends State vsync: this, duration: const Duration(milliseconds: 16), )..addListener(_advance); - _ticker.repeat(); - _start(); + _begin(); + } + + void _set(VoiceSearchState next) { + if (!mounted || next == _state) return; + final wasActive = _state.isActive; + setState(() => _state = next); + if (next.phase == VoicePhase.done) { + Navigator.of(context).pop(next.words); + return; + } + if (wasActive && !next.isActive) { + _ticker.stop(); + _levels.fillRange(0, _barCount, 0); + widget.speech.cancel(); + } } - Future _start() async { + Future _begin() async { + final attempt = ++_attempt; + _everListened = false; + _set(VoiceSearchMachine.retry()); + if (!_ticker.isAnimating) _ticker.repeat(); + + final speech = widget.speech; + var ok = false; try { - await _listen(); + ok = await speech.initialize(); } catch (e) { - // A recogniser that refuses to start — no permission, no service, another - // app holding the microphone. Closing beats a dialog with a flat - // waveform that never explains itself. - debugPrint('[voice] could not start listening: $e'); - if (mounted && !_done) _finish(''); + debugPrint('[voice] initialize failed: $e'); } + if (!mounted || attempt != _attempt) return; + if (!ok) { + _set(VoiceSearchMachine.initFailed(hasPermission: await _hasPermission())); + return; + } + // initialize() only takes listeners on its first successful call, and the + // plugin is a singleton, so this dialog claims them directly. + speech.errorListener = (e) => _onError(e, attempt); + speech.statusListener = (s) { + if (attempt == _attempt && s == SpeechToText.listeningStatus) { + _everListened = true; + _set(VoiceSearchMachine.listeningStarted(_state)); + } + }; + + if (!_localeDropped) _localeId = await _resolveLocale(); + if (!mounted || attempt != _attempt) return; + await _listen(attempt); } - Future _listen() async { - await widget.speech.listen( - onResult: (r) { - final words = r.recognizedWords.trim(); - if (mounted && words.isNotEmpty) { - setState(() => _words = words); - widget.onPartial(words); - } - if (r.finalResult) _finish(words); - }, - onSoundLevelChange: (level) { - // Absolute value: iOS reports negative decibels, Android does not. - final v = level.abs(); - if (v > _peak) _peak = v; - // Decay the peak slowly so one loud noise does not flatten the strip - // for the rest of the session. - _peak = math.max(1, _peak * 0.995); - _target = (v / _peak).clamp(0.0, 1.0); - }, - listenOptions: SpeechListenOptions( - partialResults: true, - cancelOnError: true, - listenFor: const Duration(seconds: 20), - pauseFor: const Duration(seconds: 3), - ), - ); - // NOT the end of the session. - // - // `listen()` completes as soon as the platform has STARTED listening — it - // stamps the start time, arms its pause timers and returns. Treating that - // as the end closed this dialog on the frame it opened, with an empty - // transcript, while the recogniser carried on running: the microphone - // indicator lit up and no dialog was ever visible. - // - // The session ends when a final result arrives (handled in onResult), when - // the user stops it, or when the recogniser gives up on its own — and the - // last of those is only observable through `isListening`, which [_advance] - // already watches on every frame. - // - // A start that never happens is the other failure. `listen()` can return - // without the platform ever entering the listening state, and the watchdog - // in [_advance] only fires once it has seen listening begin — so without - // this the dialog would wait forever on a microphone that was never opened. + Future _hasPermission() async { + try { + return await widget.speech.hasPermission; + } catch (_) { + return false; + } + } + + Future _resolveLocale() async { + final key = widget.appLocale.toLanguageTag(); + if (_localeCache.containsKey(key)) return _localeCache[key]; + String? id; + try { + final locales = await widget.speech.locales(); + final system = await widget.speech.systemLocale(); + id = matchSpeechLocale( + widget.appLocale, + locales.map((l) => l.localeId), + systemLocaleId: system?.localeId, + ); + } catch (e) { + debugPrint('[voice] could not read recogniser locales: $e'); + } + _localeCache[key] = id; + return id; + } + + Future _listen(int attempt) async { + try { + if (widget.speech.isListening) await widget.speech.cancel(); + await widget.speech.listen( + onResult: (r) { + if (attempt != _attempt) return; + final words = r.recognizedWords.trim(); + if (words.isNotEmpty) widget.onPartial(words); + _set(VoiceSearchMachine.heard(_state, words, isFinal: r.finalResult)); + }, + onSoundLevelChange: (level) { + final v = level.abs(); + if (v > _peak) _peak = v; + _peak = math.max(1, _peak * 0.995); + _target = (v / _peak).clamp(0.0, 1.0); + }, + listenOptions: SpeechListenOptions( + partialResults: true, + cancelOnError: true, + listenFor: const Duration(seconds: 20), + pauseFor: const Duration(seconds: 3), + localeId: _localeId, + ), + ); + } catch (e) { + debugPrint('[voice] could not start listening: $e'); + if (!mounted || attempt != _attempt) return; + _set( + VoiceSearchMachine.listenFailed(hasPermission: await _hasPermission()), + ); + return; + } + // listen() returns once the platform has been asked to start, not when the + // session ends. A start that never happens is caught here; an end is + // caught by the watchdog in [_advance]. Future.delayed(_startDeadline, () { - if (mounted && !_done && !_everListened) { - debugPrint('[voice] the recogniser never started listening'); - _finish(''); + if (mounted && attempt == _attempt && !_everListened) { + _set(VoiceSearchMachine.startTimedOut(_state)); } }); } - /// How long to wait for the microphone to actually open before giving up. - /// - /// Generous, because a cold recogniser on a slow device genuinely takes a - /// second or two, and closing on somebody who was about to be heard is worse - /// than a moment of blank waveform. - static const Duration _startDeadline = Duration(seconds: 6); - - /// Whether the recogniser has actually started. Until it has, `isListening` - /// is false for an ordinary reason and must not be read as "it stopped". - bool _everListened = false; + void _onError(SpeechRecognitionError e, int attempt) { + if (!mounted || attempt != _attempt) return; + debugPrint('[voice] ${e.errorMsg} permanent=${e.permanent}'); + // The app language is not one the recogniser can do after all: try once + // more in the system language rather than failing outright. + if (VoiceSearchMachine.isLanguageError(e.errorMsg) && + _localeId != null && + !_localeDropped) { + _localeDropped = true; + _localeCache[widget.appLocale.toLanguageTag()] = null; + _begin(); + return; + } + _set(VoiceSearchMachine.error(_state, e.errorMsg, permanent: e.permanent)); + } - /// Shift the strip one step and ease the newest bar toward the live level. - /// - /// Also the session watchdog. The recogniser can end on its own — the - /// `listenFor` ceiling, a `pauseFor` silence, a permanent error — and none of - /// those calls back into this dialog. Without noticing, the dialog would sit - /// over a dead microphone until somebody dismissed it. void _advance() { - if (!mounted) return; + if (!mounted || !_state.isActive) return; if (widget.speech.isListening) { _everListened = true; - } else if (_everListened && !_done) { - // Ended by itself. Whatever was heard is the answer; nothing is the - // answer too, and _finish turns that into a dismissal. - _finish(_words); + if (_state.phase == VoicePhase.starting) { + _set(VoiceSearchMachine.listeningStarted(_state)); + } + } else if (_everListened) { + _set(VoiceSearchMachine.ended(_state)); return; } for (var i = 0; i < _barCount - 1; i++) { @@ -174,111 +244,178 @@ class _VoiceSearchDialogState extends State } final last = _levels[_barCount - 1]; _levels[_barCount - 1] = last + (_target - last) * 0.35; - // Decay toward silence, so the strip settles flat when nothing is arriving - // rather than holding the last value forever. _target *= 0.92; setState(() {}); } - void _finish(String words) { - if (_done) return; - _done = true; - if (!mounted) return; - Navigator.of(context).pop(words.isEmpty ? null : words); - } - Future _stop() async { // stop() asks for a final result; cancel() would throw away the words // already on screen, which is not what a Done button means. await widget.speech.stop(); - if (mounted && !_done) _finish(_words); + if (!mounted) return; + _set(VoiceSearchMachine.ended(_state)); + } + + void _cancel() { + _attempt++; + widget.speech.cancel(); + Navigator.of(context).pop(); } @override void dispose() { + _attempt++; _ticker.dispose(); + widget.speech.errorListener = null; + widget.speech.statusListener = null; if (widget.speech.isListening) widget.speech.cancel(); super.dispose(); } + (IconData, String, String?) get _copy { + switch (_state.phase) { + case VoicePhase.starting: + return (Icons.mic_none_rounded, 'search.voice_starting'.tr(), null); + case VoicePhase.listening: + case VoicePhase.done: + return (Icons.mic_rounded, 'search.voice_listening'.tr(), null); + case VoicePhase.noSpeech: + return ( + Icons.hearing_disabled_rounded, + 'search.voice_no_speech'.tr(), + 'search.voice_no_speech_hint'.tr(), + ); + case VoicePhase.permissionDenied: + return ( + Icons.mic_off_rounded, + 'search.voice_permission'.tr(), + 'search.voice_permission_hint'.tr(), + ); + case VoicePhase.unavailable: + return ( + Icons.mic_off_rounded, + 'search.voice_unavailable'.tr(), + 'search.voice_unavailable_hint'.tr(), + ); + case VoicePhase.network: + return ( + Icons.wifi_off_rounded, + 'search.voice_network'.tr(), + 'search.voice_network_hint'.tr(), + ); + case VoicePhase.recognizer: + return ( + Icons.error_outline_rounded, + 'search.voice_error'.tr(), + 'search.voice_error_hint'.tr(), + ); + } + } + @override Widget build(BuildContext context) { - final listening = widget.speech.isListening; + final (icon, title, hint) = _copy; + final active = _state.isActive; + final words = _state.words; return Dialog( backgroundColor: AppColors.surface, insetPadding: const EdgeInsets.symmetric(horizontal: 32), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 26, 24, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - listening ? Icons.mic_rounded : Icons.mic_off_rounded, - size: 30, - color: listening ? AppColors.primary : AppColors.textHint, - ), - const SizedBox(height: 14), - Text( - listening ? 'search.voice_listening'.tr() : 'search.voice_done'.tr(), - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 26, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 30, + color: _state.phase == VoicePhase.listening + ? AppColors.primary + : AppColors.textHint, ), - ), - const SizedBox(height: 18), - SizedBox( - height: 56, - width: double.infinity, - child: CustomPaint( - painter: _WavePainter( - levels: _levels, - color: AppColors.primary, + const SizedBox(height: 14), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), - ), - const SizedBox(height: 18), - // Reserves its own height so the dialog does not jump the moment - // the first word arrives. - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 44), - child: Center( - child: Text( - _words.isEmpty ? 'search.voice_hint'.tr() : _words, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: _words.isEmpty ? 13 : 16, - fontWeight: - _words.isEmpty ? FontWeight.w400 : FontWeight.w600, - color: _words.isEmpty - ? AppColors.textHint - : AppColors.textPrimary, + const SizedBox(height: 18), + if (active) ...[ + SizedBox( + height: 56, + width: double.infinity, + child: CustomPaint( + painter: _WavePainter( + levels: _levels, + color: AppColors.primary, + ), ), ), - ), - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () { - widget.speech.cancel(); - Navigator.of(context).pop(); - }, - child: Text('general.cancel'.tr()), - ), - const SizedBox(width: 4), - TextButton( - onPressed: _words.isEmpty ? null : _stop, - child: Text('general.done'.tr()), - ), + const SizedBox(height: 18), ], - ), - ], + // Reserves its own height so the dialog does not jump the + // moment the first word arrives. + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Center( + child: Text( + active + ? (words.isEmpty ? 'search.voice_hint'.tr() : words) + : (hint ?? ''), + textAlign: TextAlign.center, + maxLines: active ? 2 : 4, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: active && words.isNotEmpty ? 16 : 13, + fontWeight: active && words.isNotEmpty + ? FontWeight.w600 + : FontWeight.w400, + color: active && words.isNotEmpty + ? AppColors.textPrimary + : AppColors.textHint, + ), + ), + ), + ), + const SizedBox(height: 6), + Wrap( + alignment: WrapAlignment.end, + spacing: 4, + children: [ + TextButton( + // While listening there is nothing else to focus: Done + // stays disabled until a word is heard. + autofocus: isTvPlatform && active, + onPressed: _cancel, + child: Text('general.cancel'.tr()), + ), + if (active) + TextButton( + onPressed: words.isEmpty ? null : _stop, + child: Text('general.done'.tr()), + ), + if (_state.phase == VoicePhase.permissionDenied) + TextButton( + onPressed: openAppSettings, + child: Text('search.voice_open_settings'.tr()), + ), + if (_state.canRetry) + TextButton( + // A remote has no other way into the dialog's buttons. + autofocus: isTvPlatform, + onPressed: _begin, + child: Text('search.voice_try_again'.tr()), + ), + ], + ), + ], + ), ), ), ); @@ -298,15 +435,12 @@ class _WavePainter extends CustomPainter { final slot = size.width / levels.length; final barWidth = math.max(2.0, slot * 0.5); final mid = size.height / 2; - // A floor, so silence reads as a quiet line rather than an empty box that - // looks like the widget failed to render. + // A floor, so silence reads as a quiet line rather than an empty box. const minHeight = 3.0; for (var i = 0; i < levels.length; i++) { final level = levels[i].clamp(0.0, 1.0); final h = minHeight + level * (size.height - minHeight); - // Older samples fade, which gives the strip a direction and makes the - // live end the one the eye goes to. final opacity = 0.25 + (i / levels.length) * 0.75; final paint = Paint() ..color = color.withValues(alpha: opacity) diff --git a/lib/features/search/presentation/widgets/voice_search_session.dart b/lib/features/search/presentation/widgets/voice_search_session.dart new file mode 100644 index 00000000..a199d851 --- /dev/null +++ b/lib/features/search/presentation/widgets/voice_search_session.dart @@ -0,0 +1,227 @@ +import 'dart:ui' show Locale; + +/// Where a voice search session stands. The dialog renders one of these and +/// never closes on a failure: every failure phase has something to say. +enum VoicePhase { + starting, + listening, + + /// Ended with a transcript; the dialog pops with [VoiceSearchState.words]. + done, + noSpeech, + permissionDenied, + unavailable, + network, + recognizer, +} + +class VoiceSearchState { + const VoiceSearchState(this.phase, {this.words = '', this.lastError}); + + static const VoiceSearchState initial = VoiceSearchState(VoicePhase.starting); + + final VoicePhase phase; + final String words; + + /// A non-permanent error seen mid-session, used to explain an empty ending. + final String? lastError; + + bool get isActive => + phase == VoicePhase.starting || phase == VoicePhase.listening; + + bool get isFailure => !isActive && phase != VoicePhase.done; + + /// Retrying cannot fix a device with no recogniser. + bool get canRetry => isFailure && phase != VoicePhase.unavailable; + + @override + bool operator ==(Object other) => + other is VoiceSearchState && + other.phase == phase && + other.words == words && + other.lastError == lastError; + + @override + int get hashCode => Object.hash(phase, words, lastError); + + @override + String toString() => 'VoiceSearchState($phase, "$words", $lastError)'; +} + +/// The session's transitions, kept free of the plugin so they can be tested. +abstract final class VoiceSearchMachine { + static VoiceSearchState retry() => VoiceSearchState.initial; + + static VoiceSearchState initFailed({required bool hasPermission}) => + VoiceSearchState( + hasPermission ? VoicePhase.unavailable : VoicePhase.permissionDenied, + ); + + static VoiceSearchState listenFailed({required bool hasPermission}) => + VoiceSearchState( + hasPermission ? VoicePhase.recognizer : VoicePhase.permissionDenied, + ); + + static VoiceSearchState listeningStarted(VoiceSearchState s) => + s.phase == VoicePhase.starting + ? VoiceSearchState(VoicePhase.listening, words: s.words) + : s; + + static VoiceSearchState heard( + VoiceSearchState s, + String words, { + bool isFinal = false, + }) { + if (!s.isActive) return s; + final w = words.trim().isEmpty ? s.words : words.trim(); + if (isFinal) return _end(w, s.lastError); + return VoiceSearchState(VoicePhase.listening, words: w); + } + + /// The recogniser stopped by itself: silence, the time limit, or an error + /// that did not call back. + static VoiceSearchState ended(VoiceSearchState s) => + s.isActive ? _end(s.words, s.lastError) : s; + + /// The microphone never opened within the start deadline. + static VoiceSearchState startTimedOut(VoiceSearchState s) => + s.phase == VoicePhase.starting + ? const VoiceSearchState(VoicePhase.recognizer) + : s; + + static VoiceSearchState error( + VoiceSearchState s, + String code, { + required bool permanent, + }) { + if (!s.isActive) return s; + if (!permanent) { + return VoiceSearchState(s.phase, words: s.words, lastError: code); + } + return _end(s.words, code); + } + + static VoiceSearchState _end(String words, String? error) { + if (words.isNotEmpty) { + return VoiceSearchState(VoicePhase.done, words: words); + } + return VoiceSearchState( + error == null ? VoicePhase.noSpeech : phaseForError(error), + ); + } + + static VoicePhase phaseForError(String code) { + switch (code) { + case 'error_no_match': + case 'error_speech_timeout': + return VoicePhase.noSpeech; + case 'error_permission': + case 'error_insufficient_permissions': + return VoicePhase.permissionDenied; + case 'error_network': + case 'error_network_timeout': + case 'error_server': + case 'error_server_disconnected': + case 'error_too_many_requests': + return VoicePhase.network; + case 'error_speech_recognizer_disabled': + return VoicePhase.unavailable; + default: + return VoicePhase.recognizer; + } + } + + static bool isLanguageError(String code) => + code == 'error_language_not_supported' || + code == 'error_language_unavailable'; +} + +/// Picks the recogniser locale closest to the app's language, or null to let +/// the recogniser use the system locale. +/// +/// [available] are recogniser ids as the plugin reports them (`en_US` on +/// Android, `en-US` on iOS). The system locale wins among same-language +/// candidates, since it carries the user's own region. +String? matchSpeechLocale( + Locale app, + Iterable available, { + String? systemLocaleId, +}) { + final candidates = available.toList(); + if (candidates.isEmpty) return null; + final langs = _languageAliases[app.languageCode.toLowerCase()] ?? + {app.languageCode.toLowerCase()}; + + bool sameLanguage(String id) { + final parts = _split(id); + if (langs.contains(parts.$1)) return true; + // Cantonese is often listed as Chinese for Hong Kong or Macau. + return app.languageCode == 'yue' && + parts.$1 == 'zh' && + (parts.$2 == 'hk' || parts.$2 == 'mo'); + } + + final matches = candidates.where(sameLanguage).toList(); + if (matches.isEmpty) return null; + + final country = app.countryCode?.toLowerCase(); + if (country != null && country.isNotEmpty) { + for (final id in matches) { + if (_split(id).$2 == country) return id; + } + } + if (systemLocaleId != null) { + final system = _normalise(systemLocaleId); + for (final id in matches) { + if (_normalise(id) == system) return id; + } + } + final preferred = _preferredRegion[app.languageCode.toLowerCase()]; + if (preferred != null) { + for (final id in matches) { + if (_split(id).$2 == preferred) return id; + } + } + return matches.first; +} + +/// Older Android and some recognisers use legacy or alternate codes. +const Map> _languageAliases = { + 'id': {'id', 'in'}, + 'yue': {'yue'}, + 'ckb': {'ckb'}, + 'ku': {'ku', 'kmr'}, +}; + +/// The region the app's copy is written for, when the language has several. +const Map _preferredRegion = { + 'en': 'us', + 'pt': 'br', + 'es': 'es', + 'fr': 'fr', + 'de': 'de', + 'nl': 'nl', + 'ar': 'sa', + 'ru': 'ru', + 'uz': 'uz', + 'tr': 'tr', + 'hi': 'in', + 'id': 'id', + 'vi': 'vn', + 'yue': 'hk', +}; + +String _normalise(String id) => id.replaceAll('_', '-').toLowerCase(); + +/// Language and region of an id like `en_US`, `en-US` or `yue-Hant-HK`. +(String, String?) _split(String id) { + final parts = _normalise(id).split('-'); + String? region; + for (final p in parts.skip(1)) { + if (p.length == 2 || (p.length == 3 && int.tryParse(p) != null)) { + region = p; + break; + } + } + return (parts.first, region); +} diff --git a/lib/features/shorts/presentation/widgets/short_reel_item.dart b/lib/features/shorts/presentation/widgets/short_reel_item.dart index f9be5ec1..8548bcb3 100644 --- a/lib/features/shorts/presentation/widgets/short_reel_item.dart +++ b/lib/features/shorts/presentation/widgets/short_reel_item.dart @@ -378,6 +378,13 @@ class _ShortReelItemState extends State child: Image.network( thumb, fit: BoxFit.cover, + // Blurred behind the video: a third of the screen's pixels is + // more than the blur leaves visible. + cacheWidth: + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context) / + 3) + .round(), errorBuilder: (_, e, s) => const ColoredBox(color: Colors.black), ), ), @@ -685,6 +692,8 @@ class _ShortReelItemState extends State Image.network( s.contentThumbnail, fit: BoxFit.cover, + cacheWidth: (48 * MediaQuery.devicePixelRatioOf(context)) + .round(), errorBuilder: (_, e, st) => Container( color: AppColors.surfaceVariant, child: const Icon( @@ -860,6 +869,9 @@ class _ShortReelItemState extends State ? Image.network( s.contentThumbnail, fit: BoxFit.cover, + cacheWidth: + (36 * MediaQuery.devicePixelRatioOf(context)) + .round(), errorBuilder: (_, e, st) => const Center( child: Icon( Icons.movie_rounded, diff --git a/lib/features/social/domain/social_models.dart b/lib/features/social/domain/social_models.dart index 8f177f97..4f75b7a3 100644 --- a/lib/features/social/domain/social_models.dart +++ b/lib/features/social/domain/social_models.dart @@ -4,6 +4,7 @@ library; import 'package:soplay/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; enum SocialRelation { self, @@ -65,6 +66,7 @@ class SocialUser { required this.username, this.displayName, this.photoURL, + this.decoration, }); final String id; @@ -72,6 +74,9 @@ class SocialUser { final String? displayName; final String? photoURL; + /// Level and cosmetics; null from a server that sends none. + final UserDecoration? decoration; + String get name => (displayName?.trim().isNotEmpty ?? false) ? displayName!.trim() : username; @@ -81,6 +86,7 @@ class SocialUser { username: _str(json['username']) ?? '', displayName: _str(json['displayName']), photoURL: _str(json['photoURL']), + decoration: UserDecoration.fromJson(json), ); } diff --git a/lib/features/social/presentation/pages/user_profile_page.dart b/lib/features/social/presentation/pages/user_profile_page.dart index 13c84d91..48f6007a 100644 --- a/lib/features/social/presentation/pages/user_profile_page.dart +++ b/lib/features/social/presentation/pages/user_profile_page.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/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/social/presentation/cursor_pager.dart'; @@ -449,9 +450,11 @@ class _Header extends StatelessWidget { children: [ SocialAvatar(user: user, size: 84), const SizedBox(height: 12), - Text( - user.name, - textAlign: TextAlign.center, + UserBadgeLine( + name: user.name, + decoration: user.decoration, + showTitle: true, + center: true, style: const TextStyle( color: AppColors.textPrimary, fontSize: 19, diff --git a/lib/features/social/presentation/widgets/activity_card.dart b/lib/features/social/presentation/widgets/activity_card.dart index cdf33661..d836b0b3 100644 --- a/lib/features/social/presentation/widgets/activity_card.dart +++ b/lib/features/social/presentation/widgets/activity_card.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/social/presentation/widgets/social_widgets.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; @@ -257,6 +258,7 @@ class _ActorLine extends StatelessWidget { @override Widget build(BuildContext context) { + final level = actor.decoration?.level ?? 0; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.push('/u/${Uri.encodeComponent(actor.username)}'), @@ -270,11 +272,21 @@ class _ActorLine extends StatelessWidget { children: [ TextSpan( text: actor.name, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: + Cosmetics.nameColor(actor.decoration?.nameColor) ?? + AppColors.textPrimary, fontWeight: FontWeight.w700, ), ), + if (level > 0) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 5), + child: LevelChip(level: level, fontSize: 9), + ), + ), TextSpan(text: ' $verb'), ], ), diff --git a/lib/features/social/presentation/widgets/social_widgets.dart b/lib/features/social/presentation/widgets/social_widgets.dart index 67237f95..17052fa6 100644 --- a/lib/features/social/presentation/widgets/social_widgets.dart +++ b/lib/features/social/presentation/widgets/social_widgets.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/system/app_dates.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/domain/social_models.dart'; String socialErrorText(Object? error) { @@ -103,7 +104,7 @@ class SocialAvatar extends StatelessWidget { ), ), ); - return Container( + final avatar = Container( width: size, height: size, decoration: BoxDecoration( @@ -122,6 +123,11 @@ class SocialAvatar extends StatelessWidget { errorWidget: (_, _, _) => placeholder, ), ); + return FramedAvatar( + size: size, + frame: user.decoration?.frame, + child: avatar, + ); } } @@ -155,10 +161,9 @@ class SocialUserTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - user.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, + UserBadgeLine( + name: user.name, + decoration: user.decoration, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14.5, diff --git a/lib/features/sources/data/source_check_store.dart b/lib/features/sources/data/source_check_store.dart index 6e3e2f74..f82c9193 100644 --- a/lib/features/sources/data/source_check_store.dart +++ b/lib/features/sources/data/source_check_store.dart @@ -60,8 +60,17 @@ class SourceCheck { /// How long the answer took. final int? ms; + /// Whether this is a verdict somebody has to do something about. + /// + /// [SourceVerdict.empty] is one of them. A catalogue that answered with + /// nothing is not a working source: a 200 carrying an empty list is the + /// commonest shape of a site that has shut the backend out — kisskh answers + /// `/contents/home` exactly that way today — and leaving it out of the list + /// a check run hands back meant the run counted it and then never named it. bool get isBad => - verdict == SourceVerdict.dead || verdict == SourceVerdict.outdated; + verdict == SourceVerdict.dead || + verdict == SourceVerdict.outdated || + verdict == SourceVerdict.empty; Map toJson() => { 'v': verdict.name, @@ -97,10 +106,18 @@ class SourceCheck { /// box is read once, and every write updates the copy it came from. [revision] /// moves on every change, for the widgets that draw a verdict. class SourceCheckStore { - SourceCheckStore({Box? box}) : _override = box; + SourceCheckStore({Box? box, int Function()? now}) + : _override = box, + _now = now ?? (() => DateTime.now().millisecondsSinceEpoch); final Box? _override; + /// The clock a verdict's age is measured against. Injectable for the same + /// reason as `SourceCheckService`'s: every rule here is about elapsed time, + /// and a test that cannot move the clock can only assert what happens within + /// one millisecond of now. + final int Function() _now; + Box? get _box { if (_override != null) return _override; try { @@ -157,10 +174,30 @@ class SourceCheckStore { SourceCheck? of(String id) { final c = _all[id]; if (c == null) return null; - final age = DateTime.now().millisecondsSinceEpoch - c.at; - return age > staleAfter.inMilliseconds ? null : c; + return _expired(_now(), c) ? null : c; } + /// Whether [check] has stopped meaning anything by [nowMs]. + /// + /// Older than [staleAfter], or stamped after [nowMs]. A verdict dated in the + /// future came from a clock that has since been corrected — a phone set + /// forward for an app, or a device that booted before it found the time — and + /// its age is then not just wrong but negative, which read as "checked + /// moments ago" and would have kept a stale "Not working" on the row for as + /// long as the clock was out by. + static bool _expired(int nowMs, SourceCheck check) => + nowMs - check.at < 0 || _tooOld(nowMs, check); + + /// Whether [check] is old enough to be worth nothing to anybody. + /// + /// Age only — a future-dated row is not old, it is mis-stamped, and the + /// difference matters because this is the test the table is pruned by while + /// [_expired] is the test a row is hidden by. A phone whose clock is set + /// forward for an hour stamps every check an hour ahead; when the clock is + /// corrected those rows are simply current again. + static bool _tooOld(int nowMs, SourceCheck check) => + nowMs - check.at > staleAfter.inMilliseconds; + SourceVerdict verdictOf(String id) => of(id)?.verdict ?? SourceVerdict.unknown; @@ -213,6 +250,19 @@ class SourceCheckStore { } Future _flush() async { + // A verdict nobody can be shown is a verdict nobody should carry: every + // read already hides the expired ones, so dropping them on the way out + // keeps the table the size of what was checked this week rather than of + // every source ever checked on this phone, and stops a seven-month-old row + // being re-serialised on every run. + // + // Pruned by age alone, not by [_expired]. A row stamped in the future came + // from a clock that was wrong, not from a check that has expired, and + // deleting it is permanent: a phone set forward for an app used to lose its + // entire check table on the next write — hundreds of verdicts, gone — where + // before those rows were merely hidden and came back the moment the clock + // was corrected. + _all.removeWhere((_, c) => _tooOld(_now(), c)); try { await _box?.put(_checksKey, { for (final e in _all.entries) e.key: e.value.toJson(), diff --git a/lib/features/sources/domain/source_check_service.dart b/lib/features/sources/domain/source_check_service.dart index 6c0a7501..ee4b8a1f 100644 --- a/lib/features/sources/domain/source_check_service.dart +++ b/lib/features/sources/domain/source_check_service.dart @@ -235,10 +235,28 @@ class SourceCheckService { if (attempt.succeeded) { return SourceCheck(verdict: SourceVerdict.alive, at: now, ms: attempt.ms); } - if (attempt.empty) { - return SourceCheck(verdict: SourceVerdict.empty, at: now, ms: attempt.ms); + final failure = attempt.failure; + if (failure == null) { + // An answer with nothing in it and nothing said about why. Zero items is + // not health, so it does not clear what came before it either: a source + // that has already failed twice and then hands back an empty catalogue + // has not shown it works, and starting its history over would give it + // two fresh failures to spend before anything was said about it again. + // The verdict stays [SourceVerdict.empty] rather than escalating, + // because some sources really do open onto an empty home. + final streak = previous != null && + previous.fails > 0 && + previous.verdict != SourceVerdict.alive + ? previous + : null; + return SourceCheck( + verdict: SourceVerdict.empty, + at: now, + ms: attempt.ms, + fails: streak?.fails ?? 0, + firstFailAt: streak?.firstFailAt ?? streak?.at, + ); } - final failure = attempt.failure!; final detail = (failure.detail ?? failure.headline); final trimmed = detail.length > 200 ? '${detail.substring(0, 200)}…' @@ -263,11 +281,15 @@ class SourceCheckService { default: break; } + // [SourceVerdict.empty] counts here when it carries a streak: an empty + // answer is not a failure of its own, but it does not wipe the ones + // already recorded, so the run of them continues through it. final wasFailing = previous != null && previous.fails > 0 && (previous.verdict == SourceVerdict.failing || - previous.verdict == SourceVerdict.dead); + previous.verdict == SourceVerdict.dead || + previous.verdict == SourceVerdict.empty); final fails = wasFailing ? previous.fails + 1 : 1; final firstFailAt = wasFailing ? (previous.firstFailAt ?? previous.at) diff --git a/lib/features/sources/domain/source_verdict_policy.dart b/lib/features/sources/domain/source_verdict_policy.dart new file mode 100644 index 00000000..a9a7e731 --- /dev/null +++ b/lib/features/sources/domain/source_verdict_policy.dart @@ -0,0 +1,151 @@ +/// Which verdicts are worth showing, and which ones are not about this phone. +/// +/// Two witnesses report on a source and they do not have the same standing. +/// The server sweeps every extension's SITE three times a week from a +/// datacenter; this phone runs the source's own code over its own network. For +/// a Sozo cloud source those are the same question, because the catalogue +/// reaches the app through the backend either way — if the backend cannot read +/// the site, neither can the reader. For everything else they are not: a +/// CloudStream, Aniyomi, Manga, Mangayomi or Jellyfin source talks to its site +/// from the handset, over a residential address, and a datacenter's result says +/// nothing about that leg. +/// +/// Measured against the live report on 2026-11-02: of 930 extension verdicts, +/// 229 said "dead", and 96 of those were a TLS handshake, a timeout or an +/// unreachable address — the three symptoms a site shows a datacenter and not a +/// phone. Those 96 sources carried a red "Down" badge that nothing had +/// established on the device they run on. +library; + +import 'package:soplay/features/search/data/source_health_store.dart'; +import 'package:soplay/features/sources/data/source_check_store.dart'; +import 'package:soplay/features/sources/domain/source_ecosystem.dart'; + +/// Whether [providerId] fetches its own catalogue on this phone. +/// +/// Every ecosystem but Sozo's own does: the prefixes are the plugin hosts, and +/// each resolves through its platform channel without the backend. See +/// `SourceBrowseRepository.load`, which routes by the same prefixes. +/// Takes an id or a health key, because callers have both to hand and the +/// distinction is not visible at the call site: `ProviderEntity.healthKey` +/// rewrites a CloudStream id as `csp:`, which does not start with +/// the `cs:` prefix [SourceEcosystem] classifies by, so a key would have been +/// called a Sozo cloud source and handed the datacenter's verdict. +bool runsOnDevice(String providerIdOrKey) => + providerIdOrKey.startsWith('csp:') || + SourceEcosystem.of(providerIdOrKey) != SourceEcosystem.sozo; + +/// Sweep reasons that describe the road between the prober and the site rather +/// than the site itself. +/// +/// A parked domain is parked from everywhere and a domain that does not resolve +/// does not resolve from anywhere, so those stand wherever they were found. +/// A dropped connection, a refused handshake and a timeout are what an +/// anti-bot rule looks like from a datacenter address, and they are the ones a +/// phone routinely gets through. +const Set vantageReasons = { + 'timeout', + 'unreachable', + 'tls', + 'slow_response', +}; + +/// The verdict worth putting on [providerId]'s row, or null for no badge. +/// +/// [verdict] is whatever `SourceHealthStore.badgeOf` found. This is the last +/// word before it reaches a row, and it drops the one case that cannot be +/// defended: a source that runs on the device, called down because a check +/// from somewhere else could not get through to its site. Such a source is +/// shown plainly, with no claim either way, until a check from here says +/// something — which the row's long-press offers. +RemoteVerdict? shownVerdict(String providerId, RemoteVerdict? verdict) { + if (verdict == null) return null; + // This phone's own answer, which is the one we wanted all along. + if (verdict.onDevice) return verdict; + if (verdict.state != RemoteHealth.dead) return verdict; + if (!runsOnDevice(providerId)) return verdict; + return vantageReasons.contains(verdict.reason) ? null : verdict; +} + +/// Whether [verdict] is something the reader should be shown among "problems". +/// +/// Everything except a source that answered with titles and a source nobody +/// has asked yet. An empty catalogue belongs here: zero items is not health, +/// and a 200 with an empty list is the commonest shape of a blocked site — +/// kisskh answers the backend exactly that way today. +bool isProblemVerdict(SourceVerdict verdict) => + verdict != SourceVerdict.alive && verdict != SourceVerdict.unknown; + +/// The verdict this phone found that deserves a badge of its own, or null. +/// +/// Two witnesses, and only one of them has a badge. `SourceHealthStore.badgeOf` +/// can say "Down" and "Cloudflare" and nothing else, so it answers null for an +/// empty catalogue and says nothing for a source that has merely started +/// failing. Those rows survive the problems-only filter — [isProblemVerdict] +/// counts them, and should — and then sat there with no pill and no explanation +/// of what was wrong with them. kisskh is the live example: it answers the +/// backend 200 with an empty list, which is the commonest shape of a site that +/// has shut the prober out. +/// +/// [shown] is the badge the row already has. When there is one it speaks for +/// the row and a second pill beside it would only disagree in public; the +/// device verdict is reached through the row's long-press either way. +SourceVerdict? deviceBadge(SourceVerdict device, RemoteVerdict? shown) { + if (shown != null) return null; + return switch (device) { + SourceVerdict.empty || SourceVerdict.failing => device, + _ => null, + }; +} + +/// Rows with the down ones last, and the count of the ones hiding removes. +class DownPartition { + const DownPartition({required this.rows, required this.hidable}); + + /// In order: the working ones as they came, then the down ones — minus the + /// down ones, when hiding was asked for. + final List rows; + + /// How many rows hiding takes away. The same number whether hiding is on or + /// off, so the switch can promise it before it is flipped. + final int hidable; +} + +/// Sinks the down rows of [rows], or leaves them out when [hide]. +/// +/// The hub had the count and the hiding in two places: a tally over every row +/// of the tab, and a filter over the rows left after the scope and language +/// menus. "Hide down sources · 7" then took four rows away, because three of +/// the seven were not on screen to begin with and one was the source in use, +/// which is never hidden. One function now answers both questions from the +/// same list, so the number is the number. +/// +/// [keep] names rows that are never hidden — the source in use has to stay +/// findable, even when it is the broken one. +DownPartition partitionDown( + List rows, { + required bool Function(T) isDown, + bool Function(T)? keep, + bool hide = false, +}) { + final up = []; + final down = []; + var hidable = 0; + for (final row in rows) { + if (!isDown(row)) { + up.add(row); + continue; + } + down.add(row); + if (!(keep?.call(row) ?? false)) hidable++; + } + if (down.isEmpty) return DownPartition(rows: rows, hidable: 0); + return DownPartition( + rows: [ + ...up, + for (final row in down) + if (!hide || (keep?.call(row) ?? false)) row, + ], + hidable: hidable, + ); +} diff --git a/lib/features/sources/presentation/pages/sources_hub_page.dart b/lib/features/sources/presentation/pages/sources_hub_page.dart index 1d741e73..fe3946bf 100644 --- a/lib/features/sources/presentation/pages/sources_hub_page.dart +++ b/lib/features/sources/presentation/pages/sources_hub_page.dart @@ -33,6 +33,7 @@ import 'package:soplay/features/sources/presentation/widgets/source_verdict.dart import 'package:soplay/features/sources/domain/source_check_service.dart'; import 'package:soplay/features/sources/data/source_check_store.dart'; import 'package:soplay/features/sources/domain/source_scope.dart'; +import 'package:soplay/features/sources/domain/source_verdict_policy.dart'; import 'package:soplay/features/sources/presentation/widgets/source_health_badge.dart'; import 'package:soplay/features/sources/presentation/widgets/source_scope_menu.dart'; import 'package:soplay/features/sources/domain/source_failure.dart'; @@ -170,9 +171,53 @@ class _SourcesHubPageState extends State /// search, scope and language — "check what I am looking at". List _shownIds = const []; + /// The badge this row is entitled to, after [shownVerdict] has thrown out a + /// server verdict that was never about this phone. + RemoteVerdict? _badgeFor(ProviderEntity p) => + shownVerdict(p.id, sourceHealth().badgeOf(p.id, key: p.healthKey)); + bool _isProblem(ProviderEntity p) { - if (sourceHealth().badgeOf(p.id, key: p.healthKey) != null) return true; - return SourceCheckStore.shared.verdictOf(p.id) == SourceVerdict.failing; + if (_badgeFor(p) != null) return true; + // Anything this phone found that is not "it answered with titles" — an + // empty catalogue included, which is the one a badge says nothing about. + return isProblemVerdict(SourceCheckStore.shared.verdictOf(p.id)); + } + + bool _isDown(ProviderEntity p) => _badgeFor(p)?.state == RemoteHealth.dead; + + /// The pill for what this phone found, when the server's badge has none. + /// Keyed by [ProviderEntity.id] because that is what a check on this device + /// is recorded under. + SourceVerdict? _deviceBadgeFor(ProviderEntity p) => + deviceBadge(SourceCheckStore.shared.verdictOf(p.id), _badgeFor(p)); + + /// The rows of [rows] left after the type and problems-only filters. + List _inScope(List rows, SourceScope scope) => + scope.isAll && !_problemsOnly + ? rows + : [ + for (final p in rows) + if (scope.matches(p) && (!_problemsOnly || _isProblem(p))) p, + ]; + + /// What the "Hide down sources" switch does to one tab's lists. + /// + /// The same call the lists themselves make, over the same narrowed rows, so + /// the number on the chip is the number of rows it takes away. Hiding is + /// suspended while only problems are shown — that list is asked to contain + /// the down ones — and the count is then zero because nothing is hidden. + DownPartition _down( + List rows, + SourceScope scope, + String currentProviderId, + ) { + final health = sourceHealth(); + return partitionDown( + _inScope(rows, scope), + isDown: _isDown, + keep: (p) => p.id == currentProviderId, + hide: health.hideDown && !_problemsOnly, + ); } /// Every installed source of the tab that is showing. @@ -732,11 +777,11 @@ class _SourcesHubPageState extends State final tab = _tabFor(state, mode, needle); final counts = tab.counts; final scope = _liveScope(counts); - final health = sourceHealth(); - final downCount = [ - ...tab.matched, - ...tab.unstated, - ].where((p) => health.isDown(p.id, key: p.healthKey)).length; + final current = state.currentProviderId; + final downCount = _problemsOnly + ? 0 + : _down(tab.matched, scope, current).hidable + + _down(tab.unstated, scope, current).hidable; return LayoutBuilder( builder: (context, constraints) { @@ -848,7 +893,10 @@ class _SourcesHubPageState extends State scope: scope, onPick: (picked) => setState(() => _scope = picked), ), - if (downCount > 0 || health.hideDown) + // Nothing to offer while problems-only is on: the lists are + // meant to hold the down sources then, so a switch that + // promises to take them away has nothing to take. + if (!_problemsOnly && (downCount > 0 || health.hideDown)) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Align( @@ -955,26 +1003,10 @@ class _SourcesHubPageState extends State Widget _tabList(ProviderLoaded state, ContentMode mode, String needle) { final tab = _tabFor(state, mode, needle); final scope = _liveScope(tab.counts); - List narrow(List rows) => - scope.isAll && !_problemsOnly - ? rows - : [ - for (final p in rows) - if (scope.matches(p) && (!_problemsOnly || _isProblem(p))) p, - ]; // Down sources sink rather than vanish unless the user asked: a sweep // from a datacenter is not the last word on what works from a phone. - final health = sourceHealth(); - List arrange(List rows) => health.sinkDown( - rows, - (p) => p.id, - keyOf: (p) => p.healthKey, - // Asking to see the problems is asking to see the down ones too. - hide: health.hideDown && !_problemsOnly, - keep: (p) => p.id == state.currentProviderId, - ); - final matched = arrange(narrow(tab.matched)); - final unstated = arrange(narrow(tab.unstated)); + final matched = _down(tab.matched, scope, state.currentProviderId).rows; + final unstated = _down(tab.unstated, scope, state.currentProviderId).rows; _shownIds = [ for (final p in [...matched, ...unstated]) if (state.isUsable(p) && !Catalogue.isId(p.id)) p.id, @@ -1073,7 +1105,8 @@ class _SourcesHubPageState extends State ? _currentRows[mode] : null, source: rows[i], - health: sourceHealth().badgeOf(rows[i].id, key: rows[i].healthKey), + health: _badgeFor(rows[i]), + deviceVerdict: _deviceBadgeFor(rows[i]), current: rows[i].id == state.currentProviderId, onTap: () => _use(rows[i]), onBrowse: () => setState(() => _open = rows[i]), @@ -1099,11 +1132,16 @@ class _SourceTile extends StatelessWidget { this.onLongPress, this.current = false, this.health, + this.deviceVerdict, }); final ProviderEntity source; final RemoteVerdict? health; + /// What a check from this phone found, when it is something the server's + /// badge cannot express. See [deviceBadge]. + final SourceVerdict? deviceVerdict; + /// Whether this is the currently selected source. final bool current; final VoidCallback onTap; @@ -1179,6 +1217,13 @@ class _SourceTile extends StatelessWidget { sourceName: source.name, sourceId: source.id, ), + ] else if (deviceVerdict != null) ...[ + const SizedBox(width: 6), + DeviceVerdictBadge( + verdict: deviceVerdict!, + sourceId: source.id, + sourceName: source.name, + ), ], ], ), diff --git a/lib/features/sources/presentation/widgets/source_health_badge.dart b/lib/features/sources/presentation/widgets/source_health_badge.dart index 53cf48c2..d7ea4a91 100644 --- a/lib/features/sources/presentation/widgets/source_health_badge.dart +++ b/lib/features/sources/presentation/widgets/source_health_badge.dart @@ -91,7 +91,22 @@ class SourceHealthBadge extends StatelessWidget { } } +/// The sentence behind a badge, for the reason the sweep recorded. +/// +/// The server reports a server error with the status in it — `http_503`, +/// `http_521`, `http_530` — while this list only ever knew the family, +/// `http_5xx`. Twenty-two sources in the live report badge "Down" and then +/// explained themselves with "The last server check found it failing", which +/// is the fallback for a reason nobody recognised. Folding any `http_5xx` +/// status onto the family gives them back the sentence that was written for +/// them. String healthReasonText(String? reason) { + final code = reason != null && reason.startsWith('http_') + ? int.tryParse(reason.substring(5)) + : null; + if (code != null && code >= 500 && code < 600) { + return 'sources.health_reason_http_5xx'.tr(); + } const known = { 'parked', 'dns', diff --git a/lib/features/sources/presentation/widgets/source_verdict.dart b/lib/features/sources/presentation/widgets/source_verdict.dart index f442ec18..36d4b844 100644 --- a/lib/features/sources/presentation/widgets/source_verdict.dart +++ b/lib/features/sources/presentation/widgets/source_verdict.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -36,6 +39,97 @@ String checkedAgo(int at) { return 'sources.d_ago'.tr(args: ['${d.inDays}']); } +/// What an honest pill shows for a verdict this phone reached itself. +IconData verdictIcon(SourceVerdict v) => switch (v) { + SourceVerdict.empty => Icons.inbox_outlined, + SourceVerdict.failing => Icons.error_outline_rounded, + SourceVerdict.blocked => Icons.shield_outlined, + SourceVerdict.outdated => Icons.system_update_alt_rounded, + SourceVerdict.dead => Icons.cloud_off_rounded, + SourceVerdict.alive || SourceVerdict.unknown => Icons.help_outline_rounded, +}; + +/// What the verdict means, for the viewer who tapped the pill to find out. +/// Null where the verdict is its own explanation. +String? verdictBody(SourceVerdict v) => switch (v) { + SourceVerdict.empty => 'sources.v_empty_body'.tr(), + SourceVerdict.failing => 'sources.v_failing_body'.tr(), + _ => null, +}; + +/// A pill for what THIS phone found, where the server's badge has nothing to +/// say. +/// +/// `SourceHealthBadge` takes a `RemoteVerdict`, which can only be "Down" or +/// "Cloudflare" — an empty catalogue is neither, and dressing it up as one +/// would be a louder claim than the check supports. So this is its own pill, +/// in the verdict's own colour, and tapping it opens the same sheet the row's +/// long-press does: what was found, when, and a button to ask again. +class DeviceVerdictBadge extends StatelessWidget { + const DeviceVerdictBadge({ + super.key, + required this.verdict, + required this.sourceId, + required this.sourceName, + }); + + final SourceVerdict verdict; + final String sourceId; + final String sourceName; + + @override + Widget build(BuildContext context) { + final color = verdictColor(verdict); + final label = verdictLabel(verdict); + return Semantics( + button: true, + label: label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + HapticFeedback.selectionClick(); + unawaited( + showSourceCheckSheet( + context, + id: sourceId, + name: sourceName, + note: verdictBody(verdict), + ), + ); + }, + child: Padding( + // A bigger target than the pill itself, without a bigger pill. + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(verdictIcon(verdict), size: 11, color: color), + const SizedBox(width: 3), + Text( + label, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + /// The verdict and what the source said, for the actions sheet. class VerdictLine extends StatelessWidget { const VerdictLine({super.key, required this.id, required this.store}); @@ -343,6 +437,7 @@ Future showSourceCheckSheet( BuildContext context, { required String id, required String name, + String? note, }) { return showModalBottomSheet( context: context, @@ -367,6 +462,20 @@ Future showSourceCheckSheet( fontWeight: FontWeight.w800, ), ), + if (note != null) ...[ + const SizedBox(height: 8), + // What the verdict means. "Empty" on its own is a word, not an + // explanation, and this sheet is where somebody who tapped the + // pill has come to be told. + Text( + note, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + height: 1.4, + ), + ), + ], const SizedBox(height: 10), CheckNowButton(id: id), ], diff --git a/lib/features/splash/presentation/pages/splash_page.dart b/lib/features/splash/presentation/pages/splash_page.dart index 73cc7944..74b7a6a6 100644 --- a/lib/features/splash/presentation/pages/splash_page.dart +++ b/lib/features/splash/presentation/pages/splash_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; @@ -18,18 +19,15 @@ class SplashPage extends StatefulWidget { } class _SplashPageState extends State { - /// Where to go, decided while the animation is still finishing. + /// Where to go, decided while the animation plays. /// - /// The lock check reads a file and the onboarding check reads Hive. Starting - /// both when the letter lands rather than when the animation ends overlaps - /// them with the last 800ms, so the handover is a fade into a screen that is - /// already resolved instead of a fade into a wait. - Future? _target; - - void _onSettled() => _target ??= _resolveRoute(); + /// Started with the splash: once it is known, the splash hurries to its + /// fade instead of playing the full three seconds over an app that is + /// already waiting. + late final Future _target = _resolveRoute(); Future _onDone() async { - final target = await (_target ??= _resolveRoute()); + final target = await _target; if (!mounted) return; context.go(target); } @@ -58,12 +56,13 @@ class _SplashPageState extends State { } /// The cached list decides when there is one; otherwise the account is - /// asked, but only briefly — a slow network must not hold the splash. + /// asked, but only briefly — a slow network must not hold the splash, and + /// no network is not waited for at all. Future _profilesDue() async { final session = getIt(); if (!getIt().isLoggedIn) return false; final refreshed = session.refresh(); - if (session.profiles.isEmpty) { + if (session.profiles.isEmpty && !await _offline()) { await refreshed.timeout( const Duration(milliseconds: 1500), onTimeout: () => false, @@ -72,12 +71,27 @@ class _SplashPageState extends State { return session.shouldPick; } + Future _offline() async { + try { + final kinds = await Connectivity().checkConnectivity().timeout( + const Duration(milliseconds: 300), + ); + return isOffline(kinds); + } catch (_) { + return false; + } + } + @override Widget build(BuildContext context) { - return SozoSplash(onSettled: _onSettled, onDone: _onDone); + return SozoSplash(onDone: _onDone, ready: _target); } } +/// No connection of any kind. An empty answer is not taken as offline. +bool isOffline(List kinds) => + kinds.isNotEmpty && kinds.every((k) => k == ConnectivityResult.none); + /// The setup a launch should open, or null for the app itself. /// /// Once per install, on a device nobody has signed in on. A setup already diff --git a/lib/features/splash/presentation/widgets/sozo_splash.dart b/lib/features/splash/presentation/widgets/sozo_splash.dart index 1c1e7345..d05199f2 100644 --- a/lib/features/splash/presentation/widgets/sozo_splash.dart +++ b/lib/features/splash/presentation/widgets/sozo_splash.dart @@ -49,16 +49,28 @@ import 'package:soplay/core/widgets/sozo_mark.dart'; /// The artwork's own, never the accent the user picked. The accent is /// theirs; the logo is the brand. class SozoSplash extends StatefulWidget { - const SozoSplash({super.key, required this.onSettled, required this.onDone}); + const SozoSplash({ + super.key, + this.onSettled, + required this.onDone, + this.ready, + }); - /// The letter is complete and the rest is the hold. The route resolves from - /// here rather than at the end, so the work overlaps the last second instead - /// of starting after it. - final VoidCallback onSettled; + /// The letter is complete and the rest is the hold. + final VoidCallback? onSettled; /// The animation is over and the screen may be replaced. final VoidCallback onDone; + /// Completes when the app behind the splash is ready. From then on, and + /// after [minimum], the rest of the performance plays faster: the drawing + /// hurries to the finished logo, the foot is skipped unless it is already + /// moving, and the logo fades. Null plays it in full. + final Future? ready; + + /// The least the splash is on screen, however early the app is ready. + static const Duration minimum = Duration(milliseconds: 800); + @override State createState() => _SozoSplashState(); } @@ -71,6 +83,9 @@ class _SozoSplashState extends State ); bool _settled = false; + bool _hurrying = false; + bool _done = false; + final Stopwatch _shown = Stopwatch()..start(); SozoMarkGeometry? _geometry; // TEMPORARY review loop — replays forever so the animation can be judged. @@ -98,6 +113,18 @@ class _SozoSplashState extends State } _c.addListener(_watch); _play(); + final ready = widget.ready; + if (ready != null) _whenReady(ready); + } + + /// A failure is the route's to report, when the full performance ends. + Future _whenReady(Future ready) async { + try { + await ready; + } catch (_) { + return; + } + await _readyNow(); } void _play() { @@ -108,10 +135,42 @@ class _SozoSplashState extends State if (mounted) _play(); return; } - widget.onDone(); + _finish(); }); } + void _finish() { + if (_done) return; + _done = true; + widget.onDone(); + } + + Future _readyNow() async { + if (_reviewLoop) return; + final wait = SozoSplash.minimum - _shown.elapsed; + if (wait > Duration.zero) await Future.delayed(wait); + if (!mounted || _hurrying || _done) return; + _hurrying = true; + final plan = hurryPlan(_c.value * _Beats.total); + if (plan == null) return; + if (plan.catchUp != null) { + await _c.animateTo( + plan.catchUpTo / _Beats.total, + duration: plan.catchUp!, + curve: Curves.linear, + ); + if (!mounted) return; + } + // A still frame to a still frame: the logo at rest looks the same either + // side of the foot, so the jump is not seen. + _c.value = _Beats.fadeFrom / _Beats.total; + await _c.animateTo( + 1, + duration: const Duration(milliseconds: _Beats.total - _Beats.fadeFrom), + ); + if (mounted) _finish(); + } + /// The letter completing, reported once. /// /// From the listener rather than a timer: a controller that is behind — @@ -120,7 +179,7 @@ class _SozoSplashState extends State void _watch() { if (_settled || _c.value * _Beats.total < _Beats.paintTo) return; _settled = true; - widget.onSettled(); + widget.onSettled?.call(); } @override @@ -262,6 +321,40 @@ abstract final class _Beats { static const int total = fadeFrom + 240; } +/// How a splash at [ms] reaches its fade once the app is ready, or null when +/// it is already fading. +/// +/// Before the logo is finished, the drawing and paint play out at up to +/// [hurryCap] in total; during the foot, the foot finishes at its own speed — +/// cutting a blow off mid-swing reads as a glitch. Otherwise straight to the +/// fade. +@visibleForTesting +({Duration? catchUp, int catchUpTo})? hurryPlan(double ms) { + if (ms >= _Beats.fadeFrom) return null; + if (ms < _Beats.holdFrom) { + final left = (_Beats.holdFrom - ms).round(); + return ( + catchUp: Duration(milliseconds: math.min(left, hurryCap)), + catchUpTo: _Beats.holdFrom, + ); + } + if (ms > _Beats.pressFrom && ms < _Beats.settleTo) { + return ( + catchUp: Duration(milliseconds: (_Beats.settleTo - ms).round()), + catchUpTo: _Beats.settleTo, + ); + } + return (catchUp: null, catchUpTo: _Beats.fadeFrom); +} + +/// The longest the drawing takes to finish once the app is ready. +@visibleForTesting +const int hurryCap = 380; + +/// The full performance, for tests. +@visibleForTesting +const int splashTotalMs = _Beats.total; + /// The black field the logo sits on. Separated so both branches share it and /// neither can drift from the window's own background colour. class _Stage extends StatelessWidget { diff --git a/lib/features/support/data/support_diagnostics.dart b/lib/features/support/data/support_diagnostics.dart index d17e596d..d07b7260 100644 --- a/lib/features/support/data/support_diagnostics.dart +++ b/lib/features/support/data/support_diagnostics.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:soplay/core/diagnostics/crash_log.dart'; import 'package:soplay/core/diagnostics/player_log.dart'; import 'package:soplay/core/player/player_engine.dart'; import 'package:soplay/features/support/data/support_models.dart'; @@ -57,13 +58,33 @@ class SupportDiagnostics { } if (includeLog) { final log = PlayerLog.instance; - if (log.lines.isNotEmpty) { - put('log', log.formatForSupport(maxLines: logLines)); - } + final player = log.lines.isNotEmpty + ? log.formatForSupport(maxLines: logLines) + : null; + put( + 'log', + withCrashLog(player, CrashLog.instance?.read(maxChars: crashChars)), + ); } return out; } + /// The crash log's newest end, appended to the player log. The server keeps + /// only named fields and trims a long `log` from the front, so riding at the + /// end of `log` is how it arrives intact without a server change. + static const int crashChars = 16 * 1024; + + static const String crashHeader = '===== crash log ====='; + + static String? withCrashLog(String? playerLog, String? crash) { + final c = crash?.trim(); + if (c == null || c.isEmpty) return playerLog; + final head = playerLog == null || playerLog.trim().isEmpty + ? '' + : '${playerLog.trimRight()}\n\n'; + return '$head$crashHeader\n$c'; + } + static String _utcOffset(Duration offset) { final sign = offset.isNegative ? '-' : '+'; final m = offset.inMinutes.abs(); diff --git a/lib/features/support/data/support_repository.dart b/lib/features/support/data/support_repository.dart index d292fd25..6d0ae039 100644 --- a/lib/features/support/data/support_repository.dart +++ b/lib/features/support/data/support_repository.dart @@ -57,7 +57,12 @@ class SupportRepository { if (type == null) { throw SupportException('support.error_attachment_type'.tr()); } - final size = await file.length(); + final int size; + try { + size = await file.length(); + } on FileSystemException { + throw SupportException('support.error_attachment'.tr()); + } if (size > maxAttachmentBytes) { throw SupportException('support.error_attachment_size'.tr()); } @@ -86,7 +91,11 @@ class SupportRepository { }, ), ); - } on DioException { + } on DioException catch (e) { + throw SupportException( + _offline(e) ? 'support.error_offline'.tr() : 'support.error_attachment'.tr(), + ); + } on FileSystemException { throw SupportException('support.error_attachment'.tr()); } return key; @@ -151,6 +160,7 @@ class SupportRepository { String message, { List attachments = const [], String? language, + Map? diagnostics, }) => _guard(() async { final token = await _token(); final res = await _dio.post>( @@ -158,6 +168,8 @@ class SupportRepository { data: { 'message': message, if (attachments.isNotEmpty) 'attachments': attachments, + if (diagnostics != null && diagnostics.isNotEmpty) + 'diagnostics': diagnostics, 'language': ?language, 'pushToken': ?token, }, @@ -189,6 +201,7 @@ class SupportRepository { final body = e.response?.data; final code = body is Map ? body['code']?.toString() : null; final key = switch ((e.response?.statusCode, code)) { + _ when _offline(e) => 'support.error_offline', (429, _) => 'support.error_too_many', (_, 'SUPPORT_MESSAGE') => 'support.error_short', (_, 'SUPPORT_THREAD_FULL') => 'support.error_thread_full', @@ -200,6 +213,20 @@ class SupportRepository { _ => 'support.error_generic', }; throw SupportException(key.tr(), code: code); + } on SupportException { + rethrow; + } catch (_) { + // Anything else (a reply this app cannot read, a file that went + // away) still has to end the spinner with a message. + throw SupportException('support.error_generic'.tr()); } } + + static bool _offline(DioException e) => switch (e.type) { + DioExceptionType.connectionError || + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => true, + _ => false, + }; } diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart index f92365ca..40ee40dd 100644 --- a/lib/features/support/presentation/pages/support_new_page.dart +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -13,6 +13,7 @@ import 'package:soplay/features/support/data/support_models.dart'; import 'package:soplay/features/support/data/support_repository.dart'; import 'package:soplay/features/support/presentation/support_style.dart'; import 'package:soplay/features/support/presentation/widgets/support_screenshots.dart'; +import 'package:soplay/core/diagnostics/log_viewer_sheet.dart'; /// A new request to support. /// @@ -122,14 +123,21 @@ class _SupportNewPageState extends State { context, ).showSnackBar(SnackBar(content: Text('support.sent'.tr()))); context.pushReplacement('/support/${ticket.id}'); - } on SupportException catch (e) { - if (mounted) { - setState(() { - _sending = false; - _progress = null; - _error = e.message; - }); - } + } catch (e) { + if (!mounted) return; + final message = e is SupportException + ? e.message + : 'support.error_generic'.tr(); + setState(() { + _sending = false; + _progress = null; + _error = message; + }); + // The inline line can sit below the keyboard or the fold; say it + // where it is seen too, so nobody wonders whether it went. + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text('support.not_sent'.tr(args: [message])))); } } @@ -413,6 +421,14 @@ class _DiagnosticsCard extends StatelessWidget { ), ), ), + if (log != null && + log.contains(SupportDiagnostics.crashHeader)) + TextButton.icon( + onPressed: () => + LogViewerSheet.show(context, crash: true), + icon: const Icon(Icons.report_rounded, size: 18), + label: Text('support.diag_crash_log'.tr()), + ), ], ), ), diff --git a/lib/features/support/presentation/pages/support_ticket_page.dart b/lib/features/support/presentation/pages/support_ticket_page.dart index 6b3c498f..74f2804b 100644 --- a/lib/features/support/presentation/pages/support_ticket_page.dart +++ b/lib/features/support/presentation/pages/support_ticket_page.dart @@ -4,6 +4,7 @@ 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/support/data/support_diagnostics.dart'; import 'package:soplay/features/support/data/support_models.dart'; import 'package:soplay/features/support/data/support_repository.dart'; import 'package:soplay/features/support/presentation/support_style.dart'; @@ -85,11 +86,19 @@ class _SupportTicketPageState extends State { for (final f in _shots) await _repo.uploadScreenshot(f), ]; if (!mounted) return; + final language = context.locale.languageCode; + // "It still does not play" needs the log from after that attempt, not the + // one the ticket opened with. Only if diagnostics were attached then: the + // switch on the new-ticket screen is the viewer's choice. + final diagnostics = _ticket?.hasDiagnostics == true + ? await SupportDiagnostics.collect(language: language) + : null; final t = await _repo.reply( widget.id, text, attachments: keys, - language: context.locale.languageCode, + language: language, + diagnostics: diagnostics, ); if (!mounted) return; _reply.clear(); diff --git a/lib/features/tracker/presentation/widgets/release_widgets.dart b/lib/features/tracker/presentation/widgets/release_widgets.dart index 2f305708..62c2da46 100644 --- a/lib/features/tracker/presentation/widgets/release_widgets.dart +++ b/lib/features/tracker/presentation/widgets/release_widgets.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -367,11 +368,11 @@ class NewReleasesRail extends StatelessWidget { 'release_notify.home_rail'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), diff --git a/lib/features/trivia/presentation/pages/actor_hero_page.dart b/lib/features/trivia/presentation/pages/actor_hero_page.dart index 87436eb9..ea2c9b55 100644 --- a/lib/features/trivia/presentation/pages/actor_hero_page.dart +++ b/lib/features/trivia/presentation/pages/actor_hero_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -503,11 +504,11 @@ class _FilmographySection extends StatelessWidget { 'trivia.filmography'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), diff --git a/lib/features/trivia/presentation/pages/buff_hub_page.dart b/lib/features/trivia/presentation/pages/buff_hub_page.dart index d8a4b140..6102ccc3 100644 --- a/lib/features/trivia/presentation/pages/buff_hub_page.dart +++ b/lib/features/trivia/presentation/pages/buff_hub_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.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'; @@ -113,11 +114,11 @@ class _Masthead extends StatelessWidget { 'navigation.buff'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 28, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), @@ -660,11 +661,11 @@ class _SectionHeader extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 17, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), ), @@ -866,11 +867,11 @@ class _HowItWorksCard extends StatelessWidget { 'trivia.how_it_works'.tr(), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ), const SizedBox(height: 16), diff --git a/lib/features/trivia/presentation/pages/cast_picker_page.dart b/lib/features/trivia/presentation/pages/cast_picker_page.dart index 92dbc61c..9ecd750d 100644 --- a/lib/features/trivia/presentation/pages/cast_picker_page.dart +++ b/lib/features/trivia/presentation/pages/cast_picker_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:soplay/core/localization/line_height.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/trivia/domain/entities/cast_person_entity.dart'; @@ -316,11 +317,11 @@ class _ContextCaption extends StatelessWidget { text, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 15, fontWeight: FontWeight.w800, - height: 1.1, + height: lineHeightFor(context, 1.1), ), ); }, diff --git a/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart b/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart index b98c7474..b514b7e4 100644 --- a/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart +++ b/lib/features/user_lists/data/repositories/user_lists_repository_impl.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:hive_flutter/hive_flutter.dart'; @@ -10,24 +11,89 @@ import 'package:soplay/features/user_lists/data/datasources/user_lists_remote_da import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; -/// Cache-first, server-authoritative. -/// -/// The same shape [MyListRepositoryImpl] uses, and for the same reason: these -/// lists are read on screens that must render instantly and must not go blank -/// on a flaky connection. So every read serves the cache, a successful fetch -/// replaces it, and a failed fetch leaves the last good copy in place. +/// Thrown by [UserListsRepositoryImpl.getList] when the server cannot be +/// reached and there is no cached copy to fall back on. +class UserListUnavailable implements Exception { + const UserListUnavailable(); +} + +/// One local edit the server has not confirmed yet. +class PendingListOp { + const PendingListOp({ + required this.kind, + required this.isAdd, + required this.entity, + required this.at, + }); + + final UserListKind kind; + final bool isAdd; + final FavoriteEntity entity; + final int at; + + String get key => '${kind.slug}|${entity.contentUrl}'; + + Map toJson() => { + 'kind': kind.slug, + 'op': isAdd ? 'add' : 'remove', + 'provider': entity.provider, + 'contentUrl': entity.contentUrl, + 'title': entity.title, + 'thumbnail': entity.thumbnail, + 'at': at, + }; + + static PendingListOp? fromJson(Map m) { + final kind = UserListKind.values + .where((k) => k.slug == m['kind']) + .firstOrNull; + final url = (m['contentUrl'] ?? '').toString(); + if (kind == null || url.isEmpty) return null; + return PendingListOp( + kind: kind, + isAdd: m['op'] == 'add', + entity: FavoriteEntity( + provider: (m['provider'] ?? '').toString(), + contentUrl: url, + title: (m['title'] ?? '').toString(), + thumbnail: (m['thumbnail'] ?? '').toString(), + ), + at: (m['at'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Cache-first, server-authoritative, with an outbox for offline edits. /// -/// Writes are applied to the cache FIRST so the button flips immediately, then -/// pushed. A failed push leaves the local edit standing — the next successful -/// [getList] reconciles it against the server, which is the authority. +/// Writes land in the cache and in a persisted outbox, then are pushed. The +/// outbox holds at most one op per (list, item) — the newest — and is replayed +/// oldest first before every fetch and when connectivity returns. Whatever is +/// still unsent after a fetch is laid back over the server's copy, so a fetch +/// can never silently undo an edit the server has not seen. class UserListsRepositoryImpl implements UserListsRepository { - UserListsRepositoryImpl(this.remote, this.hive); + UserListsRepositoryImpl( + this.remote, + this.hive, { + Stream? reconnects, + int Function()? clock, + }) : _clock = clock ?? (() => DateTime.now().millisecondsSinceEpoch) { + _reconnectSub = reconnects?.listen((_) => unawaited(flushOutbox())); + } final UserListsRemoteDataSource remote; final HiveService hive; + final int Function() _clock; + StreamSubscription? _reconnectSub; + Future? _flushing; + + static const outboxKey = '__pending_ops__'; Box get _box => Hive.box(ProfileScope.box(AppConstants.userListsBox)); + void dispose() => _reconnectSub?.cancel(); + + bool _hasCache(UserListKind kind) => _box.get(kind.slug) is String; + List _cached(UserListKind kind) { final raw = _box.get(kind.slug); if (raw is! String || raw.isEmpty) return const []; @@ -66,27 +132,118 @@ class UserListsRepositoryImpl implements UserListsRepository { ); } + /// Pending ops, oldest first. + List pendingOps() { + final raw = _box.get(outboxKey); + if (raw is! String || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + final ops = decoded + .whereType() + .map(PendingListOp.fromJson) + .whereType() + .toList(); + ops.sort((a, b) => a.at.compareTo(b.at)); + return ops; + } catch (_) { + return []; + } + } + + Future _saveOps(List ops) async { + if (ops.isEmpty) { + await _box.delete(outboxKey); + return; + } + await _box.put(outboxKey, jsonEncode([for (final o in ops) o.toJson()])); + } + + Future _enqueue(PendingListOp op) async { + final ops = pendingOps()..removeWhere((o) => o.key == op.key); + ops.add(op); + await _saveOps(ops); + } + + /// Sends pending ops oldest first. Stops at the first failure so the order + /// the user made the edits in is the order the server sees them. + Future flushOutbox() { + if (!hive.isLoggedIn) return Future.value(); + return _flushing ??= _flush().whenComplete(() => _flushing = null); + } + + Future _flush() async { + while (true) { + final ops = pendingOps(); + if (ops.isEmpty) return; + final op = ops.first; + try { + if (op.isAdd) { + await remote.add( + op.kind, + provider: op.entity.provider, + contentUrl: op.entity.contentUrl, + title: op.entity.title, + thumbnail: op.entity.thumbnail, + ); + } else { + await remote.remove(op.kind, op.entity.contentUrl); + } + } catch (_) { + return; + } + // Re-read: an edit made while the request was in flight replaces the op + // under the same key with a newer one, which must stay queued. + final now = pendingOps() + ..removeWhere((o) => o.key == op.key && o.at == op.at); + await _saveOps(now); + } + } + + List _overlay( + UserListKind kind, + List items, + ) { + var out = items; + for (final op in pendingOps()) { + final touches = + op.kind == kind || + (op.isAdd && + op.kind == UserListKind.watched && + kind == UserListKind.watchLater); + if (!touches) continue; + out = [ + if (op.isAdd && op.kind == kind) op.entity, + ...out.where((e) => e.contentUrl != op.entity.contentUrl), + ]; + } + return out; + } + @override Future>> getList(UserListKind kind) async { // These lists live on the account, so a signed-out user has none. Return // the (empty) cache rather than an error — the UI shows a sign-in prompt. if (!hive.isLoggedIn) return Success(_cached(kind)); + await flushOutbox(); try { final server = await remote.getList(kind); - final items = server - .map( - (m) => FavoriteEntity( - provider: m.provider, - contentUrl: m.contentUrl, - title: m.title, - thumbnail: m.thumbnail, - ), - ) - .toList(growable: false); + final items = _overlay(kind, [ + for (final m in server) + FavoriteEntity( + provider: m.provider, + contentUrl: m.contentUrl, + title: m.title, + thumbnail: m.thumbnail, + ), + ]); await _store(kind, items); return Success(items); } catch (_) { - return Success(_cached(kind)); + if (_hasCache(kind) || pendingOps().any((o) => o.kind == kind)) { + return Success(_cached(kind)); + } + return const Failure(UserListUnavailable()); } } @@ -111,31 +268,40 @@ class UserListsRepositoryImpl implements UserListsRepository { } if (hive.isLoggedIn) { - try { - await remote.add( - kind, - provider: entity.provider, - contentUrl: entity.contentUrl, - title: entity.title, - thumbnail: entity.thumbnail, - ); - } catch (_) { - // Kept locally; reconciled on the next successful getList. - } + await _enqueue( + PendingListOp(kind: kind, isAdd: true, entity: entity, at: _clock()), + ); + await flushOutbox(); } return const Success(null); } @override Future> remove(UserListKind kind, String contentUrl) async { + final existing = _cached( + kind, + ).where((e) => e.contentUrl == contentUrl).firstOrNull; await _store( kind, _cached(kind).where((e) => e.contentUrl != contentUrl).toList(growable: false), ); if (hive.isLoggedIn) { - try { - await remote.remove(kind, contentUrl); - } catch (_) {} + await _enqueue( + PendingListOp( + kind: kind, + isAdd: false, + entity: + existing ?? + FavoriteEntity( + provider: '', + contentUrl: contentUrl, + title: '', + thumbnail: '', + ), + at: _clock(), + ), + ); + await flushOutbox(); } return const Success(null); } 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 b0335ebd..b54f2a98 100644 --- a/lib/features/user_lists/presentation/pages/user_lists_page.dart +++ b/lib/features/user_lists/presentation/pages/user_lists_page.dart @@ -92,6 +92,7 @@ class _UserListTab extends StatefulWidget { class _UserListTabState extends State<_UserListTab> with AutomaticKeepAliveClientMixin { List? _items; + bool _failed = false; @override bool get wantKeepAlive => true; @@ -107,7 +108,21 @@ class _UserListTabState extends State<_UserListTab> Future _load() async { final result = await getIt().getList(widget.kind); if (!mounted) return; - setState(() => _items = result.getOrNull() ?? const []); + setState(() { + final items = result.getOrNull(); + if (items != null) { + _items = items; + _failed = false; + } else if (_items == null) { + // Nothing cached: an empty list here would claim the list is empty. + _failed = true; + } + }); + } + + void _retry() { + setState(() => _failed = false); + _load(); } Future _remove(FavoriteEntity item) async { @@ -131,6 +146,9 @@ class _UserListTabState extends State<_UserListTab> Widget build(BuildContext context) { super.build(context); final items = _items; + if (items == null && _failed) { + return UserListLoadError(onRetry: _retry); + } if (items == null) { return Center( child: CircularProgressIndicator(color: AppColors.primary), @@ -241,6 +259,50 @@ class _UserListRow extends StatelessWidget { } } +/// Shown when the list could not be fetched and nothing was cached. +class UserListLoadError extends StatelessWidget { + const UserListLoadError({super.key, required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_rounded, + color: AppColors.textHint, + size: 48, + ), + const SizedBox(height: 14), + Text( + 'user_lists.load_failed'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: onRetry, + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + ), + child: Text('general.retry'.tr()), + ), + ], + ), + ), + ); + } +} + class _EmptyState extends StatelessWidget { const _EmptyState(); diff --git a/lib/features/watch_party/data/watch_party_remote_data_source.dart b/lib/features/watch_party/data/watch_party_remote_data_source.dart index eab4cab3..fb02f9e8 100644 --- a/lib/features/watch_party/data/watch_party_remote_data_source.dart +++ b/lib/features/watch_party/data/watch_party_remote_data_source.dart @@ -2,6 +2,12 @@ import 'package:dio/dio.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +/// `429` from the invite endpoint: this friend was invited moments ago. +class PartyInviteCooldown implements Exception { + const PartyInviteCooldown(this.retryAfterSec); + final int retryAfterSec; +} + /// Thin REST layer for watch party rooms. Mirrors `StreakRemoteDataSource`: /// a single injected [Dio] whose baseUrl already ends with `/api`. class WatchPartyRemoteDataSource { @@ -9,11 +15,22 @@ class WatchPartyRemoteDataSource { const WatchPartyRemoteDataSource({required this.dio}); /// `POST /watch-party` → `{ code, party }`. - Future> createParty({PartyContent? content}) async { + Future> createParty({ + PartyContent? content, + int? maxMembers, + bool? onlyHostControls, + bool? waitForAll, + }) async { final res = await dio.post( '/watch-party', data: { if (content != null) 'content': content.toJson(), + 'maxMembers': ?maxMembers, + if (onlyHostControls != null || waitForAll != null) + 'settings': { + 'onlyHostControls': ?onlyHostControls, + 'waitForAll': ?waitForAll, + }, }, ); return _asMap(res.data); @@ -25,12 +42,29 @@ class WatchPartyRemoteDataSource { return _asMap(res.data); } - /// `POST /watch-party/:code/invite` `{ userId }` (host only). - Future invite(String code, String userId) async { - await dio.post( - '/watch-party/$code/invite', - data: {'userId': userId}, - ); + /// `POST /watch-party/:code/invite` `{ userId }` (host only). Returns the + /// cooldown the server started, in seconds. + Future invite(String code, String userId) async { + try { + final res = await dio.post( + '/watch-party/$code/invite', + data: {'userId': userId}, + ); + return (_asMap(res.data)['retryAfterSec'] as num?)?.ceil() ?? 0; + } on DioException catch (e) { + if (e.response?.statusCode == 429) { + final left = _asMap(e.response?.data)['retryAfterSec'] as num?; + throw PartyInviteCooldown(left?.ceil() ?? 600); + } + rethrow; + } + } + + /// `GET /watch-party/:code/invites` → `items: [{userId, retryAfterSec}]`. + Future> inviteStatus(String code) async { + final res = await dio.get('/watch-party/$code/invites'); + final items = _asMap(res.data)['items']; + return items is List ? items : const []; } /// `DELETE /watch-party/:code` (host only). diff --git a/lib/features/watch_party/data/watch_party_service.dart b/lib/features/watch_party/data/watch_party_service.dart index a82003b8..4a3b609c 100644 --- a/lib/features/watch_party/data/watch_party_service.dart +++ b/lib/features/watch_party/data/watch_party_service.dart @@ -15,6 +15,8 @@ import 'package:soplay/features/watch_party/domain/entities/party_playback.dart' import 'package:soplay/features/watch_party/domain/entities/party_reaction.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; import 'package:soplay/features/watch_party/domain/entities/party_state.dart'; +import 'package:soplay/features/watch_party/domain/party_clock.dart'; +import 'package:soplay/features/watch_party/domain/party_wait_policy.dart'; /// Live watch-party session. Registered as an eager get_it singleton and is the /// single source of truth via [state]. Mirrors `StreakService`'s shape @@ -38,6 +40,20 @@ class WatchPartyService with WidgetsBindingObserver { final ValueNotifier state = ValueNotifier(PartyState.empty); + /// The room's chat as shown: history from `party:state`, then live lines. + final ValueNotifier> chatLog = + ValueNotifier>(const []); + static const int _chatLogMax = 200; + + final PartyClock clock = PartyClock(); + final PartyWaitPolicy waitPolicy = PartyWaitPolicy(); + static const Duration _pingPeriod = Duration(seconds: 10); + Timer? _pingTimer; + + // When each member was first seen buffering, on this device's clock. + final Map _bufferingSince = {}; + bool? _reportedBuffering; + final StreamController _chatCtrl = StreamController.broadcast(); final StreamController _reactionCtrl = @@ -81,15 +97,26 @@ class WatchPartyService with WidgetsBindingObserver { // --------------------------------------------------------------------------- /// REST create → connect → join (join emitted on the `connect` event). - Future createParty({PartyContent? content}) async { + Future createParty({ + PartyContent? content, + int? maxMembers, + bool? onlyHostControls, + bool? waitForAll, + }) async { // New session gets a fresh one-shot handshake recovery. _handshakeRetried = false; - final body = await remote.createParty(content: content); + final body = await remote.createParty( + content: content, + maxMembers: maxMembers, + onlyHostControls: onlyHostControls, + waitForAll: waitForAll, + ); final rawParty = body['party']; final partyJson = rawParty is Map ? Map.from(rawParty) : body; final room = PartyRoom.fromRest(partyJson); _currentCode = room.code; + _resetSession(); state.value = state.value.copyWith( room: room, phase: PartyPhase.joining, @@ -111,6 +138,7 @@ class WatchPartyService with WidgetsBindingObserver { Future joinParty(String code) async { // New session gets a fresh one-shot handshake recovery. _handshakeRetried = false; + if (_currentCode != code) _resetSession(); _currentCode = code; state.value = state.value.copyWith( phase: PartyPhase.joining, @@ -138,7 +166,9 @@ class WatchPartyService with WidgetsBindingObserver { _socket.emit('party:leave', {'code': code}); } _currentCode = null; + _stopPing(); _socket.disconnect(); + _resetSession(); state.value = PartyState.empty; } @@ -154,15 +184,115 @@ class WatchPartyService with WidgetsBindingObserver { rethrow; } finally { _currentCode = null; + _stopPing(); _socket.disconnect(); + _resetSession(); state.value = PartyState.empty; } } - Future invite(String userId) async { + /// Host-only push invite. Returns the seconds until [userId] can be invited + /// again; throws [PartyInviteCooldown] when they still are cooling down. + Future invite(String userId) async { final code = _currentCode ?? state.value.code; - if (code == null) return; - await remote.invite(code, userId); + if (code == null) return 0; + return remote.invite(code, userId); + } + + /// The caller's invite cooldowns as `[{userId, retryAfterSec}]`. + Future> inviteCooldowns() async { + final code = _currentCode ?? state.value.code; + if (code == null) return const []; + return remote.inviteStatus(code); + } + + /// Host-only. Only the fields given change; the server clamps [maxMembers]. + void updateSettings({ + int? maxMembers, + bool? onlyHostControls, + bool? locked, + bool? waitForAll, + }) { + if (!_canEmit) return; + _socket.emit('party:settings', { + 'code': _currentCode, + 'maxMembers': ?maxMembers, + 'onlyHostControls': ?onlyHostControls, + 'locked': ?locked, + 'waitForAll': ?waitForAll, + }); + } + + /// Host-only: removes [userId] and keeps them out of this room. + void kick(String userId) { + if (!_canEmit) return; + _socket.emit('party:kick', { + 'code': _currentCode, + 'userId': userId, + }); + } + + /// Host-only: hands the host role to [userId]. + void transferHost(String userId) { + if (!_canEmit) return; + _socket.emit('party:transfer', { + 'code': _currentCode, + 'userId': userId, + }); + } + + /// Tells the room whether this device's player is stalled. Sent only on + /// change. + void reportBuffering(bool buffering) { + if (!_canEmit || _reportedBuffering == buffering) return; + _reportedBuffering = buffering; + _socket.emit('party:buffering', { + 'code': _currentCode, + 'buffering': buffering, + }); + } + + /// Host side of "wait for everyone", run from the player's tick. Drives the + /// local player through [pause]/[play] and tells the room. Returns true when + /// it has just paused. + bool waitForAllTick({ + required bool localPlaying, + required double positionSec, + required void Function() pause, + required void Function() play, + }) { + final s = state.value; + final action = waitPolicy.tick( + enabled: s.room?.waitForAll ?? false, + isHost: s.isHost && s.inParty, + localPlaying: localPlaying, + bufferingSince: othersBufferingSince(), + now: DateTime.now(), + ); + switch (action) { + case PartyWaitAction.pause: + pause(); + sendControl(action: 'pause', positionSec: positionSec); + return true; + case PartyWaitAction.resume: + play(); + sendControl(action: 'play', positionSec: positionSec); + return false; + case PartyWaitAction.none: + return false; + } + } + + /// When each OTHER online member started buffering. + Iterable othersBufferingSince() { + final me = _myUserId; + final online = { + for (final m in state.value.room?.members ?? const []) + if (m.online) m.userId, + }; + return _bufferingSince.entries + .where((e) => e.key != me && online.contains(e.key)) + .map((e) => e.value); } void sendControl({required String action, double? positionSec, double? rate}) { @@ -209,22 +339,22 @@ class WatchPartyService with WidgetsBindingObserver { 'clientId': clientId, }); } + final me = hive.getUser(); + final local = PartyChatMessage( + userId: _myUserId ?? '', + username: me?.username ?? me?.displayName, + photoURL: me?.photoURL, + text: capped, + ts: DateTime.now().millisecondsSinceEpoch, + ); + _appendLog(local); // Optimistic local echo: show instantly instead of waiting for the server // round-trip (which can be slow on a distant server), and — crucially — // render the sender's own message even during a brief reconnect so it never // looks like the chat is dead. The server echoes our clientId back; _onChat // suppresses only the matching pending entry on THIS device, so a second // device on the same account still renders the message. - if (!_chatCtrl.isClosed) { - final me = hive.getUser(); - _chatCtrl.add(PartyChatMessage( - userId: _myUserId ?? '', - username: me?.username ?? me?.displayName, - photoURL: me?.photoURL, - text: capped, - ts: DateTime.now().millisecondsSinceEpoch, - )); - } + if (!_chatCtrl.isClosed) _chatCtrl.add(local); } void sendReaction(String emoji) { @@ -292,10 +422,73 @@ class WatchPartyService with WidgetsBindingObserver { _socket.on('party:reaction', _onReaction); _socket.on('party:closed', _onClosed); _socket.on('party:error', _onErrorEvent); + _socket.on('party:settings', _onSettings); + _socket.on('party:system', _onSystem); + _socket.on('party:buffering', _onBuffering); + _socket.on('party:pong', _onPong); + } + + void _resetSession() { + chatLog.value = const []; + _bufferingSince.clear(); + _reportedBuffering = null; + waitPolicy.release(); + clock.reset(); + } + + void _startPing() { + _pingTimer?.cancel(); + _ping(); + _pingTimer = Timer.periodic(_pingPeriod, (_) => _ping()); + } + + void _stopPing() { + _pingTimer?.cancel(); + _pingTimer = null; + } + + void _ping() { + if (!_socket.connected) return; + _socket.emit('party:ping', { + 't0': DateTime.now().millisecondsSinceEpoch, + }); + } + + void _appendLog(PartyChatMessage msg) { + final next = [...chatLog.value, msg]; + chatLog.value = next.length > _chatLogMax + ? next.sublist(next.length - _chatLogMax) + : next; + } + + /// [pb] re-based from "when it arrived" to "when the server meant it". + PartyPlayback _aligned(PartyPlayback pb, Map json) => + PartyPlayback( + positionSec: pb.positionSec, + isPlaying: pb.isPlaying, + rate: pb.rate, + receivedAt: clock.align( + serverTime: (json['serverTime'] as num?)?.toInt(), + receivedAt: pb.receivedAt, + ), + ); + + void _syncBuffering(List members) { + final now = DateTime.now(); + final buffering = { + for (final m in members) + if (m.online && m.buffering) m.userId, + }; + _bufferingSince.removeWhere((id, _) => !buffering.contains(id)); + for (final id in buffering) { + _bufferingSince.putIfAbsent(id, () => now); + } } void _handleConnect() { _handshakeRetried = false; + _reportedBuffering = null; + _startPing(); state.value = state.value.copyWith(connection: PartyConnection.connected); // Room membership is bound to the socket — (re)join after every connect. final code = _currentCode; @@ -325,6 +518,7 @@ class WatchPartyService with WidgetsBindingObserver { void _handleDisconnect() { if (_disposed) return; + _stopPing(); if (_currentCode != null && state.value.phase != PartyPhase.closed) { // socket.io will auto-reconnect; reflect that in the UI. state.value = @@ -339,7 +533,18 @@ class WatchPartyService with WidgetsBindingObserver { void _onState(dynamic d) { final j = _asMap(d); if (j == null) return; - final room = PartyRoom.fromSnapshot(j); + var room = PartyRoom.fromSnapshot(j); + final pbJson = _asMap(j['playback']); + if (pbJson != null) { + room = room.copyWith(playback: _aligned(room.playback, pbJson)); + } + if (j.containsKey('chat')) { + final history = PartyChatMessage.listFrom(j['chat']); + chatLog.value = history.length > _chatLogMax + ? history.sublist(history.length - _chatLogMax) + : history; + } + _syncBuffering(room.members); state.value = state.value.copyWith( room: room, phase: PartyPhase.inRoom, @@ -354,7 +559,10 @@ class WatchPartyService with WidgetsBindingObserver { if (j == null) return; final pbJson = _asMap(j['playback']); if (pbJson == null) return; - final pb = PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()); + final pb = _aligned( + PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()), + pbJson, + ); final room = state.value.room; if (room != null) { state.value = state.value.copyWith(room: room.copyWith(playback: pb)); @@ -370,7 +578,10 @@ class WatchPartyService with WidgetsBindingObserver { contentJson != null ? PartyContent.fromJson(contentJson) : null; final pbJson = _asMap(j['playback']); final pb = pbJson != null - ? PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()) + ? _aligned( + PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()), + pbJson, + ) : null; final room = state.value.room; if (room != null) { @@ -387,6 +598,7 @@ class WatchPartyService with WidgetsBindingObserver { if (j == null) return; final members = PartyMember.listFrom(j['members']); final hostUserId = j['hostUserId'] as String?; + _syncBuffering(members); final room = state.value.room; if (room != null) { state.value = state.value.copyWith( @@ -410,9 +622,60 @@ class WatchPartyService with WidgetsBindingObserver { // Legacy fallback for a server that doesn't echo the clientId. return; } + _appendLog(msg); if (!_chatCtrl.isClosed) _chatCtrl.add(msg); } + void _onSystem(dynamic d) { + final j = _asMap(d); + if (j == null) return; + final msg = PartyChatMessage.tryParse(j); + if (msg == null || !msg.isSystem) return; + _appendLog(msg); + } + + void _onSettings(dynamic d) { + final j = _asMap(d); + final room = state.value.room; + if (j == null || room == null) return; + final settings = _asMap(j['settings']) ?? const {}; + state.value = state.value.copyWith( + room: room.copyWith( + onlyHostControls: settings['onlyHostControls'] as bool?, + locked: settings['locked'] as bool?, + waitForAll: settings['waitForAll'] as bool?, + maxMembers: (j['maxMembers'] as num?)?.toInt(), + maxMembersLimit: (j['maxMembersLimit'] as num?)?.toInt(), + ), + ); + } + + void _onBuffering(dynamic d) { + final j = _asMap(d); + final room = state.value.room; + final userId = j?['userId'] as String?; + if (j == null || room == null || userId == null) return; + final buffering = j['buffering'] == true; + final members = [ + for (final m in room.members) + m.userId == userId ? m.copyWith(buffering: buffering) : m, + ]; + _syncBuffering(members); + state.value = state.value.copyWith(room: room.copyWith(members: members)); + } + + void _onPong(dynamic d) { + final j = _asMap(d); + final t0 = (j?['t0'] as num?)?.toInt(); + final serverTime = (j?['serverTime'] as num?)?.toInt(); + if (t0 == null || serverTime == null) return; + clock.add( + sentAt: t0, + serverTime: serverTime, + receivedAt: DateTime.now().millisecondsSinceEpoch, + ); + } + void _onReaction(dynamic d) { final j = _asMap(d); if (j == null) return; @@ -435,6 +698,7 @@ class WatchPartyService with WidgetsBindingObserver { closedReason: reason, ); _currentCode = null; + _stopPing(); _socket.disconnect(); } @@ -494,9 +758,11 @@ class WatchPartyService with WidgetsBindingObserver { void dispose() { _disposed = true; + _stopPing(); WidgetsBinding.instance.removeObserver(this); _socket.dispose(); state.dispose(); + chatLog.dispose(); _chatCtrl.close(); _reactionCtrl.close(); _contentCtrl.close(); diff --git a/lib/features/watch_party/domain/entities/party_chat_message.dart b/lib/features/watch_party/domain/entities/party_chat_message.dart index e52c6706..a191819c 100644 --- a/lib/features/watch_party/domain/entities/party_chat_message.dart +++ b/lib/features/watch_party/domain/entities/party_chat_message.dart @@ -1,11 +1,16 @@ +/// What a system line in the chat says happened. +enum PartySystemKind { joined, left, host, removed } + /// A chat line broadcast in a watch party. Server payload nests the author -/// under `user: {id, username, photoURL}`. +/// under `user: {id, username, photoURL}`. A system line (`type: 'system'`) +/// has a [system] kind and no text; [userId] is who it is about. class PartyChatMessage { final String userId; final String? username; final String? photoURL; final String text; final int ts; + final PartySystemKind? system; const PartyChatMessage({ required this.userId, @@ -13,8 +18,36 @@ class PartyChatMessage { this.photoURL, required this.text, required this.ts, + this.system, }); + bool get isSystem => system != null; + + static PartySystemKind? _kind(dynamic raw) => switch (raw) { + 'joined' => PartySystemKind.joined, + 'left' => PartySystemKind.left, + 'host' => PartySystemKind.host, + 'removed' => PartySystemKind.removed, + _ => null, + }; + + /// Null for a system line of a kind this app does not know, so a newer + /// server cannot put a blank row in an older chat. + static PartyChatMessage? tryParse(Map j) => + j['type'] == 'system' && _kind(j['kind']) == null + ? null + : PartyChatMessage.fromJson(j); + + /// A `party:state` `chat` array, oldest first. + static List listFrom(dynamic raw) { + if (raw is! List) return const []; + return raw + .whereType() + .map((m) => tryParse(Map.from(m))) + .whereType() + .toList(); + } + factory PartyChatMessage.fromJson(Map j) { final rawUser = j['user']; final user = rawUser is Map @@ -26,6 +59,7 @@ class PartyChatMessage { photoURL: user['photoURL'] as String?, text: j['text'] as String? ?? '', ts: (j['ts'] as num?)?.toInt() ?? 0, + system: j['type'] == 'system' ? _kind(j['kind']) : null, ); } } diff --git a/lib/features/watch_party/domain/entities/party_member.dart b/lib/features/watch_party/domain/entities/party_member.dart index c6f4ea39..89fa5fcc 100644 --- a/lib/features/watch_party/domain/entities/party_member.dart +++ b/lib/features/watch_party/domain/entities/party_member.dart @@ -5,6 +5,7 @@ class PartyMember { final String? photoURL; final String role; // 'host' | 'member' final bool online; + final bool buffering; const PartyMember({ required this.userId, @@ -12,8 +13,18 @@ class PartyMember { this.photoURL, this.role = 'member', this.online = false, + this.buffering = false, }); + PartyMember copyWith({bool? buffering}) => PartyMember( + userId: userId, + username: username, + photoURL: photoURL, + role: role, + online: online, + buffering: buffering ?? this.buffering, + ); + bool get isHost => role == 'host'; factory PartyMember.fromJson(Map j) => PartyMember( @@ -22,6 +33,7 @@ class PartyMember { photoURL: j['photoURL'] as String?, role: j['role'] as String? ?? 'member', online: j['online'] as bool? ?? false, + buffering: j['buffering'] as bool? ?? false, ); /// Defensively parse a `members` array from a socket/REST payload. diff --git a/lib/features/watch_party/domain/entities/party_room.dart b/lib/features/watch_party/domain/entities/party_room.dart index dd3bbd09..fc66bd68 100644 --- a/lib/features/watch_party/domain/entities/party_room.dart +++ b/lib/features/watch_party/domain/entities/party_room.dart @@ -8,7 +8,12 @@ class PartyRoom { final String code; final String? hostUserId; final bool onlyHostControls; + final bool locked; + final bool waitForAll; final int maxMembers; + + /// The largest size the host may pick, from the server's hard cap. + final int maxMembersLimit; final List members; final PartyContent? content; final PartyPlayback playback; @@ -17,7 +22,10 @@ class PartyRoom { required this.code, this.hostUserId, this.onlyHostControls = true, + this.locked = false, + this.waitForAll = false, this.maxMembers = 2, + this.maxMembersLimit = 10, this.members = const [], this.content, required this.playback, @@ -32,6 +40,11 @@ class PartyRoom { return true; } + static bool _flag(dynamic settings, String key) => + settings is Map && settings[key] == true; + + static int _limit(dynamic raw) => (raw as num?)?.toInt() ?? 10; + static PartyContent? _content(dynamic raw) => raw is Map ? PartyContent.fromJson(Map.from(raw)) : null; @@ -47,7 +60,10 @@ class PartyRoom { code: j['code'] as String? ?? '', hostUserId: j['hostUserId'] as String?, onlyHostControls: _onlyHostControls(j['settings']), + locked: _flag(j['settings'], 'locked'), + waitForAll: _flag(j['settings'], 'waitForAll'), maxMembers: (j['maxMembers'] as num?)?.toInt() ?? 2, + maxMembersLimit: _limit(j['maxMembersLimit']), members: PartyMember.listFrom(j['members']), content: _content(j['content']), playback: _playback(j['playback']), @@ -62,7 +78,10 @@ class PartyRoom { code: j['code'] as String? ?? '', hostUserId: (j['hostUserId'] as String?) ?? host?['userId'] as String?, onlyHostControls: _onlyHostControls(j['settings']), + locked: _flag(j['settings'], 'locked') || j['locked'] == true, + waitForAll: _flag(j['settings'], 'waitForAll'), maxMembers: (j['maxMembers'] as num?)?.toInt() ?? 2, + maxMembersLimit: _limit(j['maxMembersLimit']), members: PartyMember.listFrom(j['members']), content: _content(j['content']), playback: _playback(j['playback']), @@ -73,7 +92,10 @@ class PartyRoom { String? code, String? hostUserId, bool? onlyHostControls, + bool? locked, + bool? waitForAll, int? maxMembers, + int? maxMembersLimit, List? members, PartyContent? content, PartyPlayback? playback, @@ -82,7 +104,10 @@ class PartyRoom { code: code ?? this.code, hostUserId: hostUserId ?? this.hostUserId, onlyHostControls: onlyHostControls ?? this.onlyHostControls, + locked: locked ?? this.locked, + waitForAll: waitForAll ?? this.waitForAll, maxMembers: maxMembers ?? this.maxMembers, + maxMembersLimit: maxMembersLimit ?? this.maxMembersLimit, members: members ?? this.members, content: content ?? this.content, playback: playback ?? this.playback, diff --git a/lib/features/watch_party/domain/invite_cooldowns.dart b/lib/features/watch_party/domain/invite_cooldowns.dart new file mode 100644 index 00000000..340d0dfe --- /dev/null +++ b/lib/features/watch_party/domain/invite_cooldowns.dart @@ -0,0 +1,50 @@ +/// When each friend can be invited again. The server keeps the real cooldown +/// and reports it in seconds; this turns that into deadlines on this device's +/// clock so a picker can count down without asking again. +class InviteCooldowns { + InviteCooldowns({DateTime Function()? clock}) : _clock = clock ?? DateTime.now; + + final DateTime Function() _clock; + final Map _until = {}; + + void set(String userId, int retryAfterSec) { + if (retryAfterSec <= 0) { + _until.remove(userId); + return; + } + _until[userId] = _clock().add(Duration(seconds: retryAfterSec)); + } + + /// From `GET /watch-party/:code/invites` → `items: [{userId, retryAfterSec}]`. + void applyServer(dynamic items) { + if (items is! List) return; + for (final raw in items.whereType()) { + final id = raw['userId']; + final sec = raw['retryAfterSec']; + if (id is String && sec is num) set(id, sec.ceil()); + } + } + + Duration remaining(String userId) { + final until = _until[userId]; + if (until == null) return Duration.zero; + final left = until.difference(_clock()); + if (left <= Duration.zero) { + _until.remove(userId); + return Duration.zero; + } + return left; + } + + bool coolingDown(String userId) => remaining(userId) > Duration.zero; + + bool get any => _until.keys.toList().any(coolingDown); + + /// `9:05`, rounded up so it never reads `0:00` while still blocked. + static String format(Duration d) { + final total = (d.inMilliseconds / 1000).ceil(); + final m = total ~/ 60; + final s = total % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/features/watch_party/domain/party_clock.dart b/lib/features/watch_party/domain/party_clock.dart new file mode 100644 index 00000000..5a3c3d10 --- /dev/null +++ b/lib/features/watch_party/domain/party_clock.dart @@ -0,0 +1,69 @@ +/// Server clock offset from `party:ping` / `party:pong` round trips. +/// +/// A sync's `positionSec` is where the server put the room at `serverTime`. +/// Reading it as "where the room is at the moment it arrived" is late by the +/// one-way trip, so a guest on a slow link always trails the host by that +/// much. With an offset, the local instant the server meant can be recovered +/// and the trip taken out. +/// +/// Pure: millisecond timestamps in, numbers out. +class PartyClock { + PartyClock({this.window = 8}); + + /// How many recent round trips are kept. The one with the shortest trip + /// wins: queueing only ever adds delay, so the fastest sample is the least + /// skewed. + final int window; + + /// Round trips slower than this say more about the network than the clock. + static const int maxRttMs = 5000; + + /// The most an alignment may move a receipt back. Beyond this the offset is + /// stale or wrong and the receipt time is the safer guess. + static const int maxShiftMs = 3000; + + final List<_Sample> _samples = <_Sample>[]; + + /// Records one round trip: [sentAt] and [receivedAt] on this device's clock, + /// [serverTime] on the server's. Returns false when the sample is unusable. + bool add({required int sentAt, required int serverTime, required int receivedAt}) { + final rtt = receivedAt - sentAt; + if (rtt < 0 || rtt > maxRttMs) return false; + _samples.add(_Sample(rtt, serverTime - (sentAt + rtt / 2).round())); + if (_samples.length > window) _samples.removeAt(0); + return true; + } + + void reset() => _samples.clear(); + + _Sample? get _best { + _Sample? best; + for (final s in _samples) { + if (best == null || s.rtt < best.rtt) best = s; + } + return best; + } + + /// Server clock minus local clock, or null before the first usable sample. + int? get offsetMs => _best?.offset; + + int? get rttMs => _best?.rtt; + + /// The local instant the server meant by [serverTime], for a message that + /// arrived at [receivedAt]. Falls back to [receivedAt] without an offset, or + /// when the answer would be in the future or implausibly far back. + DateTime align({required int? serverTime, required DateTime receivedAt}) { + final offset = offsetMs; + if (serverTime == null || offset == null) return receivedAt; + final local = serverTime - offset; + final shift = receivedAt.millisecondsSinceEpoch - local; + if (shift < 0 || shift > maxShiftMs) return receivedAt; + return DateTime.fromMillisecondsSinceEpoch(local); + } +} + +class _Sample { + const _Sample(this.rtt, this.offset); + final int rtt; + final int offset; +} diff --git a/lib/features/watch_party/domain/party_wait_policy.dart b/lib/features/watch_party/domain/party_wait_policy.dart new file mode 100644 index 00000000..b86eede4 --- /dev/null +++ b/lib/features/watch_party/domain/party_wait_policy.dart @@ -0,0 +1,70 @@ +/// "Wait for everyone": the host's player holds for a guest who is buffering. +/// +/// A short stall is not worth a room-wide pause, so a guest has to buffer for +/// [threshold] first. The hold is also capped at [maxHold]: a guest on a link +/// that never recovers must not freeze the room for good. +/// +/// After giving up on a guest it ignores that same stall, otherwise the room +/// would pause again on the next tick and every [maxHold] after that. +enum PartyWaitAction { none, pause, resume } + +class PartyWaitPolicy { + PartyWaitPolicy({ + this.threshold = const Duration(seconds: 3), + this.maxHold = const Duration(seconds: 15), + }); + + final Duration threshold; + final Duration maxHold; + + DateTime? _heldSince; + DateTime? _ignoreBefore; + + bool get holding => _heldSince != null; + + /// [bufferingSince] is when each other member was first seen buffering. + PartyWaitAction tick({ + required bool enabled, + required bool isHost, + required bool localPlaying, + required Iterable bufferingSince, + required DateTime now, + }) { + final held = _heldSince; + if (!enabled || !isHost) { + _heldSince = null; + return held != null && !localPlaying + ? PartyWaitAction.resume + : PartyWaitAction.none; + } + + if (held != null) { + if (localPlaying) { + // Someone pressed play while we held; the hold is over. + _heldSince = null; + return PartyWaitAction.none; + } + final stillWaiting = bufferingSince.isNotEmpty; + if (!stillWaiting || now.difference(held) >= maxHold) { + _heldSince = null; + if (stillWaiting) _ignoreBefore = now; + return PartyWaitAction.resume; + } + return PartyWaitAction.none; + } + + if (!localPlaying) return PartyWaitAction.none; + final ignore = _ignoreBefore; + final stalled = bufferingSince.any( + (t) => + (ignore == null || t.isAfter(ignore)) && + now.difference(t) >= threshold, + ); + if (!stalled) return PartyWaitAction.none; + _heldSince = now; + return PartyWaitAction.pause; + } + + /// The host took over playback themselves; the hold is theirs now. + void release() => _heldSince = null; +} diff --git a/lib/features/watch_party/domain/pending_party_code.dart b/lib/features/watch_party/domain/pending_party_code.dart new file mode 100644 index 00000000..0f680ed9 --- /dev/null +++ b/lib/features/watch_party/domain/pending_party_code.dart @@ -0,0 +1,58 @@ +/// A party invite that arrived before the user could join it: signed out, or +/// still in first-run setup. Kept through sign-in or sign-up, then opened once. +/// +/// Stored as `CODE|savedAtMs` through [read]/[write] so it survives the app +/// being killed while the user goes to fetch a password. Old invites expire: +/// opening a room someone shared yesterday, right after an unrelated sign-in, +/// would be a surprise. +class PendingPartyCode { + PendingPartyCode({ + required this.read, + required this.write, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final String? Function() read; + final Future Function(String? value) write; + final DateTime Function() _clock; + + static const Duration ttl = Duration(hours: 1); + + static final RegExp _shape = RegExp(r'^[A-Z0-9]{4,12}$'); + + /// Upper-cased, trimmed, or null when it cannot be a party code. + static String? normalize(String? raw) { + final code = raw?.trim().toUpperCase() ?? ''; + return _shape.hasMatch(code) ? code : null; + } + + Future remember(String? code) async { + final c = normalize(code); + if (c == null) return; + await write('$c|${_clock().millisecondsSinceEpoch}'); + } + + /// The pending code if there is a fresh one, without clearing it. + String? peek() { + final raw = read(); + if (raw == null || raw.isEmpty) return null; + final bar = raw.indexOf('|'); + final code = normalize(bar < 0 ? raw : raw.substring(0, bar)); + final savedAt = bar < 0 ? null : int.tryParse(raw.substring(bar + 1)); + if (code == null || savedAt == null) return null; + final age = _clock().difference( + DateTime.fromMillisecondsSinceEpoch(savedAt), + ); + if (age.isNegative || age > ttl) return null; + return code; + } + + /// The pending code, cleared so it opens once. + Future take() async { + final code = peek(); + if (read() != null) await write(null); + return code; + } + + Future clear() => write(null); +} 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 3ebb82ed..bcc0ac5c 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -9,16 +9,22 @@ import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_member.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; import 'package:soplay/features/watch_party/domain/entities/party_state.dart'; import 'package:soplay/features/watch_party/domain/party_resolve_gate.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_chat_panel.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_code_sheet.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_error_views.dart'; +import 'package:soplay/features/watch_party/presentation/widgets/party_host_settings.dart'; +import 'package:soplay/features/watch_party/presentation/widgets/party_invite_sheet.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_member_bar.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_plugin_required_view.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_reactions_bar.dart'; @@ -123,6 +129,23 @@ class _WatchPartyPageState extends State { final current = _service.state.value; if (current.inParty && current.code == code) return; + // Signed out (often a fresh install opened from an invite link): keep the + // code through sign-in or sign-up. A successful sign-in re-opens the room + // from the pending code; this page is replaced by then. + if (!getIt().isLoggedIn) { + final pending = getIt(); + await pending.remember(code); + if (!mounted) return; + await context.push('/login'); + if (!mounted) return; + if (!getIt().isLoggedIn) { + await pending.clear(); + if (mounted) setState(() => _joinErrorCode = 'unauthorized'); + return; + } + await pending.clear(); + } + setState(() { _joining = true; _joinErrorCode = null; @@ -149,6 +172,42 @@ class _WatchPartyPageState extends State { ); } + Future _onMemberAction( + PartyMember member, + PartyMemberAction action, + ) async { + switch (action) { + case PartyMemberAction.makeHost: + _service.transferHost(member.userId); + case PartyMemberAction.remove: + final name = (member.username ?? '').trim().isNotEmpty + ? member.username!.trim() + : '—'; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + content: Text('watch_party.remove_confirm'.tr(args: [name])), + actions: [ + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(ctx, false), + child: Text('general.cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'general.remove'.tr(), + style: const TextStyle(color: AppColors.error), + ), + ), + ], + ), + ); + if (ok == true) _service.kick(member.userId); + } + } + Future _copyCode(String code) async { await Clipboard.setData(ClipboardData(text: code)); if (!mounted) return; @@ -311,7 +370,7 @@ class _WatchPartyPageState extends State { return PartyErrorView( code: code, actionLabel: 'general.try_again'.tr(), - onAction: _retryConnect, + onAction: code == 'unauthorized' ? _bootstrap : _retryConnect, ); } // A failed connection, or a join that never lands, must NOT sit forever @@ -352,8 +411,24 @@ class _WatchPartyPageState extends State { _ContentCard(content: content), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: PartyMemberBar(room: room, myUserId: s.myUserId), + child: PartyMemberBar( + room: room, + myUserId: s.myUserId, + isHost: s.isHost, + onAction: _onMemberAction, + ), ), + if (s.isHost) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: AppSecondaryButton( + onPressed: () => showPartyInviteSheet(context), + icon: Icons.person_add_alt_1_rounded, + label: 'watch_party.invite_friends'.tr(), + ), + ), + PartyHostSettings(room: room, service: _service), + ], const SizedBox(height: 8), _BottomActions( isHost: s.isHost, diff --git a/lib/features/watch_party/presentation/party_entry.dart b/lib/features/watch_party/presentation/party_entry.dart index bb115d75..be623dba 100644 --- a/lib/features/watch_party/presentation/party_entry.dart +++ b/lib/features/watch_party/presentation/party_entry.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -6,6 +8,7 @@ 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/watch_party/domain/entities/party_content.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_code_sheet.dart'; /// Watch Party is auth-gated on the server (REST + socket). Returns true when a @@ -16,6 +19,22 @@ Future _ensurePartyLogin(BuildContext context) async { return getIt().isLoggedIn; } +/// Opens the party invite kept through sign-in or first-run setup, once. +/// Called wherever a signed-in user lands on Home; concurrent calls (sign-in +/// and Home's first frame) share one navigation. +Future openPendingParty(GoRouter router) => _pendingOpen ??= + _openPendingParty(router).whenComplete(() => _pendingOpen = null); + +Future? _pendingOpen; + +Future _openPendingParty(GoRouter router) async { + if (!getIt.isRegistered()) return; + if (!getIt().isLoggedIn) return; + final code = await getIt().take(); + if (code == null) return; + unawaited(router.push('/watch-party?code=${Uri.encodeComponent(code)}')); +} + /// Navigation payload for `/watch-party` (passed via `GoRouter` `state.extra`). class WatchPartyArgs { const WatchPartyArgs({this.code, this.content}); 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 dd6dd73f..364bcffc 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -9,7 +7,8 @@ import 'package:soplay/features/watch_party/data/watch_party_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_chat_message.dart'; /// Live chat for a watch party: a scrolling message list fed by -/// `service.chat`, plus a composer wired to `service.sendChat`. +/// `service.chatLog` (history first, then live lines), plus a composer wired +/// to `service.sendChat`. class PartyChatPanel extends StatefulWidget { const PartyChatPanel({super.key, required this.service, this.myUserId}); @@ -21,40 +20,36 @@ class PartyChatPanel extends StatefulWidget { } class _PartyChatPanelState extends State { - static const int _maxMessages = 200; - - final List _messages = []; final TextEditingController _controller = TextEditingController(); final FocusNode _focus = FocusNode(); final ScrollController _scroll = ScrollController(); - StreamSubscription? _sub; @override void initState() { super.initState(); - _sub = widget.service.chat.listen(_onMessage); + widget.service.chatLog.addListener(_onLog); + WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToEnd()); } @override void dispose() { - _sub?.cancel(); + widget.service.chatLog.removeListener(_onLog); _controller.dispose(); _focus.dispose(); _scroll.dispose(); super.dispose(); } - void _onMessage(PartyChatMessage msg) { + void _onLog() { if (!mounted) return; - setState(() { - _messages.add(msg); - if (_messages.length > _maxMessages) { - _messages.removeRange(0, _messages.length - _maxMessages); - } - }); + setState(() {}); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToEnd()); } + void _jumpToEnd() { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + void _scrollToEnd() { if (!_scroll.hasClients) return; _scroll.animateTo( @@ -73,17 +68,25 @@ class _PartyChatPanelState extends State { @override Widget build(BuildContext context) { + final messages = widget.service.chatLog.value; return Column( children: [ Expanded( - child: _messages.isEmpty + child: messages.isEmpty ? _EmptyChat() : ListView.builder( controller: _scroll, padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, + itemCount: messages.length, itemBuilder: (_, i) { - final m = _messages[i]; + final m = messages[i]; + if (m.isSystem) { + return _SystemRow( + message: m, + isMe: widget.myUserId != null && + m.userId == widget.myUserId, + ); + } return _ChatRow( message: m, isMe: widget.myUserId != null && @@ -126,6 +129,41 @@ class _EmptyChat extends StatelessWidget { } } +class _SystemRow extends StatelessWidget { + const _SystemRow({required this.message, required this.isMe}); + + final PartyChatMessage message; + final bool isMe; + + @override + Widget build(BuildContext context) { + final name = isMe + ? 'watch_party.you'.tr() + : (message.username ?? '').trim().isNotEmpty + ? message.username!.trim() + : '—'; + final text = switch (message.system) { + PartySystemKind.joined => 'watch_party.sys_joined'.tr(args: [name]), + PartySystemKind.left => 'watch_party.sys_left'.tr(args: [name]), + PartySystemKind.host => 'watch_party.sys_host'.tr(args: [name]), + PartySystemKind.removed => 'watch_party.sys_removed'.tr(args: [name]), + null => '', + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontStyle: FontStyle.italic, + ), + ), + ); + } +} + class _ChatRow extends StatelessWidget { const _ChatRow({required this.message, required this.isMe}); @@ -247,19 +285,22 @@ class _Composer extends StatelessWidget { ), ), const SizedBox(width: 4), - Material( - color: AppColors.primary, - shape: const CircleBorder(), - child: InkWell( - onTap: onSend, - customBorder: const CircleBorder(), - child: const SizedBox( - width: 36, - height: 36, - child: Icon( - Icons.send_rounded, - color: Colors.white, - size: 16, + Tooltip( + message: 'support.send'.tr(), + child: Material( + color: AppColors.primary, + shape: const CircleBorder(), + child: InkWell( + onTap: onSend, + customBorder: const CircleBorder(), + child: const SizedBox( + width: 36, + height: 36, + child: Icon( + Icons.send_rounded, + color: Colors.white, + size: 16, + ), ), ), ), 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 5ec005cb..8903e36d 100644 --- a/lib/features/watch_party/presentation/widgets/party_error_views.dart +++ b/lib/features/watch_party/presentation/widgets/party_error_views.dart @@ -150,6 +150,8 @@ class PartyErrorView extends StatelessWidget { 'watch_party.error_not_found', ), 'full' => (Icons.group_off_rounded, 'watch_party.error_full'), + 'locked' => (Icons.lock_rounded, 'watch_party.error_locked'), + 'banned' => (Icons.block_rounded, 'watch_party.error_banned'), 'forbidden' => (Icons.lock_outline_rounded, 'watch_party.error_forbidden'), 'rate' => (Icons.timer_outlined, 'watch_party.error_rate'), 'unauthorized' => ( @@ -185,9 +187,11 @@ class PartyClosedView extends StatelessWidget { return PartyStateView( icon: Icons.meeting_room_outlined, title: 'watch_party.room_closed'.tr(), - message: reason == 'empty' - ? 'watch_party.room_closed_empty'.tr() - : null, + message: switch (reason) { + 'empty' => 'watch_party.room_closed_empty'.tr(), + 'kicked' => 'watch_party.room_closed_kicked'.tr(), + _ => null, + }, actionLabel: actionLabel, onAction: onAction, ); diff --git a/lib/features/watch_party/presentation/widgets/party_host_settings.dart b/lib/features/watch_party/presentation/widgets/party_host_settings.dart new file mode 100644 index 00000000..89a619a8 --- /dev/null +++ b/lib/features/watch_party/presentation/widgets/party_host_settings.dart @@ -0,0 +1,146 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; + +/// The host's room controls in the lobby: size, who drives playback, the lock +/// and "wait for everyone". Each change goes out as `party:settings` and comes +/// back to every member, this one included, so the room is the only state. +class PartyHostSettings extends StatelessWidget { + const PartyHostSettings({ + super.key, + required this.room, + required this.service, + }); + + final PartyRoom room; + final WatchPartyService service; + + static const int minMembers = 2; + + @override + Widget build(BuildContext context) { + final limit = room.maxMembersLimit < minMembers + ? minMembers + : room.maxMembersLimit; + final size = room.maxMembers.clamp(minMembers, limit); + return Container( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + padding: const EdgeInsetsDirectional.fromSTEB(14, 10, 6, 6), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'watch_party.settings_title'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + 'watch_party.room_size'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + ), + IconButton( + tooltip: 'watch_party.room_size_less'.tr(), + icon: const Icon(Icons.remove_rounded), + onPressed: size > minMembers + ? () => service.updateSettings(maxMembers: size - 1) + : null, + ), + SizedBox( + width: 28, + child: Text( + '$size', + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: 'watch_party.room_size_more'.tr(), + icon: const Icon(Icons.add_rounded), + onPressed: size < limit + ? () => service.updateSettings(maxMembers: size + 1) + : null, + ), + ], + ), + _Toggle( + title: 'watch_party.only_host_setting'.tr(), + value: room.onlyHostControls, + onChanged: (v) => service.updateSettings(onlyHostControls: v), + ), + _Toggle( + title: 'watch_party.lock_room'.tr(), + subtitle: 'watch_party.lock_room_hint'.tr(), + value: room.locked, + onChanged: (v) => service.updateSettings(locked: v), + ), + _Toggle( + title: 'watch_party.wait_for_all'.tr(), + subtitle: 'watch_party.wait_for_all_hint'.tr(), + value: room.waitForAll, + onChanged: (v) => service.updateSettings(waitForAll: v), + ), + ], + ), + ); + } +} + +class _Toggle extends StatelessWidget { + const _Toggle({ + required this.title, + required this.value, + required this.onChanged, + this.subtitle, + }); + + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SwitchListTile( + dense: true, + contentPadding: const EdgeInsetsDirectional.only(end: 4), + value: value, + onChanged: onChanged, + // Same as the settings switches: a white thumb on an accent track. + activeThumbColor: Colors.white, + activeTrackColor: AppColors.primary, + title: Text( + title, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ); + } +} diff --git a/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart b/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart new file mode 100644 index 00000000..bd46d1f3 --- /dev/null +++ b/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +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/comments/domain/entities/comment_author.dart'; +import 'package:soplay/features/comments/presentation/widgets/comment_avatar.dart'; +import 'package:soplay/features/social/data/social_remote_data_source.dart'; +import 'package:soplay/features/social/domain/social_models.dart'; +import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; +import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/invite_cooldowns.dart'; + +/// Host-only: pick friends to send a push invite to the current room. +Future showPartyInviteSheet(BuildContext context) { + return showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const PartyInviteSheet(), + ); +} + +class PartyInviteSheet extends StatefulWidget { + const PartyInviteSheet({super.key}); + + @override + State createState() => _PartyInviteSheetState(); +} + +class _PartyInviteSheetState extends State { + final WatchPartyService _service = getIt(); + final InviteCooldowns _cooldowns = InviteCooldowns(); + final Set _sending = {}; + + static const int _maxFriends = 200; + + List? _friends; + bool _failed = false; + Timer? _ticker; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + Future _load() async { + setState(() { + _failed = false; + _friends = null; + }); + try { + final social = getIt(); + final out = []; + String? cursor; + do { + final page = await social.friends(cursor: cursor, limit: 50); + out.addAll(page.items.map((f) => f.user)); + cursor = page.nextCursor; + } while (cursor != null && out.length < _maxFriends); + try { + _cooldowns.applyServer(await _service.inviteCooldowns()); + } catch (_) { + // An older server has no status endpoint; cooldowns then show up as + // they are hit. + } + if (!mounted) return; + setState(() => _friends = out); + _syncTicker(); + } catch (_) { + if (mounted) setState(() => _failed = true); + } + } + + void _syncTicker() { + if (_cooldowns.any) { + _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() {}); + if (!_cooldowns.any) { + _ticker?.cancel(); + _ticker = null; + } + }); + } + } + + Future _invite(SocialUser user) async { + if (_sending.contains(user.id) || _cooldowns.coolingDown(user.id)) return; + setState(() => _sending.add(user.id)); + try { + final left = await _service.invite(user.id); + _cooldowns.set(user.id, left > 0 ? left : 600); + _toast('watch_party.invite_sent'.tr()); + } on PartyInviteCooldown catch (e) { + _cooldowns.set(user.id, e.retryAfterSec); + } catch (_) { + _toast('watch_party.invite_failed'.tr()); + } finally { + if (mounted) { + setState(() => _sending.remove(user.id)); + _syncTicker(); + } + } + } + + void _toast(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(text), + behavior: SnackBarBehavior.floating, + backgroundColor: AppColors.surfaceVariant, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final friends = _friends; + final Widget body; + if (_failed) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Text( + 'watch_party.error_generic'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 8), + TextButton( + autofocus: true, + onPressed: _load, + child: Text('general.try_again'.tr()), + ), + ], + ), + ); + } else if (friends == null) { + body = const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator(strokeWidth: 2.4)), + ); + } else if (friends.isEmpty) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 12), + child: Text( + 'watch_party.invite_no_friends'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + ); + } else { + body = ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.6, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: friends.length, + itemBuilder: (_, i) => _FriendRow( + user: friends[i], + autofocus: i == 0, + sending: _sending.contains(friends[i].id), + cooldown: _cooldowns.remaining(friends[i].id), + onInvite: () => _invite(friends[i]), + ), + ), + ); + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 10), + child: Text( + 'watch_party.invite_friends'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + ), + body, + ], + ), + ), + ); + } +} + +class _FriendRow extends StatelessWidget { + const _FriendRow({ + required this.user, + required this.autofocus, + required this.sending, + required this.cooldown, + required this.onInvite, + }); + + final SocialUser user; + final bool autofocus; + final bool sending; + final Duration cooldown; + final VoidCallback onInvite; + + @override + Widget build(BuildContext context) { + final cooling = cooldown > Duration.zero; + final Widget trailing; + if (sending) { + trailing = const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } else if (cooling) { + trailing = Text( + 'watch_party.invite_again_in'.tr( + args: [InviteCooldowns.format(cooldown)], + ), + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ); + } else { + trailing = const Icon(Icons.send_rounded, size: 20); + } + return ListTile( + autofocus: autofocus, + enabled: !cooling && !sending, + onTap: onInvite, + leading: CommentAvatar( + author: CommentAuthor( + id: user.id, + username: user.username, + photoURL: user.photoURL, + ), + size: 38, + ), + title: Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: user.displayName != null + ? Text( + '@${user.username}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ) + : null, + trailing: trailing, + ); + } +} 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 81b19bbb..bc2153ff 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -1,18 +1,94 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/comments/domain/entities/comment_author.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_avatar.dart'; import 'package:soplay/features/watch_party/domain/entities/party_member.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; -/// Horizontal strip of party members: avatar (with host crown + online dot), -/// name, and an `online/max` counter chip. +/// What the host picked from a member's menu. +enum PartyMemberAction { makeHost, remove } + +/// Horizontal strip of party members: avatar (with host crown, online dot and +/// a buffering badge), name, and an `online/max` counter chip. +/// +/// For the host, every other member is a button (tap, long-press, or D-pad +/// select on TV) that opens [onAction]'s menu. class PartyMemberBar extends StatelessWidget { - const PartyMemberBar({super.key, required this.room, this.myUserId}); + const PartyMemberBar({ + super.key, + required this.room, + this.myUserId, + this.isHost = false, + this.onAction, + }); final PartyRoom room; final String? myUserId; + final bool isHost; + final void Function(PartyMember member, PartyMemberAction action)? onAction; + + Future _openMenu(BuildContext context, PartyMember member) async { + final onAction = this.onAction; + if (onAction == null) return; + final name = (member.username ?? '').trim().isNotEmpty + ? member.username!.trim() + : '—'; + final picked = await showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + if (member.online) + ListTile( + autofocus: true, + leading: const Icon( + Icons.workspace_premium_rounded, + color: AppColors.rating, + ), + title: Text('watch_party.make_host'.tr()), + onTap: () => + Navigator.of(ctx).pop(PartyMemberAction.makeHost), + ), + ListTile( + autofocus: !member.online, + leading: const Icon( + Icons.person_remove_rounded, + color: AppColors.error, + ), + title: Text( + 'watch_party.remove_member'.tr(), + style: const TextStyle(color: AppColors.error), + ), + onTap: () => Navigator.of(ctx).pop(PartyMemberAction.remove), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (picked != null) onAction(member, picked); + } @override Widget build(BuildContext context) { @@ -56,10 +132,20 @@ class PartyMemberBar extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: members.length, separatorBuilder: (_, _) => const SizedBox(width: 14), - itemBuilder: (_, i) => _MemberTile( - member: members[i], - isMe: myUserId != null && members[i].userId == myUserId, - ), + itemBuilder: (context, i) { + final m = members[i]; + final isMe = myUserId != null && m.userId == myUserId; + final actionable = + isHost && onAction != null && !isMe && !m.isHost; + final tile = _MemberTile(member: m, isMe: isMe); + if (!actionable) return tile; + return HoverTap( + borderRadius: 12, + onTap: () => _openMenu(context, m), + onLongPress: () => _openMenu(context, m), + child: tile, + ); + }, ), ), ], @@ -115,6 +201,27 @@ class _MemberTile extends StatelessWidget { ), ), ), + if (member.buffering) + Positioned( + top: -4, + right: -4, + child: Tooltip( + message: 'watch_party.buffering'.tr(), + child: Container( + width: 18, + height: 18, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.background, + shape: BoxShape.circle, + ), + child: const CircularProgressIndicator( + strokeWidth: 1.8, + color: AppColors.rating, + ), + ), + ), + ), Positioned( right: 0, bottom: 0, diff --git a/lib/main.dart b/lib/main.dart index 97451d4f..dd20e36a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'package:media_kit/media_kit.dart'; import 'package:path_provider/path_provider.dart'; import 'package:window_manager/window_manager.dart'; import 'package:liquid_glass_widgets/liquid_glass_widgets.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/extensions/extension_bridge.dart'; import 'package:soplay/core/storage/box_recovery.dart'; @@ -48,6 +49,8 @@ import 'package:soplay/core/storage/secure_boxes.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_server_store.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/app_lock/presentation/app_lock_gate.dart'; +import 'package:soplay/core/diagnostics/crash_log.dart'; +import 'package:soplay/core/diagnostics/friendly_error_widget.dart'; import 'app.dart'; /// [args] is what the OS handed the process, and on Windows and Linux that is @@ -118,6 +121,9 @@ void main(List args) async { PlatformInAppWebViewController.debugLoggingSettings.enabled = false; await _initFirebaseSafely(); + // Off Android, where Crashlytics does not run: a local crash log instead. + await CrashLog.install(); + FriendlyErrorWidget.install(onGoHome: () => AppRouter.router.go('/main')); await configureDependencies(); // Before anything can navigate — the deep-link and push handlers below, and // the first frame — so every way in lands under the lock. @@ -162,10 +168,8 @@ void main(List args) async { () => AppRouter.router.routerDelegate.navigatorKey.currentContext, ); unawaited( - AppOrientation.set([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]).catchError((Object _) {}), + // Phones only: a tablet or an unfolded foldable is left free to rotate. + AppOrientation.set(AppOrientation.appDefault).catchError((Object _) {}), ); unawaited( SystemChrome.setEnabledSystemUIMode( @@ -201,6 +205,8 @@ void main(List args) async { Locale('fr'), Locale('tr'), Locale('id'), + Locale('vi'), + Locale('hi'), // Cantonese, in Traditional characters. `yue` rather than `zh-HK` // because the copy is written in Cantonese — 睇, 嘅, 冇 — and not in the // Standard Written Chinese a `zh` tag promises; a reader who set their @@ -244,6 +250,9 @@ void main(List args) async { // launch per install — the cold one everybody's first impression is made // of — would fall back to a flat logo. await brand; + // The player decides once, on its first build, whether its controls are + // the phone's own glass; asked here so the answer is in by then. + await NativeControls.warm(); runApp(root); diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index ac2ac6f8..9eca4f39 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -2,6 +2,12 @@ import Cocoa import FlutterMacOS class MainFlutterWindow: NSWindow { + /// What the window was before it floated as the mini player (see the + /// `sozo/pip_window` channel), put back when it stops floating. + private var savedBehavior: NSWindow.CollectionBehavior = [] + private var savedLevel: NSWindow.Level = .normal + private var floating = false + override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -17,8 +23,59 @@ class MainFlutterWindow: NSWindow { self.backgroundColor = NSColor( red: 0x18 / 255.0, green: 0x18 / 255.0, blue: 0x18 / 255.0, alpha: 1.0) + // Hover reaches the view even while another app is in front. The mini + // player's controls appear on hover, and with the default (key window + // only) they stayed hidden until the viewer clicked, which also brought + // all of Sozo forward. + flutterViewController.mouseTrackingMode = .always + RegisterGeneratedPlugins(registry: flutterViewController) + registerPipChannel(flutterViewController.engine.binaryMessenger) super.awakeFromNib() } + + /// The mini player floats the way Chrome's and Telegram's picture-in-picture + /// windows do: above other apps, on every Space, and beside another app's + /// full-screen window rather than being left behind on the desktop Space. + /// window_manager's setAlwaysOnTop only raises the level, and sets none of + /// the collection behaviour that decides which Spaces a window is on. + private func registerPipChannel(_ messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: "sozo/pip_window", binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { return result(nil) } + switch call.method { + case "enterFloat": + if !self.floating { + self.savedBehavior = self.collectionBehavior + self.savedLevel = self.level + self.floating = true + } + var behavior = self.collectionBehavior + // A window cannot both follow the active Space and be on all of them, + // and one that may go full screen is not allowed beside another + // app's full-screen window: auxiliary instead of primary. + behavior.remove([.fullScreenPrimary, .moveToActiveSpace, .managed]) + behavior.insert([.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]) + if #available(macOS 13.0, *) { behavior.insert(.canJoinAllApplications) } + self.collectionBehavior = behavior + // Above floating panels and other apps' always-on-top windows, as + // Telegram's modal-panel level is. + self.level = .modalPanel + self.orderFrontRegardless() + result(nil) + case "exitFloat": + if self.floating { + // Back before anything asks for full screen again: without + // .fullScreenPrimary the window refuses it. + self.collectionBehavior = self.savedBehavior + self.level = self.savedLevel + self.floating = false + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } } diff --git a/pubspec.lock b/pubspec.lock index 7a5aad31..773cbc1d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -282,7 +282,7 @@ packages: source: hosted version: "3.0.7" cryptography: - dependency: transitive + dependency: "direct main" description: name: cryptography sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" @@ -887,6 +887,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.3+1" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: c7fc384bdb66c3b83e24e3a41818e61d7a378d900f65f5e775344fbca95a0917 + url: "https://pub.dev" + source: hosted + version: "0.12.0" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: "8324fdc2628ebeb63568ad06437b984b716d938d87e4ce63ab24dfd3eeebf08a" + url: "https://pub.dev" + source: hosted + version: "0.16.0" google_sign_in: dependency: "direct main" description: @@ -1164,6 +1180,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + liquid_glass_renderer: + dependency: "direct main" + description: + name: liquid_glass_renderer + sha256: "789be157494b38cdef7607bb9b63e7fe3fab76fae16149e52fa313c2d858cb3f" + url: "https://pub.dev" + source: hosted + version: "0.2.0-dev.4" liquid_glass_widgets: dependency: "direct main" description: @@ -1332,6 +1356,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.4.0" + motor: + dependency: transitive + description: + name: motor + sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 + url: "https://pub.dev" + source: hosted + version: "1.1.0" multicast_dns: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9768fb15..9f16ad32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -81,6 +81,7 @@ dependencies: # Security crypto: ^3.0.6 + cryptography: ^2.9.0 local_auth: ^2.3.0 # Localization @@ -139,7 +140,12 @@ dependencies: media_kit_libs_macos_video: ^1.1.4 media_kit_libs_linux: ^1.2.1 liquid_glass_widgets: ^0.18.4 + # Exact pin: a dev release, and lib/core/glass is written against its API. + liquid_glass_renderer: 0.2.0-dev.4 mobile_scanner: ^7.4.0 + # Reader page translation, Android and iOS only. 0.17+ needs AGP 9 and 1.x + # needs Swift Package Manager; the app is on AGP 8 and CocoaPods. + google_mlkit_text_recognition: ^0.16.0 # The TV's pairing code, painted rather than pulled in as a widget package. qr: ^3.0.2 firebase_auth: ^5.7.0 diff --git a/test/core/deeplink/deeplink_links_test.dart b/test/core/deeplink/deeplink_links_test.dart new file mode 100644 index 00000000..9ae9cdb5 --- /dev/null +++ b/test/core/deeplink/deeplink_links_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/deeplink/deeplink_links.dart'; +import 'package:soplay/features/detail/domain/entities/detail_args.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; + +DetailArgs _detail(String link) { + final target = parseDeeplink(Uri.parse(link)); + expect(target, isNotNull, reason: link); + expect(target!.location, '/detail'); + return target.extra! as DetailArgs; +} + +void main() { + group('parseDeeplink', () { + test('/detail opens the title with its provider, nothing more', () { + final args = _detail( + 'https://sozo.azamov.me/detail?url=https%3A%2F%2Fx.test%2Fa&provider=hd', + ); + expect(args.contentUrl, 'https://x.test/a'); + expect(args.provider, 'hd'); + expect(args.autoPlay, isFalse); + expect(args.startEpisode, isNull); + expect(args.startPositionMs, isNull); + }); + + test('/play keeps episode and position', () { + final args = _detail( + 'https://sozo.azamov.me/play?url=u&provider=hd&ep=12&t=754', + ); + expect(args.autoPlay, isTrue); + expect(args.provider, 'hd'); + expect(args.startEpisode, 12); + expect(args.startPositionMs, 754000); + // The viewer's own history is not what a shared link resumes. + expect(args.resumeEpisodeIndex, isNull); + }); + + test('custom scheme reads the route from the authority', () { + final args = _detail('sozo://play?url=u&ep=2&t=1:05'); + expect(args.startEpisode, 2); + expect(args.startPositionMs, 65000); + }); + + test('/player is an alias of /play', () { + expect(_detail('sozo://player?url=u').autoPlay, isTrue); + }); + + test('bad ep and t are dropped, not guessed', () { + final args = _detail('sozo://play?url=u&ep=zero&t=-4'); + expect(args.startEpisode, isNull); + expect(args.startPositionMs, isNull); + expect(_detail('sozo://play?url=u&ep=0').startEpisode, isNull); + }); + + test('an empty provider means none', () { + expect(_detail('sozo://detail?url=u&provider=%20').provider, isNull); + }); + + test('/party/ opens the room with typed args', () { + final target = parseDeeplink( + Uri.parse('https://sozo.azamov.me/party/ABC123'), + )!; + expect(target.location, '/watch-party'); + expect((target.extra! as WatchPartyArgs).code, 'ABC123'); + }); + + test('/link and /pair open link-tv', () { + expect( + parseDeeplink(Uri.parse('sozo://link/XY12'))!.location, + '/link-tv?code=XY12', + ); + expect(parseDeeplink(Uri.parse('sozo://pair'))!.location, '/link-tv'); + }); + + test('ignores other hosts, missing urls and unknown paths', () { + expect( + parseDeeplink(Uri.parse('https://evil.test/detail?url=u')), + isNull, + ); + expect(parseDeeplink(Uri.parse('sozo://detail')), isNull); + expect(parseDeeplink(Uri.parse('sozo://play')), isNull); + expect(parseDeeplink(Uri.parse('sozo://party')), isNull); + expect(parseDeeplink(Uri.parse('sozo://nowhere?url=u')), isNull); + }); + }); + + group('parseStartSeconds', () { + test('reads plain, clock and unit forms', () { + expect(parseStartSeconds('90'), 90); + expect(parseStartSeconds('12.5'), 12.5); + expect(parseStartSeconds('1:30'), 90); + expect(parseStartSeconds('1:00:05'), 3605); + expect(parseStartSeconds('1h2m5s'), 3725); + expect(parseStartSeconds('2m'), 120); + expect(parseStartSeconds('45s'), 45); + }); + + test('rejects nonsense', () { + for (final bad in [null, '', '0', '-3', 'abc', '1:x', 'h', '1:2:3:4']) { + expect(parseStartSeconds(bad), isNull, reason: '$bad'); + } + }); + }); + + group('buildShareLink', () { + test('a title alone shares /detail', () { + final link = Uri.parse(buildShareLink(contentUrl: 'u', provider: 'hd')); + expect(link.path, '/detail'); + expect(link.queryParameters, {'url': 'u', 'provider': 'hd'}); + }); + + test('an episode and a position share /play, which parses back', () { + final link = buildShareLink( + contentUrl: 'u', + provider: 'hd', + episode: 4, + position: const Duration(minutes: 3, seconds: 7), + ); + expect(Uri.parse(link).path, '/play'); + final args = _detail(link); + expect(args.startEpisode, 4); + expect(args.startPositionMs, 187000); + expect(args.provider, 'hd'); + }); + }); +} diff --git a/test/core/deeplink/deeplink_service_test.dart b/test/core/deeplink/deeplink_service_test.dart new file mode 100644 index 00000000..29bacb44 --- /dev/null +++ b/test/core/deeplink/deeplink_service_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/deeplink/deeplink_service.dart'; +import 'package:soplay/features/detail/domain/entities/detail_args.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; + +class _FakeNavigator extends ChangeNotifier implements DeeplinkNavigator { + _FakeNavigator(this._location); + + String _location; + final opened = <(String, Object?)>[]; + + @override + String get location => _location; + + void go(String location) { + _location = location; + notifyListeners(); + } + + @override + Future openOverHome(String location, Object? extra) async { + opened.add((location, extra)); + _location = location.split('?').first; + } +} + +Future _settle() => Future.delayed(Duration.zero); + +Future<(DeeplinkService, _FakeNavigator)> _start(String at) async { + final nav = _FakeNavigator(at); + final service = DeeplinkService( + navigator: () => nav, + initialLink: () async => null, + linkStream: const Stream.empty(), + ); + await service.start(); + return (service, nav); +} + +void main() { + test('a link during splash is held, then opened once Home shows', () async { + final (service, nav) = await _start('/splash'); + + await service.handle( + Uri.parse('https://sozo.azamov.me/play?url=u&provider=hd&ep=3&t=90'), + ); + await _settle(); + expect(nav.opened, isEmpty); + expect(service.held, isNotNull); + + nav.go('/main'); + await _settle(); + + expect(service.held, isNull); + expect(nav.opened, hasLength(1)); + final (location, extra) = nav.opened.single; + expect(location, '/detail'); + final args = extra! as DetailArgs; + expect(args.contentUrl, 'u'); + expect(args.provider, 'hd'); + expect(args.autoPlay, isTrue); + expect(args.startEpisode, 3); + expect(args.startPositionMs, 90000); + await service.stop(); + }); + + test('held through onboarding and the profile picker', () async { + final (service, nav) = await _start('/splash'); + await service.handle(Uri.parse('sozo://detail?url=u')); + + for (final gated in [ + '/onboarding', + '/onboarding/genres', + '/profiles', + '/pin-verify', + ]) { + nav.go(gated); + await _settle(); + expect(nav.opened, isEmpty, reason: gated); + expect(service.held, isNotNull, reason: gated); + } + + nav.go('/main'); + await _settle(); + expect(nav.opened.single.$1, '/detail'); + expect((nav.opened.single.$2! as DetailArgs).autoPlay, isFalse); + + // Spent: later navigation opens nothing more. + nav.go('/settings'); + await _settle(); + expect(nav.opened, hasLength(1)); + await service.stop(); + }); + + test('a warm link opens at once with typed args', () async { + final (service, nav) = await _start('/settings'); + + await service.handle(Uri.parse('sozo://party/ROOM42')); + await service.handle(Uri.parse('https://sozo.azamov.me/link/TV9')); + + expect(nav.opened.map((o) => o.$1), ['/watch-party', '/link-tv?code=TV9']); + expect((nav.opened.first.$2! as WatchPartyArgs).code, 'ROOM42'); + await service.stop(); + }); + + test('unknown links change nothing', () async { + final (service, nav) = await _start('/main'); + + await service.handle(Uri.parse('https://example.test/detail?url=u')); + await service.handle(Uri.parse('sozo://nowhere')); + await service.handle(Uri.parse('sozo://detail')); + + expect(nav.opened, isEmpty); + expect(service.held, isNull); + await service.stop(); + }); + + test('the initial link is handled like any other', () async { + final nav = _FakeNavigator('/splash'); + final service = DeeplinkService( + navigator: () => nav, + initialLink: () async => Uri.parse('sozo://detail?url=cold'), + linkStream: const Stream.empty(), + ); + await service.start(); + expect(service.held, isNotNull); + nav.go('/main'); + await _settle(); + expect((nav.opened.single.$2! as DetailArgs).contentUrl, 'cold'); + await service.stop(); + }); +} diff --git a/test/core/deeplink/go_router_deeplink_navigator_test.dart b/test/core/deeplink/go_router_deeplink_navigator_test.dart new file mode 100644 index 00000000..6297aec4 --- /dev/null +++ b/test/core/deeplink/go_router_deeplink_navigator_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/deeplink/deeplink_service.dart'; + +void main() { + testWidgets('opens over Home, so back returns Home', (tester) async { + final router = GoRouter( + initialLocation: '/main', + routes: [ + for (final path in ['/main', '/settings', '/detail']) + GoRoute(path: path, builder: (_, _) => Text('page $path')), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + final nav = GoRouterDeeplinkNavigator(router); + + router.go('/settings'); + await tester.pumpAndSettle(); + expect(nav.location, '/settings'); + + final opening = nav.openOverHome('/detail', 'args'); + await tester.pumpAndSettle(); + await opening; + await tester.pumpAndSettle(); + + expect(nav.location, '/detail'); + expect(router.canPop(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect(nav.location, '/main'); + expect(router.canPop(), isFalse); + }); +} diff --git a/test/core/diagnostics/crash_log_test.dart b/test/core/diagnostics/crash_log_test.dart new file mode 100644 index 00000000..c5782940 --- /dev/null +++ b/test/core/diagnostics/crash_log_test.dart @@ -0,0 +1,84 @@ +// Off Android there is no Crashlytics; the crash log is the only record. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/diagnostics/crash_log.dart'; +import 'package:soplay/features/support/data/support_diagnostics.dart'; + +void main() { + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo_crash_'); + }); + + tearDown(() async { + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + test('records an error with its stack', () { + final log = CrashLog(dir); + log.record(StateError('boom'), StackTrace.fromString('#0 main')); + final text = log.read(); + expect(text, contains('boom')); + expect(text, contains('#0 main')); + }); + + test('rotates at the size cap and keeps at most maxFiles', () { + final log = CrashLog(dir, maxBytes: 300, maxFiles: 3); + for (var i = 0; i < 40; i++) { + log.record('error number $i ${'x' * 60}', null); + } + final files = log.files; + expect(files, hasLength(3)); + for (final f in files) { + expect(f.lengthSync(), lessThanOrEqualTo(300)); + } + expect( + dir.listSync().whereType().length, + 3, + reason: 'nothing past the oldest is left behind', + ); + final text = log.read(); + expect(text, contains('error number 39 '), reason: 'newest kept'); + expect(text, isNot(contains('error number 0 ')), reason: 'oldest gone'); + // Oldest first in what is read back. + expect( + text.indexOf('error number 38 '), + lessThan(text.indexOf('error number 39 ')), + ); + }); + + test('read can be cut to its newest end', () { + final log = CrashLog(dir); + log.record('first', null); + log.record('second', null); + final tail = log.read(maxChars: 20); + expect(tail.length, 20); + expect(tail, contains('second')); + }); + + test('redacts what the player log redacts', () { + final log = CrashLog(dir); + log.record('GET https://x.test/v?token=abcdef123456 failed', null); + expect(log.read(), isNot(contains('abcdef123456'))); + }); + + test('clear removes every file', () { + final log = CrashLog(dir, maxBytes: 100); + for (var i = 0; i < 10; i++) { + log.record('e$i ${'y' * 50}', null); + } + log.clear(); + expect(log.files, isEmpty); + expect(log.read(), isEmpty); + }); + + test('support diagnostics carry it at the end of the log field', () { + expect(SupportDiagnostics.withCrashLog('player', null), 'player'); + expect(SupportDiagnostics.withCrashLog(null, ''), isNull); + final both = SupportDiagnostics.withCrashLog('player', 'crash')!; + expect(both, startsWith('player')); + expect(both, endsWith('${SupportDiagnostics.crashHeader}\ncrash')); + }); +} diff --git a/test/core/glass/glass_core_test.dart b/test/core/glass/glass_core_test.dart new file mode 100644 index 00000000..2d03ad45 --- /dev/null +++ b/test/core/glass/glass_core_test.dart @@ -0,0 +1,272 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/app_glass.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/glass/glass_surface.dart'; +import 'package:soplay/core/glass/media_glass.dart'; + +/// The glass primitive (lib/core/glass/glass_surface.dart), its recipes +/// (app_glass.dart) and the two things the phone says about it: Reduce +/// Transparency (glass_sense.dart) and whether an Android phone can spend +/// the refraction (glass_power.dart). +void main() { + Color over(Color tint, Color under) => Color.alphaBlend(tint, under); + + group('the recipes', () { + test('the glass lifts black to a grey, not a dark disc', () { + // The system's dark glass over black reads #1F1F1F (31). + final on = over(GlassRecipe.of(GlassKind.regular).tint, Colors.black); + expect((on.r * 255).round(), inInclusiveRange(26, 36), reason: '$on'); + }); + + test('clear glass leaves the video its colour and frosts nothing', () { + final clear = GlassRecipe.of(GlassKind.clear); + expect(clear.clear, isTrue); + expect(clear.frost, 0); + expect(clear.blurFrost, 0); + // A red frame under it stays red, not pink. + final on = over(clear.tint, const Color(0xFFE11D2E)); + expect((on.g * 255).round(), lessThan(45), reason: '$on'); + expect(clear.lift, isEmpty); + }); + + test('a small shape alone is frosted more and a lighter grey', () { + final bar = GlassRecipe.of(GlassKind.regular); + final small = GlassRecipe.of(GlassKind.regular, compact: true); + expect(small.frost, greaterThan(bar.frost)); + expect(small.tint.a, greaterThan(bar.tint.a)); + }); + + test('every glass bends gently at its rim', () { + for (final kind in GlassKind.values) { + final r = GlassRecipe.of(kind); + expect(r.thickness, inInclusiveRange(6, 14), reason: '$kind'); + expect(r.refraction, inInclusiveRange(1.1, 1.4), reason: '$kind'); + expect(r.dispersion, 0, reason: '$kind'); + } + }); + }); + + group('how the glass is drawn', () { + Future pump( + WidgetTester tester, { + GlassKind kind = GlassKind.regular, + bool reduce = false, + }) => tester.pumpWidget( + MaterialApp( + home: GlassSenseScope( + reduceTransparency: reduce, + child: Center( + child: SizedBox( + width: 44, + height: 44, + child: GlassSurface(kind: kind, child: const SizedBox.expand()), + ), + ), + ), + ), + ); + + testWidgets('without the shader the regular glass is a blur', ( + tester, + ) async { + await pump(tester); + final blur = tester.widget(find.byType(BackdropFilter)); + expect(blur.enabled, isTrue); + }); + + testWidgets('clear glass over video falls back to its tint, never a blur', ( + tester, + ) async { + await pump(tester, kind: GlassKind.clear); + expect(find.byType(BackdropFilter), findsNothing); + }); + + testWidgets('Reduce Transparency: an opaque fill, nothing through it', ( + tester, + ) async { + await pump(tester, kind: GlassKind.clear, reduce: true); + expect(find.byType(BackdropFilter), findsNothing); + final fill = tester.widget( + find + .descendant( + of: find.byType(GlassShape), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final color = (fill.decoration as ShapeDecoration).color!; + expect(color.a, 1.0); + }); + + testWidgets('glass on glass is refused', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: GlassGroup( + child: GlassGroup(child: SizedBox(width: 10, height: 10)), + ), + ), + ); + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('the glass is held while its page is pushed', (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: key, + home: const GlassPickScope( + pick: GlassPick.liquid, + child: Center( + child: SizedBox( + width: 44, + height: 44, + child: GlassSurface(child: SizedBox.expand()), + ), + ), + ), + ), + ); + final context = tester.element(find.byType(GlassShape)); + expect(GlassGroup.holdOf(context)!.value, isEmpty); + key.currentState!.push( + MaterialPageRoute(builder: (_) => const SizedBox()), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect( + GlassGroup.holdOf(context)!.value, + contains(GlassHoldReason.route), + ); + await tester.pumpAndSettle(); + }); + }); + + group('what the phone says', () { + testWidgets('an iPhone with Reduce Transparency turns the glass opaque', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(GlassSense.channel, (call) async => true); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(GlassSense.channel, null), + ); + late BuildContext inside; + await tester.pumpWidget( + GlassSense( + child: Builder( + builder: (context) { + inside = context; + GlassSense.reduceTransparencyOf(context); + return const SizedBox(); + }, + ), + ), + ); + await tester.pump(); + expect(GlassSense.reduceTransparencyOf(inside), isTrue); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('an Android Go phone draws the blur, not the refraction', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + GlassPower.channel, + (call) async => {'lowRam': true}, + ); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(GlassPower.channel, null), + ); + late BuildContext inside; + await tester.pumpWidget( + GlassPower( + child: GlassPickScope( + pick: GlassPick.liquid, + child: Builder( + builder: (context) { + inside = context; + GlassGroup.liquidAt(context); + return const SizedBox(); + }, + ), + ), + ), + ); + expect(GlassGroup.liquidAt(inside), isTrue); + await tester.pump(); + expect(GlassGroup.liquidAt(inside), isFalse); + debugDefaultTargetPlatformOverride = null; + }); + + test('a half answer never takes the glass away', () { + expect(GlassPowerReading.fromChannel(null).affordsRefraction, isTrue); + expect( + GlassPowerReading.fromChannel({'lowRam': 'yes'}).affordsRefraction, + isTrue, + ); + }); + }); + + group('controls over video', () { + Widget row() => const MaterialApp( + home: Center( + child: MediaGlassLayer( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MediaGlass(child: SizedBox(width: 38, height: 38)), + MediaGlass(child: SizedBox(width: 38, height: 38)), + MediaGlass(radius: 10, child: SizedBox(width: 80, height: 44)), + ], + ), + ), + ), + ); + + testWidgets('on an iPhone a row is one read of the picture', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget(row()); + expect(find.byType(GlassShape), findsNWidgets(3)); + expect(find.byType(GlassGroup), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('a piece standing alone is a group of its own', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: MediaGlass(child: SizedBox(width: 38, height: 38)), + ), + ), + ); + expect(find.byType(GlassGroup), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('elsewhere it is the black disc it always was', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await tester.pumpWidget(row()); + expect(find.byType(GlassShape), findsNothing); + expect(find.byType(GlassGroup), findsNothing); + final plate = tester.widget( + find.byType(DecoratedBox).first, + ); + final decoration = plate.decoration as ShapeDecoration; + expect(decoration.color, Colors.black.withValues(alpha: 0.45)); + expect(decoration.shape, isA()); + debugDefaultTargetPlatformOverride = null; + }); + }); +} diff --git a/test/core/glass/glass_gate_test.dart b/test/core/glass/glass_gate_test.dart new file mode 100644 index 00000000..fe898f8e --- /dev/null +++ b/test/core/glass/glass_gate_test.dart @@ -0,0 +1,546 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/app_glass.dart'; +import 'package:soplay/core/glass/glass_surface.dart'; + +/// The gate that decides whether an Android phone draws the refraction +/// (lib/core/glass/glass_power.dart), the step-down that takes it away again +/// when the phone's own frames say it cannot keep up (glass_step_down.dart), +/// and what the blur it falls back to is made of (glass_surface.dart). +void main() { + /// A phone that passes every test in the gate: what the readings below + /// change one thing of, so that each test is about that one thing. + const good = GlassPowerReading( + lowRam: false, + memoryBytes: 6 * 1024 * 1024 * 1024, + sdk: 34, + vulkan11: true, + glEs: 3, + refreshHz: 120, + powerSave: false, + cores: 8, + ); + + group('the gate decides on what was measured', () { + test('a good phone draws the refraction', () { + expect(good.affordsRefraction, isTrue); + }); + + test('a 6 GB phone with no Vulkan 1.1 driver does not', () { + // The case the old gate got wrong in the generous direction: the maker + // sets no Go flag, there is plenty of RAM, and the GPU still cannot + // bend a bar sixty times a second. + const phone = GlassPowerReading( + memoryBytes: 6 * 1024 * 1024 * 1024, + sdk: 28, + vulkan11: false, + glEs: 3, + ); + expect(phone.affordsRefraction, isFalse); + }); + + test('a current 4 GB phone keeps its glass', () { + // And the case it got wrong the other way. 4 GB measures a little + // under 4 GiB once the kernel and the GPU have taken their share, so + // the threshold has to sit below it. + const phone = GlassPowerReading( + memoryBytes: 3717 * 1024 * 1024, + sdk: 33, + glEs: 3, + ); + expect(phone.affordsRefraction, isTrue); + }); + + test('a 2 GB phone does not, flag or no flag', () { + expect( + const GlassPowerReading( + memoryBytes: 2 * 1024 * 1024 * 1024, + sdk: 33, + ).affordsRefraction, + isFalse, + ); + }); + + test('below API 29 Impeller has no Vulkan backend to draw with', () { + expect( + const GlassPowerReading(sdk: 28).affordsRefraction, + isFalse, + ); + }); + + test('a GL ES 2 driver cannot compile the shader at all', () { + expect( + const GlassPowerReading(sdk: 34, glEs: 2).affordsRefraction, + isFalse, + ); + }); + + test('an Android Go phone still draws the blur', () { + expect( + const GlassPowerReading( + lowRam: true, + memoryBytes: 6 * 1024 * 1024 * 1024, + sdk: 34, + ).affordsRefraction, + isFalse, + ); + }); + + test('Battery Saver gives the glass up, and takes it back', () { + final saving = good.withPowerSave(true); + expect(saving.affordsRefraction, isFalse); + expect(saving.withPowerSave(false).affordsRefraction, isTrue); + // Nothing else of the reading is lost on the way through. + expect(saving.withPowerSave(false), good); + }); + + test('the refresh rate is a budget, not a gate', () { + // A 120 Hz panel is not a reason to refuse the glass; it is a reason + // to measure its frames against 8.3 ms rather than 16.7. + expect(good.affordsRefraction, isTrue); + expect(good.budget.frame.inMicroseconds, closeTo(8333, 2)); + expect( + const GlassPowerReading(sdk: 34, refreshHz: 60).budget, + GlassFrameBudget.sixty, + ); + }); + + test('a core count decides nothing', () { + expect( + const GlassPowerReading(sdk: 34, cores: 2).affordsRefraction, + isTrue, + ); + }); + }); + + group('a half answer never takes the glass away', () { + test('no answer at all is the generous one', () { + expect(GlassPowerReading.fromChannel(null).affordsRefraction, isTrue); + expect(GlassPowerReading.fromChannel(null), GlassPowerReading.ample); + expect( + GlassPowerReading.fromChannel({}).affordsRefraction, + isTrue, + ); + }); + + test('a field of the wrong kind is read as missing', () { + final half = GlassPowerReading.fromChannel({ + 'lowRam': 'yes', + 'memoryBytes': 'lots', + 'sdk': '34', + 'vulkan11': 1, + 'glEs': null, + }); + expect(half.affordsRefraction, isTrue); + expect(half.memoryBytes, isNull); + expect(half.sdk, isNull); + }); + + test('what the phone does send is read', () { + final read = GlassPowerReading.fromChannel({ + 'lowRam': false, + 'memoryBytes': 4000000000, + 'sdk': 31, + 'vulkan11': true, + 'glEs': 3, + // The phone sends a rate as a double; a map that came back as an int + // must still be read. + 'refreshHz': 90, + 'powerSave': false, + 'cores': 8, + }); + expect(read.memoryBytes, 4000000000); + expect(read.sdk, 31); + expect(read.refreshHz, 90.0); + expect(read.affordsRefraction, isTrue); + }); + }); + + group('the frame budget', () { + test('a rate the phone could not give falls back to sixty', () { + expect(GlassFrameBudget.ofRefreshRate(0), GlassFrameBudget.sixty); + expect(GlassFrameBudget.ofRefreshRate(-1), GlassFrameBudget.sixty); + expect(GlassFrameBudget.ofRefreshRate(double.nan), GlassFrameBudget.sixty); + }); + + test('a rate outside what a panel can be is clamped, not believed', () { + expect( + GlassFrameBudget.ofRefreshRate(100000).frame, + GlassFrameBudget.ofRefreshRate(240).frame, + ); + expect( + GlassFrameBudget.ofRefreshRate(1).frame, + GlassFrameBudget.ofRefreshRate(24).frame, + ); + }); + }); + + group('the step-down', () { + /// A policy with a small window, so a test can say in a few frames what + /// a phone says in a second. + GlassStepDown policy({double slack = 1.5}) => GlassStepDown( + budget: GlassFrameBudget.sixty, + warmup: 2, + window: 10, + misses: 5, + slack: slack, + ); + + const good = Duration(microseconds: 12000); + const bad = Duration(microseconds: 40000); + + test('a phone keeping up keeps its glass however long it runs', () { + final down = policy(); + for (var i = 0; i < 500; i++) { + expect(down.frame(good), isFalse); + } + expect(down.down, isFalse); + }); + + test('the warm-up frames are measured and thrown away', () { + final down = policy(); + // The frames that pay for the shader being compiled are over budget on + // every phone, and there are as many of them as the window needs. + expect(down.frame(bad), isFalse); + expect(down.frame(bad), isFalse); + expect(down.debugMisses, 0); + }); + + test('a phone that keeps missing is dropped to the blur', () { + final down = policy(); + down + ..frame(bad) + ..frame(bad); + for (var i = 0; i < 4; i++) { + expect(down.frame(bad), isFalse, reason: 'frame $i'); + } + // The fifth missed frame of the window is the one that decides. + expect(down.frame(bad), isTrue); + expect(down.down, isTrue); + }); + + test('it says so once, not on every frame after', () { + final down = policy(); + down + ..frame(bad) + ..frame(bad); + for (var i = 0; i < 5; i++) { + down.frame(bad); + } + expect(down.down, isTrue); + expect(down.frame(bad), isFalse); + expect(down.frame(good), isFalse); + }); + + test('one bad frame in a smooth run is not a verdict', () { + final down = policy(); + down + ..frame(bad) + ..frame(bad); + for (var i = 0; i < 100; i++) { + // A decode landing, a collection: one frame in ten goes over and the + // window never fills with misses. + expect(down.frame(i % 10 == 0 ? bad : good), isFalse); + } + expect(down.down, isFalse); + }); + + test('misses roll off the back of the window', () { + final down = policy(); + down + ..frame(bad) + ..frame(bad); + for (var i = 0; i < 4; i++) { + down.frame(bad); + } + expect(down.debugMisses, 4); + for (var i = 0; i < 10; i++) { + down.frame(good); + } + expect(down.debugMisses, 0); + expect(down.down, isFalse); + }); + + test('the glass going and coming back pays its warm-up again', () { + final down = policy(); + down + ..frame(bad) + ..frame(bad) + ..frame(bad) + ..frame(bad); + expect(down.debugMisses, 2); + down.restart(); + expect(down.debugMisses, 0); + // And the next two frames are warm-up once more. + down + ..frame(bad) + ..frame(bad); + expect(down.debugMisses, 0); + }); + + test('the slack keeps a frame a hair over the vsync off the count', () { + final down = policy(); + down + ..frame(bad) + ..frame(bad); + // Flutter reports a little over the vsync interval on a phone that is + // in fact smooth; 1.5 of the budget is well clear of that. + down.frame(const Duration(microseconds: 18000)); + expect(down.debugMisses, 0); + down.frame(const Duration(microseconds: 25500)); + expect(down.debugMisses, 1); + }); + + test('the budget is the panel\'s, not sixty hertz', () { + // The same 12 ms frame is comfortable at 60 Hz and a miss at 120. + final fast = GlassStepDown( + budget: GlassFrameBudget.ofRefreshRate(120), + warmup: 0, + window: 4, + misses: 2, + ); + fast + ..frame(const Duration(microseconds: 13000)) + ..frame(const Duration(microseconds: 13000)); + expect(fast.down, isTrue); + }); + + test('the panel\'s rate learnt late is still the budget it judges by', () { + // The phone answers over a channel, so the policy is made before the + // rate is known: a 120 Hz panel that could only be told at birth would + // spend the whole run judging against sixty and condemn nothing. + final late = GlassStepDown( + budget: GlassFrameBudget.sixty, + warmup: 0, + window: 4, + misses: 2, + ); + const comfortableAtSixty = Duration(microseconds: 13000); + late + ..frame(comfortableAtSixty) + ..frame(comfortableAtSixty); + expect(late.down, isFalse); + late.budget = GlassFrameBudget.ofRefreshRate(120); + // And the frames weighed against the wrong budget are gone, rather + // than counting toward a verdict they were never evidence for. + expect(late.debugMisses, 0); + late + ..frame(comfortableAtSixty) + ..frame(comfortableAtSixty); + expect(late.down, isTrue); + }); + }); + + group('the step-down reaches the glass', () { + setUp(GlassFrameWatch.debugReset); + tearDown(GlassFrameWatch.debugReset); + + testWidgets('a phone stepped down draws the blur, not the refraction', ( + tester, + ) async { + // Set and cleared inside the body: the test framework checks the + // foundation's debug variables before the tear-downs run. + debugDefaultTargetPlatformOverride = TargetPlatform.android; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + GlassPower.channel, + (call) async => { + 'lowRam': false, + 'memoryBytes': 8 * 1024 * 1024 * 1024, + 'sdk': 34, + 'vulkan11': true, + 'glEs': 3, + 'refreshHz': 60.0, + 'powerSave': false, + 'cores': 8, + }, + ); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(GlassPower.channel, null), + ); + late BuildContext inside; + await tester.pumpWidget( + GlassPower( + child: GlassPickScope( + pick: GlassPick.liquid, + child: Builder( + builder: (context) { + inside = context; + GlassGroup.liquidAt(context); + return const SizedBox(); + }, + ), + ), + ), + ); + await tester.pump(); + expect(GlassGroup.liquidAt(inside), isTrue); + // What sixty missed frames would have decided. + GlassFrameWatch.debugStepDown(); + await tester.pump(); + expect(GlassGroup.liquidAt(inside), isFalse); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Battery Saver thrown while the app runs takes the glass', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + GlassPower.channel, + (call) async => {'sdk': 34, 'glEs': 3}, + ); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(GlassPower.channel, null), + ); + late BuildContext inside; + await tester.pumpWidget( + GlassPower( + child: GlassPickScope( + pick: GlassPick.liquid, + child: Builder( + builder: (context) { + inside = context; + GlassGroup.liquidAt(context); + return const SizedBox(); + }, + ), + ), + ), + ); + await tester.pump(); + expect(GlassGroup.liquidAt(inside), isTrue); + // The phone says so unasked, as it does when the switch is thrown in + // Settings or the battery runs down. + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + GlassPower.channel.name, + GlassPower.channel.codec.encodeMethodCall( + const MethodCall('powerSave', true), + ), + (_) {}, + ); + await tester.pump(); + expect(GlassGroup.liquidAt(inside), isFalse); + debugDefaultTargetPlatformOverride = null; + }); + }); + + group('the blur is the same glass, plainer', () { + test('it carries the recipe\'s colour lift, as the shader does', () { + final bar = GlassRecipe.of(GlassKind.regular); + expect(bar.saturation, greaterThan(1), reason: 'the premise'); + final filter = GlassBlurFilter.of(bar, 1); + // A composed filter: the blur with the lift fused under it, inside the + // one read of the screen the frost already takes. + expect(filter.toString(), contains('compose')); + }); + + test('a recipe that asks for no lift is a plain blur', () { + final menu = GlassRecipe.of(GlassKind.menu); + expect(menu.saturation, 1, reason: 'the premise'); + expect(GlassBlurFilter.of(menu, 1).toString(), isNot(contains('compose'))); + }); + + test('thinning the glass thins the lift with the frost', () { + // At nothing of the glass there is nothing to lift either: a capsule + // coming up must not saturate at full strength through a frost that is + // barely there. + expect( + GlassBlurFilter.of(GlassRecipe.of(GlassKind.regular), 0).toString(), + isNot(contains('compose')), + ); + }); + + test('the lift keeps luminance where it was', () { + // A grey has no colour to lift, so a saturation matrix that moves it + // is one that would lighten or darken everything under the glass. + final matrix = GlassBlurFilter.saturate(1.5).toString(); + expect(matrix, contains('matrix')); + // The rows of a luminance-preserving matrix each sum to one. + const r = 0.2126, g = 0.7152, b = 0.0722; + const s = 1.5; + expect( + r + s * (1 - r) + g * (1 - s) + b * (1 - s), + closeTo(1, 1e-9), + ); + }); + + testWidgets('every shape of one group shares one read of the screen', ( + tester, + ) async { + // Blur mode, which is what the test renderer draws: three shapes of + // one gathering group used to take three full reads of the screen + // behind their clips, because nothing in the app ever put a + // BackdropGroup above them and the key they asked for was always null. + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: GlassGroup( + gathers: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GlassSurface(child: SizedBox(width: 44, height: 44)), + GlassSurface(child: SizedBox(width: 44, height: 44)), + GlassSurface(child: SizedBox(width: 44, height: 44)), + ], + ), + ), + ), + ), + ); + final blurs = tester + .widgetList(find.byType(BackdropFilter)) + .toList(); + expect(blurs, hasLength(3)); + final keys = blurs.map((blur) => blur.backdropGroupKey).toSet(); + expect(keys.single, isNotNull, reason: 'one shared read, not three'); + }); + + testWidgets('a blurred shape draws with the lift, not only round it', ( + tester, + ) async { + // The helper above is only worth having if the shape actually uses it: + // the filter the renderer is handed has to be the composed one. + expect( + GlassRecipe.of(GlassKind.regular).saturation, + greaterThan(1), + reason: 'the premise', + ); + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: GlassSurface(child: SizedBox(width: 44, height: 44)), + ), + ), + ); + final blur = tester.widget(find.byType(BackdropFilter)); + expect(blur.filter.toString(), contains('compose')); + }); + + testWidgets('a lens and the glass over video keep their own read', ( + tester, + ) async { + // A lens is held over the bar, its glyphs and its pill: a read taken + // before any of them were drawn would frost the page without the bar. + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: GlassSurface( + kind: GlassKind.panel, + child: SizedBox(width: 44, height: 44), + ), + ), + ), + ); + final blur = tester.widget(find.byType(BackdropFilter)); + expect(blur.backdropGroupKey, isNull); + }); + }); +} diff --git a/test/core/glass/media_glass_native_test.dart b/test/core/glass/media_glass_native_test.dart new file mode 100644 index 00000000..23586184 --- /dev/null +++ b/test/core/glass/media_glass_native_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/glass_surface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/media_glass.dart'; +import 'package:soplay/core/glass/native_glass.dart'; + +NativeGlassCapability _phone({ + required bool systemGlass, + Set styles = const {'regular', 'clear'}, +}) => NativeGlassCapability( + systemVersion: systemGlass ? '26.0' : '18.5', + majorVersion: systemGlass ? 26 : 18, + systemGlass: systemGlass, + styles: systemGlass ? styles : const {}, + materials: const {'ultraThin', 'thin', 'regular', 'thick', 'chrome'}, + reduceTransparency: false, +); + +void main() { + test('an iPhone with the clear glass effect draws the system glass', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: true), + reduceTransparency: false, + ), + isTrue, + ); + }); + + test('an older iPhone keeps the shader, not the blur material', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: false), + reduceTransparency: false, + ), + isFalse, + ); + }); + + test('Reduce Transparency and other platforms never host glass', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: true), + reduceTransparency: true, + ), + isFalse, + ); + expect( + mediaGlassGoesNative( + platform: TargetPlatform.android, + capability: _phone(systemGlass: true), + reduceTransparency: false, + ), + isFalse, + ); + }); + + test('a glass effect without the clear style keeps the shader', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _phone(systemGlass: true, styles: const {'regular'}), + reduceTransparency: false, + ), + isFalse, + ); + }); + + testWidgets('on an iPhone with system glass each piece is the hosted glass', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: NativeGlassScope( + capability: _phone(systemGlass: true), + child: const Center( + child: MediaGlassLayer( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + MediaGlass(child: SizedBox(width: 38, height: 38)), + MediaGlass(radius: 10, child: SizedBox(width: 80, height: 44)), + ], + ), + ), + ), + ), + ), + ); + expect(find.byType(NativeGlass), findsNWidgets(2)); + expect(find.byType(GlassGroup), findsNothing); + expect(find.byType(GlassShape), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/core/glass/native_glass_test.dart b/test/core/glass/native_glass_test.dart new file mode 100644 index 00000000..2147da40 --- /dev/null +++ b/test/core/glass/native_glass_test.dart @@ -0,0 +1,303 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/glass/native_glass.dart'; + +/// The phone's own glass, hosted as a UIKit view +/// (lib/core/glass/native_glass.dart). +/// +/// What a hosted view actually *looks* like cannot be tested here at all: a +/// `UiKitView` renders nothing in the test renderer and nothing into a +/// golden. So what is tested is everything up to the pixels — the decision +/// between the system's glass, the system's blur material and the caller's +/// own fallback, and the reading of what the phone said — which is exactly +/// why that decision was pulled out of the widget as a function +/// ([nativeGlassDraw]). The pixels are checked on a device by +/// integration_test/native_glass_test.dart. +void main() { + const glass27 = NativeGlassCapability( + systemVersion: '27.0.1', + majorVersion: 27, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin', 'regular'}, + reduceTransparency: false, + ); + const blurOnly = NativeGlassCapability( + systemVersion: '18.5', + majorVersion: 18, + systemGlass: false, + styles: {}, + materials: {'ultraThin', 'thin', 'regular', 'thick', 'chrome'}, + reduceTransparency: false, + ); + + group('what to draw', () { + test('a phone with the glass effect gets the glass effect', () { + expect( + nativeGlassDraw( + platform: TargetPlatform.iOS, + capability: glass27, + style: NativeGlassStyle.clear, + ), + NativeGlassDraw.systemGlass, + ); + }); + + test('an iPhone whose OS has no glass effect gets a real material', () { + // Not the hand-drawn blur: UIKit's own thin material, which is a + // material the platform maintains rather than one we approximate. + expect( + nativeGlassDraw(platform: TargetPlatform.iOS, capability: blurOnly), + NativeGlassDraw.systemMaterial, + ); + }); + + test('a style the phone did not offer falls to the material', () { + // The decision is by capability and not by version: a phone that + // reports the glass effect but not the style being asked for must + // degrade, because mounting a view for a style it has not got is the + // blank rectangle this probe exists to prevent. + const partial = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular'}, + materials: {'thin'}, + reduceTransparency: false, + ); + expect( + nativeGlassDraw( + platform: TargetPlatform.iOS, + capability: partial, + style: NativeGlassStyle.clear, + ), + NativeGlassDraw.systemMaterial, + ); + }); + + test('nothing is hosted where the phone said nothing', () { + expect( + nativeGlassDraw( + platform: TargetPlatform.iOS, + capability: NativeGlassCapability.absent, + ), + NativeGlassDraw.none, + ); + }); + + test('nothing is hosted on an Android phone, whatever it said', () { + // The factory is registered in ios/Runner only, so the platform is + // checked before the capability rather than trusting a stale probe + // carried across a hot restart. + expect( + nativeGlassDraw(platform: TargetPlatform.android, capability: glass27), + NativeGlassDraw.none, + ); + }); + + test('Reduce Transparency hosts nothing: the fill is painted', () { + expect( + nativeGlassDraw( + platform: TargetPlatform.iOS, + capability: glass27, + reduceTransparency: true, + ), + NativeGlassDraw.none, + ); + }); + }); + + group('what the phone said', () { + test('no answer at all is no glass, not an error', () { + expect(NativeGlassCapability.fromChannel(null), NativeGlassCapability.absent); + }); + + test('a map missing keys degrades rather than throwing', () { + // The Dart and the native halves are not always the same age on a + // phone mid-update, so an answer from an older Swift side has to be + // readable. + final said = NativeGlassCapability.fromChannel({ + 'systemVersion': '27.0.1', + }); + expect(said.systemVersion, '27.0.1'); + expect(said.systemGlass, isFalse); + expect(said.styles, isEmpty); + expect(said.materials, isEmpty); + }); + + test('two readings of the same phone are the same answer', () { + // Equality is by what was said, not by which object said it: + // `NativeGlassScope` rebuilds every surface under it when the + // capability it carries changes, so an answer that merely arrived + // twice must not count as a change. + final said = NativeGlassCapability.fromChannel({ + 'systemVersion': '27.0', + 'majorVersion': 27, + 'systemGlass': true, + 'glassStyles': ['clear', 'regular'], + 'materials': ['thin'], + }); + final again = NativeGlassCapability.fromChannel({ + 'systemVersion': '27.0', + 'majorVersion': 27, + 'systemGlass': true, + // The same set, in the order the channel happened to hand it over. + 'glassStyles': ['regular', 'clear'], + 'materials': ['thin'], + }); + expect(said, again); + expect(said.hashCode, again.hashCode); + expect(said, isNot(NativeGlassCapability.absent)); + expect( + NativeGlassCapability.fromChannel({}), + NativeGlassCapability.absent, + ); + }); + + test('the lists are read as names, and non-names are dropped', () { + final said = NativeGlassCapability.fromChannel({ + 'systemGlass': true, + 'majorVersion': 27, + 'glassStyles': ['regular', 7, null, 'clear'], + 'materials': ['thin'], + }); + expect(said.styles, {'regular', 'clear'}); + expect(said.materials, {'thin'}); + expect(said.majorVersion, 27); + }); + }); + + group('the widget', () { + /// A hosted view asks the engine to make one, which no test engine does. + /// The channel is answered so that mounting the view is not itself the + /// thing that fails. + void answerPlatformViews(WidgetTester tester) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + (call) async => call.method == 'create' ? 1 : null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ), + ); + } + + /// The platform override has to be put back inside the test body: the + /// framework checks that no foundation debug variable is still set + /// before `addTearDown` would run. + Future onIPhone(Future Function() body) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + + Widget probe({ + required NativeGlassCapability capability, + bool reduceTransparency = false, + required ValueChanged onDraw, + }) => Directionality( + textDirection: TextDirection.ltr, + child: GlassSenseScope( + reduceTransparency: reduceTransparency, + child: NativeGlassScope( + capability: capability, + child: NativeGlass( + borderRadius: BorderRadius.circular(22), + onDraw: onDraw, + fallback: (_) => const SizedBox(key: Key('fallback')), + ), + ), + ), + ); + + testWidgets('a phone with no glass gets the caller\'s own glass', ( + tester, + ) async { + NativeGlassDraw? drawn; + await tester.pumpWidget( + probe( + capability: NativeGlassCapability.absent, + onDraw: (draw) => drawn = draw, + ), + ); + await tester.pump(); + expect(find.byKey(const Key('fallback')), findsOneWidget); + expect(drawn, NativeGlassDraw.none); + }); + + testWidgets('an iPhone with the glass effect hosts the view', ( + tester, + ) async { + answerPlatformViews(tester); + await onIPhone(() async { + NativeGlassDraw? drawn; + await tester.pumpWidget( + probe(capability: glass27, onDraw: (draw) => drawn = draw), + ); + await tester.pump(); + expect(find.byType(UiKitView), findsOneWidget); + expect(find.byKey(const Key('fallback')), findsNothing); + expect(drawn, NativeGlassDraw.systemGlass); + }); + }); + + testWidgets('Reduce Transparency takes the hosted view away', ( + tester, + ) async { + answerPlatformViews(tester); + await onIPhone(() async { + NativeGlassDraw? drawn; + await tester.pumpWidget( + probe( + capability: glass27, + reduceTransparency: true, + onDraw: (draw) => drawn = draw, + ), + ); + await tester.pump(); + expect(find.byType(UiKitView), findsNothing); + expect(find.byKey(const Key('fallback')), findsOneWidget); + expect(drawn, NativeGlassDraw.none); + }); + }); + + testWidgets('the largest corner crosses the channel, not a zero', ( + tester, + ) async { + answerPlatformViews(tester); + await onIPhone(() async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: NativeGlassScope( + capability: glass27, + child: const NativeGlass( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + tint: Color(0x66101010), + fallback: _nothing, + ), + ), + ), + ); + await tester.pump(); + final view = tester.widget(find.byType(UiKitView)); + final params = view.creationParams as Map; + expect(params['cornerRadius'], 28.0); + expect(params['style'], 'regular'); + expect(params['material'], 'thin'); + expect(params['tint'], 0x66101010); + expect(params['interactive'], isFalse); + }); + }); + }); +} + +Widget _nothing(BuildContext context) => const SizedBox.shrink(); diff --git a/test/core/glass/reduced_effects_test.dart b/test/core/glass/reduced_effects_test.dart new file mode 100644 index 00000000..9b51d2bc --- /dev/null +++ b/test/core/glass/reduced_effects_test.dart @@ -0,0 +1,75 @@ +// The always-on blurs (nav bars, scroll headers) give way to a plain fill on +// phones that pay for every blurred frame. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_power.dart'; +import 'package:soplay/core/glass/reducible_blur.dart'; + +const _gb = 1024 * 1024 * 1024; + +void main() { + group('the decision', () { + test('an unanswered reading keeps the blur', () { + expect(GlassPowerReading.ample.reducesEffects, isFalse); + }); + + test('a capable phone keeps it', () { + const r = GlassPowerReading( + memoryBytes: 8 * _gb, + sdk: 34, + glEs: 3, + refreshHz: 120, + ); + expect(r.reducesEffects, isFalse); + }); + + test('no Vulkan alone loses the refraction but not the blur', () { + const r = GlassPowerReading(memoryBytes: 6 * _gb, vulkan11: false); + expect(r.affordsRefraction, isFalse); + expect(r.reducesEffects, isFalse); + }); + + test('Go flag, small RAM, GL ES 2 or Battery Saver reduce', () { + expect(const GlassPowerReading(lowRam: true).reducesEffects, isTrue); + expect( + const GlassPowerReading(memoryBytes: 2 * _gb).reducesEffects, + isTrue, + ); + expect(const GlassPowerReading(glEs: 2).reducesEffects, isTrue); + expect(const GlassPowerReading(powerSave: true).reducesEffects, isTrue); + }); + }); + + group('ReducibleBlur', () { + Widget host(GlassPowerReading reading) => Directionality( + textDirection: TextDirection.ltr, + child: GlassPower.withReading( + reading, + child: const ReducibleBlur( + sigma: 20, + reducedFill: Color(0x80000000), + child: SizedBox(width: 10, height: 10), + ), + ), + ); + + testWidgets('blurs when effects are not reduced', (tester) async { + await tester.pumpWidget(host(GlassPowerReading.ample)); + expect(find.byType(BackdropFilter), findsOneWidget); + }); + + testWidgets('fills instead when they are', (tester) async { + await tester.pumpWidget(host(const GlassPowerReading(lowRam: true))); + expect(find.byType(BackdropFilter), findsNothing); + final fill = tester.widget(find.byType(ColoredBox)); + expect(fill.color, const Color(0x80000000)); + }); + + testWidgets('flips when Battery Saver turns on', (tester) async { + await tester.pumpWidget(host(GlassPowerReading.ample)); + expect(find.byType(BackdropFilter), findsOneWidget); + await tester.pumpWidget(host(const GlassPowerReading(powerSave: true))); + expect(find.byType(BackdropFilter), findsNothing); + }); + }); +} diff --git a/test/core/js/js_value_test.dart b/test/core/js/js_value_test.dart new file mode 100644 index 00000000..b8ab287e --- /dev/null +++ b/test/core/js/js_value_test.dart @@ -0,0 +1,95 @@ +// KissKH's device extractor answers as they reach the app on iPhone. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/js/js_value.dart'; +import 'package:soplay/features/detail/data/models/detail_model.dart'; +import 'package:soplay/features/detail/data/models/media_resolve_model.dart'; +import 'package:soplay/features/detail/data/models/playback_model.dart'; +import 'package:soplay/features/home/data/models/home_data_model.dart'; +import 'package:soplay/features/home/data/models/view_all_paging_model.dart'; +import 'package:soplay/features/search/data/model/search_model.dart'; + +void main() { + final payloads = + jsonDecode( + File('test/fixtures/kisskh_extractor_v7.json').readAsStringSync(), + ) + as Map; + + // WebKit's result crosses the platform channel through this codec, which + // turns every JS object into a Map. + Object? overChannel(String key) => const StandardMessageCodec().decodeMessage( + const StandardMessageCodec().encodeMessage(payloads[key]), + ); + + test('the channel really does hand over Map', () { + final home = overChannel('home') as Map; + expect(home['sections'], isA()); + expect( + (home['sections'] as List).first, + isNot(isA>()), + ); + // A shallow cast used to throw on the first card; the model now converts + // nested maps itself. + expect( + HomeDataModel.fromJson(home.cast()).sections, + hasLength(1), + ); + }); + + test('home, view-all and search parse', () { + final home = HomeDataModel.fromJson(jsObjectFrom(overChannel('home'))!); + expect(home.sections.single.label, 'Latest'); + expect(home.sections.single.items.first.title, isNotEmpty); + expect(home.banner, hasLength(1)); + + final page = ViewAllPagingModel.fromJson( + jsObjectFrom(overChannel('category'))!, + ); + expect(page.items, hasLength(2)); + expect(page.totalPages, 1384); + + final search = SearchModel.fromJson(jsObjectFrom(overChannel('search'))!); + expect(search.items.first.title, 'Revenged Love'); + }); + + test('detail and episodes parse', () { + final detail = DetailModel.fromJson(jsObjectFrom(overChannel('detail'))!); + expect(detail.title, 'Revenged Love'); + expect(detail.genres, ['TVSeries', 'Completed', 'China']); + expect(detail.related.single.title, 'The First Frost'); + + final eps = PlaybackModel.fromJson(jsObjectFrom(overChannel('episodes'))!); + expect(eps.episodes.map((e) => e.mediaRef), [ + 'kisskh:10826:185459', + 'kisskh:10826:185460', + ]); + }); + + test('media keeps its sources and its ciphered subtitles', () { + final media = MediaResolveModel.fromJson( + jsObjectFrom(overChannel('media'))!, + ); + expect(media.videoSources.single.type, 'hls'); + expect(media.headers['Referer'], 'https://kisskh.co/'); + expect(media.subtitles.map((s) => s.label), ['English', 'Indonesia']); + final cipher = media.subtitles.first.cipher!; + expect(cipher.key, 'AmSmZVcH93UQUezi'); + expect(cipher.iv, 'ReBKWW8cqdjPEnF6'); + expect(cipher.isSupported, isTrue); + }); + + test('Android JSON text and odd values', () { + expect( + jsObjectFrom(jsonEncode(payloads['detail']))!['title'], + 'Revenged Love', + ); + expect(jsObjectFrom(null), isNull); + expect(jsObjectFrom('null'), isNull); + expect(jsObjectFrom('not json'), isNull); + expect(jsObjectFrom(const [1, 2]), isNull); + }); +} diff --git a/test/core/js/zangetsu_payloads_test.dart b/test/core/js/zangetsu_payloads_test.dart new file mode 100644 index 00000000..261d70cb --- /dev/null +++ b/test/core/js/zangetsu_payloads_test.dart @@ -0,0 +1,194 @@ +// Zangetsu payloads: an 18+ device source's live answers with the titles +// replaced, and the server wrapper's metadata passthrough. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/js/js_value.dart'; +import 'package:soplay/features/detail/data/models/detail_model.dart'; +import 'package:soplay/features/detail/data/models/media_resolve_model.dart'; +import 'package:soplay/features/detail/data/models/playback_model.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; +import 'package:soplay/features/home/data/models/home_data_model.dart'; +import 'package:soplay/features/home/data/models/view_all_paging_model.dart'; +import 'package:soplay/features/my_list/data/models/favorite_model.dart'; +import 'package:soplay/features/search/data/model/search_model.dart'; + +void main() { + final payloads = + jsonDecode( + File('test/fixtures/zangetsu_payloads.json').readAsStringSync(), + ) + as Map; + + // What WebKit hands the channel: every object a Map and + // every number a double. + Object? doubles(Object? v) => switch (v) { + final int n => n.toDouble(), + final Map m => {for (final e in m.entries) e.key: doubles(e.value)}, + final List l => l.map(doubles).toList(), + _ => v, + }; + Object? overChannel(String key) => const StandardMessageCodec().decodeMessage( + const StandardMessageCodec().encodeMessage(doubles(payloads[key])), + ); + + final arrivals = Function(String)>{ + 'JSON text': (key) => jsObjectFrom(jsonEncode(payloads[key]))!, + 'WebKit objects': (key) => jsObjectFrom(overChannel(key))!, + }; + + for (final MapEntry(key: how, value: read) in arrivals.entries) { + group('device source, $how', () { + test('home, view-all, search and an empty genre page', () { + final home = HomeDataModel.fromJson(read('home')); + expect(home.banner, hasLength(2)); + expect(home.sections.first.viewAll.slug, 'row-0'); + expect(home.sections.first.items.first.title, 'Title 1'); + + final page = ViewAllPagingModel.fromJson(read('category')); + expect(page.page, 1); + expect(page.totalPages, 1); + expect(page.items, hasLength(2)); + + expect(SearchModel.fromJson(read('search')).items, hasLength(2)); + expect(ViewAllPagingModel.fromJson(read('genre')).items, isEmpty); + }); + + test('detail and episodes', () { + final detail = DetailModel.fromJson(read('detail')); + expect(detail.genres, ['Genre A', 'Genre B']); + expect(detail.related, hasLength(2)); + expect(detail.cast, isEmpty); + + final eps = PlaybackModel.fromJson(read('episodes')); + expect(eps.episodes.single.episode, 1); + expect(eps.episodes.single.mediaRef, startsWith('sample:ep:')); + }); + + test('media keeps every source and its headers', () { + final media = MediaResolveModel.fromJson(read('media')); + expect(media.videoSources, hasLength(2)); + expect( + media.videoSources.first.headers['Origin'], + 'https://site.invalid', + ); + expect(media.headers['User-Agent'], 'UA'); + }); + }); + } + + test('WebKit numbers come back as ints', () { + final page = jsObjectFrom(overChannel('category'))!; + expect(page['page'], isA()); + expect(jsObjectFrom({'r': 7.5})!['r'], 7.5); + }); + + group('server wrapper passthrough', () { + Map overDio(String key) => + jsonDecode(jsonEncode(payloads[key])) as Map; + + test('screenshots as bare urls and cast with images', () { + final detail = DetailModel.fromJson(overDio('server_detail')); + expect(detail.screenshots.map((s) => s.full), [ + 'https://img.invalid/9/1.jpg', + 'https://img.invalid/9/2.jpg', + ]); + expect(detail.screenshots.first.thumb, detail.screenshots.first.full); + expect(detail.cast.map((c) => c.name), [ + 'Performer One', + 'Performer Two', + ]); + expect(detail.cast.first.image, 'https://img.invalid/p1.jpg'); + expect(detail.cast.last.image, ''); + expect(detail.duration, '12 min'); + expect(detail.year, 2024); + }); + + test('odd cast, screenshot and count values are tolerated', () { + final detail = DetailModel.fromJson({ + 'cast': [ + 'A Name', + 42, + null, + {'id': 7, 'name': 'B', 'avatar': 'x'}, + ], + 'screenshots': [ + {'full': 'f', 'thumb': 't'}, + '', + 3, + ], + 'genres': [ + {'name': 'G'}, + 'H', + ], + 'duration': 840, + 'likes': '12', + 'year': '2020', + 'isSerial': 1, + }); + expect(detail.cast.map((c) => c.name), ['A Name', '42', 'B']); + expect(detail.cast.last.id, '7'); + expect(detail.cast.last.image, 'x'); + expect(detail.screenshots.single.thumb, 't'); + expect(detail.genres, ['G', 'H']); + expect(detail.duration, isNull); + expect(detail.likes, 12); + expect(detail.year, 2020); + expect(detail.isSerial, isTrue); + }); + + test('actor page', () { + final page = ViewAllPagingModel.fromJson(overDio('server_actor')); + expect(page.totalPages, 2); + expect(page.items.single.provider, 'sample-server'); + }); + }); + + group('stored and read back', () { + test('a Hive map reads as Map', () { + final stored = Map.from( + overChannel('server_detail')! as Map, + ); + final detail = DetailModel.fromJson(jsonMap(stored)!); + expect(detail.screenshots, hasLength(2)); + expect(jsonMap('not a map'), isNull); + expect(jsonMaps([stored, 'x', null]), hasLength(1)); + }); + + test('favourite and history of a device title', () { + final detail = DetailModel.fromJson(jsObjectFrom(overChannel('detail'))!); + final fav = FavoriteModel( + provider: detail.provider, + contentUrl: detail.contentUrl, + title: detail.title, + thumbnail: detail.thumbnail ?? '', + description: detail.description, + ); + final favBack = FavoriteModel.fromJson( + jsonDecode(jsonEncode(fav.toJson())) as Map, + ); + expect(favBack.contentUrl, detail.contentUrl); + + final item = HistoryItem( + contentUrl: detail.contentUrl, + provider: detail.provider, + title: detail.title, + isSerial: detail.isSerial, + episodeIndex: 0, + episodeNumber: 1, + positionMs: 1000, + durationMs: 60000, + watchedAt: 1, + ); + final back = HistoryItem.fromJson(jsonMap(doubles(item.toJson()))!); + expect(back.episodeNumber, 1); + expect(back.positionMs, 1000); + final asDoubles = HistoryItem.fromJson( + (doubles(item.toJson())! as Map).cast(), + ); + expect(asDoubles.durationMs, 60000); + }); + }); +} diff --git a/test/core/line_height_test.dart b/test/core/line_height_test.dart new file mode 100644 index 00000000..93147272 --- /dev/null +++ b/test/core/line_height_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/localization/line_height.dart'; + +void main() { + Future heightIn(WidgetTester tester, Locale locale, double h) async { + late double out; + await tester.pumpWidget( + Localizations( + locale: locale, + delegates: const [ + DefaultWidgetsLocalizations.delegate, + DefaultMaterialLocalizations.delegate, + ], + child: Builder( + builder: (context) { + out = lineHeightFor(context, h); + return const SizedBox(); + }, + ), + ), + ); + return out; + } + + testWidgets('Vietnamese gets room for its stacked marks', (tester) async { + expect(await heightIn(tester, const Locale('vi'), 1.08), 1.25); + }); + + testWidgets('Hindi gets room for marks above and below the line', ( + tester, + ) async { + expect(await heightIn(tester, const Locale('hi'), 1.08), 1.25); + }); + + testWidgets('a roomier design is left alone', (tester) async { + expect(await heightIn(tester, const Locale('vi'), 1.4), 1.4); + }); + + testWidgets('other languages keep the design', (tester) async { + expect(await heightIn(tester, const Locale('en'), 1.08), 1.08); + }); +} diff --git a/test/core/localization/borrowed_localizations_test.dart b/test/core/localization/borrowed_localizations_test.dart index cc373335..37885339 100644 --- a/test/core/localization/borrowed_localizations_test.dart +++ b/test/core/localization/borrowed_localizations_test.dart @@ -34,6 +34,30 @@ void main() { expect(kBorrowedLanguages.toSet(), {'yue', 'ku', 'ckb'}); }); + testWidgets('Hindi needs no borrowing: Flutter ships its strings and dates', ( + tester, + ) async { + expect(kBorrowedLanguages, isNot(contains('hi'))); + late BuildContext ctx; + await tester.pumpWidget( + _app( + const Locale('hi'), + builder: (c) { + ctx = c; + return const SizedBox(); + }, + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(MaterialLocalizations.of(ctx).cancelButtonLabel, 'रद्द करें'); + expect(Directionality.of(ctx), TextDirection.ltr); + expect( + DateFormat.yMMMd('hi').format(DateTime(2026, 3, 1)), + contains('2026'), + ); + }); + for (final (code, cancel, direction) in const [ // Hong Kong reads Traditional characters, so the neighbour Cantonese // borrows from has to be zh-Hant and not zh-Hans. diff --git a/test/core/native/bar_harness.dart b/test/core/native/bar_harness.dart new file mode 100644 index 00000000..b9a23822 --- /dev/null +++ b/test/core/native/bar_harness.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/native_link.dart'; + +import 'fake_native_host.dart'; + +const NativeGlassCapability ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +/// An iPhone on iOS 26 with the bar factory, unless told otherwise. +Widget barHarness( + Widget child, { + required FakeNativeHost host, + NativeGlassCapability capability = ios26, + bool reduceTransparency = false, + bool disableAnimations = false, + TextDirection dir = TextDirection.ltr, +}) => MediaQuery( + data: MediaQueryData( + size: const Size(800, 600), + disableAnimations: disableAnimations, + ), + child: Directionality( + textDirection: dir, + child: NativeGlassScope( + capability: capability, + child: GlassSenseScope( + reduceTransparency: reduceTransparency, + child: NativeHostScope(host: host, child: child), + ), + ), + ), +); + +/// [body] on an iPhone (not the desktop the tests run on), the overrides +/// reset before the test ends as flutter_test requires. +void testOnIPhone( + String description, + Future Function(WidgetTester tester) body, { + bool desktop = false, + TargetPlatform platform = TargetPlatform.iOS, +}) { + testWidgets(description, (tester) async { + debugDefaultTargetPlatformOverride = platform; + debugNativeControlsDesktopOverride = desktop; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugNativeControlsDesktopOverride = null; + } + }); +} diff --git a/test/core/native/fake_native_host.dart b/test/core/native/fake_native_host.dart new file mode 100644 index 00000000..55866464 --- /dev/null +++ b/test/core/native/fake_native_host.dart @@ -0,0 +1,100 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:soplay/core/native/native_link.dart'; + +/// One thing said to a fake view. +typedef Said = ({String method, Object? arguments}); + +class FakeLink implements NativeLink { + final List said = []; + bool disposed = false; + + @override + void say(String method, [Object? arguments]) { + if (!disposed) said.add((method: method, arguments: arguments)); + } + + @override + Future ask(String method, [Object? arguments]) async { + say(method, arguments); + return null; + } + + @override + void dispose() => disposed = true; + + Iterable of(String method) => said.where((s) => s.method == method); +} + +class FakeView { + FakeView(this.type, this.params, this.onHeard); + + final String type; + final Map params; + final NativeHeard onHeard; + final FakeLink link = FakeLink(); + + /// UIKit saying [method]. + void hear(String method, [Object? arguments]) => onHeard(method, arguments); +} + +/// Draws a plain box that takes taps, and hands its control a [FakeLink] on +/// the frame after it is built, as a platform view's creation does. +class FakeNativeHost implements NativeHost { + final List views = []; + + /// Taps that landed on a hosted view rather than falling through. + int taps = 0; + + @override + Widget view({ + required String type, + required Map params, + required Set> gestures, + required void Function(NativeLink) onLink, + required NativeHeard onHeard, + }) => _FakeBox( + make: () { + final v = FakeView(type, params, onHeard); + views.add(v); + return v; + }, + onLink: onLink, + onTap: () => taps++, + ); +} + +class _FakeBox extends StatefulWidget { + const _FakeBox({ + required this.make, + required this.onLink, + required this.onTap, + }); + + final FakeView Function() make; + final void Function(NativeLink) onLink; + final VoidCallback onTap; + + @override + State<_FakeBox> createState() => _FakeBoxState(); +} + +class _FakeBoxState extends State<_FakeBox> { + late final FakeView view = widget.make(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onLink(view.link); + }); + } + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTap, + child: const SizedBox.expand(), + ); +} diff --git a/test/core/native/native_bar_layout_test.dart b/test/core/native/native_bar_layout_test.dart new file mode 100644 index 00000000..823799a3 --- /dev/null +++ b/test/core/native/native_bar_layout_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_bar_layout.dart'; + +void main() { + const icon = BarSlotSpec(); + + test('LTR lays slots left to right inside the capsule padding', () { + final l = layoutBar( + slots: const [icon, icon, icon], + dir: TextDirection.ltr, + maxExtent: 400, + )!; + expect(l.slots.map((r) => r.left), [6, 54, 102]); + expect(l.size, const Size(6 + 44 * 3 + 4 * 2 + 6, 44)); + expect(l.capsules.single, Offset.zero & l.size); + }); + + test('RTL mirrors the order', () { + final l = layoutBar( + slots: const [icon, icon, icon], + dir: TextDirection.rtl, + maxExtent: 400, + )!; + expect(l.slots.first.right, l.size.width - 6); + expect(l.slots.first.left, greaterThan(l.slots.last.left)); + }); + + test('an even cluster spaces its slots as spaceEvenly does', () { + final l = layoutBar( + slots: const [ + BarSlotSpec(extent: 52), + BarSlotSpec(extent: 72), + BarSlotSpec(extent: 52), + ], + dir: TextDirection.ltr, + maxExtent: 264, + capsules: false, + spread: BarSpread.even, + )!; + const space = (264 - 176) / 4; + expect(l.slots[0].left, space); + expect(l.slots[1].left, space * 2 + 52); + expect(l.slots[1].center.dy, l.slots[0].center.dy); + expect(l.size, const Size(264, 72)); + expect(l.capsules, isEmpty); + }); + + test('a label is measured, and grows with the text scale', () { + double width(double scale) => layoutBar( + slots: const [BarSlotSpec(label: 'Server 1080p')], + dir: TextDirection.ltr, + maxExtent: 999, + textScaler: TextScaler.linear(scale), + )!.slots.single.width; + expect(width(1), greaterThan(44)); + expect(width(1.5), greaterThan(width(1))); + }); + + test('a bar that does not fit tightens first, then gives up', () { + final slots = List.filled(8, icon); + final loose = 6 + 44 * 8 + 4 * 7 + 6; + final tight = 6 + 40 * 8 + 6; + expect( + layoutBar( + slots: slots, + dir: TextDirection.ltr, + maxExtent: loose + 0.0, + )!.slots.first.width, + 44, + ); + final squeezed = layoutBar( + slots: slots, + dir: TextDirection.ltr, + maxExtent: tight + 0.0, + )!; + expect(squeezed.slots.first.width, 40); + expect(squeezed.size.width, tight); + expect( + layoutBar(slots: slots, dir: TextDirection.ltr, maxExtent: tight - 1.0), + isNull, + ); + }); + + test('a vertical bar stacks its slots', () { + final l = layoutBar( + slots: const [icon, icon], + dir: TextDirection.rtl, + axis: Axis.vertical, + maxExtent: 400, + )!; + expect(l.slots.map((r) => r.top), [6, 54]); + expect(l.slots.every((r) => r.left == 0), isTrue); + expect(l.size, const Size(44, 6 + 44 * 2 + 4 + 6)); + }); + + test('two groups are two capsules with the group gap between', () { + final l = layoutBar( + slots: const [icon, BarSlotSpec(group: 1), BarSlotSpec(group: 1)], + dir: TextDirection.ltr, + maxExtent: 999, + )!; + expect(l.capsules, hasLength(2)); + expect(l.capsules[1].left - l.capsules[0].right, 16); + expect(l.capsules[0].contains(l.slots[0].center), isTrue); + expect(l.capsules[1].contains(l.slots[2].center), isTrue); + + final rtl = layoutBar( + slots: const [icon, BarSlotSpec(group: 1)], + dir: TextDirection.rtl, + maxExtent: 999, + )!; + expect(rtl.capsules[0].left, greaterThan(rtl.capsules[1].left)); + }); +} diff --git a/test/core/native/native_controls_gate_test.dart b/test/core/native/native_controls_gate_test.dart new file mode 100644 index 00000000..ba05b4f4 --- /dev/null +++ b/test/core/native/native_controls_gate_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; + +NativeGlassCapability _cap({ + bool systemGlass = true, + Set styles = const {'regular', 'clear'}, + bool glassControls = true, +}) => NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: systemGlass, + styles: styles, + materials: const {'thin'}, + reduceTransparency: false, + glassControls: glassControls, +); + +bool _draw({ + bool isIOS = true, + bool isTv = false, + bool isDesktop = false, + NativeGlassCapability? cap, + bool rt = false, +}) => nativeControlsDraw( + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap ?? _cap(), + reduceTransparency: rt, +); + +void main() { + test('an iOS 26 iPhone with clear glass and the bar factory hosts', () { + expect(_draw(), isTrue); + }); + + test('Reduce Transparency keeps Flutter', () { + expect(_draw(rt: true), isFalse); + }); + + test('no glassControls keeps Flutter', () { + expect(_draw(cap: _cap(glassControls: false)), isFalse); + }); + + test('regular-only glass keeps Flutter', () { + expect(_draw(cap: _cap(styles: const {'regular'})), isFalse); + expect(_draw(cap: _cap(systemGlass: false)), isFalse); + }); + + test('Android, TV and desktop never host', () { + expect(_draw(isIOS: false), isFalse); + expect(_draw(isTv: true), isFalse); + expect(_draw(isDesktop: true), isFalse); + }); + + test('absent capability keeps Flutter', () { + expect(_draw(cap: NativeGlassCapability.absent), isFalse); + }); +} diff --git a/test/core/native/native_glass_bar_test.dart b/test/core/native/native_glass_bar_test.dart new file mode 100644 index 00000000..72606f48 --- /dev/null +++ b/test/core/native/native_glass_bar_test.dart @@ -0,0 +1,280 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_bar_layout.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; +import 'package:soplay/core/system/platform_utils.dart'; + +import 'bar_harness.dart'; +import 'fake_native_host.dart'; + +class _Calls { + int play = 0; + int hold = 0; + int pageTaps = 0; +} + +/// A transport cluster centred on a page that toggles on a tap. +Widget _page( + _Calls calls, { + bool shown = true, + bool enabled = true, + bool active = false, + double width = 280, +}) => Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => calls.pageTaps++, + ), + ), + Center( + child: SizedBox( + width: width, + child: NativeGlassBar( + look: NativeBarLook.cluster, + spread: BarSpread.even, + shown: shown, + fallback: (_) => const Text('fallback'), + items: [ + const NativeBarItem( + id: 'rewind', + symbol: 'gobackward.10', + semanticLabel: 'Rewind', + extent: 52, + ), + NativeBarItem( + id: 'play', + symbol: 'play.fill', + semanticLabel: 'Play', + extent: 72, + prominent: true, + enabled: enabled, + active: active, + onPressed: () => calls.play++, + onLongPress: () => calls.hold++, + ), + const NativeBarItem( + id: 'forward', + symbol: 'goforward.10', + semanticLabel: 'Forward', + extent: 52, + ), + ], + ), + ), + ), + ], +); + +void main() { + testOnIPhone('one hosted view for the whole cluster', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, hasLength(1)); + final items = host.views.single.params['items']! as List; + expect( + [for (final i in items) (i as Map)['symbol']], + ['gobackward.10', 'play.fill', 'goforward.10'], + ); + expect(find.text('fallback'), findsNothing); + }); + + testOnIPhone('each slot says its label, enabled and selected state', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final calls = _Calls(); + await tester.pumpWidget( + barHarness(_page(calls, active: true), host: FakeNativeHost()), + ); + final play = tester.getSemantics(find.bySemanticsLabel('Play')); + expect( + play, + matchesSemantics( + label: 'Play', + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasSelectedState: true, + isSelected: true, + hasTapAction: true, + hasLongPressAction: true, + ), + ); + tester.semantics.tap(find.semantics.byLabel('Play')); + tester.semantics.longPress(find.semantics.byLabel('Play')); + expect(calls.play, 1); + expect(calls.hold, 1); + + await tester.pumpWidget( + barHarness(_page(calls, enabled: false), host: FakeNativeHost()), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Play')), + matchesSemantics( + label: 'Play', + isButton: true, + hasEnabledState: true, + hasSelectedState: true, + ), + ); + semantics.dispose(); + }); + + testOnIPhone('a tap in a gap is the page, a tap on a slot is UIKit', ( + tester, + ) async { + final host = FakeNativeHost(); + final calls = _Calls(); + await tester.pumpWidget(barHarness(_page(calls), host: host)); + await tester.pump(); + final bar = tester.getRect(find.byType(NativeGlassBar)); + + // Between rewind and play. + final rewind = (host.views.single.params['items']! as List)[0] as Map; + final rect = rewind['rect']! as List; + final gap = + bar.topLeft + Offset((rect[0] as double) + 52 + 4, bar.height / 2); + await tester.tapAt(gap); + await tester.pump(const Duration(milliseconds: 300)); + expect(calls.pageTaps, 1); + expect(host.taps, 0); + + await tester.tapAt(bar.center); + await tester.pump(const Duration(milliseconds: 300)); + expect(calls.pageTaps, 1, reason: 'the play slot took it'); + expect(host.taps, 1); + + host.views.single.hear('press', 'play'); + expect(calls.play, 1); + }); + + testOnIPhone('hiding fades in UIKit, then leaves the frame', (tester) async { + final host = FakeNativeHost(); + final calls = _Calls(); + await tester.pumpWidget(barHarness(_page(calls), host: host)); + await tester.pump(); + final link = host.views.single.link; + + await tester.pumpWidget(barHarness(_page(calls, shown: false), host: host)); + await tester.pump(); + expect(link.of('setShown').single.arguments, {'shown': false, 'ms': 220}); + expect( + tester.widget(find.byType(Offstage)).offstage, + isFalse, + reason: 'still fading', + ); + await tester.pump(NativeGlassBar.fade); + expect(tester.widget(find.byType(Offstage)).offstage, isTrue); + expect(host.views, hasLength(1), reason: 'kept alive, not remade'); + + // Hidden, a tap is the page's. + await tester.tapAt(tester.getCenter(find.byType(NativeGlassBar))); + await tester.pump(const Duration(milliseconds: 300)); + expect(calls.pageTaps, 1); + + await tester.pumpWidget(barHarness(_page(calls), host: host)); + await tester.pump(); + expect(tester.widget(find.byType(Offstage)).offstage, isFalse); + expect(link.of('setShown').last.arguments, {'shown': true, 'ms': 220}); + }); + + testOnIPhone('no Flutter opacity stands over a native bar', (tester) async { + await tester.pumpWidget( + barHarness(_page(_Calls()), host: FakeNativeHost()), + ); + expect( + find.ancestor( + of: find.byType(NativeGlassBar), + matching: find.byType(FadeTransition), + ), + findsNothing, + ); + }); + + testOnIPhone('Reduce Motion hides at once', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness(_page(_Calls()), host: host, disableAnimations: true), + ); + await tester.pump(); + await tester.pumpWidget( + barHarness( + _page(_Calls(), shown: false), + host: host, + disableAnimations: true, + ), + ); + await tester.pump(); + expect(host.views.single.link.of('setShown').single.arguments, { + 'shown': false, + 'ms': 0, + }); + }); + + group('the Flutter fallback', () { + testOnIPhone('without glassControls', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness( + _page(_Calls()), + host: host, + capability: NativeGlassCapability.absent, + ), + ); + expect(host.views, isEmpty); + expect(find.text('fallback'), findsOneWidget); + }); + + testOnIPhone('under Reduce Transparency', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness(_page(_Calls()), host: host, reduceTransparency: true), + ); + expect(host.views, isEmpty); + expect(find.text('fallback'), findsOneWidget); + }); + + testOnIPhone('on a desktop', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, isEmpty); + }, desktop: true); + + testOnIPhone('on Android', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, isEmpty); + }, platform: TargetPlatform.android); + + testOnIPhone('on a television', (tester) async { + debugSetTvPlatform(true); + try { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_page(_Calls()), host: host)); + expect(host.views, isEmpty); + } finally { + debugSetTvPlatform(false); + } + }); + + testOnIPhone('for the one bar that does not fit', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget( + barHarness( + Column( + children: [ + SizedBox(width: 120, child: _page(_Calls(), width: 120)), + SizedBox(width: 400, height: 100, child: _page(_Calls())), + ], + ), + host: host, + ), + ); + expect(find.text('fallback'), findsOneWidget); + expect(host.views, hasLength(1)); + }); + }); +} diff --git a/test/core/native/native_glass_capability_test.dart b/test/core/native/native_glass_capability_test.dart new file mode 100644 index 00000000..4ad9ae7d --- /dev/null +++ b/test/core/native/native_glass_capability_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/native_glass.dart'; + +void main() { + test('an older Swift side that sends no glassControls reads as false', () { + final cap = NativeGlassCapability.fromChannel(const { + 'systemVersion': '26.0', + 'majorVersion': 26, + 'systemGlass': true, + 'glassStyles': ['regular', 'clear'], + 'materials': ['thin'], + 'reduceTransparency': false, + }); + expect(cap.glassControls, isFalse); + expect(NativeGlassCapability.absent.glassControls, isFalse); + }); + + test('glassControls is read and counts in equality', () { + Map said(bool controls) => { + 'systemGlass': true, + 'glassStyles': ['clear'], + 'glassControls': controls, + }; + final yes = NativeGlassCapability.fromChannel(said(true)); + expect(yes.glassControls, isTrue); + expect(yes == NativeGlassCapability.fromChannel(said(false)), isFalse); + expect(yes == NativeGlassCapability.fromChannel(said(true)), isTrue); + }); +} diff --git a/test/core/native/native_link_test.dart b/test/core/native/native_link_test.dart new file mode 100644 index 00000000..979648fe --- /dev/null +++ b/test/core/native/native_link_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; + +import 'bar_harness.dart'; +import 'fake_native_host.dart'; + +Widget _bar({bool active = false, VoidCallback? onPressed, String? title}) => + Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const Text('fallback'), + items: [ + NativeBarItem( + id: 'stats', + symbol: 'info.circle', + semanticLabel: 'Stats', + active: active, + title: title, + onPressed: onPressed, + ), + const NativeBarItem( + id: 'settings', + symbol: 'gearshape', + semanticLabel: 'Settings', + ), + ], + ), + ); + +void main() { + testOnIPhone('the view is made with everything and told nothing twice', ( + tester, + ) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + final view = host.views.single; + expect(view.type, NativeGlassBar.viewType); + expect((view.params['items']! as List), hasLength(2)); + expect(view.params['look'], 'capsule'); + // `made:` counts the creation params as told: linking sends nothing. + expect(view.link.said, isEmpty); + + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + expect(view.link.said, isEmpty, reason: 'same items, nothing to say'); + }); + + testOnIPhone('a changed field is sent alone, as setItem', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + final link = host.views.single.link; + + await tester.pumpWidget(barHarness(_bar(active: true), host: host)); + await tester.pump(); + expect(link.said, hasLength(1)); + expect(link.said.single.method, 'setItem'); + expect(link.said.single.arguments, {'id': 'stats', 'active': true}); + }); + + testOnIPhone('a slot that moves is sent as a whole setItems', (tester) async { + final host = FakeNativeHost(); + await tester.pumpWidget(barHarness(_bar(), host: host)); + await tester.pump(); + final link = host.views.single.link; + + await tester.pumpWidget(barHarness(_bar(title: 'Long words'), host: host)); + await tester.pump(); + expect(link.of('setItems'), hasLength(1)); + expect(link.of('setItem'), isEmpty); + }); + + testOnIPhone('UIKit is not heard once the bar has gone', (tester) async { + final host = FakeNativeHost(); + var pressed = 0; + await tester.pumpWidget( + barHarness(_bar(onPressed: () => pressed++), host: host), + ); + await tester.pump(); + final view = host.views.single; + view.hear('press', 'stats'); + expect(pressed, 1); + + await tester.pumpWidget(barHarness(const SizedBox(), host: host)); + view.hear('press', 'stats'); + expect(pressed, 1); + expect(view.link.disposed, isTrue); + }); +} diff --git a/test/core/native/native_menu_select_test.dart b/test/core/native/native_menu_select_test.dart new file mode 100644 index 00000000..ed39b42a --- /dev/null +++ b/test/core/native/native_menu_select_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/native_glass_bar.dart'; + +import 'bar_harness.dart'; +import 'fake_native_host.dart'; + +Widget _speed(List speeds, ValueChanged onMenu) => Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(), + items: [ + NativeBarItem( + id: 'speed', + symbol: 'gauge.with.dots.needle.67percent', + semanticLabel: 'Speed', + menu: [for (final s in speeds) NativeMenuRow(title: '${s}x', key: s)], + onMenuSelected: onMenu, + ), + ], + ), +); + +void main() { + testOnIPhone('a row is chosen by what the open menu showed', (tester) async { + final host = FakeNativeHost(); + final picked = []; + await tester.pumpWidget( + barHarness(_speed([1, 1.5, 2], picked.add), host: host), + ); + await tester.pump(); + final view = host.views.single; + expect((view.params['items']! as List).single['menu'], hasLength(3)); + + view.hear('menuOpen', 'speed'); + // The rows change under the open menu: UIKit still shows the old ones. + await tester.pumpWidget( + barHarness(_speed([2, 1, 1.5], picked.add), host: host), + ); + await tester.pump(); + view.hear('select', {'id': 'speed', 'row': 0}); + view.hear('menuClose', 'speed'); + // Row 0 was 1x when the menu opened; 1x is now index 1. + expect(picked, [1]); + + // A menu opened after the change reads the new rows. + view.hear('menuOpen', 'speed'); + view.hear('select', {'id': 'speed', 'row': 0}); + expect(picked, [1, 0]); + }); + + testOnIPhone('the menu holding open and closing is said', (tester) async { + final host = FakeNativeHost(); + final said = []; + await tester.pumpWidget( + barHarness( + Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(), + onMenuOpen: () => said.add('open'), + onMenuClose: () => said.add('close'), + items: const [ + NativeBarItem( + id: 'fit', + symbol: 'aspectratio', + semanticLabel: 'Fit', + ), + ], + ), + ), + host: host, + ), + ); + await tester.pump(); + host.views.single + ..hear('menuOpen', 'fit') + ..hear('menuClose', 'fit'); + expect(said, ['open', 'close']); + }); + + testOnIPhone('a pull-down menu says so, with its rows in full', ( + tester, + ) async { + final host = FakeNativeHost(); + final pressed = []; + await tester.pumpWidget( + barHarness( + Center( + child: NativeGlassBar( + look: NativeBarLook.capsule, + fallback: (_) => const SizedBox(), + items: [ + NativeBarItem( + id: 'subtitles', + symbol: 'captions.bubble', + semanticLabel: 'Subtitles', + onPressed: () => pressed.add('subtitles'), + menuPrimary: true, + menu: const [ + NativeMenuRow(title: 'Off', checked: true, key: 'off'), + NativeMenuRow( + title: 'English', + subtitle: 'In the video', + checked: false, + key: 'en', + ), + NativeMenuRow( + title: 'More options', + symbol: 'ellipsis.circle', + breakBefore: true, + section: 'More', + disabled: true, + key: 'more', + ), + ], + onMenuSelected: (_) {}, + ), + // Asks for a pull-down with nothing to pull down: a plain button. + NativeBarItem( + id: 'settings', + symbol: 'gearshape', + semanticLabel: 'Settings', + menuPrimary: true, + onPressed: () => pressed.add('settings'), + ), + ], + ), + ), + host: host, + ), + ); + await tester.pump(); + final items = host.views.single.params['items']! as List; + final subtitles = items[0] as Map; + expect(subtitles['menuPrimary'], isTrue); + expect(items[1]['menuPrimary'], isFalse); + final rows = subtitles['menu'] as List; + expect(rows[0], containsPair('checked', true)); + expect(rows[1], containsPair('subtitle', 'In the video')); + expect(rows[2], containsPair('section', 'More')); + expect(rows[2], containsPair('disabled', true)); + expect(rows[2], containsPair('symbol', 'ellipsis.circle')); + + // VoiceOver cannot reach UIKit's menu; its tap opens the sheet instead. + final semantics = tester.ensureSemantics(); + tester.semantics.tap(find.semantics.byLabel('Subtitles')); + expect(pressed, ['subtitles']); + semantics.dispose(); + }); +} diff --git a/test/core/native/player_controls_style_test.dart b/test/core/native/player_controls_style_test.dart new file mode 100644 index 00000000..a067cb70 --- /dev/null +++ b/test/core/native/player_controls_style_test.dart @@ -0,0 +1,232 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/glass/glass_surface.dart'; +import 'package:soplay/core/glass/media_glass.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; + +const _ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +const _ios18 = NativeGlassCapability( + systemVersion: '18.5', + majorVersion: 18, + systemGlass: false, + styles: {}, + materials: {'thin'}, + reduceTransparency: false, +); + +PlayerControlsStyle _effective( + PlayerControlsStyle chosen, { + NativeGlassCapability cap = _ios26, + bool rt = false, + bool isIOS = true, + bool isTv = false, + bool isDesktop = false, +}) => effectivePlayerControlsStyle( + chosen: chosen, + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap, + reduceTransparency: rt, +); + +bool _mediaNative( + PlayerControlsStyle style, + NativeGlassCapability cap, + bool rt, +) => mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: cap, + reduceTransparency: rt, + style: style, +); + +void main() { + const native = PlayerControlsStyle.native; + const system = PlayerControlsStyle.systemGlass; + const classic = PlayerControlsStyle.classicGlass; + + group('style × iOS version × Reduce Transparency', () { + final expected = <(PlayerControlsStyle, bool, bool), PlayerControlsStyle>{ + (native, true, false): native, + (native, true, true): classic, + (native, false, false): classic, + (native, false, true): classic, + (system, true, false): system, + (system, true, true): classic, + (system, false, false): classic, + (system, false, true): classic, + (classic, true, false): classic, + (classic, true, true): classic, + (classic, false, false): classic, + (classic, false, true): classic, + }; + expected.forEach((key, want) { + final (chosen, ios26, rt) = key; + test( + '${chosen.name}, iOS ${ios26 ? 26 : 18}, RT $rt -> ${want.name}', + () { + final cap = ios26 ? _ios26 : _ios18; + expect(_effective(chosen, cap: cap, rt: rt), want); + // The media glass is the phone's own only on the two iOS 26 styles. + expect( + _mediaNative(chosen, cap, rt), + want == native || want == system, + ); + }, + ); + }); + }); + + test('native without the bar factory keeps the system glass', () { + const noBars = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + ); + expect(_effective(native, cap: noBars), system); + expect(playerControlsStyleAvailable(native, noBars), isFalse); + expect(playerControlsStyleAvailable(system, noBars), isTrue); + }); + + test('off an iPhone it is always the classic glass', () { + for (final s in PlayerControlsStyle.values) { + expect(_effective(s, isIOS: false), classic); + expect(_effective(s, isTv: true), classic); + expect(_effective(s, isDesktop: true), classic); + } + }); + + test('availability below iOS 26: only the classic glass', () { + expect(playerControlsStyleAvailable(native, _ios18), isFalse); + expect(playerControlsStyleAvailable(system, _ios18), isFalse); + expect(playerControlsStyleAvailable(classic, _ios18), isTrue); + }); + + test( + 'the stored name reads back, and anything else is the classic glass', + () { + expect(PlayerControlsStyle.fallback, classic); + for (final s in PlayerControlsStyle.values) { + expect(PlayerControlsStyle.fromName(s.name), s); + } + expect(PlayerControlsStyle.fromName(null), classic); + expect(PlayerControlsStyle.fromName('glass'), classic); + expect(PlayerControlsStyle.fromName(3), classic); + }, + ); + + group('the one-time native offer', () { + bool offer({ + bool isIOS = true, + bool isTv = false, + bool isDesktop = false, + NativeGlassCapability cap = _ios26, + bool rt = false, + PlayerControlsStyle? stored, + bool shown = false, + }) => shouldOfferNativeControls( + isIOS: isIOS, + isTv: isTv, + isDesktop: isDesktop, + cap: cap, + reduceTransparency: rt, + stored: stored, + alreadyOffered: shown, + ); + + test('offered on iOS 26 to a viewer who never chose', () { + expect(offer(), isTrue); + }); + + test('never off an iPhone or iPad', () { + expect(offer(isIOS: false), isFalse); + expect(offer(isTv: true), isFalse); + expect(offer(isDesktop: true), isFalse); + }); + + test('never where native cannot be drawn', () { + expect(offer(cap: _ios18), isFalse); + expect(offer(cap: NativeGlassCapability.absent), isFalse); + const noBars = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + ); + expect(offer(cap: noBars), isFalse); + }); + + test('never under Reduce Transparency', () { + expect(offer(rt: true), isFalse); + }); + + test('never once a style is stored', () { + for (final s in PlayerControlsStyle.values) { + expect(offer(stored: s), isFalse, reason: s.name); + } + }); + + test('never twice', () { + expect(offer(shown: true), isFalse); + }); + + test('Try it is native; Not now and any dismissal are classic', () { + expect(nativeOfferOutcome(true), native); + expect(nativeOfferOutcome(false), classic); + expect(nativeOfferOutcome(null), classic); + }); + }); + + test('no style keeps the old media glass decision', () { + expect( + mediaGlassGoesNative( + platform: TargetPlatform.iOS, + capability: _ios26, + reduceTransparency: false, + ), + isTrue, + ); + }); + + for (final (style, hosted) in [(system, true), (classic, false)]) { + testWidgets('a layer under ${style.name} draws ' + '${hosted ? 'the system glass' : 'the shader'}', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await tester.pumpWidget( + MaterialApp( + home: NativeGlassScope( + capability: _ios26, + child: PlayerControlsStyleScope( + style: style, + child: const Center( + child: MediaGlassLayer( + child: MediaGlass(child: SizedBox(width: 38, height: 38)), + ), + ), + ), + ), + ), + ); + expect(find.byType(NativeGlass), hosted ? findsOneWidget : findsNothing); + expect(find.byType(GlassGroup), hosted ? findsNothing : findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + } +} diff --git a/test/core/native/sf_symbols_test.dart b/test/core/native/sf_symbols_test.dart new file mode 100644 index 00000000..1973ba9a --- /dev/null +++ b/test/core/native/sf_symbols_test.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/native/sf_symbols.dart'; +import 'package:soplay/features/detail/domain/player_controls_layout.dart'; + +/// Every SF Symbol the player may send, as the phone's own list names them: +/// checked against CoreGlyphs' name_availability.plist (macOS 27, the +/// iOS 26 symbol set); none is newer than iOS 17. A name missing here is a +/// name to check there before it ships, since UIKit draws nothing for one +/// it does not know. +final Set _knownSymbols = { + 'arrow.counterclockwise', 'arrow.down.circle', 'arrow.down.circle.fill', // + 'arrow.up.left.and.arrow.down.right', 'aspectratio', 'backward.end.fill', + 'bubble.left.and.bubble.right', 'captions.bubble', 'captions.bubble.fill', + 'character.bubble', 'chevron.backward', 'ellipsis.circle', 'face.smiling', + 'forward.end.fill', 'forward.fill', 'gauge.with.dots.needle.67percent', + 'gearshape', 'gobackward', 'goforward', 'info.circle', 'info.circle.fill', + 'lock.fill', 'lock.open.fill', 'moon.zzz', 'moon.zzz.fill', 'pause.fill', + 'person.2', 'person.2.fill', 'pip.enter', 'play.fill', + 'rectangle.arrowtriangle.2.outward', 'rectangle.landscape.rotate', + 'rectangle.portrait.rotate', 'rectangle.stack', 'server.rack', + 'slider.horizontal.3', 'sparkles', 'tv.badge.wifi', 'tv.badge.wifi.fill', + for (final s in [5, 10, 15, 30, 45, 60, 75, 90]) ...[ + 'gobackward.$s', + 'goforward.$s', + ], +}; + +void main() { + test('every symbol the player sends is one the phone has', () { + final sent = { + ...playerSymbols.values, + ...activeSymbols.values, + orientationSymbol(portrait: true), + orientationSymbol(portrait: false), + for (var s = 1; s <= 120; s++) ...[rewindSymbol(s), forwardSymbol(s)], + }; + expect(sent.difference(_knownSymbols), isEmpty); + }); + + test('the fit menu and button wear known symbols', () { + final source = File( + 'lib/features/detail/presentation/pages/player_page.controls.dart', + ).readAsStringSync(); + final body = source.substring( + source.indexOf('static String _fitSymbol('), + source.indexOf('String _fitLabel('), + ); + final names = RegExp(r"=> '([^']+)'").allMatches(body).map((m) => m[1]!); + expect(names, hasLength(3)); + expect(names.toSet().difference(_knownSymbols), isEmpty); + }); + + test('controls Apple draws a certain way wear Apple\'s symbol', () { + expect(playerSymbol('play'), 'play.fill'); + expect(playerSymbol('pause'), 'pause.fill'); + expect(playerSymbol('previous'), 'backward.end.fill'); + expect(playerSymbol('next'), 'forward.end.fill'); + expect(playerSymbol('subtitles'), 'captions.bubble'); + expect(playerSymbol('speed'), 'gauge.with.dots.needle.67percent'); + expect(playerSymbol('pip'), 'pip.enter'); + expect(playerSymbol('back'), 'chevron.backward'); + expect(playerSymbol('unlock'), 'lock.open.fill'); + // Watch Party is not SharePlay: its symbol would promise Apple's. + expect(playerSymbol('party'), 'person.2'); + expect(playerSymbol('party', active: true), 'person.2.fill'); + }); + + test('every control in the catalogue has a symbol', () { + final missing = [ + for (final spec in PlayerControlCatalogue.all) + if (!playerSymbols.containsKey(spec.id)) spec.id, + ]; + expect(missing, isEmpty); + }); + + test('the player pieces outside the catalogue have one too', () { + for (final id in [ + 'back', 'play', 'pause', 'replay', 'rewind', 'forward', 'react', // + 'comments', 'unlock', 'skip', + ]) { + expect(playerSymbols[id], isNotNull, reason: id); + } + }); + + test( + 'a step SF Symbols numbers gets its number, another the plain arrow', + () { + expect(rewindSymbol(10), 'gobackward.10'); + expect(rewindSymbol(7), 'gobackward'); + expect(forwardSymbol(30), 'goforward.30'); + expect(forwardSymbol(20), 'goforward'); + }, + ); + + test('an active control wears its filled face where it has one', () { + expect(playerSymbol('stats', active: true), 'info.circle.fill'); + expect(playerSymbol('stats'), 'info.circle'); + expect(playerSymbol('settings', active: true), 'gearshape'); + }); +} diff --git a/test/core/network/media_request_headers_test.dart b/test/core/network/media_request_headers_test.dart new file mode 100644 index 00000000..69c2be6e --- /dev/null +++ b/test/core/network/media_request_headers_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/network/media_request_headers.dart'; +import 'package:soplay/core/network/user_agent.dart'; + +void main() { + final cdn = Uri.parse('https://cdn.example.net/hls/master.m3u8'); + + test('a source that names only a Referer still goes out as the app', () { + final headers = mediaRequestHeaders(cdn, const { + 'Referer': 'https://site.example.org/', + }); + expect(headers['User-Agent'], kSozoUserAgent); + expect(headers['Accept'], '*/*'); + expect(headers['Referer'], 'https://site.example.org/'); + expect(headers['Sec-Fetch-Site'], 'cross-site'); + expect(headers['Sec-Fetch-Mode'], 'cors'); + }); + + test('the source wins, whatever case it writes a name in', () { + final headers = mediaRequestHeaders(cdn, const { + 'user-agent': 'Plugin/1.0', + 'sec-fetch-site': 'same-origin', + }); + final agents = headers.keys.where((k) => k.toLowerCase() == 'user-agent'); + expect(agents, ['user-agent']); + expect(headers['user-agent'], 'Plugin/1.0'); + expect(headers['sec-fetch-site'], 'same-origin'); + expect(headers.containsKey('Sec-Fetch-Site'), isFalse); + }); + + test('a provider default Referer applies only when the source has none', () { + expect( + mediaRequestHeaders(cdn, const {}, provider: 'asilmedia')['Referer'], + 'https://asilmedia.org/', + ); + expect( + mediaRequestHeaders(cdn, const { + 'referer': 'https://other.example/', + }, provider: 'AsilMedia'), + isNot(contains('Referer')), + ); + expect(mediaRequestHeaders(cdn, const {}), isNot(contains('Referer'))); + }); + + test('the app\'s own loopback proxies get the source headers as they are', () { + final local = Uri.parse('http://127.0.0.1:4100/p/abc/https/cdn/x.m3u8'); + expect(mediaRequestHeaders(local, const {'Range': 'bytes=0-'}), { + 'Range': 'bytes=0-', + }); + }); + + test('applying it twice changes nothing', () { + final once = mediaRequestHeaders(cdn, const {'Referer': 'https://a.b/'}); + expect(mediaRequestHeaders(cdn, once), once); + }); +} diff --git a/test/core/network/web_page_sniff_test.dart b/test/core/network/web_page_sniff_test.dart new file mode 100644 index 00000000..6c409006 --- /dev/null +++ b/test/core/network/web_page_sniff_test.dart @@ -0,0 +1,48 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/network/web_page_sniff.dart'; + +void main() { + test('MPEG-TS served as page-N.html is media', () { + // vidapi's page-0.html and anikoto's seg-2-f1-v1-a1.html, as served. + expect(looksLikeWebPage([0x47, 0x40, 0x00, 0x30, 0x10, 0x00]), isFalse); + expect(looksLikeWebPage([0x47, 0x40, 0x00, 0x11]), isFalse); + }); + + test('containers, images and SVG are media', () { + expect( + looksLikeWebPage([0, 0, 0, 24, ...'ftypmp42'.codeUnits]), + isFalse, + ); + expect(looksLikeWebPage([0x1a, 0x45, 0xdf, 0xa3]), isFalse); + expect(looksLikeWebPage([0xff, 0xd8, 0xff, 0xe0]), isFalse); + expect(looksLikeWebPage(''.codeUnits), isFalse); + expect(looksLikeWebPage(''.codeUnits), isFalse); + }); + + test('random bytes, as an encrypted segment is, are media', () { + final random = Random(7); + for (var i = 0; i < 2000; i++) { + final bytes = List.generate(512, (_) => random.nextInt(256)); + expect(looksLikeWebPage(bytes), isFalse); + } + }); + + test('a web page is refused, past a BOM and blank lines', () { + expect(looksLikeWebPage(''.codeUnits), isTrue); + expect(looksLikeWebPage(''.codeUnits), isTrue); + expect(looksLikeWebPage('403'.codeUnits), isTrue); + expect(looksLikeWebPage('<script>location="/"'.codeUnits), isTrue); + expect( + looksLikeWebPage([0xef, 0xbb, 0xbf, ...'\r\n \t<!doctype html>'.codeUnits]), + isTrue, + ); + }); + + test('nothing, or only blanks, is not a page', () { + expect(looksLikeWebPage(const []), isFalse); + expect(looksLikeWebPage(' \n'.codeUnits), isFalse); + expect(looksLikeWebPage('<ht'.codeUnits), isFalse); + }); +} diff --git a/test/core/player/download_media_probe_test.dart b/test/core/player/download_media_probe_test.dart index 327a825a..eee5f41d 100644 --- a/test/core/player/download_media_probe_test.dart +++ b/test/core/player/download_media_probe_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -61,4 +62,104 @@ void main() { } }, ); + + group('verdict', () { + late HttpServer server; + late Dio dio; + late DownloadMediaProbe probe; + final stalled = <HttpRequest>[]; + + setUp(() async { + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + switch (req.uri.path) { + case '/expired.mp4': + req.response.statusCode = 403; + case '/challenge.mp4': + req.response.headers.contentType = ContentType('video', 'mp4'); + req.response.write('\n<!DOCTYPE html><html>Just a moment'); + case '/page-0.html': + req.response.headers.contentType = ContentType('text', 'html'); + req.response.add([0x47, 0x40, 0x00, 0x30, ...List.filled(600, 1)]); + case '/old.mov': + req.response.add([0, 0, 0, 8, ...'wide'.codeUnits, 0, 0, 0, 1]); + case '/error.json': + req.response.write('{"error":"expired"}'); + case '/slow.mp4': + // Never answers within the probe's time. + stalled.add(req); + return; + } + await req.response.close(); + }); + dio = Dio(); + probe = DownloadMediaProbe( + dio, + timeout: const Duration(milliseconds: 300), + ); + }); + + tearDown(() async { + for (final req in stalled) { + unawaited(req.response.close().catchError((_) {})); + } + stalled.clear(); + dio.close(force: true); + await server.close(force: true); + }); + + MediaResolveEntity at(String path, {String? type}) => MediaResolveEntity( + videoUrl: 'http://127.0.0.1:${server.port}$path', + headers: const {}, + type: type, + ); + + test('a page labelled video is a web page, and only that blocks', () async { + final media = at('/challenge.mp4', type: 'mp4'); + expect(await probe.verdict(media), MediaProbeVerdict.webPage); + expect(await probe.servesWebPage(media), isTrue); + expect(await probe.accepts(media), isFalse); + }); + + test('TS labelled text/html and a QuickTime file are media', () async { + expect( + await probe.verdict(at('/page-0.html', type: 'mp4')), + MediaProbeVerdict.media, + ); + expect( + await probe.verdict(at('/old.mov', type: 'mp4')), + MediaProbeVerdict.media, + ); + }); + + test('an error status or unknown bytes are rejected, not a page', () async { + final expired = at('/expired.mp4', type: 'mp4'); + expect(await probe.verdict(expired), MediaProbeVerdict.rejected); + expect(await probe.servesWebPage(expired), isFalse); + expect( + await probe.verdict(at('/error.json', type: 'mp4')), + MediaProbeVerdict.rejected, + ); + }); + + test('no answer says nothing, and does not block', () async { + final slow = at('/slow.mp4', type: 'mp4'); + expect(await probe.verdict(slow), MediaProbeVerdict.noAnswer); + expect(await probe.servesWebPage(slow), isFalse); + + final closed = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = closed.port; + await closed.close(); + expect( + await probe.verdict( + MediaResolveEntity( + videoUrl: 'http://127.0.0.1:$port/film.mp4', + headers: const {}, + type: 'mp4', + ), + ), + MediaProbeVerdict.noAnswer, + ); + }); + }); } diff --git a/test/core/player/hls_segment_filter_test.dart b/test/core/player/hls_segment_filter_test.dart new file mode 100644 index 00000000..86217000 --- /dev/null +++ b/test/core/player/hls_segment_filter_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/hls_segment_filter.dart'; + +void main() { + group('dropHlsSegments', () { + // The shape of a live KKPhim playlist, 2026-10-31. + const playlist = + '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-PLAYLIST-TYPE:vod\n' + '#EXT-X-TARGETDURATION:8\n#EXT-X-MEDIA-SEQUENCE:0\n' + '#EXTINF:4.0,\nHcTaGA8B.ts\n#EXT-X-DISCONTINUITY\n' + '#EXTINF:4.0,\nconvertv8/R9SjR8xq.ts\n#EXT-X-DISCONTINUITY\n' + '#EXTINF:4.0,\n8T4q13t8.ts\n#EXT-X-DISCONTINUITY\n' + '#EXTINF:3.0,\n/v8/d61a03b1dabe537cc05ffe5c9c280f56/segment_0000.ts\n' + '#EXTINF:2.0,\n/v8/d61a03b1dabe537cc05ffe5c9c280f56/segment_0001.ts\n' + '#EXT-X-DISCONTINUITY\n#EXTINF:4.0,\nZFE6Zblx.ts\n#EXT-X-ENDLIST\n'; + final ad = RegExp(r'^/v\d+/[0-9a-f]{32}/'); + + test('takes the ad and its timing out, and leaves the film whole', () { + final out = dropHlsSegments(playlist, ad); + expect(out, isNot(contains('segment_'))); + expect(out, contains('convertv8/R9SjR8xq.ts')); + expect(RegExp('#EXTINF').allMatches(out), hasLength(4)); + expect(out, endsWith('ZFE6Zblx.ts\n#EXT-X-ENDLIST\n')); + }); + + test('the two discontinuities around the ad become one', () { + final out = dropHlsSegments(playlist, ad); + expect(RegExp('#EXT-X-DISCONTINUITY').allMatches(out), hasLength(3)); + expect( + out, + contains( + '8T4q13t8.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:4.0,\nZFE6Zblx.ts', + ), + ); + }); + + test('a master playlist is not a list of segments', () { + const master = + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n/v8/abc/index.m3u8\n'; + expect(dropHlsSegments(master, RegExp('.')), master); + }); + + test('a rule that would empty the playlist is wrong, not the film', () { + expect(dropHlsSegments(playlist, RegExp('.')), playlist); + }); + }); + + group('transportStreamStart', () { + final ts = List<int>.generate(188 * 3, (i) => i % 188 == 0 ? 0x47 : 0x11); + const png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x47, 1, 2]; + const iend = [0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]; + + test('finds the stream behind the image and its padding', () { + final bytes = [...png, ...iend, ...List.filled(40, 0xFF), ...ts]; + expect(transportStreamStart(bytes), png.length + iend.length + 40); + }); + + test('a sync byte inside the padding is not the start', () { + final bytes = [...png, ...iend, 0x47, 0xFF, 0xFF, ...ts]; + expect(transportStreamStart(bytes), png.length + iend.length + 3); + }); + + test('says so when it needs more bytes to be sure', () { + expect(transportStreamStart([...png, ...iend, ...ts.take(100)]), isNull); + expect(transportStreamStart(png), isNull); + }); + }); +} diff --git a/test/core/player/local_hls_proxy_avplayer_test.dart b/test/core/player/local_hls_proxy_avplayer_test.dart new file mode 100644 index 00000000..36f2ace6 --- /dev/null +++ b/test/core/player/local_hls_proxy_avplayer_test.dart @@ -0,0 +1,781 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/local_hls_proxy.dart'; + +/// The proxy as AVPlayer uses it. +/// +/// On iOS the stream is played by AVPlayer through video_player_avfoundation — +/// `resolvePlayerEngine()` pins every non-Android, non-desktop platform to the +/// native engine because there is no libmpv there. AVFoundation's media loader +/// is far stricter than libmpv or ExoPlayer: it probes with HEAD, it opens a +/// segment with a byte range and insists on 206 plus a matching Content-Range, +/// it re-requests the same bytes, and it gives up with an opaque +/// "cannot open" when any of that is wrong. Each case below is one of those +/// requests, so a regression shows up here rather than on a device. +void main() { + group('LocalHlsProxy under AVPlayer-shaped requests', () { + late _RangeUpstream upstream; + late LocalHlsProxy proxy; + late Dio dio; + + setUp(() async { + upstream = await _RangeUpstream.start(); + dio = Dio(BaseOptions(validateStatus: (_) => true)); + proxy = LocalHlsProxy(dio); + }); + + tearDown(() async { + await proxy.dispose(); + await upstream.close(); + dio.close(force: true); + }); + + Future<String> registerSegmentSession() => proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {'Referer': 'https://uzmovi.net/'}, + keepOriginHeaders: true, + ); + + test('HEAD on a playlist answers with no body but a usable header', () async { + final url = await registerSegmentSession(); + final res = await _request(url, method: 'HEAD'); + expect(res.statusCode, 200); + expect(res.headers['content-type'], contains('mpegurl')); + expect(res.bytes, isEmpty, reason: 'A HEAD must not carry a body'); + expect(res.headers['content-length'], isNotNull); + expect( + int.parse(res.headers['content-length']!), + greaterThan(0), + reason: 'AVPlayer sizes the resource from this', + ); + }); + + test('a ranged playlist request is answered from the rewritten bytes', () async { + final url = await registerSegmentSession(); + final whole = await _request(url); + final total = whole.bytes.length; + final res = await _request(url, headers: const {'Range': 'bytes=0-9'}); + expect(res.statusCode, 206); + expect(res.headers['content-length'], '10'); + expect( + res.headers['content-range'], + 'bytes 0-9/$total', + reason: 'The size has to be the rewritten playlist, not the upstream one', + ); + expect(res.bytes, whole.bytes.sublist(0, 10)); + }); + + test( + 'a ranged playlist is rewritten from the whole file, not from a slice', + () async { + // The playlist is the one resource this proxy has to read end to end + // before it can answer for it, so the player's Range must not reach the + // upstream: a host that honours it hands back a few bytes of text, and + // a rewrite of those bytes is a playlist that claims to be the whole + // file and is not. uzmovi's signed endpoint ignores Range here, which + // is why only a mock can hold this line. + upstream.honourPlaylistRanges = true; + final url = await registerSegmentSession(); + final whole = await _request(url); + final total = whole.bytes.length; + final res = await _request(url, headers: const {'Range': 'bytes=0-9'}); + expect(res.statusCode, 206); + expect( + res.headers['content-range'], + 'bytes 0-9/$total', + reason: 'The total has to be the whole rewritten playlist', + ); + expect(res.bytes, whole.bytes.sublist(0, 10)); + expect( + upstream.lastPlaylistRange, + isNull, + reason: 'A Range forwarded to the upstream truncates the text this ' + 'proxy still has to parse and rewrite', + ); + }, + ); + + test('HEAD on a segment reports the size and byte-range support', () async { + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request(segment, method: 'HEAD'); + expect(res.statusCode, 200); + expect(res.headers['content-type'], 'video/mp2t'); + expect(res.headers['content-length'], '${upstream.segment.length}'); + expect(res.headers['accept-ranges'], 'bytes'); + expect(res.bytes, isEmpty, reason: 'A HEAD must not carry a body'); + expect( + upstream.lastMethod, + 'HEAD', + reason: 'A HEAD answered by downloading the segment wastes the CDN ' + 'round trip AVPlayer is trying to avoid', + ); + }); + + test('a two-byte probe range comes back as a 206 slice', () async { + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request( + segment, + headers: const {'Range': 'bytes=0-1'}, + ); + expect(res.statusCode, 206); + expect(res.headers['content-type'], 'video/mp2t'); + expect(res.headers['content-length'], '2'); + expect( + res.headers['content-range'], + 'bytes 0-1/${upstream.segment.length}', + ); + expect(res.headers['accept-ranges'], 'bytes'); + expect(res.bytes.length, 2); + }); + + test('an open-ended range comes back as a 206 to the end', () async { + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request( + segment, + headers: const {'Range': 'bytes=0-'}, + ); + final total = upstream.segment.length; + expect(res.statusCode, 206); + expect(res.headers['content-length'], '$total'); + expect(res.headers['content-range'], 'bytes 0-${total - 1}/$total'); + expect(res.bytes.length, total); + }); + + test('a mid-file range comes back as the right slice', () async { + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final total = upstream.segment.length; + final res = await _request( + segment, + headers: const {'Range': 'bytes=188-375'}, + ); + expect(res.statusCode, 206); + expect(res.headers['content-length'], '188'); + expect(res.headers['content-range'], 'bytes 188-375/$total'); + expect(res.bytes, upstream.segment.sublist(188, 376)); + }); + + test('a plain GET reports its length and advertises ranges', () async { + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request(segment); + expect(res.statusCode, 200); + expect(res.headers['content-type'], 'video/mp2t'); + expect(res.headers['content-length'], '${upstream.segment.length}'); + expect(res.headers['accept-ranges'], 'bytes'); + expect(res.bytes.length, upstream.segment.length); + }); + + test('the same segment asked for twice answers the same both times', () async { + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final first = await _request(segment, headers: const {'Range': 'bytes=0-'}); + final second = await _request( + segment, + headers: const {'Range': 'bytes=0-'}, + ); + expect(second.statusCode, first.statusCode); + expect(second.headers['content-length'], first.headers['content-length']); + expect(second.headers['content-range'], first.headers['content-range']); + expect(second.bytes, first.bytes); + }); + + test( + 'a range the upstream ignores is still answered as a 206 slice', + () async { + upstream.honourRanges = false; + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final total = upstream.segment.length; + final res = await _request( + segment, + headers: const {'Range': 'bytes=0-1'}, + ); + expect( + res.statusCode, + 206, + reason: 'AVPlayer asked for a range, and a 200 carrying the whole ' + 'file in reply is a protocol error it abandons the stream over', + ); + expect(res.headers['content-length'], '2'); + expect(res.headers['content-range'], 'bytes 0-1/$total'); + expect(res.bytes.length, 2); + }, + ); + + test( + 'a mid-file range the upstream ignores is sliced, not sent whole', + () async { + upstream.honourRanges = false; + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final total = upstream.segment.length; + final res = await _request( + segment, + headers: const {'Range': 'bytes=1000-1187'}, + ); + expect(res.statusCode, 206); + expect(res.headers['content-length'], '188'); + expect(res.headers['content-range'], 'bytes 1000-1187/$total'); + expect(res.bytes, upstream.segment.sublist(1000, 1188)); + }, + ); + + test('a HEAD the upstream refuses falls back to a GET', () async { + upstream.rejectHead = true; + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request(segment, method: 'HEAD'); + expect( + res.statusCode, + 200, + reason: 'Some CDNs answer 403/405 to HEAD; AVPlayer then has no size ' + 'and stops, so the proxy has to ask again as a GET', + ); + expect(res.headers['content-length'], '${upstream.segment.length}'); + expect(res.bytes, isEmpty); + }); + + test( + 'a playlist at an extensionless path is never answered from a slice', + () async { + // `/live/playlist` says what it is only in its first bytes, so the + // player's Range is already upstream by the time this proxy knows it is + // holding text it has to rewrite whole. A host that honours that Range + // hands back a slice, and a rewrite of a slice is a playlist cut off + // mid-segment-list that declares itself complete — AVPlayer then plays + // whatever part of the film survived the cut and stops. + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/playlist', + headers: const {'Referer': 'https://uzmovi.net/'}, + keepOriginHeaders: true, + ); + final whole = await _request(url); + final total = whole.bytes.length; + expect(utf8.decode(whole.bytes), contains('#EXT-X-ENDLIST')); + + final res = await _request(url, headers: const {'Range': 'bytes=0-9'}); + expect(res.statusCode, 206); + expect( + res.headers['content-range'], + 'bytes 0-9/$total', + reason: 'The total has to be the whole rewritten playlist, not the ' + 'few bytes the upstream was willing to hand over', + ); + expect(res.bytes, whole.bytes.sublist(0, 10)); + + final tail = await _request( + url, + headers: {'Range': 'bytes=${total - 5}-'}, + ); + expect(tail.statusCode, 206); + expect(tail.headers['content-range'], 'bytes ${total - 5}-${total - 1}/$total'); + expect( + tail.bytes, + whole.bytes.sublist(total - 5), + reason: 'The end of the playlist exists only if the whole file was ' + 'fetched; a forwarded Range never reaches it', + ); + }, + ); + + test( + 'a HEAD on an extensionless playlist reports the rewritten length', + () async { + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/playlist', + headers: const {}, + ); + final whole = await _request(url); + final res = await _request(url, method: 'HEAD'); + expect(res.statusCode, 200); + expect(res.headers['content-length'], '${whole.bytes.length}'); + expect(res.bytes, isEmpty); + }, + ); + + test('a probe range on an image-wrapped segment is a 206 slice', () async { + // The hosts that store video on image CDNs are the reason the unwrapping + // exists, and on iOS those streams are opened with exactly this probe. A + // 200 carrying the whole segment in reply, with byte ranges disclaimed, + // is the protocol error AVPlayer abandons the stream over. + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {}, + ); + final wrapped = _sibling(url, 'seg0.png'); + final total = upstream.wrappedPayload.length; + final res = await _request(wrapped, headers: const {'Range': 'bytes=0-1'}); + expect(res.statusCode, 206); + expect(res.headers['content-type'], 'video/mp2t'); + expect(res.headers['content-length'], '2'); + expect( + res.headers['content-range'], + 'bytes 0-1/$total', + reason: 'The size is the transport stream behind the picture, not the ' + 'file the upstream served', + ); + expect(res.headers['accept-ranges'], 'bytes'); + expect(res.bytes, upstream.wrappedPayload.sublist(0, 2)); + }); + + test('a mid-file range on an image-wrapped segment is the right slice', () async { + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {}, + ); + final wrapped = _sibling(url, 'seg0.png'); + final total = upstream.wrappedPayload.length; + final res = await _request( + wrapped, + headers: const {'Range': 'bytes=188-375'}, + ); + expect(res.statusCode, 206); + expect(res.headers['content-length'], '188'); + expect(res.headers['content-range'], 'bytes 188-375/$total'); + expect(res.bytes, upstream.wrappedPayload.sublist(188, 376)); + }); + + test( + 'an image-wrapped segment is unwrapped even when the host honours ranges', + () async { + // Reading from an offset would land in the middle of the picture, where + // there is no telling where the video starts, so the probe must not be + // forwarded for a path that may be a wrapper. + upstream.honourRanges = true; + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {}, + ); + final wrapped = _sibling(url, 'seg0.png'); + final res = await _request( + wrapped, + headers: const {'Range': 'bytes=0-1'}, + ); + expect(res.statusCode, 206); + expect(res.bytes, upstream.wrappedPayload.sublist(0, 2)); + expect( + upstream.lastRange, + isNull, + reason: 'A Range forwarded here returns two bytes of PNG signature ' + 'and the wrapper can never be found', + ); + }, + ); + + test('a plain GET of a wrapped segment states the unwrapped length', () async { + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {}, + ); + final wrapped = _sibling(url, 'seg0.png'); + final res = await _request(wrapped); + expect(res.statusCode, 200); + expect(res.headers['content-type'], 'video/mp2t'); + expect( + res.headers['content-length'], + '${upstream.wrappedPayload.length}', + reason: 'AVPlayer sizes the resource from this, and a segment of ' + 'unknown length it cannot seek in is one it will not play', + ); + expect(res.headers['accept-ranges'], 'bytes'); + expect(res.bytes, upstream.wrappedPayload); + }); + + test( + 'an upstream that sends fewer bytes than it declared fails before the ' + 'response is committed', + () async { + // The slice used to be streamed out under a Content-Length taken from + // the upstream's own claim. A host that overstates it left the response + // promised a length it could never reach, which dies mid-flight with + // the player already reading it; a status it never got to see is a + // cleaner answer than a truncated body. + upstream.honourRanges = false; + upstream.truncateAfter = 100; + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {}, + ); + final segment = _sibling(url, 'index0.ts'); + final res = await _request( + segment, + headers: const {'Range': 'bytes=0-187'}, + ).timeout( + const Duration(seconds: 10), + onTimeout: () => throw StateError( + 'The response never finished: it was committed to a length the ' + 'upstream could not deliver', + ), + ); + expect(res.statusCode, 502); + expect(res.bytes, isEmpty); + }, + ); + + test( + 'a HEAD whose upstream states no length still answers completely', + () async { + // The upstream HEAD is usable only when it carries a size, since that + // is the one thing AVPlayer is asking for. Without it the body has to + // be fetched, and the whole answer — status, content type and length — + // has to come from that fetch. _answerHead therefore decides before it + // writes anything at all: a half-written response handed back to the + // caller only survives while every branch below happens to set the + // same two headers again. + upstream.headWithoutLength = true; + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request(segment, method: 'HEAD'); + expect(res.statusCode, 200); + expect(res.headers['content-type'], 'video/mp2t'); + expect(res.headers['content-length'], '${upstream.segment.length}'); + expect(res.headers['accept-ranges'], 'bytes'); + expect(res.bytes, isEmpty); + }, + ); + + test( + 'a range is answered without waiting for the rest of the download', + () async { + // The upstream here ignores the range and starts sending megabytes of + // which two are wanted. Reading the rest would keep the player waiting + // on bytes nobody asked for, so the slice is answered as soon as it is + // complete. It is only the answer that is prompt: the connection itself + // is not taken down, which is measured and commented on below. + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/live/index.m3u8', + headers: const {}, + ); + final segment = _sibling(url, 'big.ts'); + final total = upstream.big.length; + final res = await _request( + segment, + headers: const {'Range': 'bytes=0-1'}, + ).timeout( + const Duration(seconds: 20), + onTimeout: () => throw StateError( + 'The range was held open waiting for the rest of the file', + ), + ); + // Read the moment the answer is in the player's hands: the upstream + // goes on sending afterwards, because cancelling the stream this + // response arrives on does not take the connection down — see the + // note on [_RangeUpstream.sentBytes]. + final sentWhenAnswered = upstream.sentBytes; + expect(res.statusCode, 206); + expect(res.headers['content-length'], '2'); + expect(res.headers['content-range'], 'bytes 0-1/$total'); + expect(res.bytes, upstream.big.sublist(0, 2)); + expect( + sentWhenAnswered, + lessThan(total), + reason: 'The upstream was still sending when the player already had ' + 'its answer, which is the point: nothing waits on bytes nobody ' + 'asked for', + ); + }, + ); + + test('a range past the end of the segment is refused with a 416', () async { + upstream.honourRanges = false; + final url = await registerSegmentSession(); + final segment = _sibling(url, 'index0.ts'); + final res = await _request( + segment, + headers: {'Range': 'bytes=${upstream.segment.length + 10}-'}, + ); + expect(res.statusCode, 416); + expect(res.headers['content-range'], 'bytes */${upstream.segment.length}'); + }); + }); +} + +/// The playlist's address with its last path element swapped, which is where +/// the rewritten segment lines point. +String _sibling(String url, String name) { + final uri = Uri.parse(url); + final segments = List<String>.from(uri.pathSegments) + ..removeLast() + ..add(name); + return uri.replace(pathSegments: segments).toString(); +} + +/// The smallest PNG shape the unwrapping looks at: signature, then IEND and +/// its CRC. +const _tinyPng = <int>[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // + 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +]; + +class _RangeUpstream { + _RangeUpstream._(this._server); + + static Future<_RangeUpstream> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final upstream = _RangeUpstream._(server); + server.listen(upstream._handle); + return upstream; + } + + final HttpServer _server; + + /// A couple of transport-stream packets, enough to slice. + final Uint8List segment = Uint8List.fromList( + List<int>.generate(1880, (i) => i % 188 == 0 ? 0x47 : i & 0xff), + ); + + /// When false the upstream answers every request with the whole file and a + /// 200, the way uzmovi's signed endpoint does. + bool honourRanges = true; + + /// When true HEAD is refused, as some CDNs do. + bool rejectHead = false; + + /// The transport stream hidden inside [wrapped], which is what the player + /// must end up with. + final Uint8List wrappedPayload = Uint8List.fromList( + List<int>.generate(188 * 4, (i) => i % 188 == 0 ? 0x47 : (i & 0x7f) + 1), + ); + + /// A segment stored on an image CDN: a picture, some padding, then the video. + Uint8List get wrapped => Uint8List.fromList([ + ..._tinyPng, + ...List.filled(37, 0xFF), + ...wrappedPayload, + ]); + + /// How many bytes of the body to send before hanging up, for a host that + /// declares a Content-Length larger than what it delivers. + int? truncateAfter; + + /// A file far larger than any socket buffer, served chunk by chunk and + /// ignoring ranges, so what the upstream had sent by the time the proxy let + /// go of the connection can be counted. + final Uint8List big = Uint8List.fromList( + List<int>.generate(8 * 1024 * 1024, (i) => i % 188 == 0 ? 0x47 : i & 0xff), + ); + + /// How much of [big] has gone out, which the proxy's answer is raced + /// against. It keeps climbing after that answer: the proxy stops reading + /// the body but the connection stays up and this mock delivers the rest, so + /// only the count taken at the moment of the answer says anything. + int sentBytes = 0; + + /// When true HEAD answers without a Content-Length, which is the one thing + /// the probe exists to learn. + bool headWithoutLength = false; + + /// When true the playlist is served with byte ranges honoured, the way any + /// plain nginx or object store serves a static .m3u8. uzmovi's own signed + /// endpoint ignores Range on the playlist, so only a mock can show what a + /// forwarded Range does to a file this proxy has to rewrite whole. + bool honourPlaylistRanges = false; + + String? lastMethod; + String? lastRange; + String? lastPlaylistRange; + + String get origin => 'http://127.0.0.1:${_server.port}'; + + Future<void> close() => _server.close(force: true); + + Future<void> _handle(HttpRequest req) async { + lastMethod = req.method; + lastRange = req.headers.value('range'); + final res = req.response; + if (req.method == 'HEAD' && rejectHead) { + res.statusCode = 405; + await res.close(); + return; + } + if (req.uri.path.endsWith('.m3u8')) { + lastPlaylistRange = lastRange; + final body = utf8.encode( + '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n' + '#EXTINF:10.0,\nindex0.ts\n#EXTINF:10.0,\nindex1.ts\n#EXT-X-ENDLIST\n', + ); + res.headers.set( + HttpHeaders.contentTypeHeader, + 'application/vnd.apple.mpegurl', + ); + final range = honourPlaylistRanges + ? _parseRange(lastRange, body.length) + : null; + if (range == null) { + res.statusCode = 200; + res.headers.set(HttpHeaders.contentLengthHeader, '${body.length}'); + if (req.method != 'HEAD') res.add(body); + await res.close(); + return; + } + final slice = body.sublist(range.$1, range.$2 + 1); + res.statusCode = 206; + res.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes ${range.$1}-${range.$2}/${body.length}', + ); + res.headers.set(HttpHeaders.contentLengthHeader, '${slice.length}'); + if (req.method != 'HEAD') res.add(slice); + await res.close(); + return; + } + + // A playlist served at a path that does not name itself one — the shape + // every static host and object store falls into, with ranges honoured. + if (req.uri.path.endsWith('/playlist')) { + lastPlaylistRange = lastRange; + final body = utf8.encode( + '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:10\n' + '#EXTINF:10.0,\nindex0.ts\n#EXTINF:10.0,\nindex1.ts\n' + '#EXTINF:10.0,\nindex2.ts\n#EXT-X-ENDLIST\n', + ); + res.headers.set(HttpHeaders.contentTypeHeader, 'text/plain'); + await _serveBody(req, Uint8List.fromList(body)); + return; + } + + if (req.uri.path.endsWith('big.ts')) { + res.statusCode = 200; + res.headers.set(HttpHeaders.contentTypeHeader, 'video/mp2t'); + res.headers.set(HttpHeaders.contentLengthHeader, '${big.length}'); + const chunk = 64 * 1024; + try { + for (var at = 0; at < big.length; at += chunk) { + final stop = at + chunk > big.length ? big.length : at + chunk; + res.add(Uint8List.sublistView(big, at, stop)); + await res.flush(); + sentBytes = stop; + // A real CDN takes time over eight megabytes, and without that pause + // loopback delivers all of them into the client's buffers before the + // proxy has answered the two bytes it was asked for — the test then + // passes or fails on who won the race rather than on whether the + // download was let go of. + await Future<void>.delayed(const Duration(milliseconds: 2)); + } + await res.close(); + } catch (_) { + // A host whose client has gone away stops here rather than failing the + // test around it. + } + return; + } + + if (req.uri.path.endsWith('.png')) { + res.headers.set(HttpHeaders.contentTypeHeader, 'image/png'); + res.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + await _serveBody(req, wrapped); + return; + } + + res.headers.set(HttpHeaders.contentTypeHeader, 'video/mp2t'); + res.headers.set(HttpHeaders.acceptRangesHeader, 'bytes'); + await _serveBody(req, segment); + } + + /// [body] served the way the flags above say: whole with a 200, or as the + /// 206 slice that was asked for, and cut short when [truncateAfter] is set. + Future<void> _serveBody(HttpRequest req, Uint8List body) async { + final res = req.response; + final range = honourRanges ? _parseRange(lastRange, body.length) : null; + final slice = range == null + ? body + : Uint8List.sublistView(body, range.$1, range.$2 + 1); + final declared = slice.length; + if (range != null) { + res.statusCode = 206; + res.headers.set( + HttpHeaders.contentRangeHeader, + 'bytes ${range.$1}-${range.$2}/${body.length}', + ); + } else { + res.statusCode = 200; + } + if (req.method == 'HEAD' && headWithoutLength) { + await res.close(); + return; + } + final cut = truncateAfter; + if (cut == null || req.method == 'HEAD' || cut >= declared) { + res.headers.set(HttpHeaders.contentLengthHeader, '$declared'); + if (req.method != 'HEAD') res.add(slice); + await res.close(); + return; + } + // Written by hand: Dart's own HttpResponse refuses to under-deliver a + // Content-Length it has been given, which is the whole point here. + final contentType = + res.headers.value(HttpHeaders.contentTypeHeader) ?? 'video/mp2t'; + final socket = await res.detachSocket(writeHeaders: false); + socket.write( + 'HTTP/1.1 ${res.statusCode} OK\r\n' + 'Content-Type: $contentType\r\n' + 'Content-Length: $declared\r\n' + 'Connection: close\r\n\r\n', + ); + socket.add(Uint8List.sublistView(slice, 0, cut)); + await socket.flush(); + await socket.close(); + socket.destroy(); + } + + (int, int)? _parseRange(String? raw, int total) { + if (raw == null) return null; + final m = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(raw.trim()); + if (m == null) return null; + final startText = m.group(1)!; + final endText = m.group(2)!; + if (startText.isEmpty && endText.isEmpty) return null; + if (startText.isEmpty) { + final length = int.parse(endText); + return (total - length, total - 1); + } + final start = int.parse(startText); + final end = endText.isEmpty ? total - 1 : int.parse(endText); + if (start > end || start >= total) return null; + return (start, end > total - 1 ? total - 1 : end); + } +} + +Future<_Res> _request( + String url, { + String method = 'GET', + Map<String, String> headers = const {}, +}) async { + final client = HttpClient(); + try { + final req = await client.openUrl(method, Uri.parse(url)); + headers.forEach(req.headers.set); + final res = await req.close(); + final out = <int>[]; + await for (final chunk in res) { + out.addAll(chunk); + } + final map = <String, String>{}; + res.headers.forEach((name, values) { + map[name.toLowerCase()] = values.join(','); + }); + return _Res(statusCode: res.statusCode, headers: map, bytes: out); + } finally { + client.close(); + } +} + +class _Res { + const _Res({ + required this.statusCode, + required this.headers, + required this.bytes, + }); + + final int statusCode; + final Map<String, String> headers; + final List<int> bytes; +} diff --git a/test/core/player/local_hls_proxy_live_uzmovi_test.dart b/test/core/player/local_hls_proxy_live_uzmovi_test.dart new file mode 100644 index 00000000..d9729f30 --- /dev/null +++ b/test/core/player/local_hls_proxy_live_uzmovi_test.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/local_hls_proxy.dart'; + +/// The same AVPlayer-shaped probes as local_hls_proxy_avplayer_test.dart, but +/// against the live uzmovi CDN, because the mock upstream can only answer the +/// way we assumed the real one does. +/// +/// Off unless `SOZO_LIVE_UZMOVI=1` is in the environment: it needs the network, +/// a healthy backend and a film that is still on the site, none of which belong +/// in the ordinary suite. Run it with +/// `SOZO_LIVE_UZMOVI=1 flutter test test/core/player/local_hls_proxy_live_uzmovi_test.dart`. +void main() { + final live = Platform.environment['SOZO_LIVE_UZMOVI'] == '1'; + + test('the live uzmovi stream survives AVPlayer-shaped requests', () async { + final api = Dio(BaseOptions(validateStatus: (_) => true)); + final resolve = await api.get<Map<String, dynamic>>( + 'https://apisozo.azamov.me/api/contents/media', + queryParameters: { + 'provider': 'uzmovi', + 'ref': + 'https://uzmovi.net/comediy/' + '4014-shrek-1-uzbek-ozbek-tilida-onlayn-korish.html', + }, + ); + expect(resolve.statusCode, 200); + final source = (resolve.data!['videoSources'] as List).first as Map; + final proxy = LocalHlsProxy(Dio(BaseOptions(validateStatus: (_) => true))); + addTearDown(proxy.dispose); + + final playlistUrl = await proxy.register( + upstreamUrl: source['videoUrl'] as String, + headers: Map<String, String>.from( + (source['headers'] as Map).cast<String, String>(), + ), + localProxy: Map<String, dynamic>.from(source['localProxy'] as Map), + requestTransform: Map<String, dynamic>.from( + (source['requestTransform'] as Map?) ?? const {}, + ), + ); + + final playlistHead = await _request(playlistUrl, method: 'HEAD'); + expect(playlistHead.statusCode, 200); + expect(playlistHead.headers['content-type'], contains('mpegurl')); + expect(int.parse(playlistHead.headers['content-length']!), greaterThan(0)); + expect(playlistHead.bytes, isEmpty); + + final playlist = await _request(playlistUrl); + expect(playlist.statusCode, 200); + final text = String.fromCharCodes(playlist.bytes); + expect(text, startsWith('#EXTM3U')); + final segmentPath = text + .split('\n') + .map((l) => l.trim()) + .firstWhere((l) => l.isNotEmpty && !l.startsWith('#')); + final segment = Uri.parse(playlistUrl).resolve(segmentPath).toString(); + + final segmentHead = await _request(segment, method: 'HEAD'); + expect(segmentHead.statusCode, 200); + final total = int.parse(segmentHead.headers['content-length']!); + expect(total, greaterThan(0)); + expect(segmentHead.headers['accept-ranges'], 'bytes'); + expect(segmentHead.bytes, isEmpty); + + final probe = await _request(segment, headers: const { + 'Range': 'bytes=0-1', + }); + expect(probe.statusCode, 206); + expect(probe.headers['content-length'], '2'); + expect(probe.headers['content-range'], 'bytes 0-1/$total'); + expect(probe.bytes.length, 2); + + final open = await _request(segment, headers: const {'Range': 'bytes=0-'}); + expect(open.statusCode, 206); + expect(open.headers['content-range'], 'bytes 0-${total - 1}/$total'); + expect(open.bytes.length, total); + + final plain = await _request(segment); + expect(plain.statusCode, 200); + expect(plain.headers['content-length'], '$total'); + expect(plain.bytes.length, total); + + final again = await _request(segment, headers: const {'Range': 'bytes=0-'}); + expect(again.statusCode, 206); + expect(again.bytes.length, total); + + // The assertions above are our reading of what AVFoundation needs. Point + // `SOZO_AVPROBE` at a binary that hands the URL to an AVPlayerItem and + // exits non-zero unless it reaches readyToPlay, and the stream is judged + // by the media stack itself instead. Only macOS has one, so it stays + // optional rather than becoming a second thing the suite needs. + final avprobe = Platform.environment['SOZO_AVPROBE']; + if (avprobe != null && avprobe.isNotEmpty) { + final run = await Process.run(avprobe, [playlistUrl]); + // Printed either way: when the probe does play the stream, what it + // reports is the only direct evidence in this suite that AVFoundation + // and not just our reading of it is satisfied. + debugPrint('[AVPROBE] ${run.stdout}'.trim()); + expect( + run.exitCode, + 0, + reason: 'AVFoundation would not play the proxied stream: ' + '${run.stdout}${run.stderr}', + ); + } + }, timeout: const Timeout(Duration(minutes: 3)), skip: !live); +} + +Future<_Res> _request( + String url, { + String method = 'GET', + Map<String, String> headers = const {}, +}) async { + final client = HttpClient(); + try { + final req = await client.openUrl(method, Uri.parse(url)); + headers.forEach(req.headers.set); + final res = await req.close(); + final out = <int>[]; + await for (final chunk in res) { + out.addAll(chunk); + } + final map = <String, String>{}; + res.headers.forEach((name, values) { + map[name.toLowerCase()] = values.join(','); + }); + return _Res(statusCode: res.statusCode, headers: map, bytes: out); + } finally { + client.close(); + } +} + +class _Res { + const _Res({ + required this.statusCode, + required this.headers, + required this.bytes, + }); + + final int statusCode; + final Map<String, String> headers; + final List<int> bytes; +} diff --git a/test/core/player/local_hls_proxy_test.dart b/test/core/player/local_hls_proxy_test.dart index 3dc0f357..0ba5d196 100644 --- a/test/core/player/local_hls_proxy_test.dart +++ b/test/core/player/local_hls_proxy_test.dart @@ -51,6 +51,41 @@ void main() { }, ); + test('a playlist served as plain text is still rewritten', () async { + upstream.respond( + path: '/master.txt', + contentType: 'text/plain', + body: '#EXTM3U\n#EXTINF:5,\n${upstream.origin}/seg.ts\n', + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/master.txt', + headers: const {'Referer': 'https://embed.test/'}, + keepOriginHeaders: true, + ); + final body = await _httpGetString(url); + expect( + body, + isNot(contains(upstream.origin)), + reason: 'A segment address left as it was goes out with no headers', + ); + }); + + test('a segment disguised as a web page passes through whole', () async { + upstream.respond( + path: '/page-1.html', + contentType: 'text/html', + body: 'G@-not-a-playlist-${'x' * 5000}', + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/page-1.html', + headers: const {'Referer': 'https://embed.test/'}, + keepOriginHeaders: true, + ); + final body = await _httpGetString(url); + expect(body.length, 18 + 5000); + expect(body, startsWith('G@-not-a-playlist-')); + }); + test( 'image-wrapped TS segments use relay but playlists stay on device', () async { @@ -74,6 +109,115 @@ void main() { }, ); + test( + 'a full-screen ad spliced into a playlist never reaches the player', + () async { + upstream.respond( + path: '/v.m3u8', + contentType: 'application/vnd.apple.mpegurl', + body: + '#EXTM3U\n#EXT-X-TARGETDURATION:8\n#EXTINF:4.0,\nfilm1.ts\n' + '#EXT-X-DISCONTINUITY\n#EXTINF:3.0,\n/v8/abc/segment_0001.ts\n' + '#EXTINF:3.0,\n/v8/abc/segment_0002.ts\n' + '#EXT-X-DISCONTINUITY\n#EXTINF:4.0,\nfilm2.ts\n#EXT-X-ENDLIST\n', + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/v.m3u8', + headers: const {}, + dropSegments: r'^/v\d+/abc/', + ); + final text = (await dio.get<String>(url)).data!; + expect(text, isNot(contains('segment_'))); + expect(RegExp('#EXTINF').allMatches(text), hasLength(2)); + expect(RegExp('film[12].ts').allMatches(text), hasLength(2)); + expect( + RegExp('#EXT-X-DISCONTINUITY').allMatches(text), + hasLength(1), + reason: 'The film still jumps there, once', + ); + }, + ); + + test( + 'a segment wrapped in an image reaches the player as bare TS', + () async { + final ts = List<int>.generate( + 188 * 3, + (i) => i % 188 == 0 ? 0x47 : 0x11, + ); + upstream.respondBytes( + path: '/file-tiktok_1.png', + contentType: 'image/png', + body: [..._tinyPng, ...List.filled(37, 0xFF), ...ts], + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/v.m3u8', + headers: const {}, + ); + final res = await dio.get<List<int>>( + url.replaceFirst('/v.m3u8', '/file-tiktok_1.png'), + options: Options(responseType: ResponseType.bytes), + ); + expect(res.data, ts); + expect(res.headers.value('content-type'), 'video/mp2t'); + }, + ); + + test( + 'a wrapped segment opened with a Range from the top is unwrapped too', + () async { + final ts = List<int>.generate( + 188 * 3, + (i) => i % 188 == 0 ? 0x47 : 0x11, + ); + final file = [..._tinyPng, ...List.filled(37, 0xFF), ...ts]; + upstream.respondBytes( + path: '/file-tiktok_2.png', + status: 206, + contentType: 'image/png', + headers: { + 'content-range': 'bytes 0-${file.length - 1}/${file.length}', + }, + body: file, + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/v.m3u8', + headers: const {}, + ); + final res = await dio.get<List<int>>( + url.replaceFirst('/v.m3u8', '/file-tiktok_2.png'), + options: Options( + responseType: ResponseType.bytes, + headers: {'Range': 'bytes=0-'}, + ), + ); + // The open-ended range is answered as a 206 over the unwrapped bytes, + // not as a 200 carrying them: a 200 in reply to a Range is what + // AVPlayer gives up on, and the length it is given has to be the TS + // left after the picture rather than the upstream file's. + expect(res.statusCode, 206); + expect(res.headers.value('content-range'), 'bytes 0-${ts.length - 1}/${ts.length}'); + expect(res.data, ts); + }, + ); + + test('a real picture passes through untouched', () async { + upstream.respondBytes( + path: '/poster.png', + contentType: 'image/png', + body: _tinyPng, + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/v.m3u8', + headers: const {}, + ); + final res = await dio.get<List<int>>( + url.replaceFirst('/v.m3u8', '/poster.png'), + options: Options(responseType: ResponseType.bytes), + ); + expect(res.data, _tinyPng); + }); + test('does not forward Origin to upstream', () async { upstream.respond( path: '/cdn/manifest/video/x.m3u8', @@ -643,3 +787,9 @@ String _mpd(String base) => </AdaptationSet> </Period> </MPD>'''; + +/// The smallest PNG shape the unwrapping looks at: signature, then IEND and its CRC. +const _tinyPng = <int>[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // + 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +]; diff --git a/test/core/player/media_kit_video_configuration_test.dart b/test/core/player/media_kit_video_configuration_test.dart new file mode 100644 index 00000000..19cd9d7d --- /dev/null +++ b/test/core/player/media_kit_video_configuration_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/media_controller.dart'; + +void main() { + test('Linux avoids the OpenGL interop', () { + expect(mediaKitVideoConfiguration(isLinux: true).hwdec, 'auto-copy'); + }); + + test('other platforms keep the library default', () { + expect(mediaKitVideoConfiguration(isLinux: false).hwdec, isNull); + }); +} diff --git a/test/core/player/mpv_auto_quality_test.dart b/test/core/player/mpv_auto_quality_test.dart new file mode 100644 index 00000000..77d52fe1 --- /dev/null +++ b/test/core/player/mpv_auto_quality_test.dart @@ -0,0 +1,113 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/media_controller.dart'; +import 'package:soplay/core/player/mpv_auto_quality.dart'; + +Duration s(num v) => Duration(milliseconds: (v * 1000).round()); + +void main() { + group('starting rung', () { + test('Auto opens on the top rendition on Wi-Fi', () { + expect( + MpvAutoQuality.startBitrate( + preference: 0, + network: NetworkKind.unmetered, + ), + 'max', + ); + expect( + MpvAutoQuality.startBitrate( + preference: 0, + network: NetworkKind.unknown, + ), + 'max', + ); + }); + + test('Auto is capped at 720p on mobile data', () { + expect( + MpvAutoQuality.startBitrate( + preference: 0, + network: NetworkKind.cellular, + ), + PlayerController.hlsBitrateFor(720), + ); + }); + + test('a pinned height ignores the network', () { + expect( + MpvAutoQuality.startBitrate( + preference: 1080, + network: NetworkKind.cellular, + ), + PlayerController.hlsBitrateFor(1080), + ); + }); + }); + + group('step down', () { + test('one rung below what is playing', () { + expect( + MpvAutoQuality.stepDown(currentHeight: 1080, current: 'max'), + PlayerController.hlsBitrateFor(720), + ); + expect( + MpvAutoQuality.stepDown(currentHeight: 720, current: '3500000'), + PlayerController.hlsBitrateFor(480), + ); + expect( + MpvAutoQuality.stepDown(currentHeight: 360, current: '1000000'), + 'min', + ); + expect( + MpvAutoQuality.stepDown(currentHeight: 240, current: 'min'), + isNull, + ); + }); + + test('without a height, one bitrate rung', () { + expect( + MpvAutoQuality.stepDown(current: 'max'), + PlayerController.hlsBitrateFor(1080), + ); + expect( + MpvAutoQuality.stepDown(current: PlayerController.hlsBitrateFor(720)), + PlayerController.hlsBitrateFor(480), + ); + expect(MpvAutoQuality.stepDown(current: 'min'), isNull); + }); + }); + + group('stall watchdog', () { + test('three stalls inside a minute trip it, then it counts afresh', () { + final w = StallWatchdog(); + expect(w.recordStall(s(0)), isFalse); + expect(w.recordStall(s(20)), isFalse); + expect(w.recordStall(s(40)), isTrue); + expect(w.recordStall(s(45)), isFalse); + }); + + test('stalls spread over more than a minute do not', () { + final w = StallWatchdog(); + expect(w.recordStall(s(0)), isFalse); + expect(w.recordStall(s(40)), isFalse); + expect(w.recordStall(s(70)), isFalse); + expect(w.recordStall(s(130)), isFalse); + }); + + test('the buffering after a seek is not counted', () { + final w = StallWatchdog(); + w.recordStall(s(0)); + w.recordStall(s(10)); + w.noteSeek(s(19)); + expect(w.recordStall(s(20)), isFalse); + expect(w.recordStall(s(30)), isTrue); + }); + + test('after an undo it stays quiet', () { + final w = StallWatchdog()..disable(); + for (var i = 0; i < 5; i++) { + expect(w.recordStall(s(i)), isFalse); + } + }); + }); +} diff --git a/test/core/player/playback_readiness_test.dart b/test/core/player/playback_readiness_test.dart index 08449743..a8cd2c89 100644 --- a/test/core/player/playback_readiness_test.dart +++ b/test/core/player/playback_readiness_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:soplay/core/player/playback_readiness.dart'; @@ -152,4 +153,239 @@ void main() { throwsStateError, ); }); + + group('picture timeout', () { + test('a known video track with no picture is a stalled decoder', () { + fakeAsync((async) { + Object? failure; + awaitPlaybackReadiness( + read: sample, + signals: [], + canceled: Completer<void>().future, + pictureTimeout: const Duration(seconds: 12), + ).catchError((Object e) => failure = e); + async.elapse(const Duration(seconds: 11)); + expect(failure, isNull); + async.elapse(const Duration(seconds: 2)); + expect(failure, isA<TimeoutException>()); + }); + }); + + test('is not counted before the track list exists', () async { + // A slow host's long open is the network's time, not the decoder's. + // Real time, scaled down: the readiness wait awaits its signal + // subscriptions' cancel, which fake time cannot see through. + var state = sample(video: false, audio: false); + final signals = StreamController<void>.broadcast(sync: true); + final waiting = awaitPlaybackReadiness( + read: () => state, + signals: [signals.stream], + canceled: Completer<void>().future, + pictureTimeout: const Duration(milliseconds: 150), + ); + await Future<void>.delayed(const Duration(milliseconds: 300)); + state = sample(); + signals.add(null); + await Future<void>.delayed(const Duration(milliseconds: 20)); + state = sample(width: 1920, height: 1080); + signals.add(null); + await waiting; + await signals.close(); + }); + + test( + 'a stall the network caused can still be told apart by the caller', + () { + // The controller turns this timeout into the held network error; the + // fallback only sees decoder-shaped failures and timeouts. + expect( + initializeWithDecoderFallback( + initialize: () async => + throw StateError('tcp: ffurl_read returned'), + canFallback: () => true, + fallback: () async => fail('no engine swap for a network failure'), + ), + throwsStateError, + ); + }, + ); + }); + + group('startup budget', () { + test('every kind of remote start is bounded; a file on disk is not', () { + expect(startupBudget(local: true), isNull); + expect(startupBudget(local: false), const Duration(seconds: 25)); + expect( + startupBudget(local: false, sniffed: true), + const Duration(seconds: 35), + ); + expect( + startupBudget(local: false, torrent: true), + const Duration(seconds: 90), + ); + expect( + startupBudget(local: false, live: true), + const Duration(seconds: 30), + ); + }); + + test('the plain budget leaves room for the slowest healthy start', () { + // log_2e4886: vidapi HLS on mobile data took 17-19 s to its first frame. + expect( + startupBudget(local: false)! > const Duration(seconds: 19), + isTrue, + ); + }); + + test('a start that never finishes times out', () { + fakeAsync((async) { + Object? failure; + withinStartupBudget( + Completer<void>().future, + budget: const Duration(seconds: 25), + ).catchError((Object e) => failure = e); + async.elapse(const Duration(seconds: 24)); + expect(failure, isNull); + async.elapse(const Duration(seconds: 2)); + expect(failure, isA<TimeoutException>()); + }); + }); + + test('a start that finishes in time passes, and its error passes', () { + fakeAsync((async) { + var done = false; + Object? failure; + withinStartupBudget( + Future<void>.delayed(const Duration(seconds: 3)), + budget: const Duration(seconds: 25), + ).then((_) => done = true); + withinStartupBudget( + Future<void>.delayed( + const Duration(seconds: 3), + () => throw StateError('Failed to open'), + ), + budget: const Duration(seconds: 25), + ).catchError((Object e) => failure = e); + async.elapse(const Duration(seconds: 4)); + expect(done, isTrue); + expect(failure, isStateError); + }); + }); + + test('an engine swap gets its own allowance, once', () { + fakeAsync((async) { + final swaps = StreamController<void>.broadcast(sync: true); + Object? failure; + withinStartupBudget( + Completer<void>().future, + budget: const Duration(seconds: 25), + restarts: swaps.stream, + ).catchError((Object e) => failure = e); + async.elapse(const Duration(seconds: 20)); + swaps.add(null); // libmpv gave way to the platform player + async.elapse(const Duration(seconds: 20)); + expect(failure, isNull, reason: '40 s in, 20 s into the new engine'); + swaps.add(null); // a second swap does not extend it again + async.elapse(const Duration(seconds: 6)); + expect(failure, isA<TimeoutException>()); + }); + }); + + test('a start that fails after its deadline does not surface', () { + fakeAsync((async) { + final late = Completer<void>(); + Object? failure; + withinStartupBudget( + late.future, + budget: const Duration(seconds: 1), + ).catchError((Object e) => failure = e); + async.elapse(const Duration(seconds: 2)); + expect(failure, isA<TimeoutException>()); + late.completeError(StateError('Player disposed')); + async.flushMicrotasks(); + expect(failure, isA<TimeoutException>()); + }); + }); + }); + + group('a network error held while playing', () { + NetworkStall judge({ + Duration position = const Duration(minutes: 10), + bool playing = true, + bool completed = false, + }) => judgeNetworkStall( + stalledAt: const Duration(minutes: 10), + position: position, + playing: playing, + completed: completed, + duration: const Duration(minutes: 23), + ); + + test('is forgotten once the playhead moves on', () { + expect( + judge(position: const Duration(minutes: 10, seconds: 20)), + NetworkStall.recovered, + ); + // Seeked back past the stall and watching again. + expect( + judge(position: const Duration(minutes: 2)), + NetworkStall.recovered, + ); + }); + + test('is fatal when playback is stuck on it', () { + expect(judge(), NetworkStall.fatal); + }); + + test('is fatal when the file ended early on it', () { + expect(judge(playing: false, completed: true), NetworkStall.fatal); + }); + + test('is fatal when the cache carried the playhead past it first', () { + // Read-ahead kept playing for a minute after the drop, then ran dry. + expect( + judge( + position: const Duration(minutes: 11, seconds: 30), + playing: false, + completed: true, + ), + NetworkStall.fatal, + ); + }); + + test('is fatal when a stream without a length ends on it', () { + expect( + judgeNetworkStall( + stalledAt: const Duration(minutes: 10), + position: const Duration(minutes: 12), + playing: false, + completed: true, + duration: Duration.zero, + ), + NetworkStall.fatal, + ); + }); + + test('is nothing at the real end of the file', () { + expect( + judge( + position: const Duration(minutes: 23), + playing: false, + completed: true, + ), + NetworkStall.recovered, + ); + }); + + test('waits while the viewer has paused', () { + expect(judge(playing: false), NetworkStall.undecided); + }); + }); + + test('a decoder line about one packet is a glitch, a codec that will not open is not', () { + expect(isDecodeGlitch('Error decoding audio.'), isTrue); + expect(isDecodeGlitch('[ffmpeg/video] h264: Error while decoding frame!'), isTrue); + expect(isDecodeGlitch('Could not open codec.'), isFalse); + expect(isDecodeGlitch('tcp: ffurl_read returned 0xffffff92'), isFalse); + }); } diff --git a/test/core/player/player_dock_geometry_test.dart b/test/core/player/player_dock_geometry_test.dart new file mode 100644 index 00000000..b00b553f --- /dev/null +++ b/test/core/player/player_dock_geometry_test.dart @@ -0,0 +1,176 @@ +// Where the desktop corner player sits is plain arithmetic, so it is pinned +// here without a window: the four corners at the margin, the size clamped to +// what leaves the page under it usable, the snap on release, and the resize +// grips growing the box toward the middle of the window. +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/player_dock.dart'; + +void main() { + const area = Size(1200, 800); + const m = DockGeometry.margin; + + group('rectFor', () { + const size = Size(400, 225); + + test('puts each corner at the margin', () { + expect( + DockGeometry.rectFor(DockCorner.topLeft, size, area), + const Rect.fromLTWH(m, m, 400, 225), + ); + expect( + DockGeometry.rectFor(DockCorner.topRight, size, area), + const Rect.fromLTWH(1200 - 400 - m, m, 400, 225), + ); + expect( + DockGeometry.rectFor(DockCorner.bottomLeft, size, area), + const Rect.fromLTWH(m, 800 - 225 - m, 400, 225), + ); + expect( + DockGeometry.rectFor(DockCorner.bottomRight, size, area), + const Rect.fromLTWH(1200 - 400 - m, 800 - 225 - m, 400, 225), + ); + }); + }); + + group('sizeFor', () { + test('keeps the picture shape', () { + expect(DockGeometry.sizeFor(400, 16 / 9, area), const Size(400, 225)); + }); + + test('never smaller than the minimum', () { + expect(DockGeometry.sizeFor(10, 16 / 9, area).width, 240); + }); + + test('never more than the share of the window', () { + expect(DockGeometry.sizeFor(5000, 16 / 9, area).width, 1200 * 0.6); + }); + + test('a short window caps it by height instead', () { + const short = Size(1600, 300); + final s = DockGeometry.sizeFor(5000, 16 / 9, short); + expect(s.height, lessThanOrEqualTo(300 - 2 * m + 1)); + }); + + test('a window narrower than the minimum still gets the minimum', () { + expect( + DockGeometry.sizeFor(400, 16 / 9, const Size(200, 150)).width, + 240, + ); + }); + }); + + group('clampAspect', () { + test('maps nonsense to 16:9 and extremes into range', () { + expect(DockGeometry.clampAspect(0), 16 / 9); + expect(DockGeometry.clampAspect(double.nan), 16 / 9); + expect(DockGeometry.clampAspect(-2), 16 / 9); + expect(DockGeometry.clampAspect(10), 2.6); + expect(DockGeometry.clampAspect(0.1), 0.5); + expect(DockGeometry.clampAspect(4 / 3), 4 / 3); + }); + }); + + group('nearest', () { + Rect at(Offset centre) => + Rect.fromCenter(center: centre, width: 300, height: 170); + + test('picks the quarter the centre is in', () { + expect( + DockGeometry.nearest(at(const Offset(200, 150)), Offset.zero, area), + DockCorner.topLeft, + ); + expect( + DockGeometry.nearest(at(const Offset(1000, 150)), Offset.zero, area), + DockCorner.topRight, + ); + expect( + DockGeometry.nearest(at(const Offset(200, 700)), Offset.zero, area), + DockCorner.bottomLeft, + ); + expect( + DockGeometry.nearest(at(const Offset(1000, 700)), Offset.zero, area), + DockCorner.bottomRight, + ); + }); + + test('a flick from the middle lands where it was thrown', () { + // Released just right of and below the middle, but flung up-left. + final r = at(const Offset(620, 420)); + expect( + DockGeometry.nearest(r, const Offset(-1500, -1500), area), + DockCorner.topLeft, + ); + expect( + DockGeometry.nearest(r, Offset.zero, area), + DockCorner.bottomRight, + ); + }); + }); + + group('resized', () { + test('grows toward the middle of the window from every corner', () { + // Left corners: the grip is on the right, so dragging right grows. + expect( + DockGeometry.resized(400, const Offset(50, 0), DockCorner.topLeft, 1), + 450, + ); + expect( + DockGeometry.resized( + 400, + const Offset(50, 0), + DockCorner.bottomLeft, + 1, + ), + 450, + ); + // Right corners: the grip is on the left, so dragging left grows. + expect( + DockGeometry.resized(400, const Offset(-50, 0), DockCorner.topRight, 1), + 450, + ); + expect( + DockGeometry.resized( + 400, + const Offset(-50, 0), + DockCorner.bottomRight, + 1, + ), + 450, + ); + }); + + test('a vertical pull changes the width through the aspect', () { + // Bottom corners: the grip is on top, so dragging up grows. + expect( + DockGeometry.resized( + 400, + const Offset(0, -10), + DockCorner.bottomRight, + 2, + ), + 420, + ); + expect( + DockGeometry.resized(400, const Offset(0, 10), DockCorner.topLeft, 2), + 420, + ); + }); + }); + + test('dragged keeps the box inside the window', () { + const r = Rect.fromLTWH(100, 100, 300, 170); + expect( + DockGeometry.dragged(r, const Offset(-500, -500), area), + const Rect.fromLTWH(0, 0, 300, 170), + ); + expect( + DockGeometry.dragged(r, const Offset(5000, 5000), area), + const Rect.fromLTWH(900, 630, 300, 170), + ); + expect( + DockGeometry.dragged(r, const Offset(10, 20), area), + const Rect.fromLTWH(110, 120, 300, 170), + ); + }); +} diff --git a/test/core/player/quality_preference_test.dart b/test/core/player/quality_preference_test.dart index df800403..8fccb2e6 100644 --- a/test/core/player/quality_preference_test.dart +++ b/test/core/player/quality_preference_test.dart @@ -185,4 +185,74 @@ void main() { isTrue, ); }); + + group('pinFor', () { + test('the viewer\'s preference wins on every engine', () { + for (final adaptive in [true, false]) { + expect( + QualityPreference.pinFor( + preference: 720, + labelledHeight: 1080, + adaptiveEngine: adaptive, + ), + 720, + ); + expect( + QualityPreference.pinFor( + preference: QualityPreference.dataSaver, + labelledHeight: 1080, + adaptiveEngine: adaptive, + ), + QualityPreference.dataSaver, + ); + } + }); + + test('on an adaptive engine, Auto pins the height the label names', () { + // ExoPlayer starting a "1080p" master on its bottom rung is the + // "1080p looks like 480p" report. + expect( + QualityPreference.pinFor( + preference: QualityPreference.auto, + labelledHeight: 1080, + adaptiveEngine: true, + ), + 1080, + ); + }); + + test('libmpv keeps its own Auto, which opens the top rendition', () { + expect( + QualityPreference.pinFor( + preference: QualityPreference.auto, + labelledHeight: 1080, + adaptiveEngine: false, + ), + QualityPreference.auto, + ); + }); + + test('an unlabelled server stays adaptive', () { + for (final h in [null, 0]) { + expect( + QualityPreference.pinFor( + preference: QualityPreference.auto, + labelledHeight: h, + adaptiveEngine: true, + ), + QualityPreference.auto, + ); + } + }); + + test('the labelled pin picks the closest rendition at or below it', () { + final pin = QualityPreference.pinFor( + preference: QualityPreference.auto, + labelledHeight: 720, + adaptiveEngine: true, + ); + expect(QualityPreference.pick([1080, 720, 480], pin), 720); + expect(QualityPreference.pick([1080, 540, 360], pin), 540); + }); + }); } diff --git a/test/core/player/shader_presets_test.dart b/test/core/player/shader_presets_test.dart index d55a8ada..a1fd3e16 100644 --- a/test/core/player/shader_presets_test.dart +++ b/test/core/player/shader_presets_test.dart @@ -91,15 +91,47 @@ void main() { expect(names.toSet().length, names.length); }); - test('no filename carries the list separator mpv uses', () { - // The chain is handed to mpv joined by ':'. A name containing one would - // split into two bogus paths. + test('no filename carries either list separator mpv uses', () { + // The chain is handed to mpv joined by ':' or, on Windows, ';'. A name + // containing one would split into two bogus paths. for (final f in ShaderPreset.allFiles) { - expect(f.split('/').last.contains(':'), isFalse, reason: f); + final name = f.split('/').last; + expect(name.contains(':'), isFalse, reason: f); + expect(name.contains(';'), isFalse, reason: f); } }); }); + group('the glsl-shaders property', () { + const chain = [ + r'C:\Users\L\AppData\Roaming\Sozo\Sozo/shaders/Anime4K_Clamp_Highlights.glsl', + r'C:\Users\L\AppData\Roaming\Sozo\Sozo/shaders/Anime4K_Upscale_CNN_x2_M.glsl', + ]; + + test('is separated by ; on Windows, where : is the drive letter', () { + // Joined by ':' there, mpv reads the whole chain as one filename and + // takes video output down with it (ticket 6ae742f9). + final property = shaderChainProperty(chain, windows: true); + expect(property, '${chain[0]};${chain[1]}'); + expect(property.split(';').length, 2); + }); + + test('is separated by : everywhere else', () { + expect( + shaderChainProperty(const ['/a/one.glsl', '/a/two.glsl'], + windows: false), + '/a/one.glsl:/a/two.glsl', + ); + }); + + test('one shader is the path itself, on either platform', () { + expect(shaderChainProperty(const ['/a/one.glsl'], windows: true), + '/a/one.glsl'); + expect(shaderChainProperty(const ['/a/one.glsl'], windows: false), + '/a/one.glsl'); + }); + }); + group('tiers', () { test('an unknown tier is the safe one', () { expect(ShaderTier.fromId(null), ShaderTier.mid); diff --git a/test/core/preview/frame_preview_service_test.dart b/test/core/preview/frame_preview_service_test.dart index cb631c43..c06b9b51 100644 --- a/test/core/preview/frame_preview_service_test.dart +++ b/test/core/preview/frame_preview_service_test.dart @@ -318,4 +318,54 @@ audio.m3u8 'https://cdn.example/a/240.m3u8', ); }); + + test( + 'timeouts and refused opens count as failures, and a frame clears them', + () async { + var answer = false; + final session = FramePreviewSession( + supported: true, + openTimeout: const Duration(milliseconds: 5), + frameTimeout: const Duration(milliseconds: 5), + invoke: (method, args) async { + if (method == 'close') return true; + if (answer) return method == 'frame' ? Uint8List.fromList([1]) : true; + return Completer<dynamic>().future; // A host that never answers. + }, + ); + await session.open('https://media.example/dead.mp4', {}); + for (var i = 0; i < 3; i++) { + expect(await session.frame(i * 20000), isNull); + } + expect(session.consecutiveFailures, 3); + expect( + session.failing, + isTrue, + reason: 'The card stops showing a skeleton', + ); + + answer = true; + expect(await session.frame(100000), [1]); + expect(session.consecutiveFailures, 0); + expect(session.failing, isFalse); + await session.close(); + }, + ); + + test( + 'moving a cached frame to the grid keeps the byte count honest', + () async { + final session = FramePreviewSession( + supported: true, + invoke: (method, args) async => + method == 'frame' ? Uint8List(1000) : true, + ); + await session.open('https://media.example/video.mp4', {}); + await session.frame(10000); + expect(session.debugCacheBytes, 1000); + await session.gridFrame(10000); + expect(session.debugCacheBytes, 0); + await session.close(); + }, + ); } diff --git a/test/core/preview/preview_never_loads_test.dart b/test/core/preview/preview_never_loads_test.dart new file mode 100644 index 00000000..7d1e4fa0 --- /dev/null +++ b/test/core/preview/preview_never_loads_test.dart @@ -0,0 +1,240 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/preview/frame_preview_service.dart'; + +/// The decoder as it behaves on a slow host: a frame takes [frameTakes] to +/// come back, and nothing the viewer does makes the host any faster. +class SlowDecoder { + SlowDecoder({required this.frameTakes}); + + final Duration frameTakes; + int frameCalls = 0; + int closes = 0; + + Future<dynamic> call(String method, Map<String, dynamic>? args) async { + switch (method) { + case 'open': + return true; + case 'close': + closes++; + return true; + default: + frameCalls++; + await Future<void>.delayed(frameTakes); + return Uint8List.fromList([7]); + } + } +} + +void main() { + const url = 'https://cdn.test/v/master.m3u8'; + + FramePreviewSession production(SlowDecoder decoder) => FramePreviewSession( + supported: true, + invoke: decoder.call, + openTimeout: FramePreviewService.openAllowance, + frameTimeout: FramePreviewService.frameAllowance, + ); + + test( + 'a 2.3 MB segment on a 2 Mbit/s connection (about 9 s) gets its frame', + () { + fakeAsync((clock) { + final decoder = SlowDecoder(frameTakes: const Duration(seconds: 9)); + final session = production(decoder); + Uint8List? got; + session.open(url, const {}).then((_) { + session.frame(60000).then((b) => got = b); + }); + clock.elapse(const Duration(seconds: 30)); + expect(got, isNotNull, reason: 'The frame arrived and was thrown away'); + }); + }, + ); + + test( + 'a finger lifted while a frame is decoding does not lose that frame', + () { + fakeAsync((clock) { + final decoder = SlowDecoder(frameTakes: const Duration(seconds: 4)); + final session = production(decoder); + session.open(url, const {}).then((_) => session.frame(60000)); + clock.elapse(const Duration(seconds: 1)); + session.endScrub(); // The drag was over in a second. + clock.elapse(const Duration(seconds: 10)); + expect( + session.nearest(60000), + isNotNull, + reason: 'Short drags on a slow host would never show a picture', + ); + }); + }, + ); + + test( + 'short drags on a slow host end with a picture, not a restart each time', + () { + fakeAsync((clock) { + final decoder = SlowDecoder(frameTakes: const Duration(seconds: 4)); + final session = production(decoder); + session.open(url, const {}); + clock.elapse(Duration.zero); + for (var drag = 0; drag < 5; drag++) { + session.frame(60000); + clock.elapse(const Duration(seconds: 1)); + session.endScrub(); + clock.elapse(const Duration(seconds: 2)); + } + clock.elapse(const Duration(seconds: 10)); + expect(session.nearest(60000), isNotNull); + expect( + decoder.frameCalls, + lessThanOrEqualTo(2), + reason: 'Every drag paid for the same segment again', + ); + }); + }, + ); + + test('a frame that lands after the source changed is not kept', () { + fakeAsync((clock) { + final decoder = SlowDecoder(frameTakes: const Duration(seconds: 4)); + final session = production(decoder); + session.open(url, const {}).then((_) => session.frame(60000)); + clock.elapse(const Duration(seconds: 1)); + session.open('https://cdn.test/other/master.m3u8', const {}); + clock.elapse(const Duration(seconds: 10)); + expect(session.nearest(60000), isNull); + }); + }); + + test('a host that was down for a while is tried again', () { + fakeAsync((clock) { + var up = false; + final session = FramePreviewSession( + supported: true, + openTimeout: const Duration(seconds: 1), + frameTimeout: const Duration(seconds: 1), + idleTimeout: const Duration(days: 1), + invoke: (method, args) async { + if (method != 'frame') return true; + if (up) return Uint8List.fromList([1]); + return Completer<dynamic>().future; + }, + ); + final warmer = PreviewGridWarmer( + session: session, + url: url, + headers: const {}, + durationMs: 1200000, + count: 40, + pace: const Duration(seconds: 1), + isScrubbing: () => false, + )..start(); + clock.elapse(const Duration(minutes: 1)); + expect(session.consecutiveFailures, greaterThanOrEqualTo(6)); + final tries = session.consecutiveFailures; + clock.elapse(const Duration(seconds: 20)); + expect( + session.consecutiveFailures, + lessThanOrEqualTo(tries + 1), + reason: 'A dead host is not hammered while the warmer rests', + ); + up = true; + clock.elapse(const Duration(minutes: 3)); + expect( + session.nearest(600000, withinMs: 1200000), + isNotNull, + reason: 'A network blip must not end the grid for the whole episode', + ); + warmer.stop(); + clock.elapse(const Duration(hours: 1)); + }); + }); + + group('a stream the retriever cannot read goes to the extractor', () { + Uint8List frame() => Uint8List.fromList([9]); + + test('a refused open', () async { + final seen = <String>[]; + final decoder = FallbackDecoder( + primary: (m, a) async => m == 'open' ? false : null, + secondary: (m, a) async { + seen.add(m); + return m == 'open' ? true : frame(); + }, + ); + expect(await decoder.call('open', {'url': url}), isTrue); + expect(await decoder.call('frame', {'posMs': 0}), [9]); + expect(seen, ['open', 'frame']); + }); + + test('an open that never comes back', () { + fakeAsync((clock) { + Object? opened; + final decoder = FallbackDecoder( + primary: (m, a) => Completer<dynamic>().future, + secondary: (m, a) async => true, + ); + decoder.call('open', {'url': url}).then((v) => opened = v); + clock.elapse(const Duration(seconds: 11)); + expect(opened, isTrue, reason: 'Inside the session allowance of 15 s'); + expect(decoder.openLimit, lessThan(FramePreviewService.openAllowance)); + }); + }); + + test('an open that throws', () async { + final decoder = FallbackDecoder( + primary: (m, a) => Future<dynamic>.error(StateError('no retriever')), + secondary: (m, a) async => true, + ); + expect(await decoder.call('open', {'url': url}), isTrue); + }); + + test('a retriever that opens but only ever gives nulls', () async { + final decoder = FallbackDecoder( + primary: (m, a) async => m == 'open' ? true : null, + secondary: (m, a) async => m == 'open' ? true : frame(), + ); + await decoder.call('open', {'url': url, 'generation': 1}); + expect(await decoder.call('frame', {'generation': 1}), isNull); + expect(await decoder.call('frame', {'generation': 1}), [9]); + }); + + test('one miss from a working retriever changes nothing', () async { + var calls = 0; + var secondary = 0; + final decoder = FallbackDecoder( + primary: (m, a) async { + if (m == 'open') return true; + return ++calls == 1 ? null : frame(); + }, + secondary: (m, a) async { + secondary++; + return true; + }, + ); + await decoder.call('open', {'url': url}); + expect(await decoder.call('frame', const {}), isNull); + expect(await decoder.call('frame', const {}), [9]); + expect(secondary, 0); + }); + + test('a new stream starts on the retriever again', () async { + var primaryOpens = 0; + final decoder = FallbackDecoder( + primary: (m, a) async { + if (m == 'open') primaryOpens++; + return m == 'open' ? primaryOpens > 1 : null; + }, + secondary: (m, a) async => true, + ); + await decoder.call('open', {'url': url}); + await decoder.call('open', {'url': '$url?other'}); + expect(primaryOpens, 2); + }); + }); +} diff --git a/test/core/router/app_router_extra_redirect_test.dart b/test/core/router/app_router_extra_redirect_test.dart new file mode 100644 index 00000000..4046ee49 --- /dev/null +++ b/test/core/router/app_router_extra_redirect_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/router/app_router.dart'; +import 'package:soplay/features/detail/domain/entities/episodes_args.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/actor_page.dart'; +import 'package:soplay/features/manga/domain/entities/reader_args.dart'; + +final _typed = <String, Object>{ + '/player': const PlayerArgs(title: 't', provider: 'p', headers: {}), + '/reader': const ReaderArgs( + title: 't', + provider: 'p', + contentUrl: 'u', + chapters: [], + initialChapterIndex: 0, + ), + '/episodes': const EpisodesArgs(title: 't', episodes: []), + '/actor': const ActorArgs(name: 'n'), +}; + +GoRouterState _state(String path, Object? extra) => GoRouterState( + AppRouter.router.configuration, + uri: Uri.parse(path), + matchedLocation: path, + path: path, + fullPath: path, + pathParameters: const {}, + extra: extra, + pageKey: ValueKey<String>(path), +); + +GoRoute _route(String path) => AppRouter.router.configuration.routes + .whereType<GoRoute>() + .firstWhere((r) => r.path == path); + +void main() { + testWidgets('routes that need an extra go Home without one', (tester) async { + late BuildContext context; + await tester.pumpWidget( + Builder( + builder: (c) { + context = c; + return const SizedBox.shrink(); + }, + ), + ); + + for (final entry in _typed.entries) { + final redirect = _route(entry.key).redirect; + expect(redirect, isNotNull, reason: entry.key); + expect( + await redirect!(context, _state(entry.key, null)), + '/main', + reason: '${entry.key} with no extra', + ); + expect( + await redirect(context, _state(entry.key, 'wrong type')), + '/main', + reason: '${entry.key} with the wrong extra', + ); + expect( + await redirect(context, _state(entry.key, entry.value)), + isNull, + reason: '${entry.key} with its args', + ); + } + }); +} diff --git a/test/core/storage/player_controls_style_store_test.dart b/test/core/storage/player_controls_style_store_test.dart new file mode 100644 index 00000000..0336ca72 --- /dev/null +++ b/test/core/storage/player_controls_style_store_test.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/storage/hive_service.dart'; + +void main() { + late Directory dir; + late HiveService hive; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo_controls_style_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.authBox); + await Hive.openBox(AppConstants.settingsBox); + hive = HiveService(); + }); + + tearDown(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + test('nothing stored reads as the classic glass', () { + expect(hive.storedPlayerControlsStyle, isNull); + expect(hive.playerControlsStyle, PlayerControlsStyle.classicGlass); + }); + + test('a stored choice is respected', () async { + for (final s in PlayerControlsStyle.values) { + await hive.setPlayerControlsStyle(s); + expect(hive.storedPlayerControlsStyle, s); + expect(hive.playerControlsStyle, s); + } + }); + + test('the offer is remembered as shown', () async { + expect(hive.nativeControlsOffered, isFalse); + await hive.markNativeControlsOffered(); + expect(hive.nativeControlsOffered, isTrue); + }); +} diff --git a/test/core/subtitle_auto_translate_test.dart b/test/core/subtitle_auto_translate_test.dart index efcab1ee..c12dc7cb 100644 --- a/test/core/subtitle_auto_translate_test.dart +++ b/test/core/subtitle_auto_translate_test.dart @@ -9,14 +9,13 @@ AutoTranslateAction decide({ bool alreadyRan = false, bool hasTargetTrack = false, bool hasReadyTranslation = false, -}) => - SubtitleAutoTranslate.decide( - enabled: enabled, - isLive: isLive, - alreadyRan: alreadyRan, - hasTargetTrack: hasTargetTrack, - hasReadyTranslation: hasReadyTranslation, - ); +}) => SubtitleAutoTranslate.decide( + enabled: enabled, + isLive: isLive, + alreadyRan: alreadyRan, + hasTargetTrack: hasTargetTrack, + hasReadyTranslation: hasReadyTranslation, +); void main() { group('free before paid', () { @@ -88,7 +87,10 @@ void main() { expect(SubtitleAutoTranslate.labelMatchesLanguage('en-US', 'en'), isTrue); expect(SubtitleAutoTranslate.labelMatchesLanguage('pt_BR', 'pt'), isTrue); expect( - SubtitleAutoTranslate.labelMatchesLanguage('Brazilian Portuguese', 'pt'), + SubtitleAutoTranslate.labelMatchesLanguage( + 'Brazilian Portuguese', + 'pt', + ), isTrue, ); }); @@ -150,7 +152,10 @@ void main() { }); test('an empty code matches nothing', () { - expect(SubtitleAutoTranslate.labelMatchesLanguage('English', ''), isFalse); + expect( + SubtitleAutoTranslate.labelMatchesLanguage('English', ''), + isFalse, + ); }); test('anyMatches scans a whole track list', () { @@ -171,9 +176,62 @@ void main() { expect(SubtitleAutoTranslate.languageOf('Klingon · 2'), 'klingon'); }); + test('Vietnamese is found under every name subtitle sites give it', () { + for (final label in [ + 'vi', + 'vie', + 'Vietnamese', + 'Tiếng Việt', + 'tieng viet', + 'Vietsub', + ]) { + expect( + SubtitleAutoTranslate.labelMatchesLanguage(label, 'vi'), + isTrue, + reason: label, + ); + } + expect( + SubtitleAutoTranslate.labelMatchesLanguage('English', 'vi'), + isFalse, + ); + }); + + test('Hindi is found under its names, but not in a hearing-impaired tag', () { + for (final label in [ + 'hi', + 'hin', + 'hi-IN', + 'Hindi', + 'हिन्दी', + 'हिंदी', + 'HI · Filemoon', + ]) { + expect( + SubtitleAutoTranslate.labelMatchesLanguage(label, 'hi'), + isTrue, + reason: label, + ); + } + for (final label in [ + 'English (HI)', + 'English HI', + 'en.hi', + 'Deutsch [HI]', + ]) { + expect( + SubtitleAutoTranslate.labelMatchesLanguage(label, 'hi'), + isFalse, + reason: label, + ); + } + expect(SubtitleAutoTranslate.languageOf('English (HI)'), 'en'); + }); + test('the core layer stays free of Flutter', () { - final source = - File('lib/core/subtitles/subtitle_auto_translate.dart').readAsStringSync(); + final source = File( + 'lib/core/subtitles/subtitle_auto_translate.dart', + ).readAsStringSync(); expect(source.contains('package:flutter/'), isFalse); expect(source.contains('get_it'), isFalse); }); diff --git a/test/core/subtitles/subtitle_cipher_test.dart b/test/core/subtitles/subtitle_cipher_test.dart new file mode 100644 index 00000000..14726961 --- /dev/null +++ b/test/core/subtitles/subtitle_cipher_test.dart @@ -0,0 +1,115 @@ +// KissKH `.srt.txt1` tracks: AES-128-CBC cue text, per docs/SUBTITLE_CIPHER.md. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/subtitles/subtitle_cipher.dart'; +import 'package:soplay/core/subtitles/subtitle_parser.dart'; +import 'package:soplay/features/detail/data/models/subtitle_model.dart'; +import 'package:soplay/features/detail/domain/entities/subtitle_entity.dart'; +import 'package:soplay/features/download/data/subtitle_sidecar.dart'; + +const kisskh = SubtitleCipher( + alg: 'aes-128-cbc', + key: 'AmSmZVcH93UQUezi', + iv: 'ReBKWW8cqdjPEnF6', +); + +// The head of Revenged Love Ep1 (English) as the CDN serves it. +const ciphered = + '1\r\n00:00:17,152 --> 00:00:19,208\r\n' + 'Sq/yslS8glo0CoTCWZBT0GepZqSNY4KqATqtSAO1798=\r\n\r\n' + '2\r\n00:00:19,968 --> 00:00:23,584\r\n' + '8eGx+aPSbxKlXGJ6GNMb7lwpbp4jM4y1/LLyz3X0TgP/ogC0dl/OB+meqM2DBows\r\n\r\n' + '3\r\n00:00:23,584 --> 00:00:28,907\r\n' + 'pNx/wGy5Ujioalb4vL7Avv1y7G3gcz9kBHuyjHRWCdGsT56/mF1Oz2kbnUoys2H7SzHzM/xvHGymaa10KzUKUw==\r\n\r\n'; + +void main() { + test('the documented test vector', () async { + expect( + await kisskh.decrypt('Sq/yslS8glo0CoTCWZBT0GepZqSNY4KqATqtSAO1798='), + '<i>♫ Set off ♫</i>', + ); + }); + + test('a real file decrypts and parses, structure untouched', () async { + final plain = await kisskh.decrypt(ciphered); + expect( + plain, + contains('1\r\n00:00:17,152 --> 00:00:19,208\r\n<i>♫ Set off ♫</i>\r\n'), + ); + final cues = parseSubtitleBytes( + await kisskh.decryptBytes(utf8.encode(ciphered)), + url: 'https://sub.cdnvideo11.shop/x.en.srt.txt1?v=1', + ).captions; + expect(cues.map((c) => c.text), [ + '♫ Set off ♫', + '♫ With the latte I just brewed ♫', + '♫ And the long-lost sunshine in early summer ♫', + ]); + expect(cues.first.start, const Duration(seconds: 17, milliseconds: 152)); + }); + + test('a line that will not open is kept, the rest still decrypt', () async { + final out = await kisskh.decrypt( + '${ciphered}4\n00:00:30,000 --> 00:00:31,000\nplain words\n', + ); + expect(out, contains('plain words')); + expect(out, contains('<i>♫ Set off ♫</i>')); + }); + + test('a wrong key or an unknown cipher fails the track', () async { + const wrong = SubtitleCipher( + alg: 'aes-128-cbc', + key: 'AAAAAAAAAAAAAAAA', + iv: 'ReBKWW8cqdjPEnF6', + ); + await expectLater( + wrong.decrypt(ciphered), + throwsA(isA<SubtitleCipherException>()), + ); + const other = SubtitleCipher(alg: 'aes-256-gcm', key: 'k', iv: 'i'); + expect(other.isSupported, isFalse); + await expectLater( + other.decrypt(ciphered), + throwsA(isA<SubtitleCipherException>()), + ); + }); + + test('the model reads the cipher and only a complete one', () { + final sub = SubtitleModel.fromJson({ + 'label': 'English', + 'src': 'https://sub.cdnvideo11.shop/a.en.srt.txt1', + 'cipher': { + 'alg': 'aes-128-cbc', + 'key': 'AmSmZVcH93UQUezi', + 'iv': 'ReBKWW8cqdjPEnF6', + 'scope': 'cue-text', + 'encoding': 'base64', + }, + }); + expect(sub.file, endsWith('.txt1')); + expect(sub.cipher!.isSupported, isTrue); + expect(SubtitleModel.fromJson({'file': 'a.srt'}).cipher, isNull); + expect(SubtitleCipher.fromJson({'alg': 'aes-128-cbc', 'key': ''}), isNull); + expect(SubtitleCipher.fromJson('aes'), isNull); + }); + + test('a downloaded ciphered track is kept decrypted', () async { + final root = await Directory.systemTemp.createTemp('sozo_cipher_root_'); + final src = await Directory.systemTemp.createTemp('sozo_cipher_src_'); + addTearDown(() async { + await root.delete(recursive: true); + await src.delete(recursive: true); + }); + final f = File('${src.path}/a.en.srt.txt1')..writeAsStringSync(ciphered); + final sidecar = SubtitleSidecar(root: () async => root); + await sidecar.save('ep', [ + SubtitleEntity(label: 'English', file: f.path, cipher: kisskh), + ]); + final back = await sidecar.load('ep'); + expect(back.single.file, endsWith('.srt')); + expect(back.single.cipher, isNull); + expect(await File(back.single.file).readAsString(), contains('Set off')); + }); +} diff --git a/test/core/system/app_orientation_test.dart b/test/core/system/app_orientation_test.dart new file mode 100644 index 00000000..127ffda0 --- /dev/null +++ b/test/core/system/app_orientation_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/system/app_orientation.dart'; + +void main() { + test('600dp shortest side is a large screen', () { + expect(AppOrientation.isLargeScreen(const Size(393, 852)), isFalse); + expect(AppOrientation.isLargeScreen(const Size(852, 393)), isFalse); + expect(AppOrientation.isLargeScreen(const Size(820, 1180)), isTrue); + expect(AppOrientation.isLargeScreen(const Size(673, 841)), isTrue); + expect(AppOrientation.isLargeScreen(Size.zero), isFalse); + }); + + test('phones keep the portrait lock', () { + expect( + AppOrientation.resolve(AppOrientation.appDefault, largeScreen: false), + AppOrientation.appDefault, + ); + }); + + test('large screens drop it, but keep the player\'s landscape', () { + expect( + AppOrientation.resolve(AppOrientation.appDefault, largeScreen: true), + DeviceOrientation.values, + ); + const landscape = [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]; + expect(AppOrientation.resolve(landscape, largeScreen: true), landscape); + }); +} diff --git a/test/core/system/foreground_poller_test.dart b/test/core/system/foreground_poller_test.dart new file mode 100644 index 00000000..2cdfee24 --- /dev/null +++ b/test/core/system/foreground_poller_test.dart @@ -0,0 +1,49 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/system/foreground_poller.dart'; + +void main() { + test('ticks every period while in the foreground', () { + fakeAsync((async) { + var ticks = 0; + final poller = ForegroundPoller( + period: const Duration(seconds: 60), + onTick: () => ticks++, + )..start(); + async.elapse(const Duration(seconds: 185)); + expect(ticks, 3); + poller.dispose(); + }); + }); + + test('stops in the background and resumes with one tick', () { + fakeAsync((async) { + var ticks = 0; + final poller = ForegroundPoller( + period: const Duration(seconds: 60), + onTick: () => ticks++, + )..start(); + + poller.handleLifecycle(AppLifecycleState.inactive); + expect(poller.isRunning, isTrue, reason: 'a shade pull is not leaving'); + + poller.handleLifecycle(AppLifecycleState.hidden); + poller.handleLifecycle(AppLifecycleState.paused); + expect(poller.isRunning, isFalse); + async.elapse(const Duration(minutes: 10)); + expect(ticks, 0); + + poller.handleLifecycle(AppLifecycleState.resumed); + expect(ticks, 1, reason: 'catches up at once on return'); + expect(poller.isRunning, isTrue); + async.elapse(const Duration(seconds: 60)); + expect(ticks, 2); + + poller.handleLifecycle(AppLifecycleState.resumed); + expect(ticks, 2, reason: 'a resume while running does not double up'); + poller.dispose(); + expect(poller.isRunning, isFalse); + }); + }); +} diff --git a/test/core/system/star_prompt_test.dart b/test/core/system/star_prompt_test.dart new file mode 100644 index 00000000..ed1624ac --- /dev/null +++ b/test/core/system/star_prompt_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/system/star_prompt.dart'; + +void main() { + StarPromptState finish(StarPromptState s, int episodes) { + for (var i = 0; i < episodes; i++) { + s = StarPromptRules.episodeCompleted(s); + } + return s; + } + + group('StarPromptRules', () { + test('stays hidden before the seventh finished episode', () { + final s = finish(const StarPromptState(), 6); + expect(s.completed, 6); + expect(StarPromptRules.shouldShow(s), isFalse); + }); + + test('shows at seven', () { + expect( + StarPromptRules.shouldShow(finish(const StarPromptState(), 7)), + isTrue, + ); + }); + + test('showing it snoozes it, so it shows once per threshold', () { + final s = StarPromptRules.shown(finish(const StarPromptState(), 7)); + expect(StarPromptRules.shouldShow(s), isFalse); + expect(s.nextAt, 27); + }); + + test('Later waits for twenty more episodes', () { + var s = StarPromptRules.later(finish(const StarPromptState(), 9)); + s = finish(s, 19); + expect(StarPromptRules.shouldShow(s), isFalse); + s = finish(s, 1); + expect(s.completed, 29); + expect(StarPromptRules.shouldShow(s), isTrue); + }); + + test('answering is final', () { + var s = StarPromptRules.answered(finish(const StarPromptState(), 7)); + expect(StarPromptRules.shouldShow(s), isFalse); + s = finish(s, 500); + expect(StarPromptRules.shouldShow(s), isFalse); + expect(StarPromptRules.shouldShow(StarPromptRules.later(s)), isFalse); + }); + + test('answering before the threshold also prevents it', () { + final s = finish(StarPromptRules.answered(const StarPromptState()), 7); + expect(StarPromptRules.shouldShow(s), isFalse); + }); + }); + + group('StarPromptState storage', () { + test('round-trips through a map', () { + const s = StarPromptState(completed: 12, nextAt: 31, done: true); + expect(StarPromptState.fromMap(s.toMap()), s); + }); + + test('anything unreadable starts from scratch', () { + expect(StarPromptState.fromMap(null), const StarPromptState()); + expect(StarPromptState.fromMap('x'), const StarPromptState()); + expect( + StarPromptState.fromMap({'completed': 'a', 'done': 'yes'}), + const StarPromptState(), + ); + }); + }); +} diff --git a/test/core/tv/tv_keys_test.dart b/test/core/tv/tv_keys_test.dart new file mode 100644 index 00000000..b455161e --- /dev/null +++ b/test/core/tv/tv_keys_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/tv/tv_keys.dart'; + +void main() { + test('the remote search and voice keys start voice search', () { + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.browserSearch), isTrue); + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.launchAssistant), isTrue); + expect( + TvKeys.isVoiceSearch( + const LogicalKeyboardKey(LogicalKeyboardKey.androidPlane | 231), + ), + isTrue, + ); + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.select), isFalse); + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.keyS), isFalse); + }); +} diff --git a/test/features/comments/episode_key_test.dart b/test/features/comments/episode_key_test.dart new file mode 100644 index 00000000..443884d2 --- /dev/null +++ b/test/features/comments/episode_key_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +void main() { + test('a film has no episode key and reacts under the empty one', () { + expect(EpisodeKey.of(isSerial: false, episodeNumber: 3), isNull); + expect(EpisodeKey.forReactions(null), ''); + }); + + test('an episode is keyed by its number, else its index, like history', () { + expect(EpisodeKey.of(isSerial: true, episodeNumber: 12), 'e12'); + expect(EpisodeKey.of(isSerial: true, episodeIndex: 4), 'e4'); + expect( + EpisodeKey.of(isSerial: true, episodeNumber: 7, episodeIndex: 2), + 'e7', + ); + expect(EpisodeKey.of(isSerial: true), isNull); + + final storage = HistoryItem.buildStorageKey( + contentUrl: 'u', + isSerial: true, + episodeNumber: 7, + episodeIndex: 2, + ); + expect(storage.endsWith('::7'), isTrue); + }); + + test('the number round-trips', () { + expect(EpisodeKey.numberOf('e12'), 12); + expect(EpisodeKey.numberOf(null), isNull); + expect(EpisodeKey.numberOf('s1e2'), isNull); + expect(EpisodeKey.numberOf('ex'), isNull); + }); +} diff --git a/test/features/comments/reaction_scheduler_test.dart b/test/features/comments/reaction_scheduler_test.dart new file mode 100644 index 00000000..f518ed37 --- /dev/null +++ b/test/features/comments/reaction_scheduler_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +ReactionBucket _b(int t, List<(String, int)> items) => ReactionBucket( + t: t, + total: items.fold(0, (n, i) => n + i.$2), + items: [ + for (final i in items) ReactionItem(kind: 'emoji', value: i.$1, count: i.$2), + ], +); + +Duration _s(int s) => Duration(seconds: s); + +/// Plays from [from] to [to] a second at a time, as the position listener +/// would, and returns what is on screen at [to]. +List<ReactionPop> _play(ReactionScheduler s, int from, int to) { + var out = const <ReactionPop>[]; + for (var t = from; t <= to; t++) { + out = s.at(_s(t)); + } + return out; +} + +void main() { + test('a reaction shows from its second for the window, then goes', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(10, [('🔥', 1)])])); + expect(_play(s, 0, 9), isEmpty); + expect(s.at(_s(10)).single.value, '🔥'); + expect(_play(s, 11, 13), hasLength(1)); + expect(s.at(_s(14)), isEmpty); + }); + + test('the same emoji in the window groups into one pill with a count', () { + final s = ReactionScheduler() + ..load( + ReactionTimeline([ + _b(10, [('🔥', 5)]), + _b(11, [('🔥', 7), ('😂', 1)]), + ]), + ); + final pops = _play(s, 9, 11); + expect(pops.first.value, '🔥'); + expect(pops.first.count, 12); + expect(pops.first.lastSec, 11); + expect(pops[1].value, '😂'); + }); + + test('no more than maxVisible, the most-sent first', () { + final s = ReactionScheduler(maxVisible: 2) + ..load( + ReactionTimeline([ + _b(5, [('😢', 1), ('🔥', 9), ('😂', 4), ('👏', 2)]), + ]), + ); + final pops = _play(s, 4, 5); + expect(pops.map((p) => p.value), ['🔥', '😂']); + }); + + test('after a seek forward nothing from before the landing point bursts in', + () { + final s = ReactionScheduler() + ..load( + ReactionTimeline([ + _b(98, [('😮', 3)]), + _b(101, [('🔥', 1)]), + ]), + ); + _play(s, 0, 5); + expect(s.at(_s(100)), isEmpty, reason: 'landed after 98; it stays unseen'); + expect(s.at(_s(101)).single.value, '🔥'); + }); + + test('after a seek back reactions replay from the new position', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(20, [('👏', 2)])])); + _play(s, 18, 30); + expect(s.at(_s(19)), isEmpty); + expect(s.at(_s(20)).single.count, 2); + }); + + test('landing exactly on a reaction shows it', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(50, [('💀', 1)])])); + _play(s, 0, 3); + expect(s.at(_s(50)).single.value, '💀'); + }); + + test('normal ticks within the jump tolerance are playback, not a seek', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(10, [('🔥', 1)])])); + s.at(_s(10)); + s.at(const Duration(seconds: 11, milliseconds: 500)); + expect(s.at(_s(13)).single.value, '🔥'); + }); + + test('clear forgets the timeline and the playhead', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(1, [('🔥', 1)])])); + s.clear(); + expect(s.at(_s(1)), isEmpty); + }); + + test('the viewer\'s own reaction is added at its second', () { + final t = ReactionTimeline([_b(10, [('🔥', 2)])]) + .withReaction(10, 'emoji', '🔥') + .withReaction(4, 'text', 'no way'); + expect(t.buckets.map((b) => b.t), [4, 10]); + expect(t.buckets[1].items.single.count, 3); + expect(t.buckets[1].total, 3); + expect(t.buckets[0].items.single.kind, 'text'); + }); + + test('the heatmap scales the busiest slice to one', () { + final t = ReactionTimeline([ + _b(0, [('🔥', 2)]), + _b(50, [('🔥', 8)]), + _b(99, [('🔥', 4)]), + ]); + final bins = t.heatmap(100, bins: 10); + expect(bins, hasLength(10)); + expect(bins[5], 1.0); + expect(bins[0], 0.25); + expect(bins[9], 0.5); + expect(bins[3], 0); + expect(ReactionTimeline.empty.heatmap(0), everyElement(0)); + }); + + test('parses the server shape', () { + final t = ReactionTimeline.fromJson({ + 'total': 3, + 'buckets': [ + { + 't': 7, + 'total': 3, + 'items': [ + {'kind': 'emoji', 'value': '🔥', 'count': 2}, + {'kind': 'text', 'value': 'wow', 'count': 1}, + ], + }, + ], + }); + expect(t.buckets.single.t, 7); + expect(t.buckets.single.items.last.value, 'wow'); + }); +} diff --git a/test/features/comments/spoiler_rules_test.dart b/test/features/comments/spoiler_rules_test.dart new file mode 100644 index 00000000..ad69c08d --- /dev/null +++ b/test/features/comments/spoiler_rules_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/comments/domain/spoiler_rules.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +SpoilerVisibility _v({ + bool spoiler = false, + bool isOwn = false, + bool revealed = false, + bool hideUnwatched = true, + String? episodeKey = 'e3', + bool episodeFinished = false, + int? atSec, + int? playheadSec, +}) => SpoilerRules.visibility( + spoiler: spoiler, + isOwn: isOwn, + revealed: revealed, + hideUnwatched: hideUnwatched, + episodeKey: episodeKey, + episodeFinished: episodeFinished, + atSec: atSec, + playheadSec: playheadSec, +); + +HistoryItem _row(int episode, {int pos = 0, int dur = 1000}) => HistoryItem( + contentUrl: 'show', + provider: 'p', + title: 'Show', + isSerial: true, + episodeNumber: episode, + positionMs: pos, + durationMs: dur, + watchedAt: 1, +); + +void main() { + group('visibility', () { + test('a flagged spoiler is blurred even on a finished episode', () { + expect( + _v(spoiler: true, episodeFinished: true), + SpoilerVisibility.spoiler, + ); + expect( + _v(spoiler: true, episodeKey: null, hideUnwatched: false), + SpoilerVisibility.spoiler, + ); + }); + + test('own comments and revealed ones always show', () { + expect(_v(spoiler: true, isOwn: true), SpoilerVisibility.visible); + expect(_v(spoiler: true, revealed: true), SpoilerVisibility.visible); + expect(_v(revealed: true), SpoilerVisibility.visible); + }); + + test('an unfinished episode hides its thread while the setting is on', () { + expect(_v(), SpoilerVisibility.unwatched); + expect(_v(hideUnwatched: false), SpoilerVisibility.visible); + expect(_v(episodeFinished: true), SpoilerVisibility.visible); + }); + + test('title-level comments are never hidden as unwatched', () { + expect(_v(episodeKey: null), SpoilerVisibility.visible); + }); + + test('while watching, only comments past the playhead stay hidden', () { + expect(_v(playheadSec: 300, atSec: 120), SpoilerVisibility.visible); + expect(_v(playheadSec: 300, atSec: 300), SpoilerVisibility.visible); + expect(_v(playheadSec: 300, atSec: 301), SpoilerVisibility.unwatched); + expect(_v(playheadSec: 300), SpoilerVisibility.visible); + }); + }); + + group('episodeFinished', () { + test('watched past the tracker threshold', () { + expect( + SpoilerRules.episodeFinished('e3', 'show', [_row(3, pos: 900)]), + isTrue, + ); + expect( + SpoilerRules.episodeFinished('e3', 'show', [_row(3, pos: 500)]), + isFalse, + ); + }); + + test('moving on to a later episode counts', () { + expect(SpoilerRules.episodeFinished('e3', 'show', [_row(5)]), isTrue); + expect(SpoilerRules.episodeFinished('e3', 'show', [_row(2)]), isFalse); + }); + + test('another title does not count, and nothing watched is unfinished', + () { + final other = HistoryItem( + contentUrl: 'other', + provider: 'p', + title: 'Other', + isSerial: true, + episodeNumber: 9, + positionMs: 1000, + durationMs: 1000, + watchedAt: 1, + ); + expect(SpoilerRules.episodeFinished('e3', 'show', [other]), isFalse); + expect(SpoilerRules.episodeFinished('e3', 'show', const []), isFalse); + }); + + test('a key that is not an episode is treated as finished', () { + expect(SpoilerRules.episodeFinished(null, 'show', const []), isTrue); + }); + }); +} diff --git a/test/features/detail/alternate_source_sheet_test.dart b/test/features/detail/alternate_source_sheet_test.dart index 7c5b644b..08c26070 100644 --- a/test/features/detail/alternate_source_sheet_test.dart +++ b/test/features/detail/alternate_source_sheet_test.dart @@ -29,8 +29,12 @@ class _Service implements AlternateSourceService { this.sources = const [], this.outcome = const AlternateSearchOutcome(asked: 2), this.results = const {}, + this.resolve, }); + /// What Auto gets for each source it tries. Null: none has the episode. + final Future<AlternateArgs> Function(AlternateSource source)? resolve; + /// What the automatic fan-out "found", in the order it finds it. final List<AlternateSource> sources; @@ -74,9 +78,20 @@ class _Service implements AlternateSourceService { Future<PlayerArgs?> buildArgs({ required AlternateSource source, required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) async => null; + @override + Future<AlternateArgs> resolveArgs({ + required AlternateSource source, + required int? episodeNumber, + SeasonEpisode? seasonEpisode, + Duration resumeAt = Duration.zero, + }) => + resolve?.call(source) ?? + Future.value(const AlternateArgs.failed(AlternateArgsFailure.noEpisode)); + /// Anything else is a call these tests did not expect the sheet to make, and /// a thrown NoSuchMethodError names it. @override @@ -520,6 +535,61 @@ void main() { }); }); + group('Auto', () { + final sources = [ + _source('an:one', 'AniOne', _title), + _source('an:two', 'AniTwo', 'Return of the Blade'), + ]; + final providers = [ + _provider('an:one', 'AniOne'), + _provider('an:two', 'AniTwo'), + ]; + + testWidgets('lists what it moved past and offers to try again', ( + tester, + ) async { + await pump(tester, _Service(sources: sources), candidates: providers); + await tester.tap(find.text('player.alt_auto')); + await tester.pumpAndSettle(); + + expect(find.text('player.alt_auto_missed'), findsOneWidget); + expect(find.text('player.alt_auto_tried'), findsOneWidget); + expect( + find.text('AniOne — player.alt_skip_no_episode'), + findsOneWidget, + ); + // The weak match is never tried, but the list still says why. + expect(find.text('AniTwo — player.alt_skip_no_match'), findsOneWidget); + expect(find.text('general.try_again'), findsOneWidget); + }); + + testWidgets('Stop ends the run while a source is still answering', ( + tester, + ) async { + final pending = Completer<AlternateArgs>(); + await pump( + tester, + _Service(sources: sources, resolve: (_) => pending.future), + candidates: providers, + ); + await tester.tap(find.text('player.alt_auto')); + await tester.pump(); + expect(find.text('player.alt_auto_checking'), findsOneWidget); + + await tester.tap(find.text('player.alt_auto_stop')); + await tester.pumpAndSettle(); + expect(find.text('player.alt_auto_stopped'), findsOneWidget); + expect(find.text('general.try_again'), findsOneWidget); + + // The answer that arrives afterwards does not close the sheet. + pending.complete( + const AlternateArgs.failed(AlternateArgsFailure.unreachable), + ); + await tester.pumpAndSettle(); + expect(find.text('player.alt_auto_stopped'), findsOneWidget); + }); + }); + group('the words themselves', () { test('every string the switcher shows has English text', () { final en = diff --git a/test/features/detail/data/tmdb_relations_test.dart b/test/features/detail/data/tmdb_relations_test.dart new file mode 100644 index 00000000..e1216dab --- /dev/null +++ b/test/features/detail/data/tmdb_relations_test.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/data/tmdb_relations.dart'; + +/// Answers each TMDB path from [routes]; anything else is a 404. +class _Tmdb implements HttpClientAdapter { + _Tmdb(this.routes); + final Map<String, Object> routes; + final asked = <String>[]; + + @override + Future<ResponseBody> fetch( + RequestOptions options, + Stream<Uint8List>? requestStream, + Future<void>? cancelFuture, + ) async { + asked.add(options.path); + final body = routes[options.path]; + return ResponseBody.fromString( + jsonEncode(body ?? {}), + body == null ? 404 : 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +TmdbRelations _with(_Tmdb tmdb) => + TmdbRelations(Dio()..httpClientAdapter = tmdb); + +void main() { + test( + "Grey's Anatomy: the show, then what TMDB recommends after it", + () async { + final tmdb = _Tmdb({ + '/tmdb/search/multi': { + 'results': [ + { + 'id': 9, + 'media_type': 'tv', + 'name': 'Anatomy', + 'first_air_date': '2020-01-01', + }, + { + 'id': 1416, + 'media_type': 'tv', + 'name': "Grey's Anatomy", + 'first_air_date': '2005-03-27', + 'genre_ids': [18], + 'original_language': 'en', + }, + ], + }, + '/tmdb/tv/1416/recommendations': { + 'results': [ + { + 'id': 2, + 'name': 'ER', + 'first_air_date': '1994-09-19', + 'poster_path': '/er.jpg', + }, + {'id': 3, 'name': 'Chicago Med', 'first_air_date': '2015-11-17'}, + ], + }, + }); + final service = _with(tmdb); + final title = await service.find( + "Grey's Anatomy", + year: 2005, + isSerial: true, + ); + expect(title?.id, 1416); + expect(title?.isAnime, isFalse); + final relations = await service.relationsOf(title!); + expect([for (final r in relations) r.title], ['ER', 'Chicago Med']); + expect(relations.first.relationType, TmdbRelations.recommended); + expect(relations.first.coverImage, endsWith('/er.jpg')); + expect(relations.first.year, 1994); + }, + ); + + test('a film: the rest of its franchise first, in release order', () async { + final tmdb = _Tmdb({ + '/tmdb/movie/2': { + 'belongs_to_collection': {'id': 77}, + }, + '/tmdb/collection/77': { + 'parts': [ + {'id': 3, 'title': 'Part 3', 'release_date': '2010-01-01'}, + {'id': 2, 'title': 'Part 2', 'release_date': '2005-01-01'}, + {'id': 1, 'title': 'Part 1', 'release_date': '2001-01-01'}, + ], + }, + '/tmdb/movie/2/recommendations': { + 'results': [ + {'id': 3, 'title': 'Part 3'}, + {'id': 50, 'title': 'Something else'}, + ], + }, + }); + final relations = await _with( + tmdb, + ).relationsOf(const TmdbTitle(id: 2, isTv: false, title: 'Part 2')); + expect( + [for (final r in relations) r.title], + ['Part 1', 'Part 3', 'Something else'], + ); + expect(relations.first.relationType, TmdbRelations.collection); + expect(relations.last.relationType, TmdbRelations.recommended); + }); + + test('a title nothing matches well is no title at all', () async { + final tmdb = _Tmdb({ + '/tmdb/search/multi': { + 'results': [ + {'id': 5, 'media_type': 'movie', 'title': 'The Return of the King'}, + ], + }, + }); + expect(await _with(tmdb).find('Return of the Blossoming Blade'), isNull); + }); + + test('Japanese animation is left to AniList', () async { + final tmdb = _Tmdb({ + '/tmdb/search/multi': { + 'results': [ + { + 'id': 46260, + 'media_type': 'tv', + 'name': 'Naruto', + 'genre_ids': [16], + 'original_language': 'ja', + }, + ], + }, + }); + final title = await _with(tmdb).find('Naruto'); + expect(title?.isAnime, isTrue); + }); +} diff --git a/test/features/detail/data/video_source_accessible_test.dart b/test/features/detail/data/video_source_accessible_test.dart new file mode 100644 index 00000000..54c34601 --- /dev/null +++ b/test/features/detail/data/video_source_accessible_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/source_ladder.dart'; +import 'package:soplay/features/detail/data/models/media_resolve_model.dart'; +import 'package:soplay/features/detail/data/models/video_source_model.dart'; + +/// `accessible` says a probe refused the source. Providers do not probe, so it +/// is usually absent, and reading absent as false left the ladder with nothing: +/// the first mirror played and its failure ended on the error screen, with the +/// others untried. +void main() { + Map<String, dynamic> source(String name, {bool? accessible, String? type}) => + { + 'quality': name, + 'videoUrl': 'https://cdn.test/$name.m3u8', + 'type': ?type, + 'accessible': ?accessible, + }; + + test('a source that does not say is one the player may try', () { + expect(VideoSourceModel.fromJson(source('a')).accessible, isTrue); + }); + + test('a source a probe refused stays refused', () { + expect( + VideoSourceModel.fromJson(source('a', accessible: false)).accessible, + isFalse, + ); + }); + + test('a resolve with no flags can be walked past its first mirror', () { + final media = MediaResolveModel.fromJson({ + 'videoUrl': 'https://cdn.test/a.m3u8', + 'videoSources': [source('a'), source('b'), source('c')], + }); + final ladder = SourceLadder( + sources: media.videoSources, + hasDirective: false, + ); + expect(ladder.ordered(), hasLength(3)); + + final afterFirst = SourceLadder( + sources: media.videoSources, + hasDirective: false, + triedUrls: {media.videoSources.first.videoUrl}, + ); + expect(afterFirst.next(), isNotNull); + }); + + test('an embed is walked only when a directive says it will be sniffed', () { + final sources = [ + VideoSourceModel.fromJson(source('direct', type: 'hls')), + VideoSourceModel.fromJson(source('embed', type: 'iframe')), + ]; + expect( + SourceLadder(sources: sources, hasDirective: false).ordered(), + hasLength(1), + ); + expect( + SourceLadder(sources: sources, hasDirective: true).ordered(), + hasLength(2), + ); + }); + + test('what the server says about the stream reaches the entity', () { + final s = VideoSourceModel.fromJson({ + ...source('a'), + 'dropSegments': r'^/v\d+/', + 'hardsub': 'vi', + }); + expect(s.dropSegments, r'^/v\d+/'); + expect(s.hardsub, 'vi'); + final plain = VideoSourceModel.fromJson(source('b')); + expect(plain.dropSegments, isNull); + expect(plain.hardsub, isNull); + }); +} diff --git a/test/features/detail/detail_tab_panes_test.dart b/test/features/detail/detail_tab_panes_test.dart new file mode 100644 index 00000000..fc9cffad --- /dev/null +++ b/test/features/detail/detail_tab_panes_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_tab_panes.dart'; + +class _Counting extends StatefulWidget { + const _Counting(this.tab, this.counts); + final String tab; + final Map<String, ({int inits, int builds})> counts; + + @override + State<_Counting> createState() => _CountingState(); +} + +class _CountingState extends State<_Counting> { + int taps = 0; + + @override + void initState() { + super.initState(); + final c = widget.counts[widget.tab] ?? (inits: 0, builds: 0); + widget.counts[widget.tab] = (inits: c.inits + 1, builds: c.builds); + } + + @override + Widget build(BuildContext context) { + final c = widget.counts[widget.tab]!; + widget.counts[widget.tab] = (inits: c.inits, builds: c.builds + 1); + return GestureDetector( + onTap: () => setState(() => taps++), + child: SizedBox(height: 200, child: Text('${widget.tab}:$taps')), + ); + } +} + +class _Host extends StatefulWidget { + const _Host(this.counts, this.hostBuilds); + final Map<String, ({int inits, int builds})> counts; + final List<int> hostBuilds; + + @override + State<_Host> createState() => _HostState(); +} + +class _HostState extends State<_Host> with SingleTickerProviderStateMixin { + static const tabs = ['A', 'B', 'C']; + late final TabController controller = TabController( + length: tabs.length, + vsync: this, + ); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + widget.hostBuilds[0]++; + return MaterialApp( + home: Scaffold( + body: DetailTabPanes( + controller: controller, + tabs: tabs, + paneBuilder: (_, tab) => _Counting(tab, widget.counts), + ), + ), + ); + } +} + +void main() { + late Map<String, ({int inits, int builds})> counts; + late List<int> hostBuilds; + + Future<TabController> pump(WidgetTester tester) async { + counts = {}; + hostBuilds = [0]; + await tester.pumpWidget(_Host(counts, hostBuilds)); + return tester.state<_HostState>(find.byType(_Host)).controller; + } + + testWidgets('only the open tab is built at first', (tester) async { + await pump(tester); + expect(counts.keys, ['A']); + expect(find.text('A:0'), findsOneWidget); + }); + + testWidgets('a pane keeps its state when the tab is left and reopened', ( + tester, + ) async { + final controller = await pump(tester); + await tester.tap(find.text('A:0')); + await tester.pump(); + expect(find.text('A:1'), findsOneWidget); + + controller.animateTo(1); + await tester.pumpAndSettle(); + expect(find.text('B:0'), findsOneWidget); + expect(find.text('A:1'), findsNothing); + + controller.animateTo(0); + await tester.pumpAndSettle(); + expect(find.text('A:1'), findsOneWidget); + expect(counts['A']!.inits, 1); + expect(counts['B']!.inits, 1); + }); + + testWidgets('a tab switch rebuilds neither the page nor idle panes', ( + tester, + ) async { + final controller = await pump(tester); + controller.animateTo(1); + await tester.pumpAndSettle(); + final aBuilds = counts['A']!.builds; + + controller.animateTo(2); + await tester.pumpAndSettle(); + controller.animateTo(1); + await tester.pumpAndSettle(); + + expect(hostBuilds[0], 1); + expect(counts['A']!.builds, aBuilds); + expect(counts['B']!.inits, 1); + expect(counts['C']!.inits, 1); + }); + + testWidgets('the outgoing pane cross-fades, then leaves the screen', ( + tester, + ) async { + final controller = await pump(tester); + controller.animateTo(1); + await tester.pump(); + await tester.pump(DetailTabPanes.fade ~/ 2); + expect(find.text('A:0'), findsOneWidget); + expect(find.text('B:0'), findsOneWidget); + + await tester.pumpAndSettle(); + expect(find.text('A:0'), findsNothing); + expect(find.text('A:0', skipOffstage: false), findsOneWidget); + }); + + testWidgets('taps reach only the open pane', (tester) async { + final controller = await pump(tester); + controller.animateTo(1); + await tester.pumpAndSettle(); + await tester.tap(find.text('B:0')); + await tester.pump(); + expect(find.text('B:1'), findsOneWidget); + expect(find.text('A:0', skipOffstage: false), findsOneWidget); + }); +} diff --git a/test/features/detail/domain/episode_seasons_test.dart b/test/features/detail/domain/episode_seasons_test.dart new file mode 100644 index 00000000..ae6c0b06 --- /dev/null +++ b/test/features/detail/domain/episode_seasons_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; +import 'package:soplay/features/detail/domain/episode_seasons.dart'; +import 'package:soplay/features/detail/domain/services/alternate_source_service.dart'; + +EpisodeEntity ep(int n, {int? season, String? label}) => EpisodeEntity( + episode: n, + label: label ?? 'Episode $n', + mediaRef: 's${season}e$n', + season: season, +); + +void main() { + test('Lupin: seasons that each start at 1 become one run, in order', () { + // As the source lists it, and as the episode list used to sort it. + final out = EpisodeSeasons.number([ + ep(1, season: 1, label: 'Chapter 1'), + ep(2, season: 1, label: 'Chapter 2'), + ep(1, season: 2, label: 'Chapter 6'), + ep(2, season: 2, label: 'Chapter 7'), + ep(1, season: 3, label: 'Chapter 1'), + ]); + expect([for (final e in out) e.episode], [1, 2, 3, 4, 5]); + expect([for (final e in out) e.label], [ + 'Chapter 1', + 'Chapter 2', + 'Chapter 6', + 'Chapter 7', + 'Chapter 1', + ]); + expect(out[2].season, 2); + expect(out[2].numberInSeason, 1); + expect(out[2].numberLabel, 'S2 E1'); + expect(out[2].mediaRef, 's2e1'); + }); + + test('season 1 keeps its numbers, so old downloads and history line up', () { + final out = EpisodeSeasons.number([ + ep(1, season: 2), + ep(3, season: 1), + ep(1, season: 1), + ep(2, season: 1), + ]); + expect(out.take(3).map((e) => (e.season, e.episode)), [ + (1, 1), + (1, 2), + (1, 3), + ]); + expect(out.last.episode, 4); + }); + + test('specials go after the last season', () { + final out = EpisodeSeasons.number([ + ep(1, season: 0), + ep(1, season: 1), + ep(1, season: 2), + ]); + expect([for (final e in out) e.season], [1, 2, 0]); + expect(EpisodeSeasons.of(out).map((s) => s.$1), [1, 2, 0]); + }); + + test('seasons that already number on keep their numbers', () { + final out = EpisodeSeasons.number([ + ep(13, season: 2), + ep(1, season: 1), + ep(12, season: 1), + ]); + expect([for (final e in out) e.episode], [1, 12, 13]); + expect(out.last.numberLabel, 'S2 E13'); + }); + + test('a single season is left as it was, its season dropped', () { + final out = EpisodeSeasons.number([ep(1, season: 1), ep(2, season: 1)]); + expect([for (final e in out) e.episode], [1, 2]); + expect(out.every((e) => e.season == null), isTrue); + expect(out.first.numberLabel, '1'); + final plain = [ep(1), ep(2)]; + expect(identical(EpisodeSeasons.number(plain), plain), isTrue); + }); + + group('switching source keeps the season', () { + final seasoned = EpisodeSeasons.number([ + for (var s = 1; s <= 2; s++) + for (var n = 1; n <= 10; n++) ep(n, season: s), + ]); + + test('S2E1 is S2E1 on a source that lists seasons', () { + final i = indexOfSeasonEpisode(seasoned, (season: 2, number: 1)); + expect(seasoned[i].mediaRef, 's2e1'); + }); + + test('a source with no seasons is matched on a first season only', () { + final flat = [for (var n = 1; n <= 10; n++) ep(n)]; + expect(indexOfSeasonEpisode(flat, (season: 1, number: 4)), 3); + expect(indexOfSeasonEpisode(flat, (season: 2, number: 4)), -1); + }); + }); +} diff --git a/test/features/detail/domain/services/auto_resolve_walk_test.dart b/test/features/detail/domain/services/auto_resolve_walk_test.dart new file mode 100644 index 00000000..78d1ba7f --- /dev/null +++ b/test/features/detail/domain/services/auto_resolve_walk_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/services/auto_resolve_walk.dart'; + +class _C { + const _C(this.id, {this.trusted = true}); + final String id; + final bool trusted; +} + +AutoResolveWalk<_C, String> _walk( + List<_C> list, + Future<AutoAttempt<String>> Function(_C) attempt, { + bool Function()? searching, + int cap = AutoResolveWalk.maxCandidates, + void Function()? onChange, +}) => AutoResolveWalk<_C, String>( + candidates: () => list, + searching: searching ?? () => false, + idOf: (c) => c.id, + isEligible: (c) => c.trusted, + attempt: attempt, + cap: cap, + poll: const Duration(milliseconds: 1), + onChange: onChange, +); + +void main() { + test('returns the first candidate that plays, in list order', () async { + final tried = <String>[]; + final walk = _walk([const _C('a'), const _C('b'), const _C('c')], (c) async { + tried.add(c.id); + return c.id == 'b' + ? AutoAttempt.found('args-b') + : AutoAttempt.skipped(AutoSkipReason.noEpisode); + }); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.found); + expect(result.value, 'args-b'); + expect(tried, ['a', 'b']); + expect(walk.skipped.single.candidate.id, 'a'); + expect(walk.skipped.single.reason, AutoSkipReason.noEpisode); + }); + + test('records why each source was skipped', () async { + final walk = _walk( + [const _C('a'), const _C('b'), const _C('weak', trusted: false)], + (c) async { + if (c.id == 'a') return AutoAttempt.skipped(AutoSkipReason.noEpisode); + throw StateError('network'); + }, + ); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.exhausted); + expect([for (final s in walk.skipped) (s.candidate.id, s.reason)], [ + ('a', AutoSkipReason.noEpisode), + ('b', AutoSkipReason.error), + ('weak', AutoSkipReason.noMatch), + ]); + }); + + test('never tries a candidate that is not trustworthy', () async { + final tried = <String>[]; + final walk = _walk([const _C('weak', trusted: false)], (c) async { + tried.add(c.id); + return AutoAttempt.found(c.id); + }); + expect((await walk.run()).end, AutoWalkEnd.exhausted); + expect(tried, isEmpty); + }); + + test('stops after the cap', () async { + final list = [for (var i = 0; i < 30; i++) _C('$i')]; + var calls = 0; + final walk = _walk(list, (c) async { + calls++; + return AutoAttempt.skipped(AutoSkipReason.error); + }); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.capped); + expect(calls, AutoResolveWalk.maxCandidates); + expect(walk.index, 20); + expect(walk.total, 20); + }); + + test('counts i of total, and the total grows as results arrive', () async { + final list = <_C>[const _C('a')]; + var searching = true; + final seen = <(int, int)>[]; + late final AutoResolveWalk<_C, String> walk; + walk = _walk( + list, + (c) async { + seen.add((walk.index, walk.total)); + if (c.id == 'a') { + list.addAll([const _C('b'), const _C('c')]); + } + if (c.id == 'c') searching = false; + return AutoAttempt.skipped(AutoSkipReason.noEpisode); + }, + searching: () => searching, + ); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.exhausted); + expect(seen, [(1, 1), (2, 3), (3, 3)]); + }); + + test('waits for more results while search is still running', () async { + final list = <_C>[]; + var searching = true; + final walk = _walk( + list, + (c) async => AutoAttempt.found(c.id), + searching: () => searching, + ); + final running = walk.run(); + await Future<void>.delayed(const Duration(milliseconds: 10)); + expect(walk.current, isNull); + list.add(const _C('late')); + searching = false; + final result = await running; + expect(result.end, AutoWalkEnd.found); + expect(result.value, 'late'); + }); + + test('stop ends the walk at once and ignores the result in flight', () async { + final pending = Completer<AutoAttempt<String>>(); + final walk = _walk([const _C('a'), const _C('b')], (_) => pending.future); + final running = walk.run(); + await Future<void>.delayed(Duration.zero); + expect(walk.current?.id, 'a'); + + walk.stop(); + final result = await running; + expect(result.end, AutoWalkEnd.stopped); + expect(result.value, isNull); + expect(walk.current, isNull); + + // The answer that lands afterwards changes nothing. + pending.complete(AutoAttempt.found('too late')); + await Future<void>.delayed(Duration.zero); + expect(walk.skipped, isEmpty); + expect(walk.index, 1); + }); + + test('stop while waiting for search ends the walk too', () async { + final walk = _walk( + [], + (c) async => AutoAttempt.found(c.id), + searching: () => true, + ); + final running = walk.run(); + await Future<void>.delayed(const Duration(milliseconds: 5)); + walk.stop(); + expect((await running).end, AutoWalkEnd.stopped); + }); + + test('an error after stop is swallowed', () async { + final pending = Completer<AutoAttempt<String>>(); + final walk = _walk([const _C('a')], (_) => pending.future); + final running = walk.run(); + await Future<void>.delayed(Duration.zero); + walk.stop(); + expect((await running).end, AutoWalkEnd.stopped); + pending.completeError(StateError('late failure')); + await Future<void>.delayed(Duration.zero); + }); + + test('tells the caller when its state changes', () async { + var changes = 0; + final walk = _walk( + [const _C('a')], + (c) async => AutoAttempt.skipped(AutoSkipReason.error), + onChange: () => changes++, + ); + await walk.run(); + expect(changes, greaterThanOrEqualTo(3)); + }); +} diff --git a/test/features/detail/domain/vtt_thumbnails_test.dart b/test/features/detail/domain/vtt_thumbnails_test.dart new file mode 100644 index 00000000..7e227684 --- /dev/null +++ b/test/features/detail/domain/vtt_thumbnails_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/vtt_thumbnails.dart'; + +void main() { + const base = 'https://cdn.test/v/thumbs.vtt'; + + test('a sprite cell, with hours, relative to the track', () { + final cues = parseVttThumbnails(''' +WEBVTT + +00:00:10.000 --> 00:00:20.000 +sheet0.jpg#xywh=160,90,160,90 +''', base); + expect(cues, hasLength(1)); + expect(cues.single.start, const Duration(seconds: 10)); + expect(cues.single.end, const Duration(seconds: 20)); + expect(cues.single.imageUrl, 'https://cdn.test/v/sheet0.jpg'); + expect(cues.single.hasSprite, isTrue); + expect(cues.single.x, 160); + }); + + test('a cue under an hour may leave the hours out', () { + final cues = parseVttThumbnails( + 'WEBVTT\n\n01:30.500 --> 01:40.000\nhttps://img.test/a.jpg\n', + base, + ); + expect(cues, hasLength(1)); + expect( + cues.single.start, + const Duration(minutes: 1, seconds: 30, milliseconds: 500), + ); + expect(cues.single.imageUrl, 'https://img.test/a.jpg'); + expect(cues.single.hasSprite, isFalse); + }); + + test('both shapes in one track, and past the hundredth hour', () { + final cues = parseVttThumbnails( + 'WEBVTT\n\n59:50.000 --> 01:00:00.000\na.jpg\n\n' + '100:00:00.000 --> 100:00:10.000\nb.jpg\n', + base, + ); + expect(cues.map((c) => c.end), [ + const Duration(hours: 1), + const Duration(hours: 100, seconds: 10), + ]); + }); +} diff --git a/test/features/detail/native_controls_offer_test.dart b/test/features/detail/native_controls_offer_test.dart new file mode 100644 index 00000000..08e552f8 --- /dev/null +++ b/test/features/detail/native_controls_offer_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/glass/native_glass.dart'; +import 'package:soplay/core/native/native_controls_gate.dart'; +import 'package:soplay/core/native/player_controls_style.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/detail/presentation/widgets/native_controls_offer.dart'; + +const _ios26 = NativeGlassCapability( + systemVersion: '26.0', + majorVersion: 26, + systemGlass: true, + styles: {'regular', 'clear'}, + materials: {'thin'}, + reduceTransparency: false, + glassControls: true, +); + +/// No translations are loaded, so every `.tr()` renders as its own key. +void main() { + late HiveService hive; + + setUp(() async { + // In memory: real file writes never finish inside a widget test. + await Hive.openBox(AppConstants.authBox, bytes: Uint8List(0)); + await Hive.openBox(AppConstants.settingsBox, bytes: Uint8List(0)); + hive = HiveService(); + }); + + tearDown(() async { + await Hive.close(); + }); + + final answers = <PlayerControlsStyle>[]; + + /// On an iPhone, reset before the test ends as the binding insists. + void iosTest(String name, Future<void> Function(WidgetTester) body) => + testWidgets(name, (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + debugNativeControlsDesktopOverride = false; + try { + await body(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + debugNativeControlsDesktopOverride = null; + } + }); + + /// Stands in for the player: each tap asks the gate the player asks, and + /// presents the offer when it says so. + Future<void> pumpHost(WidgetTester tester) async { + answers.clear(); + await tester.pumpWidget( + MaterialApp( + home: NativeGlassScope( + capability: _ios26, + child: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () async { + if (!NativeControls.shouldOffer( + context, + stored: hive.storedPlayerControlsStyle, + alreadyOffered: hive.nativeControlsOffered, + )) { + return; + } + answers.add(await presentNativeControlsOffer(context, hive)); + }, + child: const Text('controls'), + ), + ), + ), + ), + ), + ); + } + + Future<void> reveal(WidgetTester tester) async { + await tester.tap(find.text('controls')); + await tester.pumpAndSettle(); + } + + final card = find.text('player.native_offer_title'); + + iosTest('Try it stores native', (tester) async { + await pumpHost(tester); + await reveal(tester); + expect(card, findsOneWidget); + expect(hive.nativeControlsOffered, isTrue); + await tester.tap(find.byKey(const ValueKey('native-offer-try'))); + await tester.pumpAndSettle(); + expect(card, findsNothing); + expect(answers, [PlayerControlsStyle.native]); + expect(hive.storedPlayerControlsStyle, PlayerControlsStyle.native); + }); + + iosTest('Not now stores classic', (tester) async { + await pumpHost(tester); + await reveal(tester); + await tester.tap(find.byKey(const ValueKey('native-offer-not-now'))); + await tester.pumpAndSettle(); + expect(answers, [PlayerControlsStyle.classicGlass]); + expect(hive.storedPlayerControlsStyle, PlayerControlsStyle.classicGlass); + }); + + iosTest('a barrier tap counts as Not now', (tester) async { + await pumpHost(tester); + await reveal(tester); + await tester.tapAt(const Offset(4, 4)); + await tester.pumpAndSettle(); + expect(card, findsNothing); + expect(hive.storedPlayerControlsStyle, PlayerControlsStyle.classicGlass); + }); + + iosTest('the card appears once', (tester) async { + await pumpHost(tester); + await reveal(tester); + expect(card, findsOneWidget); + await tester.tap(find.byKey(const ValueKey('native-offer-not-now'))); + await tester.pumpAndSettle(); + await reveal(tester); + expect(card, findsNothing); + await reveal(tester); + expect(card, findsNothing); + expect(answers, hasLength(1)); + }); + + iosTest('shown once even if it was never answered', (tester) async { + await pumpHost(tester); + await reveal(tester); + expect(card, findsOneWidget); + // The app dies with the card up: the settings survive, the answer not. + await tester.pumpWidget(const SizedBox()); + await pumpHost(tester); + await reveal(tester); + expect(card, findsNothing); + }); +} diff --git a/test/features/detail/playback/desktop_volume_test.dart b/test/features/detail/playback/desktop_volume_test.dart new file mode 100644 index 00000000..28f7807b --- /dev/null +++ b/test/features/detail/playback/desktop_volume_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/desktop_volume.dart'; + +void main() { + test('every new controller gets the level back', () async { + const volume = DesktopVolume(level: 0.4); + final seen = <double>[]; + for (var i = 0; i < 3; i++) { + await volume.applyTo((v) async => seen.add(v)); + } + expect(seen, [0.4, 0.4, 0.4]); + }); + + test( + 'mute survives a new controller, and unmute restores the level', + () async { + final muted = const DesktopVolume(level: 0.7).toggleMute(); + final seen = <double>[]; + await muted.applyTo((v) async => seen.add(v)); + expect(seen.single, 0); + final back = muted.toggleMute(); + expect(back.applied, 0.7); + }, + ); + + test('up to 150% on mpv, clamped to 100% elsewhere', () async { + final loud = const DesktopVolume().withLevel( + 1.4, + max: DesktopVolume.boostMax, + ); + expect(loud.level, 1.4); + final seen = <double>[]; + await loud.applyTo((v) async => seen.add(v), max: 1.0); + await loud.applyTo((v) async => seen.add(v), max: 1.5); + expect(seen, [1.0, 1.4]); + expect(const DesktopVolume().withLevel(9, max: 1.5).level, 1.5); + }); + + test('dragging to zero mutes without losing the level', () { + final v = const DesktopVolume(level: 0.6).withLevel(0); + expect(v.muted, isTrue); + expect(v.toggleMute().applied, 0.6); + }); + + test('restores what was stored, with sane fallbacks', () { + final r = DesktopVolume.restore(0.3, true); + expect(r.level, 0.3); + expect(r.muted, isTrue); + expect(DesktopVolume.restore(null, null).applied, 1.0); + expect(DesktopVolume.restore(7, false).level, DesktopVolume.boostMax); + expect(DesktopVolume.restore(0, false).level, 1.0); + }); +} diff --git a/test/features/detail/playback/end_of_stream_test.dart b/test/features/detail/playback/end_of_stream_test.dart new file mode 100644 index 00000000..c9e9aa7d --- /dev/null +++ b/test/features/detail/playback/end_of_stream_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/end_of_stream.dart'; + +Duration s(num v) => Duration(milliseconds: (v * 1000).round()); + +void main() { + late EndOfStreamDetector d; + setUp(() => d = EndOfStreamDetector()); + + bool tick( + num pos, { + num dur = 1400, + bool completed = false, + bool playing = true, + bool buffering = false, + required num at, + }) => d.observe( + position: s(pos), + duration: s(dur), + isCompleted: completed, + isPlaying: playing, + isBuffering: buffering, + now: s(at), + ); + + test('the last two seconds end the episode, once', () { + expect(tick(1390, at: 0), isFalse); + expect(tick(1398.5, at: 8), isTrue); + expect(tick(1399, at: 9), isFalse); + expect(tick(1400, at: 10), isFalse); + }); + + test('completion from the engine ends it even short of the duration', () { + expect(tick(1380, completed: true, playing: false, at: 0), isTrue); + expect(tick(1380, completed: true, playing: false, at: 1), isFalse); + }); + + test('a playhead stuck within 5 s of the end ends it after 3 s', () { + expect(tick(1396, at: 0), isFalse); + expect(tick(1396, buffering: true, at: 1), isFalse); + expect(tick(1396, buffering: true, at: 2.9), isFalse); + expect(tick(1396, buffering: true, at: 3.1), isTrue); + expect(tick(1396, buffering: true, at: 5), isFalse); + }); + + test('paused near the end is not a stall', () { + expect(tick(1396, playing: false, at: 0), isFalse); + expect(tick(1396, playing: false, at: 10), isFalse); + }); + + test('a stall further from the end is just buffering', () { + expect(tick(1300, buffering: true, at: 0), isFalse); + expect(tick(1300, buffering: true, at: 30), isFalse); + }); + + test('seeking back re-arms it', () { + expect(tick(1399, at: 0), isTrue); + expect(tick(600, at: 1), isFalse); + expect(tick(1399, at: 2), isTrue); + }); + + test('a stream without a duration never ends here', () { + expect(tick(10, dur: 0, completed: true, at: 0), isFalse); + }); +} diff --git a/test/features/detail/playback/lock_pill_test.dart b/test/features/detail/playback/lock_pill_test.dart new file mode 100644 index 00000000..77b4927b --- /dev/null +++ b/test/features/detail/playback/lock_pill_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/lock_pill.dart'; + +Duration s(num v) => Duration(milliseconds: (v * 1000).round()); + +void main() { + test('the pill fades 3 s after it is shown', () { + final pill = LockPill()..show(s(0)); + expect(pill.isVisible(s(2.9)), isTrue); + expect(pill.isVisible(s(3)), isFalse); + expect(pill.hidesAt(), s(3)); + }); + + test('a tap brings it back for another 3 s', () { + final pill = LockPill()..show(s(0)); + pill.show(s(10)); + expect(pill.isVisible(s(12)), isTrue); + expect(pill.isVisible(s(13.1)), isFalse); + }); + + test('a tap on the visible pill unlocks', () { + final pill = LockPill()..show(s(0)); + expect(pill.tapPill(s(1)), isTrue); + expect(pill.isVisible(s(1)), isFalse); + }); + + test('a tap where the faded pill was only shows it', () { + final pill = LockPill()..show(s(0)); + expect(pill.tapPill(s(5)), isFalse); + expect(pill.isVisible(s(5.5)), isTrue); + expect(pill.tapPill(s(6)), isTrue); + }); +} diff --git a/test/features/detail/playback/retry_same_switch_test.dart b/test/features/detail/playback/retry_same_switch_test.dart new file mode 100644 index 00000000..393f9e6e --- /dev/null +++ b/test/features/detail/playback/retry_same_switch_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/retry_policy.dart'; +import 'package:soplay/features/detail/presentation/pages/player_page.dart'; + +void main() { + group('retrying the same source', () { + test('is automatic, so Stop is offered', () { + final step = ServerSwitch.retrySame(to: 'Server 1', attempt: 3); + expect(step.automatic, isTrue); + expect(step.sameServer, isTrue); + expect(step.attempt, 3); + expect(step.maxAttempts, RetryPolicy.maxLifetimeRetries); + }); + + test('shows one server, not a journey to itself', () { + final step = ServerSwitch.retrySame(to: 'Server 1', attempt: 1); + expect(step.from, isNull); + expect(step.to, 'Server 1'); + }); + + test('never reads attempt 0', () { + expect(ServerSwitch.retrySame(to: 'A', attempt: 0).attempt, 1); + }); + + test('a switch to another server is not a retry', () { + expect( + ServerSwitch.automatic(from: 'A', to: 'B', attempt: 1).sameServer, + isFalse, + ); + }); + + test('the last fallback of the ladder uses it inside try/finally', () { + final media = File( + 'lib/features/detail/presentation/pages/player_page.media.dart', + ).readAsStringSync(); + final start = media.indexOf('Future<void> _autoRetry()'); + final end = media.indexOf('\n }\n', start); + final body = media.substring(start, end); + expect(body, contains('ServerSwitch.retrySame')); + expect(body, contains('identical(_serverSwitch, again)')); + }); + }); + + group('system gesture edges', () { + const size = Size(800, 400); + const insets = EdgeInsets.fromLTRB(24, 0, 24, 20); + + test('a drag from an edge strip belongs to the system', () { + expect( + startsInSystemGestureArea(const Offset(10, 200), size, insets), + isTrue, + ); + expect( + startsInSystemGestureArea(const Offset(790, 200), size, insets), + isTrue, + ); + expect( + startsInSystemGestureArea(const Offset(400, 390), size, insets), + isTrue, + ); + }); + + test('anywhere else is the player\'s', () { + expect( + startsInSystemGestureArea(const Offset(400, 200), size, insets), + isFalse, + ); + expect( + startsInSystemGestureArea(const Offset(10, 200), size, EdgeInsets.zero), + isFalse, + ); + }); + }); +} diff --git a/test/features/detail/playback/tap_seek_test.dart b/test/features/detail/playback/tap_seek_test.dart new file mode 100644 index 00000000..b7f4ee12 --- /dev/null +++ b/test/features/detail/playback/tap_seek_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/tap_seek.dart'; + +Duration ms(int v) => Duration(milliseconds: v); + +void main() { + late TapSeekTracker t; + setUp(() => t = TapSeekTracker()); + + test('zones split the width 30 / 40 / 30', () { + expect(TapSeekTracker.zoneOf(10, 100), TapZone.left); + expect(TapSeekTracker.zoneOf(50, 100), TapZone.centre); + expect(TapSeekTracker.zoneOf(90, 100), TapZone.right); + }); + + test('a single tap toggles the controls once the double-tap window ends', () { + expect(t.onTap(TapZone.right, ms(0), canSeek: true), TapAction.pending); + expect(t.flush(ms(100)), isNull); + expect(t.flush(ms(300)), TapAction.toggleControls); + expect(t.flush(ms(400)), isNull); + }); + + test('a double tap on a side seeks', () { + t.onTap(TapZone.left, ms(0), canSeek: true); + expect(t.onTap(TapZone.left, ms(150), canSeek: true), TapAction.seekBack); + expect(t.steps, 1); + expect(t.flush(ms(1000)), isNull); + }); + + test('while the ripple is up, each single tap on that side adds a step', () { + t.onTap(TapZone.right, ms(0), canSeek: true); + t.onTap(TapZone.right, ms(150), canSeek: true); + expect( + t.onTap(TapZone.right, ms(500), canSeek: true), + TapAction.seekForward, + ); + expect( + t.onTap(TapZone.right, ms(900), canSeek: true), + TapAction.seekForward, + ); + expect(t.steps, 3); + // The ripple has gone: back to a single tap. + expect(t.onTap(TapZone.right, ms(2000), canSeek: true), TapAction.pending); + }); + + test('the other side during a ripple starts afresh', () { + t.onTap(TapZone.right, ms(0), canSeek: true); + t.onTap(TapZone.right, ms(150), canSeek: true); + expect(t.onTap(TapZone.left, ms(400), canSeek: true), TapAction.pending); + expect(t.onTap(TapZone.left, ms(500), canSeek: true), TapAction.seekBack); + expect(t.steps, 1); + }); + + test('a double tap in the centre toggles play', () { + t.onTap(TapZone.centre, ms(0), canSeek: true); + expect( + t.onTap(TapZone.centre, ms(120), canSeek: true), + TapAction.togglePlay, + ); + }); + + test('no wait when a double tap could not do anything', () { + expect( + t.onTap(TapZone.left, ms(0), canSeek: false, centreTogglesPlay: false), + TapAction.toggleControls, + ); + expect( + t.onTap( + TapZone.centre, + ms(1000), + canSeek: true, + centreTogglesPlay: false, + ), + TapAction.toggleControls, + ); + expect(t.hasPending, isFalse); + }); + + test('two slow taps are two single taps', () { + t.onTap(TapZone.left, ms(0), canSeek: true); + expect(t.flush(ms(400)), TapAction.toggleControls); + expect(t.onTap(TapZone.left, ms(400), canSeek: true), TapAction.pending); + }); +} diff --git a/test/features/detail/playback_fault_test.dart b/test/features/detail/playback_fault_test.dart index dda1ab68..8d223d33 100644 --- a/test/features/detail/playback_fault_test.dart +++ b/test/features/detail/playback_fault_test.dart @@ -57,6 +57,87 @@ void main() { ); }); + test('a dropped connection is a network fault, whoever reports it', () { + // log_2e4886 (libmpv) and img_2e4886 (Dio, on the re-resolve): both + // reached the viewer as English library sentences. + for (final raw in const [ + 'tcp: ffurl_read returned 0xffffff92', + 'tcp: Failed to resolve hostname premiumleadgeneration.site: ' + 'No address associated with hostname', + 'tcp: Connection to tcp://premiumleadgeneration.site:443 failed: ' + 'Connection timed out', + 'The connection errored: Connection reset by peer This indicates an ' + 'error which most likely cannot be solved by the library.', + 'SocketException: Failed host lookup: \'cdn.example\'', + 'Network is unreachable', + 'The network connection was lost.', + ]) { + expect( + PlaybackFault.classify(raw).kind, + PlaybackFaultKind.network, + reason: raw, + ); + } + }); + + test("Dio's timeouts are network faults", () { + for (final raw in const [ + 'The request connection took longer than 0:00:15.000000 and it was ' + 'aborted.', + 'The receiving data took longer than 0:00:30.000000.', + ]) { + expect( + PlaybackFault.classify(raw).kind, + PlaybackFaultKind.network, + reason: raw, + ); + } + }); + + test('HTTP statuses are classified by what they mean', () { + const cases = { + 'This exception was thrown because the response has a status code of ' + '404 and RequestOptions.validateStatus was configured to throw': + PlaybackFaultKind.notFound, + 'Source error: InvalidResponseCodeException: Response code: 410': + PlaybackFaultKind.notFound, + 'HTTP error 403 Forbidden': PlaybackFaultKind.sourceBlocked, + 'Response code: 401': PlaybackFaultKind.sourceBlocked, + 'Server returned 503 Service Unavailable': + PlaybackFaultKind.serverError, + 'status code of 429': PlaybackFaultKind.serverError, + 'Server returned 5XX Server Error reply': PlaybackFaultKind.serverError, + }; + cases.forEach((raw, kind) { + expect(PlaybackFault.classify(raw).kind, kind, reason: raw); + }); + }); + + test('error page bodies name their status', () { + expect( + PlaybackFault.classify( + '<html><title>403 Forbidden', + ).kind, + PlaybackFaultKind.sourceBlocked, + ); + expect( + PlaybackFault.classify('

404 Not Found

nginx').kind, + PlaybackFaultKind.notFound, + ); + expect( + PlaybackFault.classify('502 Bad Gateway').kind, + PlaybackFaultKind.serverError, + ); + }); + + test('a number in an address is not a status', () { + expect( + PlaybackFault.httpStatusOf('failed: https://cdn.example/404/ep.m3u8'), + isNull, + ); + expect(PlaybackFault.httpStatusOf('status code of 200'), isNull); + }); + test('anything unrecognised falls through to unknown', () { expect( PlaybackFault.classify('something nobody has seen yet').kind, @@ -103,6 +184,65 @@ void main() { }); }); + group('once nothing is left to try', () { + test('a dropped connection is named, however many servers failed', () { + expect( + PlaybackFault.exhaustedKey( + 'tcp: ffurl_read returned 0xffffff92', + timedOut: false, + serversTried: 4, + ), + PlaybackFaultKind.network.messageKey, + ); + }); + + test('several failed servers are said as such', () { + expect( + PlaybackFault.exhaustedKey( + 'Failed to open https://cdn.example/x.mp4.', + timedOut: false, + serversTried: 3, + ), + 'player.all_servers_failed', + ); + expect( + PlaybackFault.exhaustedKey( + 'TimeoutException after 0:00:25.000000: Playback did not start', + timedOut: true, + serversTried: 2, + ), + 'player.all_servers_failed', + ); + }); + + test('one server that never started says it timed out', () { + expect( + PlaybackFault.exhaustedKey( + 'TimeoutException after 0:00:25.000000: Playback did not start', + timedOut: true, + serversTried: 1, + ), + 'player.resolve_timeout', + ); + }); + + test('one server that failed keeps its own message', () { + expect( + PlaybackFault.exhaustedKey( + 'Source error', + timedOut: false, + serversTried: 1, + ), + isNull, + ); + }); + + test('the keys it returns exist in English', () { + final en = File('assets/translations/en.json').readAsStringSync(); + expect(en.contains('"resolve_timeout"'), isTrue); + }); + }); + group('localisation contract', () { test('every kind carries a distinct player.fault.* key', () { final keys = PlaybackFaultKind.values.map((k) => k.messageKey).toList(); @@ -131,6 +271,14 @@ void main() { }); }); + test("mpv's Failed to open is a blocked source, not the raw address", () { + final fault = PlaybackFault.classify( + 'Bad state: Failed to open https://fetch.example/anime/a/b/master.m3u8.', + ); + expect(fault.kind, PlaybackFaultKind.sourceBlocked); + expect(fault.raw, contains('master.m3u8')); + }); + test('the domain layer stays free of Flutter', () { // If this file ever imports material.dart the fault can no longer be // classified in a plain Dart test, which is most of why it exists. diff --git a/test/features/detail/player_dock_host_test.dart b/test/features/detail/player_dock_host_test.dart new file mode 100644 index 00000000..82debcbb --- /dev/null +++ b/test/features/detail/player_dock_host_test.dart @@ -0,0 +1,505 @@ +// The desktop corner player moves the live player page — its State, timers, +// controller and all — out of /player and into a layer above the Navigator, +// and back, under one GlobalKey. Nothing is copied or restarted, so the one +// thing that must never happen is the State being disposed and created again +// on the way. These tests use a probe in place of the real PlayerPage (which +// needs the service locator, Hive and a video engine) and count exactly that. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/features/detail/domain/entities/player_args.dart'; +import 'package:soplay/features/detail/presentation/pages/player_route.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_dock_host.dart'; + +final List _events = []; + +/// Stands in for PlayerPage: records its lifecycle, holds a piece of state +/// that must survive, and docks the way the page does. +class _Probe extends StatefulWidget { + const _Probe({super.key, required this.args}); + + final PlayerArgs args; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> { + int ticks = 0; + final FocusNode keys = FocusNode(debugLabel: 'probeKeys'); + + @override + void initState() { + super.initState(); + _events.add('init:${widget.args.title}'); + } + + @override + void dispose() { + _events.add('dispose:${widget.args.title}'); + keys.dispose(); + super.dispose(); + } + + /// What PlayerPage._dockToCorner does, minus the player. + void dock() { + PlayerDock.instance.dock( + key: widget.key! as GlobalKey, + args: widget.args, + videoSize: const Size(1920, 1080), + ); + context.pop(); + } + + @override + Widget build(BuildContext context) { + final dock = PlayerDock.instance; + // As PlayerPage: a key handler that lets Esc through while docked, and a + // click on the player that takes the keyboard. + return Focus( + focusNode: keys, + onKeyEvent: (_, e) => KeyEventResult.ignored, + child: Listener( + onPointerDown: (_) => keys.requestFocus(), + child: _body(dock), + ), + ); + } + + Widget _body(PlayerDock dock) { + return ColoredBox( + color: Colors.black, + // The real page's corner overlay drives the drag the same way. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (_) => dock.dragStart(), + onPanUpdate: (d) => dock.dragBy(d.delta), + onPanEnd: (d) => dock.dragEnd(d.velocity.pixelsPerSecond), + child: Center( + child: Text( + 'probe ${widget.args.title} $ticks', + style: const TextStyle(fontSize: 10), + ), + ), + ), + ); + } +} + +/// Something on the page under the player that holds the keyboard. +FocusNode? _homeField; + +class _Screen extends StatelessWidget { + const _Screen(this.name); + + final String name; + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: Focus( + focusNode: name == 'home' ? _homeField : null, + child: Text('screen $name'), + ), + ), + ); +} + +PlayerArgs _args(String title) => + PlayerArgs(title: title, provider: 'test', headers: const {}); + +Widget _probe(GlobalKey key, PlayerArgs args) => _Probe(key: key, args: args); + +GoRouter _router() => GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => const _Screen('home')), + GoRoute(path: '/other', builder: (_, _) => const _Screen('other')), + GoRoute(path: '/login', builder: (_, _) => const _Screen('login')), + GoRoute( + path: '/player', + builder: (_, state) => + PlayerRoute(args: state.extra! as PlayerArgs, pageBuilder: _probe), + ), + ], +); + +const _area = Size(1200, 800); +const _m = DockGeometry.margin; + +Future _pumpApp(WidgetTester tester) async { + tester.view.physicalSize = _area; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = _router(); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + builder: (context, child) => + PlayerDockHost(router: router, pageBuilder: _probe, child: child!), + ), + ); + await tester.pumpAndSettle(); + return router; +} + +/// The location on top of the stack. Not `uri`: go_router keeps that at the +/// base location for an imperative push. +String _path(GoRouter router) => + router.routerDelegate.currentConfiguration.last.matchedLocation; + +Finder get _frame => find.byKey(PlayerDockHost.frameKey); + +Finder get _inFrame => + find.descendant(of: _frame, matching: find.byType(_Probe)); + +_ProbeState _state(WidgetTester tester) => + tester.state<_ProbeState>(find.byType(_Probe)); + +/// Opens "A" in /player, gives it some state, and docks it. +Future _openAndDock(WidgetTester tester, GoRouter router) async { + unawaited(router.push('/player', extra: _args('A'))); + await tester.pumpAndSettle(); + final s = _state(tester); + // ignore: invalid_use_of_protected_member + s.setState(() => s.ticks = 3); + s.dock(); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + _events.clear(); + _homeField = null; + PlayerDock.instance.debugReset(); + }); + tearDown(PlayerDock.instance.debugReset); + + testWidgets('nothing in the corner until a page docks', (tester) async { + final router = await _pumpApp(tester); + expect(_frame, findsNothing); + unawaited(router.push('/player', extra: _args('A'))); + await tester.pumpAndSettle(); + expect(_frame, findsNothing); + expect(find.byType(_Probe), findsOneWidget); + }); + + testWidgets( + 'docking moves the same State into the corner and leaves the route', + (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + expect(_path(router), '/'); + expect(find.text('screen home'), findsOneWidget); + expect(_inFrame, findsOneWidget); + expect(find.byType(_Probe), findsOneWidget); + // Created once, never disposed, and still holding what it held. + expect(_events, ['init:A']); + expect(find.text('probe A 3'), findsOneWidget); + expect(PlayerDock.instance.isDocked, isTrue); + + // Settled in the default corner, at the picture's shape. + expect( + tester.getRect(_frame), + const Rect.fromLTWH( + 1200 - DockGeometry.defaultWidth - _m, + 800 - 225 - _m, + DockGeometry.defaultWidth, + 225, + ), + ); + + // The page finds a Navigator and the router where it now lives, so a + // Tooltip, a dialog or an app navigation from inside it does not throw. + final ctx = tester.element(find.byType(_Probe)); + expect(Navigator.maybeOf(ctx), isNotNull); + expect(GoRouter.maybeOf(ctx), same(router)); + expect(ScaffoldMessenger.maybeOf(ctx), isNotNull); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('it keeps playing while the pages under it change', ( + tester, + ) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + router.go('/other'); + await tester.pumpAndSettle(); + expect(find.text('screen other'), findsOneWidget); + expect(_inFrame, findsOneWidget); + + unawaited(router.push('/')); + await tester.pumpAndSettle(); + router.pop(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(_inFrame, findsOneWidget); + expect(_events, ['init:A']); + expect(find.text('probe A 3'), findsOneWidget); + }); + + testWidgets( + 'expanding hands the same State back to a /player route', + (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + router.go('/other'); + await tester.pumpAndSettle(); + + PlayerDock.instance.expand(); + await tester.pump(); + expect(tester.takeException(), isNull); + // Already in the new route in that same frame — offstage while the + // hero controller measures it, but mounted — and not recreated. + expect(_frame, findsNothing); + expect(find.byType(_Probe, skipOffstage: false), findsOneWidget); + expect(_events, ['init:A']); + await tester.pumpAndSettle(); + + expect(_path(router), '/player'); + expect(_frame, findsNothing); + expect(find.byType(_Probe), findsOneWidget); + expect(_events, ['init:A']); + expect(find.text('probe A 3'), findsOneWidget); + expect(PlayerDock.instance.isDocked, isFalse); + + // And back to the corner again from there. + _state(tester).dock(); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + expect(_path(router), '/other'); + expect(_inFrame, findsOneWidget); + expect(_events, ['init:A']); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets('Esc in the corner gives the keyboard back to the page', ( + tester, + ) async { + _homeField = FocusNode(debugLabel: 'home'); + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + _homeField!.requestFocus(); + await tester.pump(); + expect(_homeField!.hasFocus, isTrue); + + // A click on the player takes the keyboard… + await tester.tapAt(tester.getRect(_frame).center); + await tester.pumpAndSettle(); + final keys = _state(tester).keys; + expect(keys.hasFocus, isTrue); + + // …and Esc hands it back, without closing the player or popping the + // page underneath. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(keys.hasFocus, isFalse); + expect(_homeField!.hasFocus, isTrue); + expect(_inFrame, findsOneWidget); + expect(_path(router), '/'); + + // A click elsewhere does the same. + await tester.tapAt(tester.getRect(_frame).center); + await tester.pump(); + expect(keys.hasFocus, isTrue); + await tester.tapAt(const Offset(40, 40)); + await tester.pump(); + expect(keys.hasFocus, isFalse); + expect(_events, ['init:A']); + }); + + group('acting while the left route still animates away', () { + /// Opens "A" and docks it, stopping mid exit transition. + Future dockMidway(WidgetTester tester, GoRouter router) async { + unawaited(router.push('/player', extra: _args('A'))); + await tester.pumpAndSettle(); + _state(tester).dock(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + + testWidgets('restoring at once keeps one page', (tester) async { + final router = await _pumpApp(tester); + await dockMidway(tester, router); + + PlayerDock.instance.expand(); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + + expect(_path(router), '/player'); + expect(find.byType(_Probe), findsOneWidget); + expect(_events, ['init:A']); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets( + 'closing at once disposes it, and it stays gone', + (tester) async { + final router = await _pumpApp(tester); + await dockMidway(tester, router); + + unawaited(PlayerDock.instance.close()); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + + expect(find.byType(_Probe, skipOffstage: false), findsNothing); + expect(_events, ['init:A', 'dispose:A']); + }, + variant: TargetPlatformVariant.desktop(), + ); + + testWidgets( + 'another title disposes the docked one before it starts', + (tester) async { + final router = await _pumpApp(tester); + await dockMidway(tester, router); + + unawaited(router.push('/player', extra: _args('B'))); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpAndSettle(); + + expect(_events, ['init:A', 'dispose:A', 'init:B']); + expect(find.text('probe B 0'), findsOneWidget); + }, + variant: TargetPlatformVariant.desktop(), + ); + }); + + testWidgets('closing disposes the page', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + unawaited(PlayerDock.instance.close()); + await tester.pumpAndSettle(); + + expect(_frame, findsNothing); + expect(find.byType(_Probe), findsNothing); + expect(_events, ['init:A', 'dispose:A']); + expect(PlayerDock.instance.isDocked, isFalse); + }); + + testWidgets('a drag moves it and the release snaps to the nearest corner', ( + tester, + ) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + final start = tester.getRect(_frame); + + final gesture = await tester.startGesture(start.center); + await gesture.moveBy(const Offset(-30, 0)); + await gesture.moveBy(const Offset(-200, -100)); + await tester.pump(); + // It follows the pointer while held. + final held = tester.getRect(_frame); + expect(held.left, lessThan(start.left - 150)); + expect(held.top, lessThan(start.top - 50)); + + // Let go well into the top-left quarter. + await gesture.moveBy(const Offset(-500, -300)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(PlayerDock.instance.corner, DockCorner.topLeft); + expect( + tester.getRect(_frame), + const Rect.fromLTWH(_m, _m, DockGeometry.defaultWidth, 225), + ); + expect(_events, ['init:A']); + }); + + testWidgets('the inner edge resizes it within bounds', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + final start = tester.getRect(_frame); + + // Docked bottom-right, so the grip is the left edge. + await tester.dragFrom( + Offset(start.left + 2, start.center.dy), + const Offset(-100, 0), + ); + await tester.pumpAndSettle(); + final grown = tester.getRect(_frame); + expect(grown.width, closeTo(start.width + 100, 1)); + // Still anchored to its corner. + expect(grown.right, closeTo(1200 - _m, 0.5)); + expect(grown.bottom, closeTo(800 - _m, 0.5)); + + // Far past the limit stops at the share of the window. + await tester.dragFrom( + Offset(grown.left + 2, grown.center.dy), + const Offset(-2000, 0), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(_frame).width, 1200 * DockGeometry.maxShare); + expect(_events, ['init:A']); + }); + + testWidgets( + 'opening another title closes the docked one before the new one starts', + (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + unawaited(router.push('/player', extra: _args('B'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + // The old page's dispose runs first: it tears down page-wide + // singletons the new one would otherwise have just set up. + expect(_events, ['init:A', 'dispose:A', 'init:B']); + expect(_frame, findsNothing); + expect(find.text('probe B 0'), findsOneWidget); + }, + ); + + testWidgets('signing out closes it', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + router.go('/login'); + await tester.pumpAndSettle(); + + expect(_frame, findsNothing); + expect(_events, ['init:A', 'dispose:A']); + }); + + testWidgets('a pushed sign-in page closes it too', (tester) async { + final router = await _pumpApp(tester); + await _openAndDock(tester, router); + + unawaited(router.push('/login')); + await tester.pumpAndSettle(); + + expect(_frame, findsNothing); + expect(_events, ['init:A', 'dispose:A']); + }); + + test('the routes that close it', () { + expect(playerDockClosesOn('/login'), isTrue); + expect(playerDockClosesOn('/splash'), isTrue); + expect(playerDockClosesOn('/profiles'), isTrue); + expect(playerDockClosesOn('/onboarding'), isTrue); + expect(playerDockClosesOn('/onboarding/genres'), isTrue); + expect(playerDockClosesOn('/main'), isFalse); + expect(playerDockClosesOn('/detail'), isFalse); + expect(playerDockClosesOn('/profiles/manage'), isFalse); + }); +} diff --git a/test/features/detail/player_dock_source_test.dart b/test/features/detail/player_dock_source_test.dart new file mode 100644 index 00000000..012eafab --- /dev/null +++ b/test/features/detail/player_dock_source_test.dart @@ -0,0 +1,92 @@ +// PlayerPage needs the service locator, Hive and a video engine, so it is not +// pumped here. These pin the few lines of it that the desktop corner player +// and the always-on-top mini window depend on, in the same source-scan style +// as player_controls_visibility_test.dart. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const dir = 'lib/features/detail/presentation/pages'; + String read(String name) => File('$dir/$name').readAsStringSync(); + + /// The body of the first `case