diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b8362bb..7fa82b61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,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/.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/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..bd9580f2 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt @@ -63,6 +63,7 @@ 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 var notificationPermissionResult: MethodChannel.Result? = null @@ -150,6 +151,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( @@ -834,7 +839,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) } @@ -1466,6 +1472,8 @@ 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. 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/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/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..6e6bbb79 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -529,6 +529,10 @@ "alt_guess": "تخمين", "alt_guess_spoken": "تطابق غير مؤكد — تحقق من العنوان قبل التشغيل", "alt_your_pick": "اختيارك", + "alt_auto": "تلقائي", + "alt_auto_hint": "يشغّل من أول مصدر يتوفر فيه هذا", + "alt_auto_trying": "جارٍ تجربة {}…", + "alt_auto_missed": "لم يتمكن أي مصدر مطابق من تشغيله. اختر واحدًا أدناه أو ابحث في مصدر.", "alt_wrong_title": "العنوان خاطئ؟", "alt_wrong_title_hint": "ابحث بنفسك في {} واختر العنوان الصحيح", "alt_corrected": "يُستخدم الآن «{}»", @@ -557,7 +561,11 @@ "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": "مشغّل مصغّر · اضغط مطولًا ليطفو فوق التطبيقات الأخرى" }, "profile": { "title": "حسابي", @@ -1062,6 +1070,10 @@ "forgot_pin": "نسيت الرمز؟" }, "my_list": { + "sort": "ترتيب", + "sort_added": "المضاف حديثًا", + "sort_oldest": "الأقدم إضافة", + "sort_title": "العنوان أ–ي", "saved_account": "محفوظ في الحساب", "saved_local": "على هذا الجهاز", "untitled": "بدون عنوان", @@ -1363,6 +1375,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 +1409,8 @@ "download_batch_body": "ستُضاف الحلقات {range} إلى القائمة — {count} في المجموع. على بيانات الجوال قد يبلغ ذلك عدّة جيجابايت.", "relations": "أعمال مرتبطة", "no_relations": "لم يُعثر على أعمال مرتبطة بهذا.", + "relation_tmdb_collection": "من نفس السلسلة", + "relation_tmdb_recommended": "مقترحات", "relation_sequel": "التتمة", "relation_prequel": "الجزء السابق", "relation_parent": "القصة الأصلية", @@ -1461,6 +1479,8 @@ "offline_desc": "تُعرض القائمة المحفوظة. دون اتصال تُفتح الحلقات المنزّلة فقط.", "filter_hint": "رقم الحلقة أو عنوانها", "all_groups": "كل المجموعات", + "season_n": "الموسم {}", + "specials": "حلقات خاصة", "filter_none": "لا توجد حلقة تطابق “{query}” في هذا الجزء", "jump_to": "الانتقال إلى الحلقة {n}", "jump_loads": "سيتم تحميل {range}", @@ -1721,7 +1741,7 @@ "manual_title": "أو أدخل الرمز", "confirm": "تأكيد", "approved": "تم ربط التلفاز", - "approved_named": "\\u201c{}\\u201d تم ربطه", + "approved_named": "“{}” تم ربطه", "approved_hint": "سيسجّل التلفاز الدخول إلى حسابك خلال ثوانٍ.", "linked_devices": "الأجهزة المرتبطة", "no_devices": "لا توجد أجهزة مرتبطة بعد", @@ -2304,8 +2324,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 +2339,8 @@ "v_blocked": "محجوب", "v_dead": "لا يعمل", "v_empty": "فارغ", + "v_empty_body": "استجاب المصدر، لكن قائمته جاءت فارغة. هكذا يبدو غالبًا موقع حجب التطبيق.", + "v_failing_body": "فشل آخر فحص لهذا المصدر. قد يكون الفشل الواحد مجرد لحظة سيئة — أعد الفحص.", "v_failing": "لا يستجيب", "v_outdated": "يحتاج تحديثًا", "v_unknown": "لم يُفحص بعد", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index f97db42b..6cef5071 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -529,6 +529,10 @@ "alt_guess": "گومان", "alt_guess_spoken": "هاوتایی دڵنیا نییە — پێش لێدان ناونیشانەکە بپشکنە", "alt_your_pick": "هەڵبژاردەی تۆ", + "alt_auto": "خۆکار", + "alt_auto_hint": "لە یەکەم سەرچاوە کە ئەمەی هەیە لێدەدات", + "alt_auto_trying": "تاقیکردنەوەی {}…", + "alt_auto_missed": "هیچ سەرچاوەیەکی گونجاو نەیتوانی لێیبدات. یەکێک لە خوارەوە هەڵبژێرە یان لە سەرچاوەیەک بگەڕێ.", "alt_wrong_title": "ناونیشانی هەڵە؟", "alt_wrong_title_hint": "خۆت لە {} بگەڕێ و ناونیشانی دروست هەڵبژێرە", "alt_corrected": "ئێستا “{}” بەکاردێت", @@ -557,7 +561,11 @@ "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": "لێدەری بچووک · پەنجە ڕابگرە بۆ ئەوەی لەسەر ئەپەکانی تر بمێنێتەوە" }, "profile": { "title": "پرۆفایل", @@ -1062,6 +1070,10 @@ "forgot_pin": "PIN ت لەبیرچووە؟" }, "my_list": { + "sort": "ڕیزکردن", + "sort_added": "نوێترین زیادکراو", + "sort_oldest": "کۆنترین زیادکراو", + "sort_title": "ناونیشان ئ–ی", "saved_account": "لە هەژمار پاشەکەوتکراوە", "saved_local": "لەسەر ئەم ئامێرە", "untitled": "بێ ناونیشان", @@ -1363,6 +1375,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 +1409,8 @@ "download_batch_body": "ئەڵقەکانی {range} دەخرێنە ڕیز — بە گشتی {count}. بە داتای مۆبایل ئەمە دەتوانێت چەند گیگابایتێک بێت.", "relations": "پەیوەندییەکان", "no_relations": "هیچ ناونیشانێکی پەیوەندیدار بۆ ئەمە نەدۆزرایەوە.", + "relation_tmdb_collection": "هەمان زنجیرە", + "relation_tmdb_recommended": "پێشنیارکراو", "relation_sequel": "درێژەپێدەر", "relation_prequel": "پێشینە", "relation_parent": "چیرۆکی سەرەکی", @@ -1461,6 +1479,8 @@ "offline_desc": "لیستە پاشەکەوتکراوەکە پیشان دەدرێت. تەنها ئەڵقە داگیراوەکان بە ئۆفلاین دەکرێنەوە.", "filter_hint": "ژمارە یان ناونیشانی ئەڵقە", "all_groups": "هەموو گرووپەکان", + "season_n": "وەرزی {}", + "specials": "تایبەتەکان", "filter_none": "هیچ ئەڵقەیەک لەگەڵ “{query}” لەم بەشەدا ناگونجێت", "jump_to": "بڕۆ بۆ ئەڵقەی {n}", "jump_loads": "{range} بار دەکات", @@ -2300,8 +2320,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 +2335,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": "هێشتا نەپشکنراوە", diff --git a/assets/translations/de.json b/assets/translations/de.json index 8cc3ee27..0970f03c 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -529,6 +529,10 @@ "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_wrong_title": "Falscher Titel?", "alt_wrong_title_hint": "Suche selbst in {} und wähle den richtigen Titel", "alt_corrected": "Verwendet jetzt „{}“", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/en.json b/assets/translations/en.json index b7828f39..8fbca5aa 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -529,6 +529,10 @@ "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_wrong_title": "Wrong title?", "alt_wrong_title_hint": "Search {} yourself and pick the right title", "alt_corrected": "Now using “{}”", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profile", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -1721,7 +1741,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", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/es.json b/assets/translations/es.json index e45868f1..319ddb48 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -529,6 +529,10 @@ "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_wrong_title": "¿Título incorrecto?", "alt_wrong_title_hint": "Busca tú mismo en {} y elige el título correcto", "alt_corrected": "Ahora se usa «{}»", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Perfil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index e1a4c3dd..599703a9 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -529,6 +529,10 @@ "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_wrong_title": "Mauvais titre ?", "alt_wrong_title_hint": "Cherchez vous-même dans {} et choisissez le bon titre", "alt_corrected": "Utilise maintenant « {} »", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/id.json b/assets/translations/id.json index d3996d02..28442646 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -529,6 +529,10 @@ "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_wrong_title": "Judul salah?", "alt_wrong_title_hint": "Cari sendiri di {} dan pilih judul yang benar", "alt_corrected": "Sekarang memakai “{}”", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 556dc6e5..e876ed5b 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -529,6 +529,10 @@ "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_wrong_title": "Sernav şaş e?", "alt_wrong_title_hint": "Bi xwe li {} bigere û sernavê rast hilbijêre", "alt_corrected": "Niha “{}” tê bikaranîn", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profîl", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 20aed06f..8ca39779 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -529,6 +529,10 @@ "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_wrong_title": "Verkeerde titel?", "alt_wrong_title_hint": "Zoek zelf in {} en kies de juiste titel", "alt_corrected": "Gebruikt nu “{}”", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profiel", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 7e4d1f17..f79b5482 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -529,6 +529,10 @@ "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_wrong_title": "Título errado?", "alt_wrong_title_hint": "Procure você mesmo em {} e escolha o título certo", "alt_corrected": "Agora a usar «{}»", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Perfil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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}", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index efe8a882..c85aef09 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -529,6 +529,10 @@ "alt_guess": "Предположение", "alt_guess_spoken": "Ненадёжное совпадение — проверьте название перед запуском", "alt_your_pick": "Ваш выбор", + "alt_auto": "Авто", + "alt_auto_hint": "Включит из первого источника, где это есть", + "alt_auto_trying": "Пробуем {}…", + "alt_auto_missed": "Ни один подходящий источник не смог это воспроизвести. Выберите ниже или поищите в источнике.", "alt_wrong_title": "Не то название?", "alt_wrong_title_hint": "Найдите сами в {} и выберите нужное название", "alt_corrected": "Теперь используется «{}»", @@ -557,7 +561,11 @@ "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": "Мини-плеер · удерживайте, чтобы закрепить поверх других приложений" }, "profile": { "title": "Профиль", @@ -991,6 +999,10 @@ "forgot_pin": "Забыли PIN?" }, "my_list": { + "sort": "Сортировка", + "sort_added": "Недавно добавленные", + "sort_oldest": "Давно добавленные", + "sort_title": "Название А–Я", "saved_account": "Сохранено в аккаунте", "saved_local": "Только на устройстве", "untitled": "Без названия", @@ -1363,6 +1375,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 +1409,8 @@ "download_batch_body": "В очередь встанут серии {range} — всего {count}. На мобильном интернете это может быть несколько гигабайт.", "relations": "Связанное", "no_relations": "Связанных тайтлов не найдено.", + "relation_tmdb_collection": "ТА ЖЕ СЕРИЯ", + "relation_tmdb_recommended": "РЕКОМЕНДУЕМ", "relation_sequel": "ПРОДОЛЖЕНИЕ", "relation_prequel": "ПРИКВЕЛ", "relation_parent": "ОСНОВНАЯ ИСТОРИЯ", @@ -1461,6 +1479,8 @@ "offline_desc": "Показан сохранённый список. Офлайн открываются только скачанные серии.", "filter_hint": "Номер или название серии", "all_groups": "Все группы", + "season_n": "Сезон {}", + "specials": "Спецвыпуски", "filter_none": "В этом блоке нет серии «{query}»", "jump_to": "Перейти к серии {n}", "jump_loads": "Загрузит {range}", @@ -2301,8 +2321,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 +2336,8 @@ "v_blocked": "Заблокирован", "v_dead": "Не работает", "v_empty": "Пусто", + "v_empty_body": "Источник ответил, но каталог пришёл пустым. Так часто выглядит сайт, который закрыл доступ приложению.", + "v_failing_body": "Последняя проверка этого источника не удалась. Один сбой может быть случайным — проверьте ещё раз.", "v_failing": "Не отвечает", "v_outdated": "Нужно обновить", "v_unknown": "Ещё не проверен", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index e2324ce5..f22f299e 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -529,6 +529,10 @@ "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_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", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 1716e906..a0ec0494 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -529,6 +529,10 @@ "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_wrong_title": "Sarlavha noto‘g‘rimi?", "alt_wrong_title_hint": "{} ichidan o‘zingiz qidirib, to‘g‘ri sarlavhani tanlang", "alt_corrected": "Endi “{}” ishlatilyapti", @@ -557,7 +561,11 @@ "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" }, "profile": { "title": "Profil", @@ -1062,6 +1070,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", @@ -1363,6 +1375,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 +1409,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 +1479,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", @@ -2300,8 +2320,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 +2335,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", diff --git a/assets/translations/vi.json b/assets/translations/vi.json new file mode 100644 index 00000000..5a8f5b47 --- /dev/null +++ b/assets/translations/vi.json @@ -0,0 +1,3031 @@ +{ + "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" + }, + "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." + }, + "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_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", + "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" + }, + "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", + "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", + "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" + }, + "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_done": "Đã dừng nghe", + "voice_hint": "Nói tên một phim lẻ hoặc phim bộ", + "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." + }, + "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." + }, + "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" + }, + "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." + }, + "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." + }, + "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." + }, + "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_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ợ" + } +} diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 6ce1e7a4..91e3e54a 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -529,6 +529,10 @@ "alt_guess": "估計", "alt_guess_spoken": "唔肯定係咪同一套 — 播之前睇清楚個標題", "alt_your_pick": "你揀嘅", + "alt_auto": "自動", + "alt_auto_hint": "由第一個有呢套嘅來源播放", + "alt_auto_trying": "試緊 {}…", + "alt_auto_missed": "冇一個相符嘅來源播到。喺下面揀一個或者喺來源度搜尋。", "alt_wrong_title": "揀錯標題?", "alt_wrong_title_hint": "自己喺 {} 搵,再揀返啱嘅標題", "alt_corrected": "而家用緊「{}」", @@ -557,7 +561,11 @@ "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 上面" }, "profile": { "title": "個人檔案", @@ -1062,6 +1070,10 @@ "forgot_pin": "唔記得 PIN?" }, "my_list": { + "sort": "排序", + "sort_added": "最近加入", + "sort_oldest": "最早加入", + "sort_title": "標題 A–Z", "saved_account": "已存入帳戶", "saved_local": "喺呢部裝置", "untitled": "冇標題", @@ -1363,6 +1375,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 +1409,8 @@ "download_batch_body": "第 {range} 集會排隊下載 — 一共 {count} 集。用流動數據可能會用幾個 GB。", "relations": "相關作品", "no_relations": "搵唔到同呢套相關嘅作品。", + "relation_tmdb_collection": "同系列", + "relation_tmdb_recommended": "推薦", "relation_sequel": "續篇", "relation_prequel": "前傳", "relation_parent": "本篇故事", @@ -1461,6 +1479,8 @@ "offline_desc": "顯示緊儲低咗嘅清單。離線時淨係開到已下載嘅集數。", "filter_hint": "集數或者標題", "all_groups": "所有字幕組", + "season_n": "第 {} 季", + "specials": "特別篇", "filter_none": "呢一段冇符合「{query}」嘅集數", "jump_to": "去第 {n} 集", "jump_loads": "載入 {range}", @@ -2300,8 +2320,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 +2335,8 @@ "v_blocked": "被封鎖", "v_dead": "用唔到", "v_empty": "空白", + "v_empty_body": "來源有回應,但目錄係空嘅。一個封鎖咗應用程式嘅網站通常就係咁。", + "v_failing_body": "呢個來源上次檢查失敗。一次失敗可能只係一時出事 — 再檢查一次睇下。", "v_failing": "冇回應", "v_outdated": "要更新", "v_unknown": "未檢查", diff --git a/docs/images/banner-v2.svg b/docs/images/banner-v2.svg deleted file mode 100644 index 4d32f587..00000000 --- a/docs/images/banner-v2.svg +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - v2.0 - - Sozo - Every stream, right in your hands. - Movies · Series · Anime · Shorts — reimagined for v2. - - - - - - Downloads - - - - Shorts - - - - App Lock - - - - Streaks - - - - - - - diff --git a/docs/images/banner.png b/docs/images/banner.png deleted file mode 100644 index c4a8b8ef..00000000 Binary files a/docs/images/banner.png and /dev/null differ diff --git a/docs/images/v3-images/img.jpg b/docs/images/v3-images/img.jpg deleted file mode 100644 index 142ac1e0..00000000 Binary files a/docs/images/v3-images/img.jpg and /dev/null differ diff --git a/docs/images/v3-images/img2.jpg b/docs/images/v3-images/img2.jpg deleted file mode 100644 index a7fc3646..00000000 Binary files a/docs/images/v3-images/img2.jpg and /dev/null differ diff --git a/docs/images/v3-images/img3.jpg b/docs/images/v3-images/img3.jpg deleted file mode 100644 index 4890a236..00000000 Binary files a/docs/images/v3-images/img3.jpg and /dev/null differ diff --git a/docs/images/v3-images/img4.jpg b/docs/images/v3-images/img4.jpg deleted file mode 100644 index c29a4be3..00000000 Binary files a/docs/images/v3-images/img4.jpg and /dev/null differ diff --git a/docs/images/v3-images/img5.jpg b/docs/images/v3-images/img5.jpg deleted file mode 100644 index 22fb6dce..00000000 Binary files a/docs/images/v3-images/img5.jpg and /dev/null differ diff --git a/docs/images/v3.2.0/banner.png b/docs/images/v3.2.0/banner.png deleted file mode 100644 index 68d34341..00000000 Binary files a/docs/images/v3.2.0/banner.png and /dev/null differ diff --git a/integration_test/ios_uzmovi_playback_test.dart b/integration_test/ios_uzmovi_playback_test.dart new file mode 100644 index 00000000..0eb7c4be --- /dev/null +++ b/integration_test/ios_uzmovi_playback_test.dart @@ -0,0 +1,156 @@ +// Why this test exists +// +// uzmovi films do not play on iOS 3.2.1, and every layer below the player has +// already been cleared: the backend resolve answers 200 with a complete +// uzmovi-rc4-v1 config, the signed CDN returns a real playlist and real +// segments, and `LocalHlsProxy` serves both correctly when driven from Dart on +// macOS. What is left is the one thing that is only true on iOS — the stream is +// played by AVFoundation (`resolvePlayerEngine()` returns +// `PlayerEngine.native` on every non-Android, non-desktop platform, because +// there is no libmpv for iOS), and AVFoundation's refusal arrives as an opaque +// message unless somebody reads it off a device. +// +// So this runs the real chain on a real iOS target and prints what the player +// says: resolve live, register the proxy, fetch the playlist and the first +// segment through it from inside the app (proving the proxy half on-device), +// then hand the proxy URL to the player and report `errorDescription` verbatim. +// +// flutter test integration_test/ios_uzmovi_playback_test.dart -d +// +// A failure here is the point of the test, not a defect in it: the assertions +// come last, after everything has been printed. +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:soplay/core/player/local_hls_proxy.dart'; +import 'package:soplay/core/player/media_controller.dart'; +import 'package:soplay/core/player/player_engine.dart'; + +const _api = 'https://apisozo.azamov.me/api'; +// A film rather than a serial: one resolve, no episode list in the way. +const _page = + 'https://uzmovi.net/tarjima-kinolarri/9102-xudo-asrasin-2-premyera-qirgiz-filmi-ozbek-tilida-uzbek.html'; + +void say(String line) => debugPrint('UZMOVI_IOS $line'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('a uzmovi film plays on this device, or says why not', ( + tester, + ) async { + say('platform=${Platform.operatingSystem} ${Platform.operatingSystemVersion}'); + say('engine=${resolvePlayerEngine().id}'); + + final dio = Dio(BaseOptions(validateStatus: (_) => true)); + final resolved = await dio.get>( + '$_api/contents/media', + queryParameters: const {'provider': 'uzmovi', 'ref': _page}, + ); + say('resolve status=${resolved.statusCode}'); + final body = resolved.data!; + final sources = (body['videoSources'] as List).cast>(); + say('sources=${sources.length}'); + final src = sources.first; + say('quality=${src['quality']} type=${src['type']} ' + 'useLocalProxy=${src['useLocalProxy']} host=${src['host']}'); + + final proxy = LocalHlsProxy(dio); + final proxied = await proxy.register( + upstreamUrl: src['videoUrl'] as String, + headers: Map.from(src['headers'] as Map), + localProxy: Map.from(src['localProxy'] as Map), + requestTransform: Map.from( + (src['requestTransform'] ?? const {}) as Map, + ), + ); + say('proxy url=$proxied'); + + // The proxy half, from inside this app on this device. If this fails the + // player never had a chance and the fault is not AVFoundation's. + final client = HttpClient(); + final playlistReq = await client.getUrl(Uri.parse(proxied)); + final playlistRes = await playlistReq.close(); + final playlist = await playlistRes.transform(utf8.decoder).join(); + say('playlist status=${playlistRes.statusCode} ' + 'type=${playlistRes.headers.contentType} ' + 'len=${playlist.length} ' + 'cl=${playlistRes.headers.contentLength}'); + final firstSegment = playlist + .split('\n') + .map((l) => l.trim()) + .firstWhere((l) => l.isNotEmpty && !l.startsWith('#'), orElse: () => ''); + say('first segment line=$firstSegment'); + if (firstSegment.isNotEmpty) { + final segUri = Uri.parse(proxied).resolve(firstSegment); + final segReq = await client.getUrl(segUri); + // The way AVFoundation opens a segment. + segReq.headers.set(HttpHeaders.rangeHeader, 'bytes=0-'); + final segRes = await segReq.close(); + var bytes = 0; + await for (final chunk in segRes) { + bytes += chunk.length; + } + say('segment status=${segRes.statusCode} ' + 'range=${segRes.headers.value('content-range')} ' + 'accept-ranges=${segRes.headers.value('accept-ranges')} ' + 'cl=${segRes.headers.contentLength} read=$bytes'); + } + client.close(); + + // Now the player itself. + final controller = PlayerController.networkUrl( + Uri.parse(proxied), + httpHeaders: const {}, + formatHint: VideoFormat.hls, + ); + say('controller=${controller.runtimeType}'); + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: controller.buildView())), + ); + String? failure; + try { + await controller.initialize().timeout(const Duration(seconds: 60)); + say('initialized size=${controller.value.size} ' + 'duration=${controller.value.duration}'); + await controller.play(); + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (controller.value.position < const Duration(seconds: 2) && + !controller.value.hasError && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 250)); + await tester.pump(); + } + say('position=${controller.value.position} ' + 'playing=${controller.value.isPlaying} ' + 'buffered=${controller.value.buffered}'); + } catch (e) { + failure = '$e'; + say('THREW: $e'); + } + // Verbatim, because this is the line that has been missing all along. + say('hasError=${controller.value.hasError}'); + say('errorDescription=${controller.value.errorDescription}'); + + final position = controller.value.position; + await tester.pumpWidget(const SizedBox()); + await controller.dispose(); + await proxy.dispose(); + + expect(failure, isNull, reason: 'player threw: $failure'); + expect( + controller.value.errorDescription, + isNull, + reason: 'player reported: ${controller.value.errorDescription}', + ); + expect( + position, + greaterThanOrEqualTo(const Duration(seconds: 2)), + reason: 'playback did not reach 2s', + ); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/integration_test/native_glass_test.dart b/integration_test/native_glass_test.dart new file mode 100644 index 00000000..ecdb5a71 --- /dev/null +++ b/integration_test/native_glass_test.dart @@ -0,0 +1,123 @@ +// Why this test exists +// +// `NativeGlass` (lib/core/glass/native_glass.dart) hosts the phone's own +// glass as a UIKit view, and nothing about that half can be seen from a +// widget test: a `UiKitView` renders nothing in the test renderer, the +// factory `soplay/native_glass` is only registered by a real engine +// (ios/Runner/AppDelegate.swift), and `glassCapability` is only answered by +// a real `UIVisualEffectView` on a real OS. The unit test +// (test/core/glass/native_glass_test.dart) covers the decision; this covers +// the part that can only be true on a device. +// +// It does three things: asks the running OS what its glass can do and +// prints the answer verbatim, checks that the answer and the OS version +// agree (a phone on iOS 26 or later must report the glass effect — if it +// does not, the bridge is not registered, which is the failure this test is +// here to catch), and mounts the widget to prove the platform view is +// actually created rather than throwing. +// +// flutter test integration_test/native_glass_test.dart -d +// +// The prints come before the assertions on purpose: what the device said is +// the result, whether or not the assertions pass. +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; +import 'package:soplay/core/glass/native_glass.dart'; + +void say(String line) => debugPrint('NATIVE_GLASS $line'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('the phone says what its glass can do, and hosts one', ( + tester, + ) async { + say('platform=${Platform.operatingSystem} ${Platform.operatingSystemVersion}'); + + // The raw map, not the parsed capability: a key the Dart side does not + // know about yet should still be visible in the output. + final raw = await GlassSense.channel.invokeMapMethod( + NativeGlass.capabilityMethod, + ); + say('raw=$raw'); + + final said = await NativeGlass.capability(); + say('capability=$said'); + + final decided = nativeGlassDraw( + platform: defaultTargetPlatform, + capability: said, + reduceTransparency: said.reduceTransparency, + ); + say('draw=$decided'); + + NativeGlassDraw? reported; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: GlassSense( + child: Center( + child: SizedBox( + width: 240, + height: 120, + child: NativeGlass( + borderRadius: BorderRadius.circular(26), + onDraw: (draw) => reported = draw, + fallback: (_) => + const ColoredBox(color: Color(0x33FFFFFF), key: Key('fallback')), + ), + ), + ), + ), + ), + ); + // One frame to mount, one for the probe's answer, one for the hosted + // view the answer asks for. + await tester.pumpAndSettle(); + say('reported=$reported'); + say('hosted=${find.byType(UiKitView).evaluate().length}'); + say('fellBack=${find.byKey(const Key('fallback')).evaluate().isNotEmpty}'); + + // How many views UIKit really built. Counting the Dart `UiKitView` + // above proves only that Dart asked for one: with the factory + // unregistered, Flutter leaves the element in the tree and logs the + // failure instead of throwing, so every assertion here passed while + // nothing native existed. This is the number that cannot be true + // unless ios/Runner/NativeGlass.swift ran. + final built = await GlassSense.channel.invokeMethod('glassViewsBuilt'); + say('built=$built'); + + // Now the assertions. + expect(Platform.isIOS, isTrue, reason: 'this test is about the iPhone'); + expect(raw, isNotNull, reason: 'soplay/glass did not answer glassCapability'); + expect(said.systemVersion, isNotEmpty); + expect(said.majorVersion, greaterThan(0)); + // Only the dark materials are offered, and all of them are iOS 13+, so + // every phone that can run this app must list them. + expect(said.materials, contains('thin')); + if (said.majorVersion >= 26) { + expect( + said.systemGlass, + isTrue, + reason: 'iOS ${said.systemVersion} has UIGlassEffect', + ); + expect(said.styles, containsAll(['regular', 'clear'])); + expect(decided, NativeGlassDraw.systemGlass); + } else { + expect(decided, NativeGlassDraw.systemMaterial); + } + expect(reported, decided, reason: 'the widget drew something else'); + expect(find.byType(UiKitView), findsOneWidget); + expect( + built, + 1, + reason: 'UIKit built no glass view: the factory soplay/native_glass is ' + 'not registered, or the effect could not be made', + ); + }); +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c194690f..59a51e96 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ A1B2C3D40000000000000001 /* RepoFileImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* RepoFileImport.swift */; }; A1B2C3D40000000000000003 /* SystemControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000004 /* SystemControls.swift */; }; A1B2C3D40000000000000005 /* NativePip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000006 /* NativePip.swift */; }; + A1B2C3D40000000000000007 /* GlassSense.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000008 /* GlassSense.swift */; }; + A1B2C3D40000000000000009 /* NativeGlass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000000000000000A /* NativeGlass.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -62,6 +64,8 @@ A1B2C3D40000000000000002 /* RepoFileImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepoFileImport.swift; sourceTree = ""; }; A1B2C3D40000000000000004 /* SystemControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemControls.swift; sourceTree = ""; }; A1B2C3D40000000000000006 /* NativePip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePip.swift; sourceTree = ""; }; + A1B2C3D40000000000000008 /* GlassSense.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassSense.swift; sourceTree = ""; }; + A1B2C3D4000000000000000A /* NativeGlass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGlass.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -149,6 +153,8 @@ A1B2C3D40000000000000002 /* RepoFileImport.swift */, A1B2C3D40000000000000004 /* SystemControls.swift */, A1B2C3D40000000000000006 /* NativePip.swift */, + A1B2C3D40000000000000008 /* GlassSense.swift */, + A1B2C3D4000000000000000A /* NativeGlass.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -412,6 +418,8 @@ A1B2C3D40000000000000001 /* RepoFileImport.swift in Sources */, A1B2C3D40000000000000003 /* SystemControls.swift in Sources */, A1B2C3D40000000000000005 /* NativePip.swift in Sources */, + A1B2C3D40000000000000007 /* GlassSense.swift in Sources */, + A1B2C3D40000000000000009 /* NativeGlass.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ad522e36..4d3328e7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -68,6 +68,11 @@ import UIKit // contract as the Android host, so the Flutter side is platform-agnostic. RepoFileImport.register(messenger: registrar.messenger()) SystemControls.register(with: registrar) + GlassSense.register(with: registrar) + // The hosted glass surface (lib/core/glass/native_glass.dart) is a + // platform view, so its factory has to be registered on the registrar + // and not only on the method channel GlassSense owns. + NativeGlass.register(with: registrar) NativePip.register(messenger: registrar.messenger()) let previewChannel = FlutterMethodChannel( diff --git a/ios/Runner/GlassSense.swift b/ios/Runner/GlassSense.swift new file mode 100644 index 00000000..a696cba3 --- /dev/null +++ b/ios/Runner/GlassSense.swift @@ -0,0 +1,60 @@ +import Flutter +import UIKit + +/// Whether the phone asks for glass that cannot be seen through — Reduce +/// Transparency (lib/core/glass/glass_sense.dart). Flutter carries Reduce +/// Motion and Increase Contrast to the app but not this, so the app asks. +/// +/// - `reduceTransparency` → Bool, asked when the app starts and whenever it +/// comes back to the front. +/// - The switch thrown while the app is in front (the accessibility +/// shortcut) is said unasked, as a `reduceTransparency` call with the new +/// value, so the glass turns opaque on the same frame. +/// - `glassCapability` → a map of what the phone's own glass can give +/// (NativeGlass.swift). It lives on this channel rather than on one of +/// its own because it answers the same question from the same place — +/// what this particular iPhone will let the glass be — and a second +/// channel would be a second thing to keep registered. +/// - `glassViewsBuilt` → how many hosted glass views UIKit has really +/// built. Flutter keeps a `UiKitView` in the tree whether or not the +/// native half of it was ever made, so this is the only way a test on a +/// device can tell a registered factory from a missing one. +enum GlassSense { + private static var channel: FlutterMethodChannel? + + /// The one observer of the switch: registered once however many engines + /// register the bridge (each later one only takes over the channel), and + /// kept so it is never added twice. + private static var observer: NSObjectProtocol? + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "soplay/glass", + binaryMessenger: registrar.messenger() + ) + self.channel = channel + channel.setMethodCallHandler { call, result in + switch call.method { + case "reduceTransparency": + result(UIAccessibility.isReduceTransparencyEnabled) + case "glassCapability": + result(NativeGlass.capability()) + case "glassViewsBuilt": + result(NativeGlassView.built) + default: + result(FlutterMethodNotImplemented) + } + } + guard observer == nil else { return } + observer = NotificationCenter.default.addObserver( + forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification, + object: nil, + queue: .main + ) { _ in + GlassSense.channel?.invokeMethod( + "reduceTransparency", + arguments: UIAccessibility.isReduceTransparencyEnabled + ) + } + } +} diff --git a/ios/Runner/NativeGlass.swift b/ios/Runner/NativeGlass.swift new file mode 100644 index 00000000..246a3f73 --- /dev/null +++ b/ios/Runner/NativeGlass.swift @@ -0,0 +1,213 @@ +import Flutter +import UIKit + +/// The phone's own glass, handed to Flutter as a hosted view +/// (lib/core/glass/native_glass.dart). +/// +/// Everything else in the app approximates Liquid Glass in a shader +/// (lib/core/glass/glass_surface.dart): it bends the page it reads under a +/// frost and lights a rim by hand, because that is the only way a Flutter +/// layer can be glass. From iOS 26 the system draws the material itself — +/// `UIGlassEffect` — and what it draws is the reference the shader is +/// measured against: the real specular rim, the real dynamic tint over +/// whatever moves beneath, the real behaviour under Reduce Transparency and +/// Increase Contrast, all of it maintained by the platform rather than by +/// us. So where a surface can afford to be a hosted view, it should be the +/// system's material and not our imitation of it. +/// +/// Two things are registered here: +/// +/// * the view factory `soplay/native_glass`, which Flutter mounts through +/// `UiKitView`; and +/// * `glassCapability` on the existing `soplay/glass` channel +/// (GlassSense.swift calls into `capability()`), so Dart learns what this +/// particular phone will actually give instead of inferring it from a +/// version string. The app's floor is iOS 15 and the SDK is iOS 27, so +/// the answer is genuinely different on the phones we ship to and a +/// wrong guess here is a blank rectangle on somebody's screen. +/// +/// The degradation is by what the running OS has, not by what we were built +/// against: `UIGlassEffect` where it exists, a system blur material where +/// it does not, and in both cases a `UIVisualEffectView` — never a nil +/// effect, which renders as an invisible view and reads as a bug rather +/// than as a fallback. +enum NativeGlass { + /// The view type Flutter mounts. Named like the channel so a `grep` for + /// `soplay/` finds every bridge the app has. + static let viewType = "soplay/native_glass" + + static func register(with registrar: FlutterPluginRegistrar) { + registrar.register(NativeGlassFactory(), withId: viewType) + } + + /// What this phone can give, read at runtime and answered as a plain map + /// so Dart can branch on capabilities rather than on `systemVersion`. + /// + /// `materials` is reported as the names Dart passes back in, not as the + /// `UIBlurEffect.Style` raw values: the raw values are an ABI detail that + /// has been renumbered before, and a number that shifts under us would + /// silently pick a different material. + static func capability() -> [String: Any] { + let os = ProcessInfo.processInfo.operatingSystemVersion + var styles: [String] = [] + if #available(iOS 26.0, *) { + // Both styles ship together; there is no partial adoption to probe + // for, so the availability check *is* the probe. + styles = ["regular", "clear"] + } + return [ + "systemVersion": UIDevice.current.systemVersion, + "majorVersion": os.majorVersion, + "minorVersion": os.minorVersion, + // Whether `UIGlassEffect` exists on the phone running this, which is + // the one fact Dart cannot work out for itself. + "systemGlass": !styles.isEmpty, + "glassStyles": styles, + // The blur materials the fallback may ask for. All of these are + // iOS 13+, so on anything that can run this app the list is the same + // — it is sent anyway so that a phone which ever stops offering one + // can say so without a new app build. + "materials": Array(NativeGlassFactory.materials.keys).sorted(), + // Repeated from `reduceTransparency` so a caller that only wants to + // know "may I host glass at all" needs one round trip, not two. + "reduceTransparency": UIAccessibility.isReduceTransparencyEnabled, + ] + } +} + +/// Makes a [NativeGlassView] for each `UiKitView` Flutter mounts. +final class NativeGlassFactory: NSObject, FlutterPlatformViewFactory { + /// The blur materials the fallback can name, keyed by what Dart sends. + /// Dark variants only: the app's glass is dark everywhere (GlassRecipe in + /// lib/core/glass/app_glass.dart), and the automatic `.systemMaterial` + /// would follow the phone's appearance and turn white on a light phone + /// over our dark page. + static let materials: [String: UIBlurEffect.Style] = [ + "ultraThin": .systemUltraThinMaterialDark, + "thin": .systemThinMaterialDark, + "regular": .systemMaterialDark, + "thick": .systemThickMaterialDark, + "chrome": .systemChromeMaterialDark, + ] + + /// Creation parameters arrive as a Dart `Map`, so the standard codec + /// rather than the default (which carries nothing). + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeGlassView(frame: frame, args: args as? [String: Any] ?? [:]) + } +} + +/// One hosted glass surface. +/// +/// The view is a `UIVisualEffectView` and nothing else: no subviews, no +/// content. What stands on the glass — a glyph, a label — stays in Flutter +/// and is drawn over this view, the same division the shader path keeps +/// (`GlassGroup` draws only the glass). +final class NativeGlassView: NSObject, FlutterPlatformView { + /// How many of these UIKit has actually built since launch. + /// + /// Dart cannot otherwise tell a hosted view that was made from one that + /// was not: when the factory is missing or the effect cannot be built, + /// Flutter still keeps the `UiKitView` element in the tree and the + /// failure is logged rather than thrown, so counting the Dart widgets + /// proves only that Dart asked. This counter is what + /// integration_test/native_glass_test.dart asserts on, and it is the + /// reason that test can fail when the bridge is not registered. + private(set) static var built = 0 + + private let effectView: UIVisualEffectView + + init(frame: CGRect, args: [String: Any]) { + let requestedStyle = args["style"] as? String ?? "regular" + let requestedMaterial = args["material"] as? String ?? "regular" + let radius = (args["cornerRadius"] as? NSNumber)?.doubleValue ?? 0 + let interactive = (args["interactive"] as? NSNumber)?.boolValue ?? false + let tint = (args["tint"] as? NSNumber)?.uint32Value + + var effect: UIVisualEffect + if #available(iOS 26.0, *) { + // `UIGlassEffect` is declared main-actor-bound, and Flutter calls the + // view factory on the platform thread without saying so in the type + // system, so the isolation is asserted rather than hopped to: a hop + // would make this initialiser async, which the factory cannot await. + effect = MainActor.assumeIsolated { () -> UIVisualEffect in + let glass = UIGlassEffect(style: requestedStyle == "clear" ? .clear : .regular) + // The system's own interactive glass reacts to touches landing on + // the view. A hosted view that Flutter is gesturing over never + // receives those touches (see `view()` below), so this is off + // unless the caller has said the surface really is the touch + // target. + glass.isInteractive = interactive + if let tint = tint { + glass.tintColor = UIColor(argb: tint) + } + return glass + } + } else { + let style = NativeGlassFactory.materials[requestedMaterial] + ?? .systemThinMaterialDark + effect = UIBlurEffect(style: style) + } + effectView = UIVisualEffectView(effect: effect) + effectView.frame = frame + // Flutter sizes the hosted view by changing its frame rather than by + // laying it out, so the effect view has to follow its own bounds. + effectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + if radius > 0 { + effectView.layer.cornerRadius = CGFloat(radius) + // `.continuous` is the squircle UIKit uses for its own bars and + // buttons; `.circular` next to a Flutter `BorderRadius` on the same + // screen is visibly a different corner. + effectView.layer.cornerCurve = .continuous + effectView.clipsToBounds = true + } + + // A tint on the pre-26 material is laid on by hand: `UIBlurEffect` has + // no tint of its own, and without it the fallback is a plain blur where + // the glass path has a grey over it. + if #unavailable(iOS 26.0) { + if let tint = tint { + let wash = UIView(frame: effectView.bounds) + wash.backgroundColor = UIColor(argb: tint) + wash.autoresizingMask = [.flexibleWidth, .flexibleHeight] + wash.isUserInteractionEnabled = false + effectView.contentView.addSubview(wash) + } + } + + super.init() + NativeGlassView.built += 1 + } + + func view() -> UIView { + // Flutter wraps a hosted view in a touch-intercepting view and asks the + // native view whether it wants each touch. Glass is a surface, not a + // control: letting it claim touches would take them away from the + // Flutter button drawn on top of it, which is where every tap on glass + // in this app actually belongs. So it claims none. + effectView.isUserInteractionEnabled = false + return effectView + } +} + +private extension UIColor { + /// Dart hands colours over the channel as a packed ARGB int, which is how + /// `Color.value` is laid out, so it is unpacked the same way here. + convenience init(argb: UInt32) { + self.init( + red: CGFloat((argb >> 16) & 0xFF) / 255, + green: CGFloat((argb >> 8) & 0xFF) / 255, + blue: CGFloat(argb & 0xFF) / 255, + alpha: CGFloat((argb >> 24) & 0xFF) / 255 + ) + } +} diff --git a/lib/app.dart b/lib/app.dart index 10c2d06e..e38b9998 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,12 +2,16 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/glass/glass_power.dart'; +import 'package:soplay/core/glass/glass_sense.dart'; import 'package:soplay/features/profiles/presentation/widgets/profile_gate.dart'; import 'package:soplay/core/localization/borrowed_localizations.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/core/localization/app_language.dart'; import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/features/detail/presentation/widgets/player_dock_host.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/app_lock/presentation/widgets/app_lock_overlay.dart'; import 'package:soplay/features/notifications/data/services/notification_service.dart'; @@ -193,10 +197,24 @@ class _MyAppState extends State { // keeps the whole navigation stack — a redirect would have thrown // away the pages below, and the `extra` arguments pages like the // player are opened with. - final app = AppLockOverlay( - child: ProfileGate( - child: AchievementCelebrationHost( - child: child ?? const SizedBox.shrink(), + final routed = child ?? const SizedBox.shrink(); + // What every glass under it reads: whether an Android phone can + // spend the refraction, and whether an iPhone asks for Reduce + // Transparency. Both answer nothing elsewhere. + final app = GlassPower( + child: GlassSense( + child: AppLockOverlay( + child: ProfileGate( + child: AchievementCelebrationHost( + // Desktop: the corner player lives here, above the + // Navigator, so it keeps playing while pages change + // under it. Inside the lock, which covers it too; below + // the title bar, so its corners are the content area's. + child: PlayerDock.supported + ? PlayerDockHost(child: routed) + : routed, + ), + ), ), ), ); diff --git a/lib/core/content/catalogue.dart b/lib/core/content/catalogue.dart index a19b1cb2..270e5c55 100644 --- a/lib/core/content/catalogue.dart +++ b/lib/core/content/catalogue.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:soplay/core/content/content_mode.dart'; @@ -86,6 +87,11 @@ enum Catalogue { return null; } + /// [providerId] as a person reads it: a catalogue's id is stored where a + /// source's is, and `cat:anilist` is nothing to show anyone. + static String displayName(String providerId) => + fromId(providerId)?.labelKey.tr() ?? providerId; + /// The catalogue whose own page [url] is, or null for any other link. /// /// A card opened with no provider means "the current source", and when the diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index d1609170..f2f65fee 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/player/player_dock.dart'; import 'package:soplay/core/player/download_media_probe.dart'; import 'package:soplay/features/detail/domain/entities/extractor_config_entity.dart'; import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; @@ -410,7 +411,12 @@ Future configureDependencies() async { ProfileSession( remote: getIt(), isLoggedIn: () => getIt().isLoggedIn, - beforeSwitch: () => getIt().settle(), + // The corner player first: closing it saves its history, which has to + // land in the profile it was watched in, before the sync settles. + beforeSwitch: () async { + await PlayerDock.instance.close(); + await getIt().settle(); + }, onScopeChanged: _onProfileScopeChanged, ), ); @@ -948,11 +954,16 @@ Future configureDependencies() async { getIt.registerSingleton( GetPagesUseCase(getIt()), ); + // One probe for both questions: is a mirror playable media, and does a + // direct file turn out to be a web page (refused at tap time, not minutes + // later in the list). + final downloadProbe = DownloadMediaProbe(getIt().dio); getIt.registerSingleton( DownloadRequestBuilder( resolve: getIt(), getPages: getIt(), - canDownload: DownloadMediaProbe(getIt().dio).accepts, + canDownload: downloadProbe.accepts, + servesWebPage: downloadProbe.servesWebPage, resolveEmbed: (media) async { final stream = await getIt().extract( pageUrl: media.videoUrl, diff --git a/lib/core/glass/app_glass.dart b/lib/core/glass/app_glass.dart new file mode 100644 index 00000000..513d548a --- /dev/null +++ b/lib/core/glass/app_glass.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; + +/// Which glass a floating thing is made of. +/// +/// iOS 26 has two materials, regular and clear, and never mixes them on one +/// thing (WWDC25 219). Sozo has one dark theme, so every recipe here is the +/// after-dark one. +/// +/// * [regular] — a bar, a bar button: a grey veil. What passes under it +/// shows through softened and bent at the rim, so a glyph on it always +/// reads. +/// * [lens] — the clear glass a selection becomes under a finger: no frost, +/// no tint, drawn by a bright rim. +/// * [clear] — clear glass over video: the player's controls. No frost, next +/// to no tint, no light inside — the picture shows through, bent at the +/// rim. The dimming that keeps a white glyph legible over a white sky is +/// laid in by whoever stands the glass up, not baked in here. +/// * [panel] — a panel of words over a busy page: the thick glass iOS gives +/// its tips and popovers. +/// * [menu] — a menu's card of choices: the thickest glass, nearly a fill. +enum GlassKind { regular, lens, clear, panel, menu } + +/// The blur under each glass, in sigma. +abstract final class GlassBlur { + /// Under the regular glass's refraction: a veil, so an edge under it stays + /// an edge. + static const double liquid = 2.5; + + /// A small shape standing alone: on a 44-point dome a sharper page reads + /// as streaks, so it is a soft wash of colour instead. + static const double edge = 8; + + /// A panel or a menu: the page under it a hint, never shapes. + static const double panel = 24; +} + +/// One glass: what it is made of on each of the three ways a phone may draw +/// it (`GlassSurface` picks the way). +/// +/// * **Liquid** — the phone runs the refraction shader (Impeller): [tint] is +/// laid over the bent page and its light [frost] alike, and the shader +/// bends what is under it ([thickness], [refraction]). The shader lights +/// no rim of its own: the rim is drawn by hand, as on the other two ways. +/// * **Blur** — no shader (Skia, a test, a weak Android phone, or the glass +/// put down while its page moves): a blur of [blurFrost] under the thicker +/// [blurTint]. +/// * **Opaque** — the phone asks for less transparency (Reduce +/// Transparency): [opaque], and nothing of the page through it. +/// +/// What the numbers follow, from the system's own dark glass: +/// +/// * The glass **lifts** the page, it does not cover it: black under a bar +/// button reads #1F1F1F, a grey laid over at 0.39, not a dark disc. +/// * It **magnifies**: a dome the whole height of the shape, so what is at a +/// capsule's rim folds back on itself in a band along its edge. +/// * The rim is **one fine highlight**, not a ring: brightest along the top, +/// gone at the sides, fainter along the foot. No colour of its own and no +/// shadow. +@immutable +final class GlassRecipe { + const GlassRecipe({ + required this.tint, + required this.blurTint, + required this.opaque, + required this.rimLit, + required this.rimShade, + required this.contrastEdge, + required this.thickness, + required this.refraction, + required this.frost, + required this.blurFrost, + required this.saturation, + this.hairline = Colors.transparent, + this.sheenTop = Colors.transparent, + this.sheenFoot = Colors.transparent, + this.edgeShade = Colors.transparent, + this.lift = const [], + this.clear = false, + this.dispersion = 0, + }); + + /// The glass's own colour, laid over the glass: over the bent page and the + /// frost alike, so the band the rim draws in from beyond the glass is the + /// glass's colour too, not a black ring of page. + final Color tint; + + /// The same where there is only a blur under it: a little thicker, because + /// a blur alone does not separate the glass from the page the way a + /// bending rim does. + final Color blurTint; + + /// The glass with nothing through it, for Reduce Transparency. + final Color opaque; + + /// The rim's highlight: [rimLit] along the top, nothing at the sides, + /// [rimShade] along the foot — neutral white, because the light falls on + /// glass, it is not a colour of it. + final Color rimLit; + final Color rimShade; + + /// A hair round the glass. None after dark: the highlight alone draws the + /// edge against black. + final Color hairline; + + /// The crisp edge Increase Contrast asks for, on every way of drawing. + final Color contrastEdge; + + /// How deep the glass is at its rim, in points (the shader is handed it in + /// the screen's pixels): how far in from the edge the page is bent. + final double thickness; + + /// The refractive index: how hard the dome bends what is under it. Much + /// over 1.3 the band at the rim eats the glass. + final double refraction; + + /// The blur under the liquid glass, in sigma. + final double frost; + + /// The blur where there is no shader: more than [frost], because without + /// the bending a sharp page under a thin tint reads as no glass at all. + final double blurFrost; + + /// How much the glass lifts the colour of what is under it. + final double saturation; + + /// The light inside the glass, laid over [tint]: brightest just under the + /// top, nothing through the middle, a little again at the foot — what + /// makes it read as a drop of glass rather than a flat disc. + final Color sheenTop; + final Color sheenFoot; + + /// A shade just inside the rim, fading toward the middle within a few + /// points: the thickness of a drop seen edge-on. + final Color edgeShade; + + /// A soft shadow outside the glass, never under it. + final List lift; + + /// Clear glass: no frost on any way of drawing, because a blur would smear + /// the very picture it is held over to be seen through. + final bool clear; + + /// How far the shader splits the colours at the rim. None: its red and + /// blue fringes read as a tinted capsule. + final double dispersion; + + /// The one shadow a glass asks for when it must stand off a busy page + /// (`GlassShape.shadow`), laid outside the glass only. + static const List float = [ + BoxShadow(color: Color(0x47000000), blurRadius: 16, offset: Offset(0, 6)), + ]; + + /// The recipe for [kind]; [compact] — a small shape standing alone, a + /// 44-point button — takes the regular glass's small-shape variant. + static GlassRecipe of(GlassKind kind, {bool compact = false}) => + switch (kind) { + GlassKind.regular => compact ? _compact : _regular, + GlassKind.lens => _lens, + GlassKind.clear => _clear, + GlassKind.panel => _panel, + GlassKind.menu => _menu, + }; + + /// iOS's opaque dark bar, #1C1C1E: what Reduce Transparency puts down. + static const Color _opaque = Color(0xFF1C1C1E); + + /// The elevated rung, #2C2C2E: an opaque menu. + static const Color _elevated = Color(0xFF2C2C2E); + + static final GlassRecipe _regular = GlassRecipe( + tint: const Color(0xFF4F4F4F).withValues(alpha: 0.39), + blurTint: const Color(0xFF4F4F4F).withValues(alpha: 0.45), + opaque: _opaque, + rimLit: Colors.white.withValues(alpha: 0.3), + rimShade: Colors.white.withValues(alpha: 0.15), + contrastEdge: Colors.white.withValues(alpha: 0.7), + thickness: 12, + refraction: 1.3, + frost: GlassBlur.liquid, + blurFrost: 8, + saturation: 1.3, + sheenTop: Colors.white.withValues(alpha: 0.05), + sheenFoot: Colors.white.withValues(alpha: 0.025), + ); + + // A small dome bent as hard as a bar reads as streaks, so it takes a + // heavier frost and a lighter grey, and lifts no colour: a red poster + // washed through at full chroma under a tinted glyph is red on red. + static final GlassRecipe _compact = GlassRecipe( + tint: const Color(0xFF4F4F4F).withValues(alpha: 0.52), + blurTint: const Color(0xFF4F4F4F).withValues(alpha: 0.56), + opaque: _opaque, + rimLit: _regular.rimLit, + rimShade: _regular.rimShade, + contrastEdge: _regular.contrastEdge, + thickness: _regular.thickness, + refraction: _regular.refraction, + frost: GlassBlur.edge, + blurFrost: GlassBlur.edge, + saturation: 1, + sheenTop: _regular.sheenTop, + sheenFoot: _regular.sheenFoot, + ); + + // No colour of its own: drawn by a bright rim. Without the shader a breath + // of white keeps it from vanishing. + static final GlassRecipe _lens = GlassRecipe( + tint: Colors.transparent, + blurTint: Colors.white.withValues(alpha: 0.14), + opaque: const Color(0xFF3A3A3C), + rimLit: Colors.white.withValues(alpha: 0.85), + rimShade: Colors.white.withValues(alpha: 0.5), + contrastEdge: Colors.white.withValues(alpha: 0.7), + thickness: 8, + refraction: 1.3, + frost: 0, + blurFrost: 0, + saturation: 1, + clear: true, + ); + + // Over video: white at 0.04 so the picture keeps its colour, a highlight + // along the top that is gone by the sides, no hair, no sheen, no lift — the + // bending at the rim is what says glass. A finer dome than the bar's: a + // 44-point circle bent 12 deep is all rim. + static final GlassRecipe _clear = GlassRecipe( + tint: Colors.white.withValues(alpha: 0.04), + blurTint: Colors.white.withValues(alpha: 0.04), + opaque: _opaque, + rimLit: Colors.white.withValues(alpha: 0.42), + rimShade: Colors.white.withValues(alpha: 0.14), + contrastEdge: Colors.white.withValues(alpha: 0.7), + thickness: 10, + refraction: 1.3, + frost: 0, + blurFrost: 0, + saturation: 1, + clear: true, + ); + + // Thick enough that a white frame under it leaves its secondary words + // over 4.5:1: #2D2D2F over black. + static final GlassRecipe _panel = GlassRecipe( + tint: const Color(0xFF2F2F31).withValues(alpha: 0.94), + blurTint: const Color(0xFF2F2F31).withValues(alpha: 0.94), + opaque: _opaque, + rimLit: _regular.rimLit, + rimShade: _regular.rimShade, + contrastEdge: _regular.contrastEdge, + thickness: _regular.thickness, + refraction: _regular.refraction, + frost: GlassBlur.panel, + blurFrost: GlassBlur.panel, + saturation: 1.1, + sheenTop: _regular.sheenTop, + sheenFoot: _regular.sheenFoot, + ); + + // #2C2C2E over black and within a few steps of it over anything, with no + // lit rim: iOS's dark menus have no bright edge. + static final GlassRecipe _menu = GlassRecipe( + tint: const Color(0xFF2E2E30).withValues(alpha: 0.96), + blurTint: const Color(0xFF2E2E30).withValues(alpha: 0.96), + opaque: _elevated, + rimLit: Colors.transparent, + rimShade: Colors.transparent, + contrastEdge: _regular.contrastEdge, + thickness: _regular.thickness, + refraction: _regular.refraction, + frost: GlassBlur.panel, + blurFrost: GlassBlur.panel, + saturation: 1, + ); +} diff --git a/lib/core/glass/glass_hold.dart b/lib/core/glass/glass_hold.dart new file mode 100644 index 00000000..5532d2dc --- /dev/null +++ b/lib/core/glass/glass_hold.dart @@ -0,0 +1,198 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// Why a `GlassGroup`'s refraction is held — drawn as its tint alone, +/// reading nothing of the screen — right now. +/// +/// Every reason the app has for holding glass is one of these, and a group +/// keeps them all in one [GlassHoldReasons]: "why is this glass flat?" is +/// answered by reading that one listenable, not six mechanisms. +enum GlassHoldReason { + /// A [GlassHold] above the group is down, or a [GlassHoldWhile] — the + /// player's seek bar being dragged, the screen turning. + above, + + /// The route the group stands in is moving: coming, going, covered, + /// uncovered, or dragged back by a finger on any navigator above it + /// (`RouteMotion`). + route, + + /// One of the phone's own views stands under the glass, or is about to + /// (`_GlassWatch`, `GlassUnderlay`): the shader would draw a dark block + /// over it. + underView, + + /// The keyboard is moving under a [GlassKeyboardHold] above the group: + /// every frame of the slide the shader would read the whole screen again + /// for glass that moved with it, and the slide would drop frames. Held as + /// a route is: the refraction only, the blur recipe drawn in its place, + /// back once the keyboard has stood still for [GlassKeyboardHold.settle]. + keyboard, +} + +/// The reasons one group's glass is held, as one listenable: empty when the +/// refraction may be drawn. +/// +/// The group writes each reason as its source says it; [GlassHoldReason.above] +/// is written [quietly] — it arrives through the group's own dependency on +/// the holds above it, which rebuilds the group anyway. +final class GlassHoldReasons extends ChangeNotifier + implements ValueListenable> { + Set _value = const {}; + + @override + Set get value => _value; + + /// Whether any reason holds the glass. + bool get held => _value.isNotEmpty; + + /// Whether [reason] is one of them. + bool has(GlassHoldReason reason) => _value.contains(reason); + + /// Says whether [reason] holds the glass now; listeners are told when the + /// set changes, unless [quietly]. + void set(GlassHoldReason reason, bool holds, {bool quietly = false}) { + if (holds == _value.contains(reason)) return; + _value = Set.unmodifiable( + holds ? {..._value, reason} : ({..._value}..remove(reason)), + ); + if (!quietly) notifyListeners(); + } + + @override + String toString() => + 'GlassHoldReasons(${_value.map((reason) => reason.name).join(', ')})'; +} + +/// The glass put down: for the frames the screen moves under it most and +/// shows through it least — a page pushed over the tabs, the shell sliding +/// in — the glass under this is drawn with its blur switched off and its +/// tint alone (`GlassMode.blur`, disabled), and comes back up when they +/// land. +/// +/// Holds nest, and combine: the glass is down while **any** hold above it +/// is ([downOf] reads up the chain), so a page's own hold that stands +/// false — no fade of its own running — never lifts the shell's that is +/// down. A group reads the chain as its [GlassHoldReason.above]. +class GlassHold extends InheritedNotifier> { + const GlassHold({ + super.key, + required ValueListenable down, + required super.child, + }) : super(notifier: down); + + /// Whether the glass at [context] is put down: any [GlassHold] above it + /// down. [context] is told again when any hold it read changes; the walk + /// stops at the first one down, which is all a change could matter + /// through until it lifts. + static bool downOf(BuildContext context) { + var hold = context.getElementForInheritedWidgetOfExactType(); + while (hold != null) { + context.dependOnInheritedElement(hold); + if ((hold.widget as GlassHold).notifier?.value ?? false) return true; + hold = _above(hold); + } + return false; + } + + /// The hold enclosing [hold], if any. + static InheritedElement? _above(InheritedElement hold) { + Element? parent; + hold.visitAncestorElements((element) { + parent = element; + return false; + }); + return parent?.getElementForInheritedWidgetOfExactType(); + } +} + +/// Puts the glass in [child] down ([GlassHold]) while [moving] says so, +/// asked again every time [source] ticks — a fade part-way, a page +/// controller between whole pages. +/// +/// The one shape for "glass held while this is part-way". It nests in the +/// holds above it and combines with them ([GlassHold.downOf]). +class GlassHoldWhile extends StatefulWidget { + const GlassHoldWhile({ + super.key, + required this.source, + required this.moving, + required this.child, + }); + + /// What ticks while the thing moves: an animation, a page controller. + final Listenable source; + + /// Whether it is part-way now; read on every tick of [source] and every + /// time this is rebuilt. + final bool Function() moving; + + final Widget child; + + @override + State createState() => _GlassHoldWhileState(); +} + +class _GlassHoldWhileState extends State { + final ValueNotifier _down = ValueNotifier(false); + + @override + void initState() { + super.initState(); + widget.source.addListener(_read); + _read(); + } + + @override + void didUpdateWidget(GlassHoldWhile old) { + super.didUpdateWidget(old); + if (!identical(old.source, widget.source)) { + old.source.removeListener(_read); + widget.source.addListener(_read); + } + _read(); + } + + void _read() => _down.value = widget.moving(); + + @override + void dispose() { + widget.source.removeListener(_read); + _down.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + GlassHold(down: _down, child: widget.child); +} + +/// Says to the groups under it whether the keyboard is moving now — their +/// [GlassHoldReason.keyboard]. Here it is only the word, so the glass reads +/// it without knowing where the keyboard is measured. +/// +/// Unlike a [GlassHold] it does not put the glass down: a group drawn as +/// the blur keeps its blur; only a refracting group gives way to the blur +/// recipe, as it does for its route moving. +class GlassKeyboardHold extends InheritedNotifier> { + const GlassKeyboardHold({ + super.key, + required ValueListenable moving, + required super.child, + }) : super(notifier: moving); + + /// How long the keyboard stands still before the glass is given back — + /// the length of [GlassGroup]'s own cross-fade back to the refraction, + /// so a keyboard that pauses between two frames of its slide does not + /// let the shader in for one of them. + static const Duration settle = Duration(milliseconds: 120); + + /// Whether a [GlassKeyboardHold] above [context] says the keyboard is + /// moving; [context] is told again when it changes. + static bool movingOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.notifier + ?.value ?? + false; +} diff --git a/lib/core/glass/glass_pick.dart b/lib/core/glass/glass_pick.dart new file mode 100644 index 00000000..5bb77a85 --- /dev/null +++ b/lib/core/glass/glass_pick.dart @@ -0,0 +1,54 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/widgets.dart'; + +import 'glass_power.dart'; + +/// Which way the glass under a [GlassPickScope] may be drawn, whatever the +/// phone can do. +enum GlassPick { + /// As the phone can: the refraction where the shader runs (Impeller) and + /// the phone can spend it ([GlassPower]: not an Android Go phone), the + /// blur elsewhere. What the app runs on — there is no scope over it. + platform, + + /// The blur, never the refraction: a golden of the glass, the token + /// gallery. The test renderer has no shader anyway; this says so where a + /// test wants it said. + blur, + + /// The refraction even where the renderer has no shader (it then draws + /// its stand-in): tests that check which glass is handed to the shader, + /// and when it is held. The phone's power still has its say + /// ([GlassPower]): this stands in for the renderer, not for the phone. + liquid, +} + +/// Picks how the glass under it is drawn ([GlassPick]). +/// +/// A scope rather than a static a test sets and resets: one left set by a +/// test that failed half way would go on into every test after it. A scope +/// ends with the tree it was pumped in. +class GlassPickScope extends InheritedWidget { + const GlassPickScope({super.key, required this.pick, required super.child}); + + final GlassPick pick; + + /// The pick over [context]; [GlassPick.platform] with no scope. + static GlassPick of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.pick ?? + GlassPick.platform; + + /// Whether glass at [context] may draw the refraction at all: the + /// renderer has the shader (or [GlassPick.liquid] says it has), and the + /// phone can spend it ([GlassPower.affordsAt]). + static bool liquidAt(BuildContext context) => switch (of(context)) { + GlassPick.platform => + ImageFilter.isShaderFilterSupported && GlassPower.affordsAt(context), + GlassPick.blur => false, + GlassPick.liquid => GlassPower.affordsAt(context), + }; + + @override + bool updateShouldNotify(GlassPickScope old) => old.pick != pick; +} diff --git a/lib/core/glass/glass_power.dart b/lib/core/glass/glass_power.dart new file mode 100644 index 00000000..ec250c85 --- /dev/null +++ b/lib/core/glass/glass_power.dart @@ -0,0 +1,451 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'glass_step_down.dart'; + +export 'glass_step_down.dart'; + +/// What an Android phone can actually spend on the glass. +/// +/// The refraction reads the screen under every glass on every frame and +/// bends it in a shader, so a phone that cannot keep up with it draws the +/// blur instead ([affordsRefraction] false): the recipe's frost under its +/// thicker tint, the same tint, rim and geometry — a plainer version of the +/// same glass, not a different one. +/// +/// This used to be one field, `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 answered the question wrong in both directions. 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 and dropped frames; a current +/// 4 GB phone whose maker sets it loses the glass for nothing. So the phone +/// now sends what can be read off it +/// (android/app/src/main/kotlin/com/soplay/sozo/GlassPower.kt) and the +/// decision is made here, where it can be tested. +/// +/// What is asked of each reading, and why it and not something else: +/// +/// * **Vulkan 1.1 and API 29** — Impeller's Vulkan backend starts at API 29 +/// and wants a Vulkan 1.1 driver. Below either, Impeller draws through +/// GL ES, where reading the screen back for a backdrop costs far more per +/// glass than it does through Vulkan. This is the signal that actually +/// tracks the cost of the refraction, which is why it comes first. +/// * **GL ES 3** — below it the shader cannot be compiled at all, whatever +/// the renderer says. +/// * **Memory, measured** — `totalMem`, the thing the Go flag stands in for. +/// Under [leastMemoryBytes] the phone is small by measurement rather than +/// by a maker's word. +/// * **The Go flag, kept** — a phone that shipped as a Go phone is running a +/// build tuned for a phone with nothing to spare, whatever it measures. +/// * **Battery Saver** — the user has asked this phone to spend less. The +/// most expensive thing on the screen is the first thing to give way, and +/// it comes back when they turn it off ([GlassPower] is told as it turns). +/// +/// Two things are read and deliberately *not* gated on. The refresh rate is +/// not a pass or a fail — it is the frame budget, and it is handed to +/// [GlassStepDown] instead. The core count is carried for the same reason a +/// log line is: it says which phone a report came from, and nothing is +/// decided by it, because a core count has never predicted a GPU. +/// +/// Anything the phone did not send is taken generously, so that a half +/// answer — an older build of the bridge, a maker's ROM with a service +/// missing — never takes the glass away. +@immutable +final class GlassPowerReading { + const GlassPowerReading({ + this.lowRam = false, + this.memoryBytes, + this.sdk, + this.vulkan11 = true, + this.glEs, + this.refreshHz, + this.powerSave = false, + this.cores, + }); + + /// What a phone that has not answered — an iPhone, a test, a build without + /// the bridge — is taken to have: enough. + static const GlassPowerReading ample = GlassPowerReading(); + + /// Read off the channel's map; anything missing or of the wrong kind keeps + /// its generous default, so a half answer never takes the glass away. + factory GlassPowerReading.fromChannel(Object? answer) { + if (answer is! Map) return ample; + T? typed(String key) { + final value = answer[key]; + return value is T ? value : null; + } + + return GlassPowerReading( + lowRam: typed('lowRam') ?? false, + memoryBytes: typed('memoryBytes'), + sdk: typed('sdk'), + vulkan11: typed('vulkan11') ?? true, + glEs: typed('glEs'), + refreshHz: typed('refreshHz')?.toDouble(), + powerSave: typed('powerSave') ?? false, + cores: typed('cores'), + ); + } + + /// `ActivityManager.isLowRamDevice`: the phone's maker says it is one of + /// the phones Android Go is for. + final bool lowRam; + + /// `MemoryInfo.totalMem`, in bytes: the phone's RAM as the phone measures + /// it. Null where it could not be read. + final int? memoryBytes; + + /// `Build.VERSION.SDK_INT`. + final int? sdk; + + /// Whether the driver offers Vulkan 1.1 — the most the engine could have + /// chosen to draw with. + final bool vulkan11; + + /// The major OpenGL ES version the driver requires. + final int? glEs; + + /// The default display's refresh rate, in hertz: the frame budget, not a + /// gate ([budget]). + final double? refreshHz; + + /// Battery Saver is on. The one reading that changes while the app runs. + final bool powerSave; + + /// `Runtime.availableProcessors`: carried for a report, decided on by + /// nothing. + final int? cores; + + /// The least RAM, measured, a phone may have and still bend the screen: + /// 3 GiB. A 4 GB phone — the floor of a current budget phone — passes, and + /// the 2 GB phones that cannot keep a bar and a page and a decoder in + /// memory at once do not. Not 4 GiB: a 4 GB phone measures a little under + /// it once the kernel and the GPU have taken their share. + static const int leastMemoryBytes = 3 * 1024 * 1024 * 1024; + + /// The API level Impeller's Vulkan backend starts at. + static const int leastSdk = 29; + + /// Whether the phone may draw the refraction's shader at all. + bool get affordsRefraction { + if (lowRam) return false; + if (powerSave) return false; + if (sdk != null && sdk! < leastSdk) return false; + if (!vulkan11) return false; + if (glEs != null && glEs! < 3) return false; + if (memoryBytes != null && memoryBytes! < leastMemoryBytes) return false; + return true; + } + + /// What one frame has on this phone's panel ([GlassStepDown]). + GlassFrameBudget get budget => refreshHz == null + ? GlassFrameBudget.sixty + : GlassFrameBudget.ofRefreshRate(refreshHz!); + + /// The same reading with Battery Saver turned as the phone has just said. + GlassPowerReading withPowerSave(bool saving) => GlassPowerReading( + lowRam: lowRam, + memoryBytes: memoryBytes, + sdk: sdk, + vulkan11: vulkan11, + glEs: glEs, + refreshHz: refreshHz, + powerSave: saving, + cores: cores, + ); + + @override + bool operator ==(Object other) => + other is GlassPowerReading && + other.lowRam == lowRam && + other.memoryBytes == memoryBytes && + other.sdk == sdk && + other.vulkan11 == vulkan11 && + other.glEs == glEs && + other.refreshHz == refreshHz && + other.powerSave == powerSave && + other.cores == cores; + + @override + int get hashCode => Object.hash( + lowRam, + memoryBytes, + sdk, + vulkan11, + glEs, + refreshHz, + powerSave, + cores, + ); + + @override + String toString() => + 'GlassPowerReading(lowRam: $lowRam, memoryBytes: $memoryBytes, ' + 'sdk: $sdk, vulkan11: $vulkan11, glEs: $glEs, refreshHz: $refreshHz, ' + 'powerSave: $powerSave, cores: $cores)'; +} + +/// Asks an Android phone what it can spend on the glass +/// ([GlassPowerReading]), watches whether the phone it let through is in +/// fact keeping up ([GlassFrameWatch]), and hands the answer to every glass +/// under it, as [GlassSense] hands down the iPhone's Reduce Transparency. +/// +/// Asked over `soplay/glass_power` +/// (android/app/src/main/kotlin/com/soplay/sozo/GlassPower.kt) when the app +/// starts, and again whenever it comes back to the front — most of what it +/// asks is the phone's make, which does not change while the app runs, but +/// Battery Saver does, and can be thrown with the app behind. The phone also +/// says that one unasked as it turns. An iPhone is not asked: Apple's own +/// glass is the design there, and the gate for it is Apple's. +/// +/// Wrapped round the whole app once; a test puts one round what it pumps, +/// and speaks for the phone on [channel]. +class GlassPower extends StatefulWidget { + const GlassPower({super.key, required this.child}); + + /// The channel the Android phone answers on. + static const MethodChannel channel = MethodChannel('soplay/glass_power'); + + final Widget child; + + /// What the phone over [context] said; [GlassPowerReading.ample] with no + /// [GlassPower] above it, or before the phone has answered. [context] + /// is told again only when [GlassPowerReading.affordsRefraction] turns. + static GlassPowerReading readingOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_GlassPowerScope>()?.reading ?? + GlassPowerReading.ample; + + /// Whether glass at [context] may draw the refraction, as far as the + /// phone's power goes (`GlassPickScope.liquidAt` asks the renderer). + /// + /// Both halves of the judgement are here: what the phone said before a + /// frame was drawn, and what the frames it drew then showed + /// ([GlassFrameWatch]). Both are read off the scope rather than one off a + /// static, so that a glass depending on this is told when either turns. + static bool affordsAt(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_GlassPowerScope>()?.affords ?? + true; + + @override + State createState() => _GlassPowerState(); +} + +class _GlassPowerState extends State with WidgetsBindingObserver { + GlassPowerReading _reading = GlassPowerReading.ample; + + /// Only an Android phone has the bridge: asking anything else would be an + /// error logged on every launch. + bool get _asks => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + @override + void initState() { + super.initState(); + if (!_asks) return; + WidgetsBinding.instance.addObserver(this); + GlassPower.channel.setMethodCallHandler(_told); + GlassFrameWatch.down.addListener(_onStepDown); + unawaited(_ask()); + } + + @override + void dispose() { + if (_asks) { + WidgetsBinding.instance.removeObserver(this); + GlassPower.channel.setMethodCallHandler(null); + GlassFrameWatch.down.removeListener(_onStepDown); + } + super.dispose(); + } + + /// Battery Saver can be thrown while the app is behind, and a build where + /// the phone's own broadcast does not reach us is covered by asking again. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(_ask()); + } + + /// The phone saying, unasked, that Battery Saver has turned. + Future _told(MethodCall call) async { + if (call.method == 'powerSave' && call.arguments is bool) { + _set(_reading.withPowerSave(call.arguments as bool)); + } + } + + Future _ask() async { + try { + _set( + GlassPowerReading.fromChannel( + await GlassPower.channel.invokeMethod('read'), + ), + ); + } on MissingPluginException { + // A runner without the bridge: the glass as the renderer allows. + } on PlatformException { + // Nothing to read: as above. + } + } + + void _set(GlassPowerReading reading) { + if (!mounted) return; + // The step-down measures against this phone's panel, not against sixty — + // set whether or not the rest of the reading has changed, since this is + // the only place the panel's rate is ever learnt. + GlassFrameWatch.budget = reading.budget; + if (reading == _reading) return; + setState(() => _reading = reading); + } + + void _onStepDown() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) => _GlassPowerScope( + reading: _reading, + steppedDown: GlassFrameWatch.down.value, + child: widget.child, + ); +} + +class _GlassPowerScope extends InheritedWidget { + const _GlassPowerScope({ + required this.reading, + required this.steppedDown, + required super.child, + }); + + final GlassPowerReading reading; + + /// The frames this phone drew said no, whatever its make said + /// ([GlassFrameWatch]). + final bool steppedDown; + + /// The whole judgement, both halves: what the phone said it could do and + /// what it then did. + bool get affords => reading.affordsRefraction && !steppedDown; + + /// Only a change of the answer that matters wakes the glass. A reading + /// that changed in some field the gate does not look at — a core count, a + /// refresh rate — is no reason to build every glass on the screen again. + @override + bool updateShouldNotify(_GlassPowerScope old) => old.affords != affords; +} + +/// The measured half of the gate: whether the phone the gate let through is +/// in fact drawing the refraction at the frame rate, and the decision to +/// stop if it is not. +/// +/// The policy is [GlassStepDown]; this is only the wiring that feeds it. +/// `_GlassWatch` says when a refracting glass comes up on the screen and +/// when the last one goes ([refracting]), the engine says how long each +/// frame took, and when the policy has seen enough it turns [down], which +/// every glass reads through [GlassPower.affordsAt]. +/// +/// Armed on an Android phone only. On an iPhone the glass is the system's +/// own design drawn by the system's own gate, and a phone of Apple's that +/// misses frames is not a phone we take the design away from. +/// +/// Armed in a release or profile build only, too. A debug build runs the app +/// from a JIT with every assertion on and misses frames on the fastest phone +/// made, for reasons that have nothing to do with the glass; a step-down +/// there would take the refraction off the developer's own desk and never +/// off a phone in anyone's hand. It is also why a frame cost measured in +/// debug says nothing: the cost of this glass has to be read in profile. +abstract final class GlassFrameWatch { + static final ValueNotifier _down = ValueNotifier(false); + + /// Whether the glass has been stepped down to the blur for the rest of + /// this run. Read by [GlassPower.affordsAt]; latches true. + static ValueListenable get down => _down; + + static GlassStepDown? _policy; + static bool _hooked = false; + + /// How many refracting glasses stand on the screen: the policy measures + /// frames only while this is above zero. + static int _up = 0; + + /// What one frame has on this phone's panel, as the phone said + /// ([GlassPowerReading.budget]). + /// + /// It arrives late and has to reach a policy that already exists: the rate + /// comes back over the channel, while the first refracting glass of the run + /// is up and armed within the first frame, so by the time the phone has + /// answered the policy has usually already been made with the assumed + /// sixty hertz. Handing it on rather than only keeping it for the next + /// policy is what makes the budget the panel's in a real run and not only + /// in a test that sets it first. + static GlassFrameBudget get budget => _budget; + static set budget(GlassFrameBudget value) { + _budget = value; + _policy?.budget = value; + } + + static GlassFrameBudget _budget = GlassFrameBudget.sixty; + + /// Whether this run watches frames at all. + static bool get _watches => + !kIsWeb && + !kDebugMode && + defaultTargetPlatform == TargetPlatform.android; + + /// Said by `_GlassWatch` as a refracting glass comes up and goes. + static void refracting(bool up) { + if (!_watches) return; + _up += up ? 1 : -1; + assert(_up >= 0, 'A refracting glass went down twice over.'); + if (_up < 0) _up = 0; + if (_down.value) return; + // Only the first glass up and the last one down matter: the policy's + // window is about the screen having a refraction on it at all, not about + // how many pieces of it there are. + if ((up && _up == 1) || (!up && _up == 0)) { + _arm(); + _policy?.restart(); + } + } + + static void _arm() { + _policy ??= GlassStepDown(budget: budget); + if (_hooked) return; + _hooked = true; + SchedulerBinding.instance.addTimingsCallback(_frames); + } + + static void _frames(List timings) { + final policy = _policy; + if (policy == null || policy.down || _up == 0) return; + for (final timing in timings) { + // The whole span, build and raster together: the glass costs in both — + // the layer walk in the build, the screen read in the raster — and + // what the user sees is the sum. + if (policy.frame(timing.totalSpan)) { + _down.value = true; + return; + } + } + } + + /// Steps the glass down as if the frames had, and puts it back: the only + /// way a test can reach a decision that is otherwise made by a real GPU + /// over sixty real frames. + @visibleForTesting + static void debugStepDown({bool down = true}) { + _down.value = down; + } + + /// Everything this run has gathered, thrown away: a test that armed the + /// watch leaves nothing behind for the next one. + @visibleForTesting + static void debugReset() { + _down.value = false; + _policy = null; + _up = 0; + budget = GlassFrameBudget.sixty; + } +} diff --git a/lib/core/glass/glass_sense.dart b/lib/core/glass/glass_sense.dart new file mode 100644 index 00000000..a559390e --- /dev/null +++ b/lib/core/glass/glass_sense.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Whether the phone asks for glass that cannot be seen through — iOS's +/// Reduce Transparency — handed to every glass under it. +/// +/// Flutter carries Reduce Motion and Increase Contrast to the app +/// (`AccessibilityFeatures`, `MediaQuery.highContrast`) but not this one, +/// so the iPhone is asked itself, over `soplay/glass` +/// (ios/Runner/GlassSense.swift): once when the app starts, again whenever +/// it comes back to the front — the switch lives in Settings, so a change +/// is made with the app behind — and the phone also says it unasked when +/// the switch is thrown from the accessibility shortcut while the app is +/// in front. Anywhere else, and on an Android phone, the answer is no. +/// +/// Wrapped round the whole app once; a test puts a [GlassSenseScope] round +/// what it pumps instead. +class GlassSense extends StatefulWidget { + const GlassSense({super.key, required this.child}); + + /// The channel the iPhone answers on. + static const MethodChannel channel = MethodChannel('soplay/glass'); + + final Widget child; + + /// Whether the glass under [context] should be drawn opaque. + static bool reduceTransparencyOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.reduceTransparency ?? + false; + + @override + State createState() => _GlassSenseState(); +} + +class _GlassSenseState extends State with WidgetsBindingObserver { + bool _reduce = false; + + /// Only an iPhone has the switch, and only an iPhone answers: asking + /// anything else would be an error logged on every launch. + bool get _asks => defaultTargetPlatform == TargetPlatform.iOS; + + @override + void initState() { + super.initState(); + if (!_asks) return; + WidgetsBinding.instance.addObserver(this); + GlassSense.channel.setMethodCallHandler(_told); + unawaited(_ask()); + } + + @override + void dispose() { + if (_asks) { + WidgetsBinding.instance.removeObserver(this); + GlassSense.channel.setMethodCallHandler(null); + } + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(_ask()); + } + + Future _told(MethodCall call) async { + if (call.method == 'reduceTransparency' && call.arguments is bool) { + _set(call.arguments as bool); + } + } + + Future _ask() async { + try { + final reduce = await GlassSense.channel.invokeMethod( + 'reduceTransparency', + ); + _set(reduce ?? false); + } on MissingPluginException { + // A runner without the bridge — a test, an old build: glass it is. + } on PlatformException { + // Nothing to read: glass it is. + } + } + + void _set(bool reduce) { + if (!mounted || reduce == _reduce) return; + setState(() => _reduce = reduce); + } + + @override + Widget build(BuildContext context) => + GlassSenseScope(reduceTransparency: _reduce, child: widget.child); +} + +/// The answer [GlassSense] reads, for the glass under it. +class GlassSenseScope extends InheritedWidget { + const GlassSenseScope({ + super.key, + required this.reduceTransparency, + required super.child, + }); + + final bool reduceTransparency; + + @override + bool updateShouldNotify(GlassSenseScope old) => + old.reduceTransparency != reduceTransparency; +} diff --git a/lib/core/glass/glass_step_down.dart b/lib/core/glass/glass_step_down.dart new file mode 100644 index 00000000..617e721e --- /dev/null +++ b/lib/core/glass/glass_step_down.dart @@ -0,0 +1,171 @@ +import 'package:flutter/foundation.dart'; + +/// How long one frame has on this screen: the thing every judgement about +/// whether the refraction is affordable is actually made against. +/// +/// A phone's refresh rate is the only honest budget. The same GPU that keeps +/// the glass at 60 Hz has 8.3 ms rather than 16.7 to do it in on a 120 Hz +/// panel, and the glass is the most expensive thing on the screen, so the +/// panel is read from the phone ([GlassPowerReading.refreshHz]) rather than +/// assumed. +@immutable +final class GlassFrameBudget { + const GlassFrameBudget(this.frame); + + /// What a frame has, end to end. + final Duration frame; + + /// Sixty hertz: what a phone that has not said — a test, a build without + /// the bridge — is taken to have. + static const GlassFrameBudget sixty = GlassFrameBudget( + Duration(microseconds: 16667), + ); + + /// The budget of a panel running at [hz]. A rate the phone could not give + /// — zero, a negative, not a number — is [sixty]; anything outside what a + /// phone panel can actually be is clamped into range, because a budget + /// read wrong is worse than a budget assumed. + factory GlassFrameBudget.ofRefreshRate(double hz) { + if (!hz.isFinite || hz <= 0) return sixty; + final rate = hz.clamp(24.0, 240.0); + return GlassFrameBudget( + Duration(microseconds: (1000000 / rate).round()), + ); + } + + @override + bool operator ==(Object other) => + other is GlassFrameBudget && other.frame == frame; + + @override + int get hashCode => frame.hashCode; + + @override + String toString() => 'GlassFrameBudget(${frame.inMicroseconds}us)'; +} + +/// Whether this phone, having been let through the gate, is in fact keeping +/// up with the refraction — and, when it plainly is not, the decision to +/// stop drawing it. +/// +/// No flag a phone carries says whether its GPU can bend the screen sixty +/// times a second; the frames it actually draws do. So the gate +/// ([GlassPowerReading]) only keeps out the phones that could never manage +/// it, and this watches what happens to the ones it let through: while a +/// refracting glass is on the screen, each frame's whole span is measured +/// against the panel's budget, and a phone that keeps missing it is dropped +/// to the blur. A plainer glass at the full frame rate is the better phone; +/// the right glass as a slideshow is not. +/// +/// Three things keep it from firing on frames that are not the glass's +/// fault: +/// +/// * **Warm-up.** The frames just after a refracting glass comes up pay for +/// the shader being compiled and the page being laid out, and are always +/// over budget. [warmup] of them are measured and thrown away, and the +/// count starts again every time the glass goes and comes back. +/// * **A window, not a frame.** A frame is missed on any phone — a decode +/// landing, a garbage collection — so [misses] of the last [window] have +/// to be over before anything happens. +/// * **Slack.** A frame is only counted missed past [slack] of the budget: +/// Flutter's own reported span runs a little over the vsync interval on a +/// perfectly smooth phone, and 1.5 of it is well clear of that while +/// still being half the frame rate. +/// +/// The decision **latches**: once down the glass stays the blur for the rest +/// of the app's run. A phone drawn as glass on a quiet page and as blur the +/// moment anything scrolls would flip back and forth all day, and glass that +/// comes and goes reads as a fault rather than as a kindness. +final class GlassStepDown { + GlassStepDown({ + GlassFrameBudget budget = GlassFrameBudget.sixty, + this.warmup = 12, + this.window = 60, + this.misses = 18, + this.slack = 1.5, + }) : _budget = budget, + assert(warmup >= 0), + assert(window > 0), + assert(misses > 0 && misses <= window), + assert(slack >= 1); + + /// What one frame has on this phone's panel. + GlassFrameBudget get budget => _budget; + GlassFrameBudget _budget; + + /// The panel's rate is not known when the policy is made. It is read off + /// the phone over a channel, and the answer lands a frame or two after the + /// first refracting glass has already come up and armed the measuring — so + /// a policy that could only be told its budget at birth would spend every + /// run on a 120 Hz phone judging frames against sixty hertz, which is the + /// one direction that makes the measurement useless rather than wrong: + /// nothing would ever be condemned. + /// + /// Setting it throws the window away and pays the warm-up again, because a + /// frame already weighed against the wrong budget is not evidence of + /// anything. + set budget(GlassFrameBudget value) { + if (value == _budget) return; + _budget = value; + restart(); + } + + /// Frames measured and thrown away each time the glass comes up. + final int warmup; + + /// How many of the last frames are kept. + final int window; + + /// How many of them have to be over budget. + final int misses; + + /// How far over the budget a frame goes before it counts as missed. + final double slack; + + /// The frames kept, oldest first: true for each one over budget. A list + /// of bools rather than a count, because a count cannot be rolled off the + /// back of the window. + final List _kept = []; + + int _warmed = 0; + bool _down = false; + + /// Whether the glass has been dropped to the blur. Once true, always true. + bool get down => _down; + + /// How far through the window the misses stand: what a test reads to say + /// the policy is counting, and what a debug overlay would show. + @visibleForTesting + int get debugMisses => _kept.where((missed) => missed).length; + + /// A refracting glass has come up on the screen, or the last one has gone: + /// either way the warm-up and the window start again. Frames drawn with no + /// refracting glass say nothing about the refraction and are not kept, and + /// the glass coming back pays its warm-up afresh, so both turns clear the + /// same two things. + void restart() { + _warmed = 0; + _kept.clear(); + } + + /// One frame of the phone's, start to finish. Returns whether this frame + /// is the one that stepped the glass down — true once, never again — so + /// that the caller tells the tree only on the turn. + bool frame(Duration span) { + if (_down) return false; + if (_warmed < warmup) { + _warmed++; + return false; + } + _kept.add(span > budget.frame * slack); + if (_kept.length > window) _kept.removeAt(0); + if (_kept.length < misses) return false; + if (debugMisses < misses) return false; + _down = true; + return true; + } + + @override + String toString() => + 'GlassStepDown(down: $_down, misses: $debugMisses/${_kept.length})'; +} diff --git a/lib/core/glass/glass_surface.dart b/lib/core/glass/glass_surface.dart new file mode 100644 index 00000000..7c51149a --- /dev/null +++ b/lib/core/glass/glass_surface.dart @@ -0,0 +1,961 @@ +import 'dart:math' as math; +import 'dart:ui' show ColorFilter, ImageFilter; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:liquid_glass_renderer/liquid_glass_renderer.dart'; + +import 'app_glass.dart'; +import 'route_arrival.dart' show RouteMotion; +import 'glass_hold.dart'; +import 'glass_pick.dart'; +// Imported as well as exported: the part file reaches [GlassFrameWatch] to +// say when a refraction is on the screen, and a part can only see what its +// own library imports. +import 'glass_power.dart'; +import 'glass_sense.dart'; + +export 'glass_hold.dart'; +export 'glass_pick.dart'; +export 'glass_power.dart'; + +part 'glass_watch.dart'; + +/// How the glass under a [GlassGroup] is being drawn right now. +enum GlassMode { + /// Refraction and a lit rim, on the phone's GPU (Impeller): the real + /// Liquid Glass. + liquid, + + /// A blur under a tint and a rim drawn by hand: where the shader cannot + /// run (Skia, a test), on an Android phone the gate kept out or whose own + /// frames said it could not keep up ([GlassPower], [GlassFrameWatch]), and + /// for the frames the glass is put down ([GlassHold]). The same tint, + /// frost, colour lift, rim and geometry as the refraction, without the + /// bend: a plainer version of the one design, never a second design. + blur, + + /// No glass at all — an opaque fill: the phone asks for less + /// transparency (Reduce Transparency, [GlassSense]). + opaque, +} + +/// The one way the app draws glass: a group of [GlassShape]s that share one +/// read of the screen under them and one recipe ([GlassRecipe]). +/// +/// iOS 26 keeps glass to one floating layer — a bar, its buttons, the +/// player's controls over the video — and never lays glass on glass. So a +/// group is a whole floating thing: two capsules side by side are one group, +/// and melt into each other where they meet ([blend]) the way two drops do; +/// a lone button is a group of its own ([GlassSurface]). A group inside +/// another group is the glass-on-glass the rule forbids, and asserts. A +/// lens held over a bar by a finger is a group beside the bar's, never +/// inside it. +/// +/// What is drawn is decided here, once for the whole group ([GlassMode]): +/// +/// * the phone asking for less transparency → an opaque fill; +/// * a phone that can run the refraction shader, and can spend it +/// ([GlassPower]) → `liquid_glass_renderer` +/// (pinned 0.2.0-dev.4): the page bends at the glass's rim under a light +/// frost, the tint over it all, and a rim drawn by hand; +/// * otherwise, or while the shell has the glass put down ([GlassHold]) → +/// a blur (the recipe's `blurFrost`) under a thicker tint, with the +/// recipe's colour lift fused into it ([GlassBlurFilter]) and the same +/// rim, every shape of the group sharing one read of the screen +/// ([_shareRead]) as the refracting path does. +/// +/// A small shape standing alone ([compact]) — a 44-point bar button — +/// takes the recipe's small-shape glass: a +/// heavier frost and a lighter grey, as the system's are. +/// +/// Increase Contrast (`MediaQuery.highContrast`) adds a crisp edge to +/// every shape, whichever way it is drawn. +/// +/// Glass in a modal — a sheet's ✕ and ←, anything on an alert or a dialog +/// ([PopupRoute]) — stands on the modal's own opaque material, and reads +/// nothing of the screen ([standsOnModal]): the refraction shader samples +/// round its shape in screen pixels, and on a sheet that moved without a +/// frame after it, or near the sheet's top edge, it sampled the dimmed page +/// *behind* the sheet — a dark band and a white blob in the lower half of +/// the ✕. Over an opaque surface the bent, +/// frosted surface is the surface itself, so the tint laid on it alone is +/// the same glass, pixel for pixel, at rest and through the slide. +/// The menu's glass ([GlassKind.menu]) is the one exception: it is the +/// floating surface itself, over the page. +/// +/// The refraction is held — the glass drawn as [GlassHold] draws it, its +/// tint alone, reading nothing of the screen — while the route it stands +/// in moves (coming, going, covered, uncovered, or dragged back by a +/// finger on any navigator above it), and while one of the phone's own +/// views stands under it ([_GlassWatch]): over either the shader drew a +/// hard-edged dark block — and, under a [GlassKeyboardHold], while the +/// keyboard moves. Once they all settle the refraction comes back under the +/// held tint, which fades off it ([release]). +/// +/// Only the glass is in here: what stands on it — a glyph, a label — is +/// drawn over it by its owner, and the tree keeps one shape whichever way +/// the glass is drawn, so that switching (a push holding it) never builds +/// the things on it again. +class GlassGroup extends StatefulWidget { + const GlassGroup({ + super.key, + this.recipe, + this.kind = GlassKind.regular, + this.blend = 0, + this.visibility = 1, + this.compact = false, + this.gathers = false, + required this.child, + }); + + final GlassKind kind; + + /// Whether the [GlassSurface]s under it — and so the [GlassIconButton]s + /// drawn on one — are shapes of this group rather than groups of their + /// own: the neighbouring pieces of one floating thing (the player's back + /// button and the buttons along its top) read the screen once between + /// them, not once each. Each takes this group's [kind], [compact] and + /// [visibility] — its own are not read — and they melt only within + /// [blend]. A hosted UIKit view standing among them holds the whole + /// group, not one circle: + /// gather only glass Flutter draws. + final bool gathers; + + /// A glass of its own for this group, in place of [kind]'s; null, [kind]'s. + final GlassRecipe? recipe; + + /// How far apart, in points, two shapes of the group start to melt into + /// one. Zero: they never do. + final double blend; + + /// How much of the glass is there, 0 to 1: controls fading in and out. + /// The glass itself thins — on the + /// shader its bending and frost, on every way of drawing its tint, its + /// rim and its hair, each by the same amount ([GlassShape]) — rather + /// than a layer of it being faded: an opacity over a blur reads an + /// empty layer. + final double visibility; + + /// A small shape standing alone — a 44-point button: the glass's + /// small-shape recipe (`GlassRecipe.of`, `compact`). + final bool compact; + + final Widget child; + + /// Whether glass at [context] can be drawn as [GlassMode.liquid] at all: + /// the phone's shader, or what a [GlassPickScope] over it picks. + static bool liquidAt(BuildContext context) => + GlassPickScope.liquidAt(context); + + /// Why the glass of the group around [context] is held right now — + /// empty when it is not ([GlassHoldReasons]); null outside a group. + static ValueListenable>? holdOf(BuildContext context) => + context.getInheritedWidgetOfExactType<_GlassScope>()?.reasons; + + /// Whether glass at [context] stands on a modal's own opaque material — + /// a sheet, an alert, a dialog — rather than on the page: it then reads + /// nothing of the screen, only lays its tint on what it stands on. + static bool standsOnModal(BuildContext context) => + ModalRoute.of(context) is PopupRoute; + + /// How the glass under [context] would be drawn by a group of [kind]. + static GlassMode modeOf(BuildContext context) { + if (GlassSense.reduceTransparencyOf(context)) return GlassMode.opaque; + if (!liquidAt(context) || + (!GlassLive.of(context) && GlassHold.downOf(context))) { + return GlassMode.blur; + } + return GlassMode.liquid; + } + + /// How long the held glass takes to give way to the refraction once + /// what held it has settled: a cross-fade, so the glass never pops. + static const Duration release = Duration(milliseconds: 120); + + @override + State createState() => _GlassGroupState(); +} + +class _GlassGroupState extends State + with SingleTickerProviderStateMixin { + /// Every reason the glass is held, in one place: the holds above it, + /// the route moving, a view of the phone's under it. + final GlassHoldReasons _reasons = GlassHoldReasons(); + + /// The route the group stands in, and every navigator above it: while + /// any of them moves the glass is held ([GlassHoldReason.route]). + RouteMotion? _motion; + + /// The held glass's tint laid over the refraction just given back, fading + /// out ([GlassGroup.release]). + late final AnimationController _veil; + + bool _held = false; + + /// Whether this group draws the refraction at all: a phone that can, and + /// glass on the page rather than on a modal's material. + bool _watching = false; + + @override + void initState() { + super.initState(); + _veil = AnimationController(vsync: this, duration: GlassGroup.release); + _reasons.addListener(_onReasons); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _rewatch(); + // Said quietly: the hold above is depended on, and this group is being + // built again for it anyway. + _reasons.set( + GlassHoldReason.above, + GlassHold.downOf(context), + quietly: true, + ); + // The same for the keyboard moving under a [GlassKeyboardHold]. + _reasons.set( + GlassHoldReason.keyboard, + GlassKeyboardHold.movingOf(context), + quietly: true, + ); + } + + /// A group handed back with another [GlassGroup.kind] (a menu's glass + /// becoming a page's, or the other way) may now stand on a modal's + /// material where it did not before: whether it watches, and the routes + /// it listens to, are worked out again rather than kept from its first + /// kind. + @override + void didUpdateWidget(covariant GlassGroup oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.kind != widget.kind) _rewatch(); + } + + void _rewatch() { + _watching = + GlassGroup.liquidAt(context) && + !(widget.kind != GlassKind.menu && GlassGroup.standsOnModal(context)); + _listenRoutes(); + } + + /// Listens to the route the glass stands in moving ([RouteMotion]). + void _listenRoutes() { + final route = _watching ? ModalRoute.of(context) : null; + final motion = _motion; + if (motion != null && _watching && identical(route, motion.route)) return; + if (motion == null && !_watching) return; + motion?.cancel(); + _motion = null; + if (_watching) { + _motion = RouteMotion( + route: route, + context: context, + onChange: _onRouteMotion, + ); + } + _onRouteMotion(quietly: true); + } + + void _onRouteMotion({bool quietly = false}) { + if (!mounted) return; + _reasons.set( + GlassHoldReason.route, + _motion?.moving ?? false, + quietly: quietly, + ); + } + + bool _queued = false; + + /// A route's word can come while the tree is being built — a page pushed + /// from a build — and a group cannot be marked for a rebuild then: it + /// waits for the end of the frame. + void _onReasons() { + if (!mounted) return; + if (SchedulerBinding.instance.schedulerPhase != + SchedulerPhase.persistentCallbacks) { + setState(() {}); + return; + } + if (_queued) return; + _queued = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _queued = false; + if (mounted) setState(() {}); + }); + } + + void _onUnder(bool under) { + if (!mounted) return; + _reasons.set(GlassHoldReason.underView, under); + } + + @override + void dispose() { + _motion?.cancel(); + _reasons.dispose(); + _veil.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + context.getInheritedWidgetOfExactType<_GlassScope>() == null, + 'Glass on glass: a GlassGroup inside another. iOS 26 never stacks ' + 'glass — put what stands on the glass over ' + 'it as a fill or a glyph, not as more glass.', + ); + // Only a group that draws the refraction is held for its route or a + // view under it; the holds above put down every glass. + final held = GlassLive.of(context) + ? _watching && _reasons.has(GlassHoldReason.underView) + : _reasons.has(GlassHoldReason.above) || + (_watching && + (_reasons.has(GlassHoldReason.route) || + _reasons.has(GlassHoldReason.keyboard) || + _reasons.has(GlassHoldReason.underView))); + var mode = GlassGroup.modeOf(context); + if (mode == GlassMode.liquid && held) mode = GlassMode.blur; + // Held: the held tint is all there is. Given back: the refraction + // comes back under that tint, which fades off it. + if (_watching && held != _held) { + _held = held; + if (held) { + _veil.value = 1; + } else { + _veil.reverse(from: 1); + } + } + final recipe = + widget.recipe ?? GlassRecipe.of(widget.kind, compact: widget.compact); + final shown = widget.visibility.clamp(0.0, 1.0); + final grounded = + widget.kind != GlassKind.menu && GlassGroup.standsOnModal(context); + final scoped = _GlassScope( + mode: mode, + recipe: recipe, + down: held, + shown: shown, + grounded: grounded, + gathers: widget.gathers, + reasons: _reasons, + veil: mode == GlassMode.liquid ? _veil : null, + child: widget.child, + ); + final read = _shareRead(scoped); + if (!_watching) return read; + // One shape of tree whichever way the glass is drawn: the layer with no + // refracting shape in it is a plain pass-through, and the things + // standing on the glass — a field being typed in — keep their state + // across a push. + return _GlassWatch( + liquid: mode == GlassMode.liquid, + onUnder: _onUnder, + child: GlassFoldGuard(child: _liquid(context, recipe, shown, read)), + ); + } + + /// One read of the screen for every shape of this group drawn as a blur. + /// + /// The refracting path has always shared its read — one [LiquidGlassLayer] + /// per group, `useBackdropGroup` — but the blur path asked + /// `BackdropGroup.of(context)` for a key and the app never put a + /// [BackdropGroup] anywhere, so the lookup returned null on every frame + /// and each shape took a full read of the screen behind its own clip. A + /// bar with a pill on it and a lens beside them is three reads where the + /// same glass refracting is one — and the blur path is precisely the path + /// the phones that cannot afford reads are on. + /// + /// Shared for the same glass the refraction shares it for, and withheld + /// for the same reasons: a lens is held *over* the bar and its pill, and a + /// read taken before any of them were drawn would frost the page as it was + /// without the bar; clear glass over video stands over a page pushed above + /// the shell, and a read shared with the shell's bar would be the shell's + /// page. Both are groups of their own and keep their own read. + /// + /// The shapes of one group never overlap each other — glass is never laid + /// on glass — so one snapshot taken as the group is entered is what every + /// shape in it would have read for itself. + Widget _shareRead(Widget scoped) { + if (widget.kind != GlassKind.regular) return scoped; + return BackdropGroup(child: scoped); + } + + Widget _liquid( + BuildContext context, + GlassRecipe recipe, + double shown, + Widget scoped, + ) { + return LiquidGlassLayer( + settings: LiquidGlassSettings( + visibility: shown, + // The shader measures its dome in the screen's pixels; the recipe + // gives it in points, so a 2x and a 3x phone bend alike. + thickness: recipe.thickness * MediaQuery.devicePixelRatioOf(context), + blur: recipe.frost, + // No colour split: its red and blue fringes read as a pink and + // purple cast at a capsule's ends. The colour at the rim is the + // page, bent. + chromaticAberration: recipe.dispersion, + // No light of the shader's own: it lights the rim from both ends + // of its light's axis about equally, a bright ring all round. The + // rim is drawn by hand ([_Rim]) as the system draws it — one fine + // highlight along the top, fainter along the foot. + lightIntensity: 0, + ambientStrength: 0, + refractiveIndex: recipe.refraction, + saturation: recipe.saturation, + ), + // The frost shares the screen layer's one read of what is under it + // with every other glass on it (one BackdropGroup per screen layer) — + // all but the lens: it is held over the bar, its glyphs and its pill, + // and a shared read is taken before any of them is drawn, so a lens + // frosting it would lay the page as it was before the bar over the + // bar. Nor the clear glass over video: it stands over a page pushed + // over the shell, and a read shared with the shell's bar would be the + // shell's page, bent. + useBackdropGroup: widget.kind == GlassKind.regular, + child: LiquidGlassBlendGroup(blend: widget.blend, child: scoped), + ); + } +} + +/// Keeps the refraction shader off a glass the screen has folded to a line. +/// +/// The renderer draws its glass's shape into an image the size of the shape +/// *on the screen* — through every transform above it — rounded to whole +/// pixels. A page turning edge-on, or a scale coming up from nothing, +/// starts at zero: the glass on it is less than a pixel wide, the image is +/// 0 pixels, and the engine throws "Invalid image dimensions" from inside +/// paint. +/// +/// So while the group's own box, carried to the screen, is squeezed to under +/// [least] of itself along either axis — or is not a number at all — the +/// glass and what stands on it are not painted: a sliver that thin shows +/// nothing anyway. The smallest glass the app draws is a 28-point circle; +/// at a 20th, on a 2x phone, that is still near 3 pixels, so no shape of a +/// group that passes rounds to nothing. Nothing is built again: the check +/// is made each paint, and the glass is back the frame the page opens out. +class GlassFoldGuard extends SingleChildRenderObjectWidget { + const GlassFoldGuard({super.key, super.child}); + + /// The least share of its own width and height a glass may keep on the + /// screen and still be drawn. + static const double least = 1 / 20; + + /// Whether a box of [size] carried to the screen by [toScreen] is folded + /// too thin to draw glass in. + static bool folded(Matrix4 toScreen, Size size) { + if (size.isEmpty) return true; + final shown = MatrixUtils.transformRect(toScreen, Offset.zero & size); + if (!shown.isFinite) return true; + return shown.width < size.width * least || + shown.height < size.height * least; + } + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderGlassFoldGuard(); +} + +class _RenderGlassFoldGuard extends RenderProxyBox { + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + if (GlassFoldGuard.folded(getTransformTo(null), size)) return; + super.paint(context, offset); + } +} + +/// One piece of glass in a [GlassGroup]: a rounded superellipse of +/// [radius], or a capsule when [radius] is null — the shape iOS 26 gives +/// every floating control. It takes the size it is laid out at. +/// +/// No shadow by default: the system's glass casts none — white right up to +/// its half-point rim over a white page, measured on the simulator — and +/// the lit rim and the bending are what lift it off the page. [shadow] +/// lays the app's one float ([AppSurface.float]) outside the glass only, +/// where something must stand off a busy page: under see-through glass a +/// shadow would show through as a stain. +class GlassShape extends StatelessWidget { + const GlassShape({super.key, this.radius, this.shadow = false}); + + final double? radius; + final bool shadow; + + @override + Widget build(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_GlassScope>(); + assert(scope != null, 'A GlassShape stands in a GlassGroup.'); + final mode = scope?.mode ?? GlassMode.blur; + final recipe = scope?.recipe ?? GlassRecipe.of(GlassKind.regular); + final contrast = MediaQuery.maybeHighContrastOf(context) ?? false; + final shown = scope?.shown ?? 1.0; + Color fade(Color c) => shown >= 1 ? c : c.withValues(alpha: c.a * shown); + return LayoutBuilder( + builder: (context, box) { + final size = box.biggest; + final r = radius ?? math.min(size.width, size.height) / 2; + final border = RoundedSuperellipseBorder( + borderRadius: BorderRadius.circular(r), + ); + final grounded = scope?.grounded ?? false; + final Widget glass = switch (mode) { + // On a modal's opaque material: its tint over what it stands + // on, which is all the shader or the blur would show there. + GlassMode.liquid || GlassMode.blur when grounded => ClipPath( + clipper: ShapeBorderClipper(shape: border), + child: _Tint( + fade(mode == GlassMode.liquid ? recipe.tint : recipe.blurTint), + recipe, + shown, + ), + ), + GlassMode.liquid => Stack( + fit: StackFit.expand, + children: [ + LiquidGlass.grouped( + shape: LiquidRoundedSuperellipse(borderRadius: r), + // The tint is laid over the glass — over the bent page as + // well as the frost — so the band its rim draws in from + // beyond its edge is the glass's grey, not a black ring of + // page; and it is the same alpha-blend whatever is under it + // (the shader's own tint multiplies a dark glass into black + // over a black page). + glassContainsChild: false, + child: _Tint(fade(recipe.tint), recipe, shown), + ), + // The held glass's tint, fading off the refraction just + // given back: no pop between the two. + if (scope?.veil case final veil?) + IgnorePointer( + child: FadeTransition( + opacity: veil, + child: ClipPath( + clipper: ShapeBorderClipper(shape: border), + child: _Tint(fade(recipe.blurTint), recipe, shown), + ), + ), + ), + ], + ), + GlassMode.blur when recipe.clear => ClipPath( + clipper: ShapeBorderClipper(shape: border), + child: _Tint(fade(recipe.blurTint), recipe, shown), + ), + GlassMode.blur => ClipPath( + clipper: ShapeBorderClipper(shape: border), + child: BackdropFilter( + filter: GlassBlurFilter.of(recipe, shown), + // Put down by switching the blur off, not by taking it out: + // the tree keeps its shape across a push. + enabled: !scope!.down, + backdropGroupKey: BackdropGroup.of(context)?.backdropKey, + child: _Tint(fade(recipe.blurTint), recipe, shown), + ), + ), + GlassMode.opaque => DecoratedBox( + decoration: ShapeDecoration( + color: fade(recipe.opaque), + shape: border, + ), + ), + }; + // The lens's lift ([GlassRecipe.lift]) is the glass's own; [shadow] + // is the app's float, asked for by whoever stands the glass up. + final lift = mode == GlassMode.opaque || recipe.lift.isEmpty + ? null + : [for (final s in recipe.lift) s.copyWith(color: fade(s.color))]; + return CustomPaint( + painter: shadow + ? _OuterShadow(border, GlassRecipe.float) + : lift != null + ? _OuterShadow(border, lift) + : null, + foregroundPainter: _Rim( + border, + recipe, + // One rim on the shader's glass and the blur's alike; the + // opaque bar Reduce Transparency asks for is flat, its hair + // the only edge. + lit: mode != GlassMode.opaque, + contrast: contrast, + shown: shown, + ), + child: SizedBox.expand(child: glass), + ); + }, + ); + } +} + +/// What a glass drawn as the blur does to the screen under it: the recipe's +/// frost, and the recipe's colour lift with it. +/// +/// A phone that cannot run the shader should get a plainer version of the +/// same glass, not a different glass — and the colour lift was the one part +/// of the recipe the blur path dropped. The shader is handed +/// [GlassRecipe.saturation] and lifts what it bends; the blur read it off no +/// recipe at all, so the Android bar's glass (`GlassRecipe.androidBar`, +/// saturation 1.5, the number that makes a poster read as a poster through +/// it) came out grey on exactly the phones that fall back to it. Here the +/// same number is a saturation matrix composed under the blur. +/// +/// It costs nothing extra to read: the lift is fused into the same backdrop +/// the frost already takes, so a shape that blurs now also saturates within +/// that one read. It is left off when the recipe asks for no lift, so that +/// the common glass is still a plain blur and nothing is composed for a +/// multiplication by one. +abstract final class GlassBlurFilter { + /// The filter for [recipe] at [shown] of itself. As everywhere else in the + /// glass, thinning the glass thins each part of it by the same amount: the + /// frost's sigma scales, and the lift eases back toward 1 — a capsule + /// coming up must not lift colour at full strength through a frost that is + /// barely there. + static ImageFilter of(GlassRecipe recipe, double shown) { + final blur = ImageFilter.blur( + sigmaX: recipe.blurFrost * shown, + sigmaY: recipe.blurFrost * shown, + ); + final lift = 1 + (recipe.saturation - 1) * shown; + if ((lift - 1).abs() < _flat) return blur; + // The page's colour is lifted first and frosted after, which is the + // order the shader works in; saturation is linear in RGB, so the two + // commute and the choice is one of reading rather than of pixels. + return ImageFilter.compose(outer: blur, inner: saturate(lift)); + } + + /// A lift this close to 1 is no lift: not worth a second filter in the + /// chain. + static const double _flat = 0.01; + + /// A saturation matrix that keeps luminance where it was — the Rec. 709 + /// weights, as the shader and every other saturation in the app use — so + /// the glass lifts the colour of what is under it without lightening or + /// darkening it. + static ColorFilter saturate(double s) { + const r = 0.2126, g = 0.7152, b = 0.0722; + return ColorFilter.matrix([ + r + s * (1 - r), g * (1 - s), b * (1 - s), 0, 0, // + r * (1 - s), g + s * (1 - g), b * (1 - s), 0, 0, // + r * (1 - s), g * (1 - s), b + s * (1 - b), 0, 0, // + 0, 0, 0, 1, 0, // + ]); + } +} + +/// A [GlassShape] with something standing on it: a bar button, a pill, a +/// circle — one shape, a group of its own; or, under a [GlassGroup] that +/// [GlassGroup.gathers], one shape of that group. +/// +/// The glass takes the size of [child]; [child] is drawn over it. +class GlassSurface extends StatelessWidget { + const GlassSurface({ + super.key, + this.kind = GlassKind.regular, + this.radius, + this.shadow = false, + this.compact = false, + this.visibility = 1, + required this.child, + }); + + final GlassKind kind; + final double? radius; + final bool shadow; + + /// A small shape — a 44-point bar button: [GlassGroup.compact]. + final bool compact; + + /// How much of the glass is there, 0 to 1 ([GlassGroup.visibility]): a + /// capsule coming up and going again thins its own bending, frost, tint + /// and rim, never through an [Opacity] laid over it — an opacity over + /// the shader or its blur reads an empty layer. Only the glass thins: + /// [child] — the glyph, the words on it — is faded by its owner, with an + /// opacity of its own, since nothing in it reads the screen. + final double visibility; + final Widget child; + + @override + Widget build(BuildContext context) { + final shaped = Stack( + children: [ + Positioned.fill( + child: GlassShape(radius: radius, shadow: shadow), + ), + child, + ], + ); + // Inside a group that gathers its surfaces, one shape of it. + final gathered = + context.getInheritedWidgetOfExactType<_GlassScope>()?.gathers ?? false; + if (gathered) return shaped; + return GlassGroup( + kind: kind, + compact: compact, + visibility: visibility, + child: shaped, + ); + } +} + +/// Glass that stays itself while its page moves, as UIKit's glass never +/// stops being glass mid-push. Every other glass is held to its tint while +/// its route moves ([GlassHold]); under this a group is held only where a +/// view of the phone's stands under it, or the phone asks for less +/// transparency. +class GlassLive extends InheritedWidget { + const GlassLive({super.key, required super.child}); + + /// Whether the glass at [context] stays live through motion. + static bool of(BuildContext context) => + context.getInheritedWidgetOfExactType() != null; + + @override + bool updateShouldNotify(GlassLive oldWidget) => false; +} + +class _GlassScope extends InheritedWidget { + const _GlassScope({ + required this.mode, + required this.recipe, + required this.down, + required this.shown, + required this.grounded, + required this.reasons, + this.gathers = false, + this.veil, + required super.child, + }); + + /// Whether a [GlassSurface] under it is a shape of it + /// ([GlassGroup.gathers]). + final bool gathers; + + /// Why the group's glass is held ([GlassGroup.holdOf]). + final GlassHoldReasons reasons; + + final GlassMode mode; + + /// The held glass's tint fading off the refraction given back + /// ([GlassGroup.release]); null when the refraction is not drawn. + final Animation? veil; + final GlassRecipe recipe; + final bool down; + + /// [GlassGroup.standsOnModal]: the glass reads nothing of the screen. + final bool grounded; + + /// [GlassGroup.visibility]. + final double shown; + + @override + bool updateShouldNotify(_GlassScope old) => + old.mode != mode || + old.recipe != recipe || + old.down != down || + old.shown != shown || + old.grounded != grounded || + old.gathers != gathers || + old.veil != veil; +} + +/// The glass's own colour, and the light inside it ([GlassRecipe.sheenTop]): +/// a fill where the recipe has no sheen. +class _Tint extends StatelessWidget { + const _Tint(this.color, this.recipe, this.shown); + + final Color color; + final GlassRecipe recipe; + final double shown; + + @override + Widget build(BuildContext context) { + if (recipe.sheenTop.a == 0 && recipe.sheenFoot.a == 0) { + return ColoredBox(color: color); + } + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color.alphaBlend(_fade(recipe.sheenTop), color), + color, + color, + Color.alphaBlend(_fade(recipe.sheenFoot), color), + ], + stops: const [0, 0.35, 0.7, 1], + ), + ), + ); + } + + Color _fade(Color c) => c.withValues(alpha: c.a * shown); +} + +/// The float, outside the glass only. +class _OuterShadow extends CustomPainter { + const _OuterShadow(this.border, this.shadows); + + final OutlinedBorder border; + final List shadows; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final shape = border.getOuterPath(rect); + canvas + ..save() + ..clipPath( + Path() + ..fillType = PathFillType.evenOdd + ..addRect(rect.inflate(64)) + ..addPath(shape, Offset.zero), + ); + for (final shadow in shadows) { + canvas.drawPath(shape.shift(shadow.offset), shadow.toPaint()); + } + canvas.restore(); + } + + @override + bool shouldRepaint(_OuterShadow old) => + old.border != border || !listEquals(old.shadows, shadows); +} + +/// The rim drawn by hand, on every way of drawing the glass: a hair round +/// it where the recipe has one (by day), and the highlight — about a point +/// wide and fading inward, brightest along the top and a touch to the left, +/// nothing at the sides, fainter along the foot, as the simulator draws +/// the system's own. Never an even ring: that reads as a stroked outline +/// round a 44-point button. When the phone asks for more +/// contrast, a crisp edge instead of both. +class _Rim extends CustomPainter { + const _Rim( + this.border, + this.recipe, { + required this.lit, + required this.contrast, + required this.shown, + }); + + final OutlinedBorder border; + final GlassRecipe recipe; + final bool lit; + final bool contrast; + + /// The tallest a shape may be and still be lit as a low capsule: under a + /// 44-point bar button's height plus a few, so the button and a bar + /// keep their own rim. + static const double _lowCapsule = 46; + + /// How much of the glass is there ([GlassGroup.visibility]). + final double shown; + + Color _fade(Color c) => c.withValues(alpha: c.a * shown); + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + if (shown <= 0) return; + if (contrast) { + canvas.drawPath( + border.getInnerPath(rect.deflate(0.5)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = _fade(recipe.contrastEdge), + ); + return; + } + if (recipe.hairline.a > 0) { + canvas.drawPath( + border.getOuterPath(rect.deflate(0.25)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.5 + ..color = _fade(recipe.hairline), + ); + } + if (!lit) return; + if (recipe.edgeShade.a > 0) { + // Inside the rim only: a band a few points deep, darkest at the edge + // and gone toward the middle — the glass's thickness seen edge-on. + final shape = border.getOuterPath(rect); + canvas + ..save() + ..clipPath(shape) + ..drawPath( + shape, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 5 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.5) + ..color = _fade(recipe.edgeShade), + ) + ..restore(); + } + // A low capsule — a filter strip, a segmented track, under the height + // of a bar button and well wider than it is tall — is lit along its + // top alone. Its round ends run the whole of its short height, so the + // lit top and the shaded foot met round them and closed into an even + // grey ring on black (the home audit's filter capsules); the system's + // strips there show a highlight on the top arc and nothing below it. + final low = size.height <= _lowCapsule && size.width >= size.height * 1.6; + // Down the height, tilted a little so the top left leads: full along + // the top, gone by a third of the way down, back to [rimShade] at the + // foot. On a capsule the whole flat top is lit and its round ends are + // not, which is where the system's highlight is too. + // + // A low capsule's run is far wider than tall, so any tilt there leans + // the light along it — the right of its top unlit, the left end lit + // down its whole height — and it is lit straight down instead. + Shader light(double strength) => LinearGradient( + begin: low ? Alignment.topCenter : const Alignment(-0.3, -1), + end: low ? Alignment.bottomCenter : const Alignment(0.3, 1), + colors: [ + recipe.rimLit.withValues(alpha: recipe.rimLit.a * strength * shown), + recipe.rimLit.withValues(alpha: 0), + recipe.rimShade.withValues(alpha: 0), + recipe.rimShade.withValues( + alpha: low ? 0 : recipe.rimShade.a * strength * shown, + ), + ], + stops: low ? const [0, 0.26, 0.7, 1] : const [0, 0.32, 0.7, 1], + ).createShader(rect); + // Two strokes, the outer half point at full strength and a point + // inside it at 0.4, so the light fades into the glass rather than + // ending in a line. + canvas + ..drawPath( + border.getOuterPath(rect.deflate(0.25)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 0.5 + ..shader = light(1), + ) + ..drawPath( + border.getOuterPath(rect.deflate(1)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..shader = light(0.4), + ); + } + + @override + bool shouldRepaint(_Rim old) => + old.border != border || + old.recipe != recipe || + old.lit != lit || + old.contrast != contrast || + old.shown != shown; +} diff --git a/lib/core/glass/glass_watch.dart b/lib/core/glass/glass_watch.dart new file mode 100644 index 00000000..1b6a60d8 --- /dev/null +++ b/lib/core/glass/glass_watch.dart @@ -0,0 +1,454 @@ +part of 'glass_surface.dart'; + +/// Watches where a group's glass stands on the screen: over one of the +/// phone's own views, and carried somewhere without being painted again. +/// +/// **Over the phone's own view.** A view UIKit draws inside the app — the +/// native tab bar, a web page, the AirPlay button — is +/// not in Flutter's picture: the engine cuts the picture at it, and what +/// Flutter draws over the view goes into a layer of its own above it. The +/// refraction shader there reads that layer's backdrop, which holds +/// nothing of the page, and it writes the glass fully opaque wherever its +/// shape is — so the glass over the view's frame came out a hard-edged +/// dark block under the tint, the size of the view's frame. Such a group +/// is held ([GlassHoldReason.underView]) for as long as any view drawn +/// before it on the screen reaches under it, and given back when none does. +/// Views drawn after it stand over the glass and leave its picture alone. +/// +/// Where the views are is read after a frame from the layers the frame +/// was made of ([GlassUnderlay]), and decides the next frame — so it is +/// read ahead: a view or a glass moving is taken to move as far again by +/// the next frame, and a glass about to be drawn over where a view stood +/// is not drawn for that one frame rather than drawn wrong. Only the views +/// drawn before the glass in the last frame are read ahead: a view drawn +/// after the page — the native tab bar slid away under a pushed page — taken +/// for one under a glass would keep that glass out of every frame. A frame +/// the glass was left out of that the +/// frame's own layers then find clear is followed by one it is drawn in, +/// whatever the reading ahead says: left out, it stays out until something +/// else asks for a frame. +/// +/// **Carried without a paint.** The renderer bakes where its shapes stand +/// on the screen into the shader's matte when it paints, and notices that +/// they have moved only while the frame is being composited — too late +/// for that frame, and, as the frame is already under way, without asking +/// for another (`ensureVisualUpdate` asks for nothing mid-frame). A glass +/// carried by a layout — a sheet growing to its detent, a sheet fitting +/// content that landed, a keyboard pushing it up — kept the matte of where +/// it stood a frame before, until anything else drew a frame: the shader +/// bent the shape where it had been, and the ✕'s lower half showed the +/// page above the sheet. One frame more after the move, and the matte is +/// where the glass is. +class _GlassWatch extends SingleChildRenderObjectWidget { + const _GlassWatch({required this.liquid, required this.onUnder, super.child}); + + /// The refraction is drawn: only then does a move ask for a frame more. + final bool liquid; + + /// Told after a frame whether a view of the phone's stands under the + /// glass. + final ValueChanged onUnder; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderGlassWatch(liquid: liquid, onUnder: onUnder); + + @override + void updateRenderObject(BuildContext context, _RenderGlassWatch box) { + box + ..liquid = liquid + ..onUnder = onUnder; + } +} + +class _RenderGlassWatch extends RenderProxyBox { + _RenderGlassWatch({required bool liquid, required this.onUnder}) + : _liquid = liquid; + + bool _liquid; + bool get liquid => _liquid; + set liquid(bool value) { + if (value == _liquid) return; + _liquid = value; + _count(); + markNeedsPaint(); + } + + /// Whether this glass is counted among the refracting ones on the screen + /// right now ([GlassFrameWatch]). + bool _counted = false; + + /// Tells the frame watch whether there is a refraction of ours on the + /// screen. It measures frames only while there is: a frame drawn with no + /// refracting glass up says nothing about whether this phone can draw one, + /// and counting it would let a slow page elsewhere take the glass away. + void _count() { + final up = attached && _liquid; + if (up == _counted) return; + _counted = up; + GlassFrameWatch.refracting(up); + } + + ValueChanged onUnder; + + /// What the last frame found, and where the glass stood on the screen + /// then. + bool under = false; + Rect? lastOnScreen; + + /// Where the glass stood, through every transform above it, the last + /// time the frame's layers were read: a change asks for a frame more + /// (carried without a paint). + Matrix4? lastTransform; + + /// The views drawn before the glass in the last frame — the ones that + /// can stand under it; null before its first frame. + Set? viewsBefore; + + /// The glass was left out of this frame, read ahead to be over a view. + bool skipped = false; + + /// The frame's layers found it clear after all: the next paint draws it + /// whatever the reading ahead says. + bool cleared = false; + + /// The group's place in the frame's layers ([_WatchLayer]). + Layer? get mark => layer; + + @override + bool get alwaysNeedsCompositing => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + GlassUnderlay._watch(this); + _count(); + } + + @override + void detach() { + GlassUnderlay._unwatch(this); + super.detach(); + // After the detach, so that [attached] already reads false: a glass taken + // off the screen is one fewer refraction to measure. + _count(); + } + + /// Said once the frame is through: the group cannot be rebuilt from + /// inside one. + void tell(bool value) { + if (value == under) return; + under = value; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (attached && under == value) onUnder(value); + }); + } + + @override + void paint(PaintingContext context, Offset offset) { + final mark = (layer as _WatchLayer?) ?? _WatchLayer(); + layer = mark + ..box = this + ..rect = offset & size; + context.pushLayer(mark, (context, offset) {}, offset); + final wasCleared = cleared; + cleared = false; + skipped = + _liquid && + !wasCleared && + GlassUnderlay._ahead( + MatrixUtils.transformRect(getTransformTo(null), Offset.zero & size), + lastOnScreen, + viewsBefore, + ); + if (skipped) { + // About to refract over a view of the phone's: not drawn this one + // frame, and held from the next. + tell(true); + return; + } + super.paint(context, offset); + } + + /// The frame's layers found the glass clear of every view under it: a + /// glass left out of this frame on the reading ahead is drawn in the + /// next, asked for once this one is through. + void foundClear() { + if (!skipped) return; + skipped = false; + cleared = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (attached) markNeedsPaint(); + }); + } +} + +/// A mark in the frame's layers: laid in again — attached — whenever +/// whatever it stands in is painted again, moved by a paint, or brought +/// back into the frame (a tab shown again). That is the only way a glass +/// or a view can come to stand somewhere else on the screen, so a frame in +/// which no mark was laid in again is one in which nothing a glass could be +/// over has changed ([GlassUnderlay]). +/// +/// An empty layer: it costs a push in the scene, and is never composited +/// again for its own sake — the glass's used to be, every frame +/// ([Layer.alwaysNeedsAddToScene]), to notice a move; that now happens +/// when the layers are read. +class _Mark extends OffsetLayer { + @override + void attach(Object owner) { + super.attach(owner); + GlassUnderlay._stir(); + } + + @override + void detach() { + GlassUnderlay._stir(); + super.detach(); + } +} + +/// The group's place in the frame's layers: where it stands, and what was +/// drawn before it. +class _WatchLayer extends _Mark { + _RenderGlassWatch? box; + + /// The group's box in its parent layer's space. + Rect rect = Rect.zero; +} + +/// Where one of the app's own views of the phone's is drawn, just before +/// it in the frame's layers ([PlatformViewMark]). +class _ViewMarkLayer extends _Mark {} + +/// Marks a view of the phone's — a [UiKitView] the app makes +/// (`ChannelNativeHost`) — in the frame's layers, so that the glass knows +/// when it may have moved, come or gone ([GlassUnderlay]) without reading +/// every layer of every frame. +/// +/// A view drawn without one (a web page, a test's stand-in) is still found +/// and still holds the glass; only, while one is on the screen, every +/// frame's layers are read, as they all were before. +class PlatformViewMark extends SingleChildRenderObjectWidget { + const PlatformViewMark({super.key, required super.child}); + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderPlatformViewMark(); +} + +class _RenderPlatformViewMark extends RenderProxyBox { + @override + bool get alwaysNeedsCompositing => true; + + @override + void paint(PaintingContext context, Offset offset) { + final mark = (layer as _ViewMarkLayer?) ?? _ViewMarkLayer(); + layer = mark; + context.pushLayer(mark, (context, offset) {}, offset); + super.paint(context, offset); + } +} + +/// After a frame, the views of the phone's that the frame drew, and which +/// glass stands over one of them ([_GlassWatch]). +/// +/// The frame's layers are read only after a frame that may have moved +/// something a glass could be over — not after every frame while any glass +/// is on the screen, as before (a spinner elsewhere on a settled page drew +/// sixty frames a second, and each one walked every layer of the screen): +/// +/// * a glass's or a marked view's mark was laid in again or taken out +/// ([_Mark]) — painted, moved by a paint, shown or hidden; +/// * a glass came or went; +/// * the last reading found a view moving (it is read again to see it +/// stop), or a view no [PlatformViewMark] marks, or a glass or view +/// carried by a [FollowerLayer] (which moves with its leader without +/// a paint of its own) — then every frame is read, as before. +abstract final class GlassUnderlay { + static final Set<_RenderGlassWatch> _watches = {}; + static bool _hooked = false; + + /// Something may have moved since the layers were last read. + static bool _stirred = true; + + /// The last reading asked for the next frame to be read whatever: a view + /// moving, unmarked, or following a leader. + static bool _again = false; + + /// Where each view stood after the last reading, by its id, and how far + /// it had moved since the one before. + static Map _views = const {}; + static Map _moves = const {}; + + static int _reads = 0; + + /// How many times the frame's layers have been read: what the frame-cost + /// tests count. + @visibleForTesting + static int get debugReads => _reads; + + static void _stir() => _stirred = true; + + static void _watch(_RenderGlassWatch box) { + // What was read while no glass was watched is no word on the next + // frame. + if (_watches.isEmpty) { + _views = const {}; + _moves = const {}; + } + _watches.add(box); + _stirred = true; + if (_hooked) return; + _hooked = true; + SchedulerBinding.instance.addPersistentFrameCallback((_) => _read()); + } + + static void _unwatch(_RenderGlassWatch box) { + _watches.remove(box); + _stirred = true; + } + + /// Whether a glass now at [onScreen] (and at [before] a frame ago) is + /// about to stand over a view, by where the views stood after the last + /// frame and how they were moving — of them, only [under], the views + /// drawn before the glass then, where the glass has been drawn before. + static bool _ahead(Rect onScreen, Rect? before, Set? under) { + if (_views.isEmpty) return false; + final glass = _reach(onScreen, before); + for (final MapEntry(key: id, value: view) in _views.entries) { + if (under != null && !under.contains(id)) continue; + if (_reach( + view, + view.shift(-(_moves[id] ?? Offset.zero)), + ).overlaps(glass)) { + return true; + } + } + return false; + } + + /// [now], and as far again as it came from [before]: where a thing + /// moving may stand by the next frame. + static Rect _reach(Rect now, Rect? before) { + if (before == null) return now; + final step = now.center - before.center; + return now.expandToInclude(now.shift(step)); + } + + static void _read() { + if (_watches.isEmpty) { + _views = const {}; + _moves = const {}; + _again = false; + return; + } + if (!_stirred && !_again) return; + _stirred = false; + _reads++; + // Any watched glass leads to the root of the frame's layers. + ContainerLayer? root; + for (final box in _watches) { + Layer? up = box.mark; + while (up?.parent != null) { + up = up!.parent; + } + if (up is ContainerLayer && up.attached) { + root = up; + break; + } + } + final walk = _Walk(); + if (root != null) { + // In points, as the boxes are: the root's own transform is the + // screen's pixel ratio. + for ( + var child = root.firstChild; + child != null; + child = child.nextSibling + ) { + walk.visit(child, Matrix4.identity(), followed: false); + } + } + final moves = {}; + for (final MapEntry(key: id, value: view) in walk.views.entries) { + final was = _views[id]; + if (was != null && view.center != was.center) { + moves[id] = view.center - was.center; + } + } + _views = walk.views; + _moves = moves; + _again = moves.isNotEmpty || walk.unmarked || walk.followed; + for (final box in _watches) { + _carried(box); + } + } + + /// A refracting glass that stands elsewhere on the screen than when the + /// layers were last read asks for a frame more: the renderer's matte is + /// a frame behind (carried without a paint, [_GlassWatch]). + static void _carried(_RenderGlassWatch box) { + if (!box.attached || !box.liquid) { + box.lastTransform = null; + return; + } + final now = box.getTransformTo(null); + final last = box.lastTransform; + box.lastTransform = now; + if (last == null || MatrixUtils.matrixEquals(last, now)) return; + SchedulerBinding.instance.scheduleFrame(); + } +} + +/// One reading of the frame's layers: the views and the glass in the order +/// they were drawn, carried to the screen. +class _Walk { + final Map views = {}; + + /// A view was drawn with no [PlatformViewMark] before it. + bool unmarked = false; + + /// A glass or a view stands under a [FollowerLayer]. + bool followed = false; + + void visit(Layer layer, Matrix4 toScreen, {required bool followed}) { + if (layer is PlatformViewLayer) { + views[layer.viewId] = MatrixUtils.transformRect(toScreen, layer.rect); + if (layer.previousSibling is! _ViewMarkLayer) unmarked = true; + if (followed) this.followed = true; + return; + } + if (layer is _WatchLayer) { + final box = layer.box; + if (box != null && box.attached) { + if (followed) this.followed = true; + final glass = MatrixUtils.transformRect(toScreen, layer.rect); + final reach = GlassUnderlay._reach(glass, box.lastOnScreen); + box.lastOnScreen = glass; + // Only the views drawn so far: they are the ones under it. + box.viewsBefore = views.keys.toSet(); + final under = views.entries.any( + (view) => GlassUnderlay._reach( + view.value, + GlassUnderlay._views[view.key], + ).overlaps(reach), + ); + box.tell(under); + if (!under) box.foundClear(); + } + return; + } + if (layer is! ContainerLayer) return; + final under = followed || layer is FollowerLayer; + for ( + var child = layer.firstChild; + child != null; + child = child.nextSibling + ) { + final carried = toScreen.clone(); + layer.applyTransform(child, carried); + visit(child, carried, followed: under); + } + } +} diff --git a/lib/core/glass/media_glass.dart b/lib/core/glass/media_glass.dart new file mode 100644 index 00000000..956db30a --- /dev/null +++ b/lib/core/glass/media_glass.dart @@ -0,0 +1,145 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'app_glass.dart'; +import 'glass_surface.dart'; + +/// The plate a control stands on over video — the player's buttons, its +/// pills — as an iPhone draws controls over a picture: clear glass. +/// +/// iOS 26 uses its clear glass over media (WWDC25 219): no frost, next to no +/// tint, the picture seen through it bent at its rim — with a dimming laid in +/// it, because a white glyph over clear glass over a white sky is no glyph. +/// [glyphDim] is the black the player's discs have always been, so on an +/// iPhone the controls keep their contrast and gain the glass. +/// +/// Everywhere else — Android, TV, desktop — it is that black plate alone, as +/// before: glass over 60 fps video is a read of the screen every frame, and +/// those devices have no system glass for it to match. +/// +/// Glass is never laid on glass: the pieces of one row stand in one +/// [MediaGlassLayer] and share its read of the picture; a piece standing +/// alone is a layer of its own. +class MediaGlass extends StatelessWidget { + const MediaGlass({super.key, required this.child, this.radius, this.dim}); + + /// The piece's corners; null is a capsule — a circle when it is square. + final double? radius; + + /// What is laid in the glass under [child]: black at [glyphDim] when null. + /// A control showing an active state passes its accent here instead. + final Color? dim; + + final Widget child; + + /// The dimming in a glass that carries a white glyph, from the worst + /// picture — a blown-out white one — with the clear glass's own white + /// (0.04) counted over it. WCAG asks 3:1 of a glyph, so the glass over + /// white may be at most the sRGB grey 0.584. Black at 0.45 under a 0.04 + /// white leaves 0.568: 3.16:1, and over a real frame the picture still + /// shows through. + static const double glyphDim = 0.45; + + static const Color _plate = Color(0xFF000000); + + /// Whether controls over video are drawn as glass here. + static bool get drawsGlass => defaultTargetPlatform == TargetPlatform.iOS; + + @override + Widget build(BuildContext context) { + final fill = dim ?? _plate.withValues(alpha: glyphDim); + final plate = Positioned.fill( + child: _Plate(radius: radius, color: fill, glass: drawsGlass), + ); + if (!drawsGlass) { + return Stack(children: [plate, child]); + } + final piece = Stack( + children: [ + // Laid before the shape: the renderer paints its layer's glass first + // whatever the order, so the dimming lands over the bent picture and + // under the glass's tint and its lit rim — the rim stays a light. + plate, + Positioned.fill(child: GlassShape(radius: radius)), + child, + ], + ); + if (_InLayer.of(context)) return piece; + return MediaGlassLayer(child: piece); + } +} + +/// One read of the picture for the [MediaGlass] pieces of one row: on an +/// iPhone a group of the clear glass ([GlassGroup], [GlassKind.clear]); +/// elsewhere nothing at all. +/// +/// The whole row fades as one: the renderer draws a group's glass through +/// the group, not through an opacity between the group and a piece, so a +/// piece faded on its own inside a row would leave its glass behind. +class MediaGlassLayer extends StatelessWidget { + const MediaGlassLayer({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + if (!MediaGlass.drawsGlass) return child; + return GlassGroup( + kind: GlassKind.clear, + child: _InLayer(child: child), + ); + } +} + +/// Marks what stands in a [MediaGlassLayer], so a piece in it is a shape of +/// that layer rather than a layer — glass on glass — of its own. +class _InLayer extends InheritedWidget { + const _InLayer({required super.child}); + + static bool of(BuildContext context) => + context.getInheritedWidgetOfExactType<_InLayer>() != null; + + @override + bool updateShouldNotify(_InLayer oldWidget) => false; +} + +/// The dimming: in the glass's own shape on an iPhone, and the plain circle +/// or rounded plate the player has always drawn everywhere else. +class _Plate extends StatelessWidget { + const _Plate({ + required this.radius, + required this.color, + required this.glass, + }); + + final double? radius; + final Color color; + final bool glass; + + @override + Widget build(BuildContext context) { + final r = radius; + if (!glass) { + return DecoratedBox( + decoration: ShapeDecoration( + color: color, + shape: r == null + ? const StadiumBorder() + : RoundedRectangleBorder(borderRadius: BorderRadius.circular(r)), + ), + ); + } + return LayoutBuilder( + builder: (context, box) => DecoratedBox( + decoration: ShapeDecoration( + color: color, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.circular( + r ?? box.biggest.shortestSide / 2, + ), + ), + ), + ), + ); + } +} diff --git a/lib/core/glass/native_glass.dart b/lib/core/glass/native_glass.dart new file mode 100644 index 00000000..aa909a3c --- /dev/null +++ b/lib/core/glass/native_glass.dart @@ -0,0 +1,433 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'glass_sense.dart'; + +/// The phone's own glass, hosted in the tree as a UIKit view +/// (ios/Runner/NativeGlass.swift). +/// +/// Everywhere else the app draws Liquid Glass itself — a refraction shader +/// over the page it reads, a rim lit by hand (`GlassGroup` in +/// glass_surface.dart) — because that is the only kind of glass a Flutter +/// layer can be. From iOS 26 the platform draws the material itself +/// (`UIGlassEffect`), and that material is the thing our shader is an +/// approximation *of*: its specular rim, the way its tint answers whatever +/// moves beneath it, and its behaviour under Reduce Transparency and +/// Increase Contrast are all maintained by Apple rather than by us, and +/// they change with the OS rather than with our next release. Where a +/// surface can afford to be a hosted view, the system's material is the +/// better glass. +/// +/// ## When to use this, and when not to +/// +/// A hosted view is not a Flutter layer, and the difference is not a +/// detail — it decides which surfaces this is right for. +/// +/// * **It composites outside Flutter's picture.** The engine cuts the +/// Flutter picture where the view is and puts what Flutter draws after it +/// into a layer above. That is the same seam `_GlassWatch` in +/// glass_watch.dart already has to work around for the native tab bar and +/// the AirPlay button: the refraction shader over a hosted view reads a +/// backdrop with nothing of the page in it and writes an opaque block. So +/// a [NativeGlass] standing inside a `GlassGroup` that draws the +/// refraction will ruin that group's glass, not join it — it belongs +/// *instead of* the group, never inside one. +/// * **It cannot be freely transformed.** Flutter applies a transform to a +/// hosted view by handing UIKit a frame and, where it can, a mask; a +/// rotation, a non-rectangular clip, a shader mask or an arbitrary +/// `Transform` either does not reach it or forces the engine onto a much +/// more expensive path. The shader glass survives all of those because it +/// is paint. So: the still surfaces — a bar that sits where it sits, a +/// panel, a sheet's own material — can be native; the moving pieces — the +/// tab bar's travelling lens, a glass that melts into its neighbour +/// (`GlassGroup.blend`), anything mid-route-transition — should stay on +/// the shader. +/// * **It is invisible to goldens.** A `UiKitView` renders nothing in the +/// test renderer and nothing in a golden file, so a surface drawn this way +/// silently stops being covered by the golden that used to guard it. The +/// decision below ([nativeGlassDraw]) and the fallback are unit-testable; +/// the pixels are only ever testable on a device. +/// * **Its hit-testing is its own.** Flutter wraps a hosted view in a +/// touch-intercepting view and offers it each touch first. This glass +/// declines them all (`isUserInteractionEnabled = false` on the Swift +/// side) so that the Flutter button drawn over it still gets the tap — +/// which is where every tap on glass in this app belongs. The cost is +/// that the system's *interactive* glass, which reacts to a finger +/// landing on the material, cannot light up on its own: pass +/// [interactive] only for a surface that really is the touch target and +/// has no Flutter gesture over it. +/// +/// ## What it is told, and by whom +/// +/// Nothing here guesses from a version string. The Swift side answers +/// `glassCapability` on the `soplay/glass` channel [GlassSense] already +/// owns, saying what *this* phone has — whether `UIGlassEffect` exists on +/// it, which glass styles, which blur materials, and whether Reduce +/// Transparency is on — and [nativeGlassDraw] decides from that. The app's +/// floor is iOS 15 and it is built against the iOS 27 SDK, so the answer +/// genuinely differs across the phones it ships to, and a wrong guess is a +/// blank rectangle on somebody's screen rather than a slightly wrong grey. +/// +/// A phone without the glass effect gets the system blur material, which is +/// a real material and not an imitation of one; a phone that is not an +/// iPhone, or one asking for Reduce Transparency, gets nothing hosted at +/// all and the caller's [fallback] instead. +class NativeGlass extends StatefulWidget { + const NativeGlass({ + super.key, + this.style = NativeGlassStyle.regular, + this.material = NativeGlassMaterial.thin, + this.borderRadius = BorderRadius.zero, + this.tint, + this.interactive = false, + this.onDraw, + required this.fallback, + }); + + /// The view type the Swift factory is registered under. + static const String viewType = 'soplay/native_glass'; + + /// The method [GlassSense.channel] answers with the capability map. + static const String capabilityMethod = 'glassCapability'; + + /// Which of the system's two glasses: the standard material, or the clear + /// one for glass over video. + final NativeGlassStyle style; + + /// The blur material to stand in with where the OS has no glass effect. + final NativeGlassMaterial material; + + /// The corner the view is clipped to. UIKit clips it with its own + /// continuous curve — the squircle its bars and buttons use — so only the + /// radius crosses the channel, and a non-uniform [BorderRadius] takes its + /// largest corner rather than being silently squared off. + final BorderRadius borderRadius; + + /// A colour laid on the material: the glass effect's own `tintColor` + /// where the OS has one, a wash inside the effect view's content where it + /// does not. Null leaves the material untinted. + final Color? tint; + + /// Whether this surface is itself the touch target, so the system's + /// interactive glass may react to a finger on it. False for anything with + /// a Flutter gesture drawn over it, which is nearly everything here. + final bool interactive; + + /// Told what was drawn, each time the decision changes. Only for tests + /// and for a caller that wants to log what a device actually gave: the + /// widget needs nothing back. + final ValueChanged? onDraw; + + /// Drawn in place of the hosted view wherever one cannot be hosted — not + /// an iPhone, Reduce Transparency, a capability that says no, or the + /// moment before the first answer arrives. The caller passes its own + /// glass here, so a surface that goes native loses nothing where native + /// is unavailable. + final WidgetBuilder fallback; + + /// Asks the phone what its glass can do, once per process. + /// + /// The future is kept rather than the answer: every [NativeGlass] built + /// before the first reply awaits the same round trip instead of starting + /// one of its own. The capability is a property of the OS build, so it + /// cannot change under a running app — Reduce Transparency can, and that + /// is why it is read from [GlassSense] on every build and not from this. + static Future? _asked; + + static Future capability() => + _asked ??= _ask().catchError((Object _) { + // Only an unforeseen throw reaches here: [_ask] answers `absent` + // itself for the two failures it expects, and both of those are + // permanent facts about the build (no bridge compiled in, or the + // channel refusing the call), so remembering them is right. What + // must not be remembered is a failure nobody predicted, since the + // reason for it may well have passed — hence the promise is dropped + // and the next caller asks the phone again. + _asked = null; + return NativeGlassCapability.absent; + }); + + static Future _ask() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return NativeGlassCapability.absent; + } + try { + final said = await GlassSense.channel.invokeMapMethod( + capabilityMethod, + ); + return NativeGlassCapability.fromChannel(said); + } on MissingPluginException { + // A runner without the bridge — a test, a build from before this + // existed: no hosted glass, and no error worth logging. + return NativeGlassCapability.absent; + } on PlatformException { + return NativeGlassCapability.absent; + } + } + + /// Forgets the remembered probe. Only for a test that pumps more than one + /// capability in the one process. + @visibleForTesting + static void resetCapability() => _asked = null; + + @override + State createState() => _NativeGlassState(); +} + +class _NativeGlassState extends State { + /// Null until the phone has answered, which is why the first frame of a + /// native surface is the [NativeGlass.fallback]: a hosted view cannot be + /// mounted speculatively and taken away again without a visible flash. + NativeGlassCapability? _said; + + NativeGlassDraw? _told; + + @override + void initState() { + super.initState(); + unawaited(_probe()); + } + + Future _probe() async { + final said = await NativeGlass.capability(); + if (!mounted) return; + setState(() => _said = said); + } + + /// Reports a changed decision after the frame: [NativeGlass.onDraw] is a + /// listener, and calling it from `build` would let it set state during a + /// build. + void _report(NativeGlassDraw draw) { + if (draw == _told) return; + _told = draw; + final onDraw = widget.onDraw; + if (onDraw == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) onDraw(draw); + }); + } + + @override + Widget build(BuildContext context) { + final said = _said ?? NativeGlassCapability.absent; + final draw = nativeGlassDraw( + platform: defaultTargetPlatform, + capability: NativeGlassScope.of(context) ?? said, + style: widget.style, + reduceTransparency: GlassSense.reduceTransparencyOf(context), + ); + _report(draw); + if (draw == NativeGlassDraw.none) return widget.fallback(context); + + final corners = widget.borderRadius; + final radius = [ + corners.topLeft.x, + corners.topRight.x, + corners.bottomLeft.x, + corners.bottomRight.x, + ].reduce((a, b) => a > b ? a : b); + + return UiKitView( + viewType: NativeGlass.viewType, + creationParams: { + 'style': widget.style.name, + 'material': widget.material.name, + 'cornerRadius': radius, + 'interactive': widget.interactive, + 'tint': widget.tint?.toARGB32(), + }, + creationParamsCodec: const StandardMessageCodec(), + // Glass takes no touches of its own: the Flutter button over it does + // (see the class comment), and `opaque: false` with no gesture + // recognizers keeps the hosted view out of the gesture arena. + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + ); + } +} + +/// Which of the system's two glass materials. +enum NativeGlassStyle { + /// The standard material: what a bar or a button is made of. + regular, + + /// The clear material: glass over video, which keeps the frame's colour + /// rather than frosting it — the native counterpart of + /// `GlassKind.clear`. + clear, +} + +/// The system blur material to stand in with where the OS has no glass +/// effect. These name UIKit's dark materials, thinnest first; the app's +/// glass is dark everywhere, so the appearance-following variants are not +/// offered — on a light phone over our dark page they turn white. +enum NativeGlassMaterial { ultraThin, thin, regular, thick, chrome } + +/// What a [NativeGlass] draws. +enum NativeGlassDraw { + /// The system's own glass material (`UIGlassEffect`, iOS 26+). + systemGlass, + + /// The system's blur material, on an iPhone whose OS has no glass effect. + systemMaterial, + + /// Nothing hosted: the caller's fallback draws instead. + none, +} + +/// What this phone's glass can do, as the Swift side reported it. +/// +/// Read once per process ([NativeGlass.capability]) because it is a property +/// of the OS build. Reduce Transparency is in here too — the probe answers +/// it for a caller that wants one round trip — but it is *not* what the +/// widget branches on: that switch can be thrown while the app runs, so the +/// live value comes from [GlassSense] on every build. +@immutable +final class NativeGlassCapability { + const NativeGlassCapability({ + required this.systemVersion, + required this.majorVersion, + required this.systemGlass, + required this.styles, + required this.materials, + required this.reduceTransparency, + }); + + /// No hosted glass of any kind: not an iPhone, or a phone that did not + /// answer. Chosen over a nullable capability so the decision below never + /// has two ways to say "nothing". + static const NativeGlassCapability absent = NativeGlassCapability( + systemVersion: '', + majorVersion: 0, + systemGlass: false, + styles: {}, + materials: {}, + reduceTransparency: false, + ); + + /// Reads the map `glassCapability` answers with. Every field is defaulted: + /// an older Swift side that has not learned a key yet should degrade, not + /// throw, because the Dart and the native halves of this app are not + /// always the same age on a phone mid-update. + factory NativeGlassCapability.fromChannel(Map? said) { + if (said == null) return absent; + Set names(Object? raw) => switch (raw) { + final List list => list.whereType().toSet(), + _ => const {}, + }; + return NativeGlassCapability( + systemVersion: said['systemVersion'] as String? ?? '', + majorVersion: (said['majorVersion'] as num?)?.toInt() ?? 0, + systemGlass: said['systemGlass'] as bool? ?? false, + styles: names(said['glassStyles']), + materials: names(said['materials']), + reduceTransparency: said['reduceTransparency'] as bool? ?? false, + ); + } + + /// `UIDevice.systemVersion`, for a log line. Nothing branches on it. + final String systemVersion; + + /// The OS's major version, for a log line and for the one case a + /// capability cannot express: telling a phone that *should* have the + /// glass effect but did not report it apart from one that should not. + final int majorVersion; + + /// Whether `UIGlassEffect` exists on the phone running this — the one + /// fact Dart cannot work out for itself. + final bool systemGlass; + + /// The glass styles it offers, as [NativeGlassStyle] names. + final Set styles; + + /// The blur materials it offers, as [NativeGlassMaterial] names. + final Set materials; + + /// Whether the phone asked for less transparency when it was probed. Use + /// [GlassSense] for the live answer, not this. + final bool reduceTransparency; + + /// Compared by what it says and not by which object said it, because + /// [NativeGlassScope.updateShouldNotify] asks this question and two + /// probes of the same phone are the same answer — without this, a scope + /// rebuilt with an equal capability would rebuild every surface under it. + @override + bool operator ==(Object other) => + other is NativeGlassCapability && + other.systemVersion == systemVersion && + other.majorVersion == majorVersion && + other.systemGlass == systemGlass && + other.reduceTransparency == reduceTransparency && + setEquals(other.styles, styles) && + setEquals(other.materials, materials); + + @override + int get hashCode => Object.hash( + systemVersion, + majorVersion, + systemGlass, + reduceTransparency, + Object.hashAllUnordered(styles), + Object.hashAllUnordered(materials), + ); + + @override + String toString() => + 'NativeGlassCapability(iOS $systemVersion, glass: $systemGlass, ' + 'styles: $styles, materials: $materials)'; +} + +/// What a [NativeGlass] should draw, given the phone and what it said. +/// +/// Pulled out of the widget as a function of its inputs so the decision can +/// be tested without a device — which matters more here than usual, because +/// the thing it decides between renders nothing at all in a test. +/// +/// Reduce Transparency hosts nothing: the opaque fill the app uses then +/// (`GlassMode.opaque`) is a plain painted colour, and painting it in +/// Flutter keeps it inside the Flutter picture where it can be clipped and +/// animated with everything else — a hosted view that is merely opaque buys +/// nothing and still costs a composition seam. +NativeGlassDraw nativeGlassDraw({ + required TargetPlatform platform, + required NativeGlassCapability capability, + NativeGlassStyle style = NativeGlassStyle.regular, + bool reduceTransparency = false, +}) { + if (platform != TargetPlatform.iOS) return NativeGlassDraw.none; + if (reduceTransparency) return NativeGlassDraw.none; + if (capability.systemGlass && capability.styles.contains(style.name)) { + return NativeGlassDraw.systemGlass; + } + if (capability.materials.isNotEmpty) return NativeGlassDraw.systemMaterial; + return NativeGlassDraw.none; +} + +/// Hands the glass under it a capability in place of the phone's. +/// +/// A scope rather than a static a test sets and resets, for the reason +/// `GlassPickScope` gives: one left set by a test that failed half way +/// would go on into every test after it. +class NativeGlassScope extends InheritedWidget { + const NativeGlassScope({ + super.key, + required this.capability, + required super.child, + }); + + final NativeGlassCapability capability; + + /// The capability over [context]; null with no scope, so the widget falls + /// back to what it probed. + static NativeGlassCapability? of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.capability; + + @override + bool updateShouldNotify(NativeGlassScope old) => + old.capability != capability; +} diff --git a/lib/core/glass/route_arrival.dart b/lib/core/glass/route_arrival.dart new file mode 100644 index 00000000..9289ed6c --- /dev/null +++ b/lib/core/glass/route_arrival.dart @@ -0,0 +1,188 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/widgets.dart'; + +/// Where the route a widget stands in is on its way in: still sliding, +/// landed, or taken back — and work held until it has come to rest. +/// +/// A page's first frames are the slide's frames. Anything that can wait — +/// a picture decoded, a menu readied, the keyboard raised, a long list +/// laid out — waits for the route to land, or the slide pays for it. Every +/// page that held work back wrote the same dozen lines against +/// `ModalRoute.of(context)?.animation`; this is them once. +/// +/// It is read *quietly*: asking `ModalRoute.of` from a page's own state +/// makes the page depend on whether its route is the top one, so the whole +/// page was built again on the frame a page was pushed over it and on the +/// frame that page started back — the two busiest frames of the way +/// there and back. Here the route is asked for through the nearest +/// render object above [BuildContext] instead: one that is rebuilt anyway +/// is told, and it rebuilds as a render object does, without building +/// anything under it again. +@immutable +final class RouteArrival { + const RouteArrival._(this.route); + + /// The arrival of the route [context] stands in; one with no route (a + /// widget above every navigator, a test pumping a bare widget) has + /// always arrived. + factory RouteArrival.of(BuildContext context) => + RouteArrival._(routeOf(context)); + + /// The route [context] stands in, without [context] depending on it. + static ModalRoute? routeOf(BuildContext context) { + Element? quiet; + context.visitAncestorElements((element) { + if (element is RenderObjectElement) { + quiet = element; + return false; + } + return true; + }); + return ModalRoute.of(quiet ?? context); + } + + final ModalRoute? route; + + /// The route's way in, 0 to 1: always complete without a route. + Animation get entrance => + route?.animation ?? kAlwaysCompleteAnimation; + + /// Whether the route has landed — or there is none to land. + bool get arrived => entrance.isCompleted; + + /// Whether the page is moving on the glass: coming in or going, a page + /// over it coming or going, or a thumb holding a back swipe — which + /// moves the page without an animation status of its own. + bool get moving { + final route = this.route; + if (route == null) return false; + if (route.animation?.isAnimating ?? false) return true; + if (route.secondaryAnimation?.isAnimating ?? false) return true; + return route.navigator?.userGestureInProgress ?? false; + } + + /// Calls [then] once the route has come to rest — with true when it has + /// landed, false when it was taken back before it did — or at once (with + /// true, or false for a route already gone) when it already stands. + /// + /// Only where it comes to rest counts: a back swipe let go short of the + /// edge turns the page round and brings it in again, and that is not + /// rest. [then] is not called once [context] has left the tree; the + /// returned wait can also be [ArrivalWait.cancel]led — from `dispose`, + /// by a state that wants its listener off at once. + static ArrivalWait whenSettled( + BuildContext context, + void Function(bool landed) then, + ) { + final arrival = RouteArrival.of(context); + final animation = arrival.route?.animation; + if (animation == null || !animation.isAnimating) { + then(animation == null || animation.isCompleted); + return ArrivalWait._settled(); + } + return ArrivalWait._(animation, (landed) { + if (context.mounted) then(landed); + }); + } + + /// Calls [then] once the route has landed, never if it is taken back: + /// the work belongs to a page that stays. At once when it has already + /// landed. + static ArrivalWait whenLanded(BuildContext context, VoidCallback then) => + whenSettled(context, (landed) { + if (landed) then(); + }); +} + +/// A wait for a route to come to rest ([RouteArrival.whenSettled]). +class ArrivalWait { + ArrivalWait._(Animation animation, this._then) + : _animation = animation { + animation.addStatusListener(_heard); + } + + ArrivalWait._settled() : _animation = null, _then = null, _over = true; + + final Animation? _animation; + final void Function(bool landed)? _then; + bool _over = false; + + /// Whether it is still waiting. + bool get waiting => !_over; + + void _heard(AnimationStatus status) { + if (status.isAnimating) return; + cancel(); + _then?.call(status.isCompleted); + } + + /// Stops waiting; [RouteArrival.whenSettled]'s `then` is not called. + void cancel() { + if (_over) return; + _over = true; + _animation?.removeStatusListener(_heard); + } +} + +/// Whether a route is moving on the glass, told as it starts and stops: +/// its own way in or out ([ModalRoute.animation]), a page coming over it or +/// leaving it ([ModalRoute.secondaryAnimation]), or a finger dragging any +/// navigator above it back ([NavigatorState.userGestureInProgressNotifier]) — +/// the iPhone's swipe back, held half way, moves the page without a status +/// of its own. +/// +/// [RouteArrival.moving] asks the same once; this keeps listening, for +/// what must change while the route moves — the glass held for as long as +/// it does (`GlassHoldReason.route`). +class RouteMotion { + /// Listens to [route] and to every navigator from [route]'s own (or, with + /// no route, the one above [context]) up to the root; [onChange] is told + /// on every status change and every drag start or end. + RouteMotion({ + required ModalRoute? route, + required BuildContext context, + required VoidCallback onChange, + }) : _route = route, + _onChange = onChange { + route?.animation?.addStatusListener(_status); + route?.secondaryAnimation?.addStatusListener(_status); + var navigator = + route?.navigator ?? context.findAncestorStateOfType(); + while (navigator != null) { + final gesture = navigator.userGestureInProgressNotifier; + gesture.addListener(_onChange); + _gestures.add(gesture); + navigator = navigator.context.findAncestorStateOfType(); + } + } + + final ModalRoute? _route; + final VoidCallback _onChange; + final List> _gestures = []; + + /// The route listened to. + ModalRoute? get route => _route; + + /// Whether the route is moving now. + bool get moving { + final route = _route; + if (route != null && + ((route.animation?.isAnimating ?? false) || + (route.secondaryAnimation?.isAnimating ?? false))) { + return true; + } + return _gestures.any((gesture) => gesture.value); + } + + void _status(AnimationStatus _) => _onChange(); + + /// Stops listening. + void cancel() { + _route?.animation?.removeStatusListener(_status); + _route?.secondaryAnimation?.removeStatusListener(_status); + for (final gesture in _gestures) { + gesture.removeListener(_onChange); + } + _gestures.clear(); + } +} diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index 72c4aebd..09c41bfd 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -50,6 +50,9 @@ class JsRuntimeService { window.dartFetch = function(req) { return window.flutter_inappwebview.callHandler('dartFetch', req); }; + // What this build of the player can do that older ones cannot, for the served + // scripts to read. + window.__sozoCaps = { imageWrappedSegments: true }; window.__sozoReady = true; '''; diff --git a/lib/core/localization/app_language.dart b/lib/core/localization/app_language.dart index db67d45a..b24bec49 100644 --- a/lib/core/localization/app_language.dart +++ b/lib/core/localization/app_language.dart @@ -36,6 +36,7 @@ abstract final class AppLanguage { 'fr': 'Français', 'tr': 'Türkçe', 'id': 'Bahasa Indonesia', + 'vi': 'Tiếng Việt', 'yue': '廣東話', 'ku': 'Kurdî (Kurmancî)', 'ckb': 'کوردی (سۆرانی)', @@ -55,6 +56,7 @@ abstract final class AppLanguage { 'fr': '🇫🇷', 'tr': '🇹🇷', 'id': '🇮🇩', + 'vi': '🇻🇳', '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..f23a5183 --- /dev/null +++ b/lib/core/localization/line_height.dart @@ -0,0 +1,14 @@ +import 'package:flutter/widgets.dart'; + +/// A tight [height] for one line of text, with room for Vietnamese where needed. +/// +/// A Vietnamese capital carries two marks, as in Ẳ or Ấ, and reaches about 1.03 em +/// above the baseline. 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 vietnamese = Localizations.maybeLocaleOf(context)?.languageCode == 'vi'; + return vietnamese && height < _vietnamese ? _vietnamese : height; +} + +const double _vietnamese = 1.25; 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/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..d4c6e053 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, @@ -233,6 +238,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(); } @@ -467,6 +480,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 @@ -638,6 +665,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 @@ -724,6 +767,32 @@ class _MediaKitController extends PlayerController { } 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 +831,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 +891,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 +913,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 +1025,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 +1069,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 +1217,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,6 +1253,7 @@ class _MediaKitController extends PlayerController { _player.stream.error, ], canceled: _closed.future, + pictureTimeout: _pictureTimeout, ); @override @@ -1109,10 +1342,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 +1485,7 @@ class _MediaKitController extends PlayerController { Future _disposeOnce() async { if (!_closed.isCompleted) _closed.complete(); await _teardownMpv(); + await _backendSwitches.close(); await _fallback?.dispose(); } @@ -1266,6 +1501,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/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..e3b68b08 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'; @@ -261,7 +261,9 @@ class AppRouter { path: '/player', 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( 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..f02b2f72 100644 --- a/lib/core/subtitles/subtitle_auto_translate.dart +++ b/lib/core/subtitles/subtitle_auto_translate.dart @@ -137,6 +137,7 @@ abstract final class SubtitleAutoTranslate { 'dut': 'nl', 'pol': 'pl', 'ind': 'id', + 'vie': 'vi', 'uzb': 'uz', }; @@ -158,6 +159,7 @@ abstract final class SubtitleAutoTranslate { 'pl': ['polski', 'polish'], 'uk': ['українськ', 'ukrainian'], 'id': ['indonesia', 'indonesian'], + 'vi': ['tiếng việt', 'tieng viet', 'vietnamese', 'vietsub'], 'ja': ['日本語', 'japanese'], 'ko': ['한국어', 'korean'], 'zh': ['中文', 'chinese'], diff --git a/lib/core/subtitles/subtitle_languages.dart b/lib/core/subtitles/subtitle_languages.dart index aa6240c8..474bffab 100644 --- a/lib/core/subtitles/subtitle_languages.dart +++ b/lib/core/subtitles/subtitle_languages.dart @@ -32,6 +32,7 @@ const List<(String, String)> kSubtitleTranslateLanguages = [ ('pl', 'Polski'), ('uk', 'Українська'), ('id', 'Bahasa Indonesia'), + ('vi', 'Tiếng Việt'), ('ja', '日本語'), ('ko', '한국어'), ('zh', '中文'), 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/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 605b396d..ad5b2e29 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -5,6 +5,7 @@ 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/stats/data/watch_stats_store.dart'; @@ -275,6 +276,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. 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/playback_model.dart b/lib/features/detail/data/models/playback_model.dart index bd9f831b..8813b288 100644 --- a/lib/features/detail/data/models/playback_model.dart +++ b/lib/features/detail/data/models/playback_model.dart @@ -1,5 +1,6 @@ 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,6 +21,8 @@ class PlaybackModel extends PlaybackEntity { super.total, super.totalPages, super.sort, + super.playable, + super.notice, }); factory PlaybackModel.fromJson(Map json) { @@ -49,6 +52,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 +85,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/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/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/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/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/playback_fault.dart b/lib/features/detail/domain/playback/playback_fault.dart index bdd834a2..bad2e5fe 100644 --- a/lib/features/detail/domain/playback/playback_fault.dart +++ b/lib/features/detail/domain/playback/playback_fault.dart @@ -90,6 +90,8 @@ 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); } @@ -99,12 +101,47 @@ class PlaybackFault { 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') || + 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); } + /// 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/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/services/alternate_source_service.dart b/lib/features/detail/domain/services/alternate_source_service.dart index 80ceb2c6..0d949ae1 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,7 @@ class AlternateSourceService { Future buildArgs({ required AlternateSource source, required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) async { final playback = (await _episodes( @@ -316,7 +318,12 @@ class AlternateSourceService { if (episodes.isEmpty) return null; 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 @@ -400,3 +407,25 @@ class AlternateSourceService { return null; } } + +/// 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/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/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 5934a74e..e031e61d 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'; @@ -46,7 +47,9 @@ import 'package:soplay/features/private_list/presentation/private_unlock.dart'; import 'package:share_plus/share_plus.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_hero.dart'; @@ -66,7 +69,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'; @@ -554,6 +556,10 @@ class _DetailViewState extends State<_DetailView> 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( @@ -641,6 +647,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 +682,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 +734,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 +769,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; @@ -1100,6 +1121,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,6 +1171,30 @@ 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; @@ -1320,6 +1373,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 +1539,7 @@ class _DetailViewState extends State<_DetailView> resolvingSource: widget.resolvingSource, onChangeSource: widget.via == null ? null : _changeSource, onFindSource: _onFindOtherSources, + unplayable: _unplayable, ), ), if (widget.offline) diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index af1cca41..c0cf34f1 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'; @@ -18,6 +19,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'; @@ -203,6 +205,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 @@ -253,6 +298,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; } @@ -545,7 +591,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++) @@ -962,11 +1013,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; } @@ -1248,6 +1295,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) @@ -1856,11 +1912,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 +2108,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; @@ -2152,7 +2209,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 +2225,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 @@ -2771,3 +2830,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..d1b2f130 100644 --- a/lib/features/detail/presentation/pages/player_page.aniskip.dart +++ b/lib/features/detail/presentation/pages/player_page.aniskip.dart @@ -178,6 +178,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 +217,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.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index d580c97d..6eefaaec 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -230,6 +230,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 { @@ -353,6 +359,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 +383,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,7 +392,62 @@ extension _PlayerControls on _PlayerPageState { !_isEncrypted && TorrentStreamUrl.parse(_videoUrl) == null; + 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, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + 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, @@ -741,7 +802,7 @@ extension _PlayerControls on _PlayerPageState { } Widget _buildThumbnailImage( - _VttThumbnail thumb, { + VttThumbnail thumb, { double displayWidth = 160, double displayHeight = 90, }) { @@ -764,6 +825,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 +852,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,6 +1027,24 @@ 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(); + }); + } + + void _unlock() { + if (isMobilePlatform) HapticFeedback.mediumImpact(); + setState(() => _locked = false); + } + Widget _buildLockOverlay() { return Positioned( bottom: 0, @@ -961,37 +1055,36 @@ extension _PlayerControls on _PlayerPageState { 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), + onTap: _unlock, + // 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( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.lock_rounded, color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, + size: 16, ), - ), - ], + const SizedBox(width: 8), + Text( + 'player.tap_to_unlock'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ), ), @@ -1030,153 +1123,158 @@ 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(), + ), + ], + ), ), ), ), @@ -1201,16 +1299,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,38 +1366,40 @@ 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); - }, - ), - 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, + return MediaGlassLayer( + child: 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); + }, + ), + 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: () { + _seekRelative(Duration(seconds: step)); + _showSeekRipple(1); + }, ), - ), - forward: _CenterIconButton( - icon: _forwardIconFor(step), - onTap: () { - _seekRelative(Duration(seconds: step)); - _showSeekRipple(1); - }, ), ); } @@ -1417,14 +1524,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 +1642,7 @@ extension _PlayerControls on _PlayerPageState { Widget _buildScrubPreviewCard( Duration position, { - Duration? current, + required Duration total, double? caretX, }) { final w = _previewWidth; @@ -1540,8 +1650,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, ); @@ -1659,7 +1770,7 @@ extension _PlayerControls on _PlayerPageState { bottom: 40, child: _buildScrubPreviewCard( previewPosition, - current: value.position, + total: value.duration, caretX: thumbX - left, ), ), @@ -1797,15 +1908,7 @@ 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( - icon: Icons.lock_rounded, - onTap: () => setState(() { - _locked = true; - _controlsVisible = false; - _controlsAnimation.reverse(); - _hideTimer?.cancel(); - }), - ); + return _IconButton(icon: Icons.lock_rounded, onTap: _lock); case 'stats': return _IconButton( icon: Icons.info_rounded, @@ -1886,11 +1989,15 @@ extension _PlayerControls on _PlayerPageState { 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( icon: Icons.picture_in_picture_alt_rounded, - onTap: _toggleDesktopMini, + onTap: _openMiniPlayer, + onLongPress: _toggleDesktopMini, + tooltip: 'player.mini_popout_hint'.tr(), ); } if (!isAndroidPlatform && !isIosPlatform) return null; @@ -1928,12 +2035,7 @@ extension _PlayerControls on _PlayerPageState { 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; @@ -2082,7 +2184,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( @@ -2252,9 +2356,11 @@ extension _PlayerControls on _PlayerPageState { _fittingControls(box.maxWidth, left.length), ) .toList(); - return PlayerBottomControlRow( - leading: left, - trailing: right, + return MediaGlassLayer( + child: PlayerBottomControlRow( + leading: left, + trailing: right, + ), ); }, ); diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index a38d82b3..31055dfb 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'; @@ -16,7 +17,7 @@ import 'package:soplay/features/detail/data/title_prefs_store.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'; @@ -28,6 +29,8 @@ import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; 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'; @@ -68,11 +71,13 @@ 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/theme/app_colors.dart'; +import 'package:soplay/core/glass/media_glass.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 +93,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'; @@ -221,6 +227,24 @@ 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; @@ -230,6 +254,12 @@ class _PlayerPageState extends State 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. /// @@ -486,7 +516,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 @@ -609,6 +639,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 = {}; @@ -773,6 +813,11 @@ class _PlayerPageState extends State case AppLifecycleState.inactive: case AppLifecycleState.hidden: case AppLifecycleState.paused: + // 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()); + } if (!isDesktopPlatform && c.value.isInitialized && c.value.isPlaying && @@ -788,7 +833,12 @@ 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. + if (_isPip && !_compact && mounted) { setState(() => _isPip = false); } if (_resumeAfterPause && c.value.isInitialized) { @@ -812,6 +862,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); @@ -893,6 +948,7 @@ class _PlayerPageState extends State @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 -> @@ -902,6 +958,9 @@ class _PlayerPageState extends State canPop: !isTvPlatform || _tvPopAllowed, onPopInvokedWithResult: (didPop, _) { 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; } @@ -943,7 +1002,7 @@ class _PlayerPageState extends State behavior: HitTestBehavior.opaque, onTap: _locked ? null - : _desktopMini + : _compact ? _togglePlay : _toggleControls, onDoubleTapDown: _locked || isDesktopPlatform @@ -951,6 +1010,8 @@ class _PlayerPageState extends State : (d) => _onDoubleTapDown(d, constraints), onDoubleTap: _locked ? null + : _docked + ? _expandFromDock : _desktopMini ? _toggleDesktopMini : isDesktopPlatform @@ -961,20 +1022,19 @@ class _PlayerPageState extends State // 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,6 +1043,11 @@ 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( @@ -1017,7 +1082,7 @@ class _PlayerPageState extends State if (!_locked && _panel != _SidePanel.none) _buildSidePanel(), if (!_locked && _inParty) _buildPartyReactionsLayer(), - if (_desktopMini) _buildDesktopMiniOverlay(), + if (_compact) _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 @@ -1050,14 +1115,21 @@ class _PlayerPageState extends State ); } 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 +1152,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 +1314,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 +1628,21 @@ 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, +]; diff --git a/lib/features/detail/presentation/pages/player_page.gestures.dart b/lib/features/detail/presentation/pages/player_page.gestures.dart index f60c652f..49d23109 100644 --- a/lib/features/detail/presentation/pages/player_page.gestures.dart +++ b/lib/features/detail/presentation/pages/player_page.gestures.dart @@ -14,7 +14,12 @@ extension _PlayerGestures on _PlayerPageState { } else if (dx > rightEdge) { _seekRelative(step); _showSeekRipple(1); + } else { + return; } + // The ripple is on the far side of the thumb; the tick is what says the + // double tap landed while the eyes stay on the picture. + if (isMobilePlatform) HapticFeedback.selectionClick(); } /// [initialDeltaPx]: how far the finger has already travelled from where diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 1510eddf..5ea409e3 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, @@ -337,6 +340,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; diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 8ba1fd34..3ed1ed7f 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(); @@ -275,6 +263,8 @@ extension _PlayerMedia on _PlayerPageState { _countedComplete = false; _endHandled = false; _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 +344,20 @@ 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: ', ''); + // img_2e4886 is Dio's own English sentence about a reset connection + // ("…This indicates an error which most likely cannot be solved by + // the library."), wrong about the remedy as well as in the wrong + // language. A dropped connection is said as the network fault in the + // viewer's language; a provider's own message (no mirrors, a 422) + // keeps its words. + final fault = PlaybackFault.classify(raw); + _errorMessage = fault.kind == PlaybackFaultKind.network + ? fault.messageKey.tr() + : raw; + _errorRaw = raw; }); return null; } @@ -501,13 +502,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 +640,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; + /// 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 +844,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 +894,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 +915,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 +1004,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 +1017,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 +1074,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 +1125,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 +1158,8 @@ extension _PlayerMedia on _PlayerPageState { type: effType, resumeAt: resumeAt, party: party, + sniffed: sniffedStream, + reconnect: reconnect, ); } @@ -1236,6 +1277,7 @@ extension _PlayerMedia on _PlayerPageState { localProxy: parent.localProxy, requestTransform: parent.requestTransform, longLived: true, + dropSegments: parent.dropSegments, ) : v.url; rows.add( @@ -1248,6 +1290,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 +1403,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 +1439,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 +1456,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 +1499,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' @@ -1564,8 +1615,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 || @@ -1723,7 +1796,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 +1899,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; @@ -1834,6 +1924,22 @@ extension _PlayerMedia on _PlayerPageState { 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: @@ -1885,12 +1991,36 @@ extension _PlayerMedia on _PlayerPageState { _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 @@ -1933,7 +2063,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; } @@ -2097,9 +2234,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(); @@ -2170,7 +2305,7 @@ extension _PlayerMedia on _PlayerPageState { // not the stream. See [_playSourceUrl]. url: _playSourceUrl ?? _videoUrl!, headers: _playSourceUrl != null ? _playSourceHeaders : _headers, - type: _mediaType, + type: _playSourceType, resumeAt: keepPosition, ); } else { @@ -2181,6 +2316,122 @@ extension _PlayerMedia on _PlayerPageState { if (mounted && generation == _mediaGeneration) _autoRetrying = false; } + /// 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; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('player.reconnecting'.tr()), + backgroundColor: Colors.black87, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + 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; + 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 +2557,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 +2577,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 +2622,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 +2632,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 +2665,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 +2728,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 +2745,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.models.dart b/lib/features/detail/presentation/pages/player_page.models.dart index e3778207..38662f83 100644 --- a/lib/features/detail/presentation/pages/player_page.models.dart +++ b/lib/features/detail/presentation/pages/player_page.models.dart @@ -141,97 +141,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.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index 3a23f32e..c2b6ff63 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -281,9 +281,12 @@ 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 diff --git a/lib/features/detail/presentation/pages/player_page.pip.dart b/lib/features/detail/presentation/pages/player_page.pip.dart index 31dbce4d..55912b6a 100644 --- a/lib/features/detail/presentation/pages/player_page.pip.dart +++ b/lib/features/detail/presentation/pages/player_page.pip.dart @@ -157,10 +157,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 +189,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 +211,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 +329,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'.tr(), + () => _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'.tr(), + () => _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 { diff --git a/lib/features/detail/presentation/pages/player_page.subtitles.dart b/lib/features/detail/presentation/pages/player_page.subtitles.dart index 2b5b0b06..169595e5 100644 --- a/lib/features/detail/presentation/pages/player_page.subtitles.dart +++ b/lib/features/detail/presentation/pages/player_page.subtitles.dart @@ -711,6 +711,7 @@ extension _PlayerSubtitles on _PlayerPageState { isSerial: widget.args.isSerial, season: _currentSeasonNumber(), episode: _currentEpisodeNumber(), + want: _hive.getSubtitleTranslateLang(), ); if (!ctx.mounted) return; setSheet(() { @@ -1010,6 +1011,7 @@ extension _PlayerSubtitles on _PlayerPageState { 'de': 'Deutsch', 'fr': 'Français', 'es': 'Español', + 'vi': 'Tiếng Việt', }; return map[code.toLowerCase()] ?? code.toUpperCase(); } @@ -1119,6 +1121,7 @@ extension _PlayerSubtitles on _PlayerPageState { 'dut': 'nl', 'pol': 'pl', 'ind': 'id', + 'vie': 'vi', }; return map[code]; } @@ -1136,6 +1139,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 +1206,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 +1217,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 +1371,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 diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index d0c206de..7af53132 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -264,10 +264,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 +300,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 +317,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 +330,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), + ), + ); + }, ), ), ), @@ -411,35 +504,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 +579,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 +659,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 +816,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), @@ -1768,7 +1862,6 @@ class _ScrubPreviewCard extends StatelessWidget { required this.frame, required this.width, required this.time, - required this.delta, this.segment, this.caretX, }); @@ -1778,9 +1871,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 +1907,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) 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..67897278 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -14,6 +14,7 @@ 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'; @@ -64,6 +65,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 +83,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 +94,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 +117,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 +126,7 @@ class AlternateSourceSheet extends StatefulWidget { provider: provider, category: category, episodeNumber: episodeNumber, + seasonEpisode: seasonEpisode, resumeAt: resumeAt, ); return picked is PlayerArgs ? picked : null; @@ -149,6 +158,7 @@ class AlternateSourceSheet extends StatefulWidget { required String provider, required String category, required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) { return showAdaptiveModal( @@ -163,6 +173,7 @@ class AlternateSourceSheet extends StatefulWidget { provider: provider, category: category, episodeNumber: episodeNumber, + seasonEpisode: seasonEpisode, resumeAt: resumeAt, ), ); @@ -212,6 +223,12 @@ class _AlternateSourceSheetState extends State { /// Play button that did nothing at all. ({String provider, String message})? _failed; + /// True while Auto is going down the list looking for one that plays. + bool _auto = false; + + /// Auto went through every match and none could be played from here. + bool _autoMissed = false; + /// How the run ended, so the empty state can say which kind of empty. AlternateSearchOutcome? _outcome; @@ -220,8 +237,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 = {}; @@ -377,9 +393,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; } @@ -416,6 +430,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 +451,117 @@ 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. + Future _runAuto() async { + if (_auto || _preparing != null) return; + setState(() { + _auto = true; + _autoMissed = false; + _failed = null; + }); + final service = getIt(); + final tried = {}; + while (mounted) { + _Row? next; + for (final r in _rows) { + if (tried.contains(r.provider.id)) continue; + if (r.chosen || r.source.isTrustworthy) { + next = r; + break; + } + } + if (next == null) { + if (!_searching) break; + await Future.delayed(const Duration(milliseconds: 250)); + continue; + } + tried.add(next.provider.id); + setState(() => _preparing = next!.provider.id); + final args = await service.buildArgs( + source: next.source, + episodeNumber: widget.episodeNumber, + seasonEpisode: widget.seasonEpisode, + resumeAt: widget.resumeAt, + ); + if (!mounted) return; + if (args != null) { + Navigator.of(context).pop(args); + return; + } + } + if (!mounted) return; + setState(() { + _auto = false; + _preparing = null; + _autoMissed = true; + }); + } + + /// The row that offers [_runAuto], above the matches. + Widget _autoTile() { + final subtitle = _autoMissed + ? 'player.alt_auto_missed'.tr() + : _auto && _preparing != null + ? 'player.alt_auto_trying'.tr(args: [_nameOf(_preparing!)]) + : 'player.alt_auto_hint'.tr(); + return ListTile( + onTap: _auto || _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( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _autoMissed ? AppColors.error : Colors.white54, + fontSize: 12, + ), + ), + trailing: _auto + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ) + : const Icon(Icons.play_arrow_rounded, color: Colors.white70), + ); + } + + String _nameOf(String providerId) { + for (final r in _rows) { + if (r.provider.id == providerId) return r.provider.name; + } + return providerId; + } + /// 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 @@ -532,6 +658,7 @@ class _AlternateSourceSheetState extends State { child: ListView( shrinkWrap: true, children: [ + if (!_reads && (list.isNotEmpty || _searching)) _autoTile(), if (list.isNotEmpty) _matchGrid(list), if (list.isEmpty) _emptyBlock(), if (quiet.isNotEmpty) ..._silentSection(quiet), @@ -586,151 +713,150 @@ 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, + 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), ), ], @@ -789,11 +915,7 @@ class _AlternateSourceSheetState extends State { 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), ), ), ), 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_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/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/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/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/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_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/view_all_widgets.dart b/lib/features/home/presentation/widgets/view_all_widgets.dart index 5310d870..1e910a9d 100644 --- a/lib/features/home/presentation/widgets/view_all_widgets.dart +++ b/lib/features/home/presentation/widgets/view_all_widgets.dart @@ -3,6 +3,7 @@ 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'; @@ -59,11 +60,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), ), ), ), diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index 14be2cfc..5b60b0fb 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -1,6 +1,7 @@ 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'; @@ -652,6 +653,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 +755,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 diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index 7b05a08a..8e4e3f02 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -44,6 +44,7 @@ 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/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'; @@ -1519,22 +1520,26 @@ 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, + ); + }, + ), ), ); } 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/presentation/widgets/favorite_card.dart b/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart index 3e4273dd..4a875667 100644 --- a/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart +++ b/lib/features/my_list/my_list/presentation/widgets/favorite_card.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.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/features/home/presentation/widgets/home_shared_widgets.dart'; import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; @@ -13,7 +15,9 @@ class FavoriteCard extends StatelessWidget { 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(); + final meta = description.isNotEmpty + ? description + : Catalogue.displayName(item.provider.trim()); return GestureDetector( behavior: HitTestBehavior.opaque, @@ -75,11 +79,11 @@ class FavoriteCard extends StatelessWidget { title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 12, fontWeight: FontWeight.w700, - height: 1.18, + height: lineHeightFor(context, 1.18), ), ), if (meta.isNotEmpty) ...[ 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..bbe227e6 100644 --- a/lib/features/my_list/presentation/widgets/my_list_header.dart +++ b/lib/features/my_list/presentation/widgets/my_list_header.dart @@ -6,18 +6,37 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.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({ super.key, 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. 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/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 0b65e0a8..f3a28419 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -8,6 +8,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'; diff --git a/lib/features/profile/presentation/pages/profile_page.header.dart b/lib/features/profile/presentation/pages/profile_page.header.dart index 90c1c83b..f1ea1594 100644 --- a/lib/features/profile/presentation/pages/profile_page.header.dart +++ b/lib/features/profile/presentation/pages/profile_page.header.dart @@ -198,11 +198,11 @@ class _UserContent extends StatelessWidget { children: [ Text( name, - style: const TextStyle( + style: TextStyle( color: AppColors.textPrimary, fontSize: 19, fontWeight: FontWeight.w800, - height: 1.15, + height: lineHeightFor(context, 1.15), ), maxLines: 1, overflow: TextOverflow.ellipsis, 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/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_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/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/support/data/support_repository.dart b/lib/features/support/data/support_repository.dart index d292fd25..40e4a5cd 100644 --- a/lib/features/support/data/support_repository.dart +++ b/lib/features/support/data/support_repository.dart @@ -151,6 +151,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 +159,8 @@ class SupportRepository { data: { 'message': message, if (attachments.isNotEmpty) 'attachments': attachments, + if (diagnostics != null && diagnostics.isNotEmpty) + 'diagnostics': diagnostics, 'language': ?language, 'pushToken': ?token, }, 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/main.dart b/lib/main.dart index 97451d4f..ca60d30a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -201,6 +201,7 @@ void main(List args) async { Locale('fr'), Locale('tr'), Locale('id'), + Locale('vi'), // 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 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..d3343c34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1164,6 +1164,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 +1340,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..494106ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -139,6 +139,8 @@ 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 # The TV's pairing code, painted rather than pulled in as a widget package. qr: ^3.0.2 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/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/line_height_test.dart b/test/core/line_height_test.dart new file mode 100644 index 00000000..48c15114 --- /dev/null +++ b/test/core/line_height_test.dart @@ -0,0 +1,37 @@ +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('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/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/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/subtitle_auto_translate_test.dart b/test/core/subtitle_auto_translate_test.dart index efcab1ee..73892baa 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,31 @@ 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('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/features/detail/alternate_source_sheet_test.dart b/test/features/detail/alternate_source_sheet_test.dart index 7c5b644b..72863a50 100644 --- a/test/features/detail/alternate_source_sheet_test.dart +++ b/test/features/detail/alternate_source_sheet_test.dart @@ -74,6 +74,7 @@ class _Service implements AlternateSourceService { Future<PlayerArgs?> buildArgs({ required AlternateSource source, required int? episodeNumber, + SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, }) async => null; 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/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/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/playback_fault_test.dart b/test/features/detail/playback_fault_test.dart index dda1ab68..b922e136 100644 --- a/test/features/detail/playback_fault_test.dart +++ b/test/features/detail/playback_fault_test.dart @@ -57,6 +57,29 @@ 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('anything unrecognised falls through to unknown', () { expect( PlaybackFault.classify('something nobody has seen yet').kind, @@ -103,6 +126,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(); 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<String> _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<GoRouter> _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<void> _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<void> 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 <label>:` up to the next `case` / `}` at + /// the same indent. + String caseBody(String source, String label) { + final start = source.indexOf('case $label:'); + expect(start, isNonNegative, reason: label); + final rest = source.substring(start + label.length + 6); + final end = RegExp(r'\n case |\n }').firstMatch(rest)?.start; + return rest.substring(0, end ?? rest.length); + } + + test('a desktop focus change does not undo the compact player', () { + // On the desktop `resumed` fires whenever the window gets focus back. It + // used to clear _isPip unconditionally, which drew the full control bar + // into the 426-px always-on-top window on the first click. + final resumed = caseBody( + read('player_page.dart'), + 'AppLifecycleState.resumed', + ); + expect(resumed, contains('_isPip = false')); + expect(resumed, contains('!_compact')); + }); + + test('_compact covers both small forms', () { + expect( + read('player_page.dart'), + contains('bool get _compact => _desktopMini || _docked;'), + ); + }); + + test('the small player draws its own controls in both forms', () { + final page = read('player_page.dart'); + expect(page, contains('if (_compact) _buildDesktopMiniOverlay()')); + expect(page, isNot(contains('if (_desktopMini) _buildDesktopMiniOverlay'))); + }); + + test('leaving from the corner closes it rather than popping a route', () { + final controls = read('player_page.controls.dart'); + final exit = controls.substring(controls.indexOf('void _exit() {')); + final docked = exit.indexOf('if (_docked)'); + final pop = exit.indexOf('context.canPop()'); + expect(docked, isNonNegative); + expect(pop, isNonNegative); + expect(docked, lessThan(pop)); + }); + + test('the docking pop does not release what the corner still uses', () { + final page = read('player_page.dart'); + final pop = page.substring(page.indexOf('onPopInvokedWithResult')); + expect( + pop.indexOf('if (_docked) return;'), + lessThan(pop.indexOf('_restoreSystemUi();')), + ); + }); + + test('the PiP button docks, and a long press pops the window out', () { + final controls = read('player_page.controls.dart'); + final taps = RegExp( + r'icon: Icons\.picture_in_picture_alt_rounded,\s*' + r'onTap: _openMiniPlayer,\s*' + r'onLongPress: _toggleDesktopMini,', + ).allMatches(controls); + // The desktop control row and the layout's 'pip' control. + expect(taps.length, 2); + expect(controls, isNot(contains('onTap: _toggleDesktopMini'))); + }); + + test('/player builds through PlayerRoute, which keys the page', () { + final router = File('lib/core/router/app_router.dart').readAsStringSync(); + final route = router.substring(router.indexOf("path: '/player',")); + final body = route.substring(0, route.indexOf('GoRoute(')); + expect(body, contains('PlayerRoute(args: args)')); + expect(body, isNot(contains('PlayerPage('))); + }); + + test('a disposed page is forgotten by the dock', () { + final page = read('player_page.dart'); + final dispose = page.substring(page.indexOf('void dispose() {')); + expect(dispose, contains('PlayerDock.instance.forget(')); + }); +} diff --git a/test/features/detail/player_history_cadence_test.dart b/test/features/detail/player_history_cadence_test.dart index 82a93241..6060c64b 100644 --- a/test/features/detail/player_history_cadence_test.dart +++ b/test/features/detail/player_history_cadence_test.dart @@ -48,9 +48,9 @@ void main() { // every assertion about the method silently vacuous. // The brace that opens the BODY, which is the one after the parameter // list's `)`. The signature's own `{` is the parameter list's. - final bodyOpen = RegExp(r'\)\s*(async\s*)?\{').firstMatch( - source.substring(start), - ); + final bodyOpen = RegExp( + r'\)\s*(async\s*)?\{', + ).firstMatch(source.substring(start)); final from = bodyOpen == null ? start : start + bodyOpen.end; final end = source.indexOf('\n$indent}', from); if (end == -1) { @@ -163,7 +163,9 @@ void main() { void gate(String signature) { final body = bodyOf(media, signature); - final capture = body.indexOf('_controller?.value.position'); + // Through [_retryPosition], which reads the controller and falls back + // to the last position seen while playing — gated below. + final capture = body.indexOf('_retryPosition'); final teardown = body.indexOf('_disposeController'); expect( capture, @@ -201,11 +203,24 @@ void main() { reason: 'this reload inside $signature restarts from zero:\n$call', ); } + } + test('the position a retry keeps survives the engine forgetting it', () { + final getter = bodyOf(media, 'Duration get _retryPosition'); // A live channel has no position worth returning to, and resuming one an // hour behind the edge would stall on a window the CDN has dropped. - expect(body, contains('_isLive')); - } + expect(getter, contains('_isLive')); + expect(getter, contains('_controller?.value')); + // ExoPlayer zeroes its whole value on an error + // (VideoPlayerValue.erroneous), so the controller alone sent a drop + // thirty minutes in back to 0:00. + expect(getter, contains('_lastPlayedPosition')); + expect(getter, contains('_pendingResume')); + }); + + test('the in-place reconnect after a dropped connection', () { + gate('Future<void> _reconnectInPlace()'); + }); test('the automatic retry after a network drop', () { gate('Future<void> _autoRetry()'); diff --git a/test/features/detail/retry_policy_test.dart b/test/features/detail/retry_policy_test.dart index f2a2f0e4..6427604d 100644 --- a/test/features/detail/retry_policy_test.dart +++ b/test/features/detail/retry_policy_test.dart @@ -77,6 +77,103 @@ void main() { }); }); + group('a dropped connection', () { + const drops = [ + // log_2e4886: libmpv, mid-episode and on the reconnect after it. + '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', + // img_2e4886: Dio, on the re-resolve. + 'The connection errored: Connection reset by peer', + 'Network is unreachable', + ]; + + test('is worth another attempt', () { + for (final raw in drops) { + expect(RetryPolicy.isNetworkDrop(raw), isTrue, reason: raw); + expect(RetryPolicy.isRecoverableError(raw), isTrue, reason: raw); + } + }); + + test('is not a refusal or a bad file', () { + for (final raw in const [ + 'HTTP 403 Forbidden', + 'Source error', + 'Failed to recognize file format.', + 'Decoder failed', + ]) { + expect(RetryPolicy.isNetworkDrop(raw), isFalse, reason: raw); + } + }); + + test('is reconnected in place while the budget lasts, never live', () { + const msg = 'tcp: ffurl_read returned 0xffffff92'; + for (var n = 0; n < RetryPolicy.maxNetworkReconnects; n++) { + expect( + RetryPolicy.shouldReconnectInPlace( + message: msg, + isLive: false, + reconnects: n, + ), + isTrue, + ); + } + expect( + RetryPolicy.shouldReconnectInPlace( + message: msg, + isLive: false, + reconnects: RetryPolicy.maxNetworkReconnects, + ), + isFalse, + ); + expect( + RetryPolicy.shouldReconnectInPlace( + message: msg, + isLive: true, + reconnects: 0, + ), + isFalse, + reason: 'a channel has its own reconnect loop', + ); + expect( + RetryPolicy.shouldReconnectInPlace( + message: 'HTTP 403 Forbidden', + isLive: false, + reconnects: 0, + ), + isFalse, + reason: 'a refusal goes to another mirror, not the same one again', + ); + }); + + test('earns a fresh budget once the stream has played a while', () { + expect( + RetryPolicy.networkReconnectsSpent( + reconnects: 3, + playedSinceLast: const Duration(seconds: 10), + ), + 3, + ); + expect( + RetryPolicy.networkReconnectsSpent( + reconnects: 3, + playedSinceLast: RetryPolicy.networkReconnectsRenewAfter, + ), + 0, + ); + }); + + test('backs off, and then holds', () { + expect(RetryPolicy.networkBackoff(0), const Duration(seconds: 2)); + expect(RetryPolicy.networkBackoff(1), const Duration(seconds: 4)); + expect(RetryPolicy.networkBackoff(2), const Duration(seconds: 8)); + expect(RetryPolicy.networkBackoff(9), const Duration(seconds: 8)); + expect(RetryPolicy.networkBackoff(-1), const Duration(seconds: 2)); + }); + }); + group('live is a different problem', () { test('a channel reconnects where a film would give up', () { // A film either plays or is broken. A channel drops constantly and the diff --git a/test/features/detail/source_notice_test.dart b/test/features/detail/source_notice_test.dart new file mode 100644 index 00000000..ee3ce5dc --- /dev/null +++ b/test/features/detail/source_notice_test.dart @@ -0,0 +1,293 @@ +// What the app does with a source that says, in a field, that it cannot play a +// title. Verified against production on 2026-11-02: both bodies below are the +// live ones, copied from +// GET /api/contents/episodes?provider=asilmedia&url=.../18717-apokalipsis-z-... +// GET /api/contents/media?provider=asilmedia&ref=.../18717-apokalipsis-z-... +// so the parsing here is tested against the shape that actually arrives rather +// than against a shape this test invented. +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/error/result.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/detail/data/datasources/detail_data_source.dart'; +import 'package:soplay/features/detail/data/models/playback_model.dart'; +import 'package:soplay/features/detail/data/repositories/detail_repository_impl.dart'; +import 'package:soplay/features/detail/domain/entities/detail_entity.dart'; +import 'package:soplay/features/detail/domain/entities/source_notice.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_info.dart'; +import 'package:soplay/features/extensions/data/mangayomi_bridge.dart'; +import 'package:soplay/features/history/data/history_service.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/mal/data/mal_service.dart'; +import 'package:soplay/core/trailer/trailer_query.dart'; +import 'package:soplay/core/trailer/trailer_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Translations extends AssetLoader { + const _Translations(); + @override + Future<Map<String, dynamic>> load(String path, Locale locale) async => + jsonDecode(File('assets/translations/en.json').readAsStringSync()) + as Map<String, dynamic>; +} + +class _Hive implements HiveService { + @override + String getCurrentProvider() => 'asilmedia'; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Mangayomi implements MangayomiBridge { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _History implements HistoryService { + @override + final ValueNotifier<int> revision = ValueNotifier<int>(0); + @override + HistoryItem? get(String contentUrl, {int? episodeIndex, int? episodeNumber}) => + null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// The header carries a tracking row and a trailer button, neither of which is +/// what this test is about: connected to nothing, they draw nothing. +class _Anilist implements AnilistService { + @override + bool get isConnected => false; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Mal implements MalService { + @override + bool get isConnected => false; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Trailer implements TrailerService { + @override + Future<TrailerResult?> resolveFor(TrailerQuery query) async => null; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Answers every request with [status] and [body], so the repository meets the +/// error the live API returns rather than a Dio timeout. +class _Adapter implements HttpClientAdapter { + _Adapter(this.status, this.body); + + final int status; + final Map<String, Object?> body; + + @override + Future<ResponseBody> fetch(RequestOptions options, _, _) async => + ResponseBody.fromString( + jsonEncode(body), + status, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + + @override + void close({bool force = false}) {} +} + +DetailRepositoryImpl _repo(int status, Map<String, Object?> body) { + final dio = Dio(BaseOptions(baseUrl: 'https://example.invalid/api')) + ..httpClientAdapter = _Adapter(status, body); + return DetailRepositoryImpl( + DetailDataSource(dio: dio), + hive: _Hive(), + mangayomi: _Mangayomi(), + ); +} + +const _detail = DetailEntity( + provider: 'asilmedia', + contentId: '18717', + contentUrl: 'https://asilmedia.org/18717-apokalipsis-z', + title: 'Apokalipsis Z', + description: '', + thumbnail: null, + year: null, + duration: null, + country: null, + director: null, + genres: [], + cast: [], + likes: 0, + dislikes: 0, + isSerial: false, + isFavorited: null, + screenshots: [], + related: [], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await EasyLocalization.ensureInitialized(); + }); + + group('reading the reason', () { + test('a bare code and a code at the head of a message are the same ' + 'reason', () { + // The episodes response sends the code in a field; a failed resolve has + // to carry it at the head of `message`, because the API's error handler + // serialises only that. One reader, both shapes. + expect(SourceNotice.read('SOURCE_MOVED_TO_APP'), SourceNotice.movedToApp); + expect( + SourceNotice.read( + 'SOURCE_MOVED_TO_APP: asilmedia serves this title only in its own app', + ), + SourceNotice.movedToApp, + ); + expect(SourceNotice.read('SOURCE_NO_PLAYER'), SourceNotice.noPlayer); + expect( + SourceNotice.read( + 'SOURCE_IS_SERIES: this asilmedia page is a series; ask ' + '/contents/episodes for an episode ref', + ), + SourceNotice.isSeries, + ); + }); + + test('anything else is no reason at all', () { + // Better the plain failure than a confident wrong explanation. + expect(SourceNotice.read(null), isNull); + expect(SourceNotice.read(''), isNull); + expect(SourceNotice.read('Video URL aniqlanmadi'), isNull); + expect(SourceNotice.read('SOURCE_SOMETHING_NEW: whatever'), isNull); + }); + + test('a series still plays; the two dead ends do not', () { + expect(SourceNotice.isSeries.playable, isTrue); + expect(SourceNotice.movedToApp.playable, isFalse); + expect(SourceNotice.noPlayer.playable, isFalse); + }); + }); + + test('an episodes response that says "not playable" arrives saying so', () { + final playback = PlaybackModel.fromJson({ + 'provider': 'asilmedia', + 'contentUrl': 'https://asilmedia.org/18717-apokalipsis-z', + 'isSerial': false, + 'videoSources': <Object?>[], + 'playerSrc': null, + 'playable': false, + 'playableReason': 'SOURCE_MOVED_TO_APP', + 'noticeCode': 'SOURCE_MOVED_TO_APP', + 'providerNotice': + "asilmedia: Ushbu filmni Yangi TV ilovasidan O'zbek tilida tomosha " + 'qiling.', + 'episodes': <Object?>[], + }); + expect(playback.playable, isFalse); + expect(playback.notice, SourceNotice.movedToApp); + // The ordering a sort does must not lose it: the page reads `playable` off + // whatever the repository hands back, which is the sorted copy. + expect(playback.copyWithEpisodes(const []).playable, isFalse); + expect(playback.copyWithEpisodes(const []).notice, SourceNotice.movedToApp); + }); + + test('a source that says nothing is a source with nothing wrong', () { + final playback = PlaybackModel.fromJson({ + 'provider': 'kinoshka', + 'contentUrl': 'https://example.invalid/1', + 'isSerial': false, + 'playerSrc': 'https://cdn.example.invalid/v.mp4', + 'episodes': <Object?>[], + }); + expect(playback.playable, isTrue); + expect(playback.notice, isNull); + }); + + test('a failed resolve comes back with the reason, not with "Video URL ' + 'aniqlanmadi"', () async { + final result = await _repo(422, { + 'message': 'SOURCE_MOVED_TO_APP: asilmedia serves this title only in ' + 'its own app', + 'providerNotice': 'asilmedia: Ushbu filmni Yangi TV ilovasidan ...', + }).resolveMedia(ref: 'https://asilmedia.org/18717', provider: 'asilmedia'); + + final error = (result as Failure).error; + expect(error, isA<SourceNoticeException>()); + expect((error as SourceNoticeException).notice, SourceNotice.movedToApp); + expect(error.providerNotice, contains('Yangi TV')); + }); + + test('a 422 with no reason in it is still the plain failure', () async { + final result = await _repo(422, { + 'message': 'no stream', + }).resolveMedia(ref: 'x', provider: 'asilmedia'); + final error = (result as Failure).error; + expect(error, isNot(isA<SourceNoticeException>())); + }); + + testWidgets('a title the source called unplayable offers another source ' + 'instead of Play', (tester) async { + getIt.registerSingleton<HistoryService>(_History()); + getIt.registerSingleton<AnilistService>(_Anilist()); + getIt.registerSingleton<MalService>(_Mal()); + getIt.registerSingleton<TrailerService>(_Trailer()); + addTearDown(getIt.reset); + + Future<void> pump(SourceNotice? unplayable) async { + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en')], + startLocale: const Locale('en'), + path: 'assets/translations', + assetLoader: const _Translations(), + saveLocale: false, + child: Builder( + builder: (context) => MaterialApp( + locale: context.locale, + supportedLocales: context.supportedLocales, + localizationsDelegates: context.localizationDelegates, + home: Scaffold( + body: SingleChildScrollView( + child: DetailContentHeader( + detail: _detail, + onPrimaryAction: () {}, + playButtonKey: const Key('play'), + onFindSource: () {}, + unplayable: unplayable, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + } + + await pump(null); + expect(find.text('Play'), findsOneWidget); + + await pump(SourceNotice.movedToApp); + expect(find.text('Play'), findsNothing); + expect(find.text('Find other sources'), findsOneWidget); + expect( + find.textContaining('moved this title into its own app'), + findsOneWidget, + ); + }); +} diff --git a/test/features/download/download_choices_proxy_test.dart b/test/features/download/download_choices_proxy_test.dart new file mode 100644 index 00000000..f361b93e --- /dev/null +++ b/test/features/download/download_choices_proxy_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/download_choices.dart'; +import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; + +void main() { + const proxied = 'http://127.0.0.1:40123/hls/ab12/v/master.m3u8?t=1'; + const source = VideoSourceEntity( + quality: '1080p', + videoUrl: 'https://cdn.test/v/master.m3u8?t=1', + isDefault: true, + accessible: true, + type: 'hls', + ); + + test('the local HLS proxy is recognised, the torrent stream is not', () { + expect(DownloadChoices.isLocalProxyUrl(proxied), isTrue); + expect( + DownloadChoices.isLocalProxyUrl('http://localhost:9/hls/x/a.m3u8'), + isTrue, + ); + expect( + DownloadChoices.isLocalProxyUrl('http://127.0.0.1:8090/stream/film.mkv'), + isFalse, + ); + expect( + DownloadChoices.isLocalProxyUrl('https://cdn.test/hls/x/a.m3u8'), + isFalse, + ); + }); + + test('a proxied stream offers the url the proxy fetches, not 127.0.0.1', () { + final offers = DownloadChoices.from( + sources: const [source], + currentIndex: 0, + currentUrl: proxied, + currentHeaders: const {}, + hasDirective: false, + currentUpstreamUrl: 'https://cdn.test/v/master.m3u8?t=1', + currentUpstreamHeaders: const {'Referer': 'https://site.test/'}, + ); + expect(offers, hasLength(1)); + expect(offers.single.url, 'https://cdn.test/v/master.m3u8?t=1'); + expect(offers.single.headers, {'Referer': 'https://site.test/'}); + expect(offers.single.isCurrent, isTrue); + }); + + test('without an upstream url, or when not proxied, nothing changes', () { + final bare = DownloadChoices.from( + sources: const [source], + currentIndex: 0, + currentUrl: proxied, + currentHeaders: const {}, + hasDirective: false, + ); + expect(bare.first.url, proxied); + + final direct = DownloadChoices.from( + sources: const [source], + currentIndex: 0, + currentUrl: 'https://cdn.test/signed.m3u8?sig=2', + currentHeaders: const {'Referer': 'r'}, + hasDirective: false, + currentUpstreamUrl: 'https://cdn.test/unsigned.m3u8', + ); + // A signed url is not swapped for the unsigned file it came from. + expect(direct.first.url, 'https://cdn.test/signed.m3u8?sig=2'); + expect(direct.first.headers, {'Referer': 'r'}); + }); +} diff --git a/test/features/download/download_failure_test.dart b/test/features/download/download_failure_test.dart new file mode 100644 index 00000000..955af3fd --- /dev/null +++ b/test/features/download/download_failure_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/download/domain/entities/download_failure.dart'; + +void main() { + group('DownloadFailureKind.classify', () { + test('the engines\' own not-media words are a web page', () { + expect( + DownloadFailureKind.classify( + 'not media: the server answered with a web page (text/html)', + ), + DownloadFailureKind.notMedia, + ); + // What the Android service wrote before 3.2.1. + expect( + DownloadFailureKind.classify( + 'not media: the server answered with text/html; charset=UTF-8', + ), + DownloadFailureKind.notMedia, + ); + }); + + test('a segment url ending in .html is not a web page', () { + expect( + DownloadFailureKind.classify( + 'could not fetch https://cdn.test/IgBCTApFy/content/page-3.html', + ), + isNot(DownloadFailureKind.notMedia), + ); + expect( + DownloadFailureKind.classify( + 'Connection reset while reading ' + 'https://ifhid.test/seg-2-f1-v1-a1.html', + ), + DownloadFailureKind.network, + ); + }); + + test('the other buckets are unchanged', () { + expect(DownloadFailureKind.classify('HTTP 403'), DownloadFailureKind.refused); + expect(DownloadFailureKind.classify('HTTP 404'), DownloadFailureKind.gone); + expect( + DownloadFailureKind.classify('Read timed out'), + DownloadFailureKind.network, + ); + expect( + DownloadFailureKind.classify('incomplete: 10 of 20 bytes'), + DownloadFailureKind.incomplete, + ); + expect(DownloadFailureKind.classify(''), DownloadFailureKind.unknown); + }); + }); +} diff --git a/test/features/download/download_request_builder_test.dart b/test/features/download/download_request_builder_test.dart index 5b8820f1..811341c8 100644 --- a/test/features/download/download_request_builder_test.dart +++ b/test/features/download/download_request_builder_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/detail/domain/entities/episode_entity.dart'; +import 'package:soplay/features/detail/domain/entities/extractor_config_entity.dart'; import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; import 'package:soplay/features/detail/domain/entities/subtitle_entity.dart'; import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; @@ -275,6 +276,142 @@ void main() { expect(r.imageHeaders.last, isEmpty); }); + group('a plain direct result is probed at tap time', () { + DownloadRequestBuilder probed( + MediaResolveEntity media, + Future<bool> Function(MediaResolveEntity) servesWebPage, + ) => DownloadRequestBuilder( + resolve: _Resolve(Success(media)), + getPages: _Pages(Failure(Exception('unused'))), + servesWebPage: servesWebPage, + ); + + const film = MediaResolveEntity( + videoUrl: 'https://files.test/film.mp4', + headers: {}, + type: 'mp4', + ); + + test('a web page fails as webPage before anything is queued', () async { + final asked = <String>[]; + final built = await probed(film, (m) async { + asked.add(m.videoUrl); + return true; + }).quietVideo(_title, _ep); + expect(built.request, isNull); + expect(built.failure, DownloadBuildFailure.webPage); + expect(asked, ['https://files.test/film.mp4']); + }); + + test('the mirror asked is the one a download takes by default', () async { + final asked = <String>[]; + final built = await probed( + MediaResolveEntity( + videoUrl: 'https://cdn/first.m3u8', + headers: const {}, + type: 'hls', + videoSources: [_source('360p', 360), _source('1080p', 1080)], + ), + (m) async { + asked.add('${m.videoUrl} ${m.headers['Referer']}'); + return false; + }, + ).quietVideo(_title, _ep); + expect(asked, ['https://cdn/1080.m3u8 r1080']); + expect(built.request!.sourceUrl, 'https://cdn/1080.m3u8'); + }); + + test( + 'a page on the default mirror falls back to one that is not', + () async { + final built = await probed( + MediaResolveEntity( + videoUrl: 'https://cdn/first.m3u8', + headers: const {}, + type: 'hls', + videoSources: [_source('360p', 360), _source('1080p', 1080)], + ), + (m) async => m.videoUrl.contains('1080'), + ).quietVideo(_title, _ep); + expect(built.failure, isNull); + expect(built.request!.sourceUrl, 'https://cdn/360.m3u8'); + }, + ); + + test('the list fails only when every mirror is a page', () async { + final result = await probed( + MediaResolveEntity( + videoUrl: 'https://cdn/first.m3u8', + headers: const {}, + type: 'hls', + videoSources: [_source('360p', 360), _source('1080p', 1080)], + ), + (_) async => true, + ).resolveVideo(_ep, provider: 'p'); + expect(result.failure, DownloadBuildFailure.webPage); + }); + + test('anything short of a page queues as before', () async { + final built = await probed( + film, + (_) async => false, + ).quietVideo(_title, _ep); + expect(built.request!.sourceUrl, 'https://files.test/film.mp4'); + }); + + test('a probe that throws is no answer, and does not block', () async { + final built = await probed( + film, + (_) async => throw Exception('socket closed'), + ).quietVideo(_title, _ep); + expect(built.request!.sourceUrl, 'https://files.test/film.mp4'); + }); + }); + + test('a sibling of unknown shape under a directive is sniffed, not taken as ' + 'direct', () async { + const directive = ExtractorConfigEntity( + hostPattern: 'embed.test', + urlPatterns: ['.m3u8'], + ); + final sniffed = <String>[]; + final builder = DownloadRequestBuilder( + resolve: _Resolve(Failure(Exception('unused'))), + getPages: _Pages(Failure(Exception('unused'))), + canDownload: (m) async => true, + resolveEmbed: (m) async { + sniffed.add(m.videoUrl); + expect(m.extractor, same(directive)); + // The default page finds nothing; the sibling is the way in. + if (m.videoUrl.endsWith('/1')) return null; + return const MediaResolveEntity( + videoUrl: 'https://cdn.test/sniffed.m3u8', + headers: {}, + type: 'hls', + ); + }, + ); + final result = await builder.prepareVideo( + const MediaResolveEntity( + videoUrl: 'https://embed.test/e/1', + headers: {}, + type: 'iframe', + extractor: directive, + videoSources: [ + VideoSourceEntity( + quality: 'mirror 2', + videoUrl: 'https://embed.test/e/2', + isDefault: false, + accessible: true, + ), + ], + ), + ); + // It used to be handed to the downloader as the video itself. + expect(sniffed, ['https://embed.test/e/1', 'https://embed.test/e/2']); + expect(result.media!.videoUrl, 'https://cdn.test/sniffed.m3u8'); + }); + test('a chapter whose pages fail is pagesFailed', () async { final built = await _builder().chapter(_title, _ep); expect(built.failure, DownloadBuildFailure.pagesFailed); diff --git a/test/features/download/transfer_engine_test.dart b/test/features/download/transfer_engine_test.dart index ef609ef6..cfd7eafc 100644 --- a/test/features/download/transfer_engine_test.dart +++ b/test/features/download/transfer_engine_test.dart @@ -7,6 +7,7 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:soplay/features/download/data/datasources/download_transfer_data_source.dart'; +import 'package:soplay/features/download/domain/entities/download_failure.dart'; import 'package:soplay/features/download/domain/entities/download_kind.dart'; void main() { @@ -257,6 +258,82 @@ video/360.m3u8 }, ); + test('TS segments labelled text/html, as vidapi and anikoto serve them, ' + 'are saved', () async { + handle = (r) async { + if (r.uri.path.endsWith('.m3u8')) { + r.response.write( + '#EXTM3U\n#EXTINF:4,\npage-0.html\n#EXTINF:4,\nseg-1-f1.html\n', + ); + } else { + r.response.headers.contentType = ContentType( + 'text', + 'html', + charset: 'UTF-8', + ); + r.response.add([0x47, 0x40, 0x00, 0x30, ...List.filled(184, 0xff)]); + } + await r.response.close(); + }; + final result = await run(DownloadKind.hls, '$base/index.m3u8'); + expect(result.ok, isTrue, reason: result.detail); + expect(File('${dir.path}/seg_0.ts').readAsBytesSync().take(2), [ + 0x47, + 0x40, + ]); + expect(File('${dir.path}/seg_1.ts').lengthSync(), 188); + }); + + test('a segment that is a real web page fails once, not three times, ' + 'whatever its label', () async { + handle = (r) async { + if (r.uri.path.endsWith('.m3u8')) { + r.response.write('#EXTM3U\n#EXTINF:4,\nchallenge.ts\n'); + } else { + r.response.headers.contentType = ContentType('video', 'mp2t'); + // The markup arrives after a lone newline, in a chunk of its own. + r.response.bufferOutput = false; + r.response.write('\n'); + await r.response.flush(); + await Future<void>.delayed(const Duration(milliseconds: 20)); + r.response.write('<!DOCTYPE html><html>Just a moment</html>'); + } + await r.response.close(); + }; + final result = await run(DownloadKind.hls, '$base/index.m3u8'); + expect(result.ok, isFalse); + expect(result.failure, DownloadFailureKind.notMedia); + expect(result.detail, startsWith('not media')); + expect(result.detail, contains('video/mp2t')); + expect(hits.where((h) => h == '/challenge.ts'), hasLength(1)); + expect(File('${dir.path}/seg_0.ts').existsSync(), isFalse); + }); + + test('a direct file is judged by its bytes, not its label', () async { + final mp4 = [0, 0, 0, 24, ...'ftypmp42'.codeUnits, ...List.filled(500, 7)]; + handle = (r) async { + if (r.uri.path == '/page.mp4') { + r.response.headers.contentType = ContentType('video', 'mp4'); + r.response.write('<html><body>File not found</body></html>'); + } else { + r.response.headers.contentType = ContentType('text', 'plain'); + r.response.add(mp4); + } + await r.response.close(); + }; + final saved = await run(DownloadKind.video, '$base/film.mp4'); + expect(saved.ok, isTrue, reason: saved.detail); + expect(File(saved.artefactPath).readAsBytesSync(), mp4); + + await dir.delete(recursive: true); + await dir.create(); + final page = await run(DownloadKind.video, '$base/page.mp4'); + expect(page.failure, DownloadFailureKind.notMedia); + expect(hits.where((h) => h == '/page.mp4'), hasLength(1)); + expect(File('${dir.path}/video.mp4.part').existsSync(), isFalse); + expect(File('${dir.path}/video.mp4').existsSync(), isFalse); + }); + test('a file cut off midway resumes from its bytes', () async { final body = Uint8List.fromList(List.generate(40000, (i) => i % 256)); var calls = 0; diff --git a/test/features/download/transfer_headers_test.dart b/test/features/download/transfer_headers_test.dart new file mode 100644 index 00000000..e80a0de0 --- /dev/null +++ b/test/features/download/transfer_headers_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/network/user_agent.dart'; +import 'package:soplay/features/download/data/repositories/download_repository_impl.dart'; +import 'package:soplay/features/download/domain/entities/download_item.dart'; +import 'package:soplay/features/download/domain/entities/download_kind.dart'; + +void main() { + DownloadItem item({ + required DownloadKind kind, + String url = 'https://cdn.example.net/v/index.m3u8', + Map<String, String> headers = const {}, + }) => DownloadItem( + id: 'id', + contentUrl: '/title', + provider: 'anikoto', + title: 'Title', + sourceUrl: url, + relativePath: 'downloads/id/index.m3u8', + createdAt: 1, + kind: kind, + headers: headers, + ); + + test('a video transfer carries the agent the player plays it with', () { + for (final kind in [DownloadKind.hls, DownloadKind.video]) { + final row = item( + kind: kind, + headers: const {'Referer': 'https://megaplay.example/'}, + ); + final headers = DownloadRepositoryImpl.transferHeaders(row); + expect(headers['User-Agent'], kSozoUserAgent); + expect(headers['Referer'], 'https://megaplay.example/'); + expect(headers['Sec-Fetch-Site'], 'cross-site'); + // Computed for the request, not written back to the row. + expect(row.headers, {'Referer': 'https://megaplay.example/'}); + } + }); + + test('an agent the source chose is kept', () { + final headers = DownloadRepositoryImpl.transferHeaders( + item(kind: DownloadKind.video, headers: const {'User-Agent': 'CS/1.0'}), + ); + expect(headers['User-Agent'], 'CS/1.0'); + }); + + test('chapters and loopback urls keep their own headers', () { + const own = {'Referer': 'https://reader.example/'}; + expect( + DownloadRepositoryImpl.transferHeaders( + item(kind: DownloadKind.manga, headers: own), + ), + own, + ); + expect( + DownloadRepositoryImpl.transferHeaders( + item( + kind: DownloadKind.hls, + url: 'http://127.0.0.1:4100/p/t/https/cdn/x.m3u8', + headers: own, + ), + ), + own, + ); + }); +} diff --git a/test/features/manga/pinch_zoom_scroll_test.dart b/test/features/manga/pinch_zoom_scroll_test.dart new file mode 100644 index 00000000..e377ffb4 --- /dev/null +++ b/test/features/manga/pinch_zoom_scroll_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/manga/presentation/widgets/pinch_zoom_scroll.dart'; + +void main() { + final controller = ScrollController(); + + Widget reader() => MaterialApp( + home: PinchZoomScroll( + builder: (context, physics) => ListView.builder( + controller: controller, + physics: physics, + itemCount: 50, + itemBuilder: (context, i) => SizedBox(height: 400, child: Text('$i')), + ), + ), + ); + + double scaleOf(WidgetTester tester) => tester + .widget<InteractiveViewer>(find.byType(InteractiveViewer)) + .transformationController! + .value + .getMaxScaleOnAxis(); + + testWidgets('one finger scrolls the pages', (tester) async { + await tester.pumpWidget(reader()); + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(controller.offset, greaterThan(200)); + }); + + testWidgets('a pinch zooms, even with its fingers moving up and down', ( + tester, + ) async { + await tester.pumpWidget(reader()); + final start = controller.offset; + final top = await tester.createGesture(pointer: 1); + final bottom = await tester.createGesture(pointer: 2); + await top.down(const Offset(200, 280)); + await bottom.down(const Offset(200, 320)); + await tester.pump(); + for (var i = 0; i < 10; i++) { + await top.moveBy(const Offset(0, -10)); + await bottom.moveBy(const Offset(0, 10)); + await tester.pump(); + } + await top.up(); + await bottom.up(); + await tester.pumpAndSettle(); + expect(scaleOf(tester), greaterThan(1.5)); + expect(controller.offset, start); + }); +} diff --git a/test/features/mobile_ux_test.dart b/test/features/mobile_ux_test.dart index 2785c5e1..714d576f 100644 --- a/test/features/mobile_ux_test.dart +++ b/test/features/mobile_ux_test.dart @@ -7,6 +7,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:showcaseview/showcaseview.dart'; import 'package:soplay/core/localization/language_picker.dart'; import 'package:soplay/core/content/content_mode.dart'; import 'package:soplay/core/navigation/app_tab.dart'; @@ -97,6 +98,7 @@ void main() { Locale('fr'), Locale('tr'), Locale('id'), + Locale('vi'), ], startLocale: Locale(language), fallbackLocale: const Locale('en'), @@ -429,6 +431,43 @@ void main() { expect(selected, 5); }); + testWidgets('capsule bar opens the tab under the finger in Arabic', ( + tester, + ) async { + final items = [for (final id in kDefaultTabs) kTabRegistry[id]!]; + // The Shorts slot anchors the main page's coach mark. + ShowcaseView.register(scope: 'main-nav'); + addTearDown(() => ShowcaseView.getNamed('main-nav').unregister()); + for (final language in ['en', 'ar']) { + final opened = <int>[]; + await pump( + tester, + Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: glassCapsuleForTest( + index: 0, + items: items, + onTabSelected: opened.add, + ), + ), + ), + language: language, + ); + await tester.pumpAndSettle(); + for (var i = 0; i < items.length; i++) { + final label = find.text(items[i].labelKey.tr()).first; + await tester.tap(label); + await tester.pumpAndSettle(); + expect(opened.last, i, reason: '$language: ${items[i].id}'); + } + // Home leads: on the left in English, on the right in Arabic. + final home = tester.getCenter(find.text(items.first.labelKey.tr()).first); + final end = tester.getCenter(find.text(items.last.labelKey.tr()).first); + expect(home.dx < end.dx, language == 'en', reason: language); + } + }); + testWidgets( 'HLS download offers actual renditions and keeps the chosen URL', (tester) async { diff --git a/test/features/sources/source_check_clock_test.dart b/test/features/sources/source_check_clock_test.dart new file mode 100644 index 00000000..5d24f263 --- /dev/null +++ b/test/features/sources/source_check_clock_test.dart @@ -0,0 +1,87 @@ +// A phone whose clock is wrong must not lose what it knows. +// +// Every rule in the check table is about elapsed time, so a clock set forward — +// for an app, or because the device booted before it found the time — stamps +// every verdict in the future. Those rows are hidden while the clock is out, +// which is right. Deleting them is not: the correction comes minutes later and +// the table is gone for good. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/features/sources/data/source_check_store.dart'; + +void main() { + late Box box; + var clock = 0; + + setUpAll(() async { + Hive.init('${Directory.systemTemp.path}/sozo_source_check_clock_test'); + box = await Hive.openBox('source_check_clock_test'); + }); + tearDownAll(Hive.close); + + setUp(() async { + await box.clear(); + clock = DateTime.now().millisecondsSinceEpoch; + }); + + SourceCheckStore store() => SourceCheckStore(box: box, now: () => clock); + + test('a clock set forward hides the check table and then gives it back', () async { + final written = store(); + for (final id in ['mn:1', 'mn:2', 'mn:3']) { + await written.put(id, SourceCheck(verdict: SourceVerdict.alive, at: clock)); + } + expect(written.of('mn:1')?.verdict, SourceVerdict.alive); + + // The clock jumps back two hours — every row is now stamped in the future. + clock -= const Duration(hours: 2).inMilliseconds; + final shifted = store(); + expect( + shifted.of('mn:1'), + isNull, + reason: 'a verdict dated in the future is not shown', + ); + + // Any write at all rewrites the whole table. This is where three hundred + // verdicts used to be deleted. + await shifted.put( + 'mn:9', + SourceCheck(verdict: SourceVerdict.alive, at: clock), + ); + + // The clock is corrected. + clock += const Duration(hours: 2).inMilliseconds; + final corrected = store(); + for (final id in ['mn:1', 'mn:2', 'mn:3']) { + expect( + corrected.of(id)?.verdict, + SourceVerdict.alive, + reason: '$id survived the write made under the wrong clock', + ); + } + // And from disk, not only from the copy in memory. + expect( + (box.get('source_checks') as Map).keys.map((k) => '$k').toSet(), + {'mn:1', 'mn:2', 'mn:3', 'mn:9'}, + ); + }); + + test('a verdict older than a week is still dropped on the way out', () async { + final s = store(); + await s.put( + 'mn:old', + SourceCheck( + verdict: SourceVerdict.dead, + at: clock - SourceCheckStore.staleAfter.inMilliseconds - 1, + ), + ); + await s.put('mn:new', SourceCheck(verdict: SourceVerdict.alive, at: clock)); + expect( + (box.get('source_checks') as Map).keys.map((k) => '$k').toSet(), + {'mn:new'}, + reason: 'the table stays the size of what was checked this week', + ); + }); +} diff --git a/test/features/sources/source_check_service_test.dart b/test/features/sources/source_check_service_test.dart index 16430edc..21967cfe 100644 --- a/test/features/sources/source_check_service_test.dart +++ b/test/features/sources/source_check_service_test.dart @@ -33,8 +33,11 @@ void main() { setUp(() async { await box.clear(); - store = SourceCheckStore(box: box); clock = DateTime.now().millisecondsSinceEpoch; + // The store reads the same clock the service writes with, so a test that + // moves time forward does not leave the store judging the age of a + // verdict stamped in its future. + store = SourceCheckStore(box: box, now: () => clock); }); SourceCheckService service(Map<String, List<SourceAttempt>> script) { @@ -85,6 +88,44 @@ void main() { expect((await s.check('old')).verdict, SourceVerdict.outdated); }); + test('a catalogue with nothing in it is not a working source', () async { + // kisskh, measured against the live API: `/contents/home` answers 200 + // with an empty `sections` and no banner, because the site shut the + // backend out and the backend reported what it got. Zero items is not + // health, and the check run has to name it. + final s = service({ + 'kisskh': [const SourceAttempt(items: 0, ms: 240, empty: true)], + }); + expect((await s.check('kisskh')).verdict, SourceVerdict.empty); + expect(store.of('kisskh')!.isBad, isTrue); + }); + + test('an answer with nothing and no reason given does not throw', () async { + // An attempt built without the empty flag used to reach `failure!`. + final s = service({ + 'a': [const SourceAttempt(items: 0, ms: 10)], + }); + expect((await s.check('a')).verdict, SourceVerdict.empty); + }); + + test('an empty answer does not wipe the failures before it', () async { + final s = service({ + 'a': [ + fail('HTTP error 500'), + const SourceAttempt(items: 0, ms: 50, empty: true), + fail('HTTP error 500'), + ], + }); + expect((await s.check('a')).verdict, SourceVerdict.failing); + final empty = await s.check('a'); + expect(empty.verdict, SourceVerdict.empty); + expect(empty.fails, 1); + clock += const Duration(minutes: 11).inMilliseconds; + // Second real failure, eleven minutes after the first: the streak ran + // through the empty answer rather than starting over, so this settles it. + expect((await s.check('a')).verdict, SourceVerdict.dead); + }); + test('an answer clears the record', () async { final s = service({ 'a': [fail('HTTP error 404'), fail('HTTP error 404'), ok()], @@ -229,4 +270,38 @@ void main() { }, ); }); + group('how long a verdict lasts', () { + test('past the stale window it is not shown, and the table drops it', + () async { + await store.put('a', SourceCheck(verdict: SourceVerdict.dead, at: clock)); + expect(store.verdictOf('a'), SourceVerdict.dead); + clock += (SourceCheckStore.staleAfter + const Duration(hours: 1)) + .inMilliseconds; + expect(store.of('a'), isNull); + expect(store.verdictOf('a'), SourceVerdict.unknown); + // And the next write takes it out of the stored table, so it is not + // carried and re-serialised for ever. + await store.put('b', SourceCheck(verdict: SourceVerdict.alive, at: clock)); + expect((box.get('source_checks') as Map).keys, ['b']); + }); + + test('a verdict stamped in the future is not shown either', () async { + await store.put( + 'a', + SourceCheck( + verdict: SourceVerdict.dead, + at: clock + const Duration(days: 2).inMilliseconds, + ), + ); + expect(store.of('a'), isNull); + }); + + test('a stale verdict is re-checked rather than trusted', () async { + await store.put('a', SourceCheck(verdict: SourceVerdict.alive, at: clock)); + clock += (SourceCheckStore.staleAfter + const Duration(hours: 1)) + .inMilliseconds; + final s = service({}); + expect(s.staleOf(['a']), ['a']); + }); + }); } diff --git a/test/features/sources/source_health_ui_test.dart b/test/features/sources/source_health_ui_test.dart index 28d32003..05d63169 100644 --- a/test/features/sources/source_health_ui_test.dart +++ b/test/features/sources/source_health_ui_test.dart @@ -214,6 +214,79 @@ void main() { expect(find.text('Down'), findsOneWidget); }); + // Before the test below, which leaves a verdict of its own in the shared + // check store: this one needs a hub where nothing has been checked on the + // device, so the server's sweep is the only witness. + testWidgets('a sweep that could not reach a site is no verdict on a source ' + 'that runs here, and the hide count is what hiding removes', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch; + await Hive.box(AppConstants.settingsBox).put( + 'search_source_health_remote', + { + 'fetchedAt': now, + 'checkedAt': now, + 'down': <String>[], + 'extCheckedAt': now, + 'ext': { + // The domain does not resolve, which it does not resolve from + // anywhere: that one stands. + 'mn:1': {'s': 'dead', 'r': 'dns'}, + // Never reached from the datacenter. A Manga source talks to its + // site from the handset, so this says nothing about this phone. + 'mn:2': {'s': 'dead', 'r': 'timeout'}, + // Down, and the one in use: shown, but never hidden. + 'mn:3': {'s': 'dead', 'r': 'dns'}, + }, + }, + ); + await pump(tester, const SourcesHubPage()); + + expect(find.text('Down'), findsNWidgets(2)); + expect(find.text('Beta Walled'), findsOneWidget); + // Two rows are down and one of them is the source in use, so the switch + // can only take one away — and that is the number it offers. + expect(find.text('Hide down sources · 1'), findsOneWidget); + + await tester.tap(find.text('Hide down sources · 1')); + await tester.pumpAndSettle(); + expect(find.text('Alpha Dead'), findsNothing); + expect(find.text('Beta Walled'), findsOneWidget); + expect(find.text('Gamma Fine'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + // Also before that test, for the same reason: this one needs the server to + // be the only witness. + testWidgets('a server error says so, with the status the sweep recorded', ( + tester, + ) async { + // Twenty-two sources in the live report are down with a numbered 5xx — + // `http_503`, `http_521`, `http_530` — and the sentence written for them + // is keyed by the family. Without the fold they badged "Down" and then + // explained themselves with the fallback for a reason nobody knew. + await Hive.box(AppConstants.settingsBox).put('search_source_health_remote', { + 'fetchedAt': DateTime.now().millisecondsSinceEpoch, + 'checkedAt': DateTime.now().millisecondsSinceEpoch, + 'down': <String>[], + 'extCheckedAt': DateTime.now().millisecondsSinceEpoch, + 'ext': { + 'mn:1': {'s': 'dead', 'r': 'http_503'}, + }, + }); + await pump(tester, const SourcesHubPage()); + + expect(find.text('Down'), findsOneWidget); + await tester.tap(find.text('Down')); + await tester.pumpAndSettle(); + expect(find.text('The site is returning server errors'), findsOneWidget); + expect( + find.text('The last server check found it failing'), + findsNothing, + ); + }); + testWidgets('a source this phone found dead, in use, badges and explains', ( tester, ) async { @@ -258,4 +331,53 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); + + testWidgets('an empty catalogue says so on the row, and the pill explains ' + 'itself', (tester) async { + // A 200 with an empty list is the commonest shape of a site that has shut + // the prober out — kisskh answers the backend exactly that way. The row + // already survived the problems-only filter; what it lacked was anything + // saying why it was in there. + final now = DateTime.now().millisecondsSinceEpoch; + await Hive.box(AppConstants.settingsBox).put( + 'search_source_health_remote', + { + 'fetchedAt': now, + 'checkedAt': now, + 'down': <String>[], + 'extCheckedAt': now, + 'ext': const <String, dynamic>{}, + }, + ); + await SourceCheckStore.shared.put( + 'mn:3', + SourceCheck(verdict: SourceVerdict.empty, at: now, ms: 420), + ); + addTearDown( + () => SourceCheckStore.shared.put( + 'mn:3', + SourceCheck(verdict: SourceVerdict.alive, at: now), + ), + ); + await pump(tester, const SourcesHubPage()); + + expect(find.text('Empty'), findsOneWidget); + expect( + find.text('Down'), + findsNothing, + reason: 'an empty answer is not a dead site, and must not claim to be', + ); + + await tester.tap(find.text('Empty')); + await tester.pumpAndSettle(); + expect( + find.textContaining('its catalogue came back empty'), + findsOneWidget, + ); + // The sheet is the row's own check sheet — the source named, the verdict, + // and (with the check service registered, which this harness does not do) + // the button to ask again. + expect(find.text('Gamma Fine'), findsWidgets); + expect(tester.takeException(), isNull); + }); } diff --git a/test/features/sources/source_verdict_policy_test.dart b/test/features/sources/source_verdict_policy_test.dart new file mode 100644 index 00000000..d20a64e6 --- /dev/null +++ b/test/features/sources/source_verdict_policy_test.dart @@ -0,0 +1,232 @@ +// Which verdict a row is entitled to: a datacenter cannot speak for a source +// that runs on the handset, and the hide-down count is the count of what hiding +// takes away. +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/features/search/data/source_health_store.dart'; +import 'package:soplay/features/sources/data/source_check_store.dart'; +import 'package:soplay/features/sources/domain/source_verdict_policy.dart'; + +RemoteVerdict dead(String reason) => + RemoteVerdict(state: RemoteHealth.dead, reason: reason); + +void main() { + group('who may call a source down', () { + test('a sweep that could not reach the site says nothing about a ' + 'source that runs on the phone', () { + for (final reason in ['timeout', 'unreachable', 'tls', 'slow_response']) { + expect(shownVerdict('an:1', dead(reason)), isNull, reason: reason); + expect(shownVerdict('cs:1', dead(reason)), isNull, reason: reason); + expect(shownVerdict('my:1', dead(reason)), isNull, reason: reason); + expect(shownVerdict('mn:1', dead(reason)), isNull, reason: reason); + expect(shownVerdict('jf:1', dead(reason)), isNull, reason: reason); + } + }); + + test('the same answer stands for a Sozo source, which only ever reaches ' + 'the app through that same backend', () { + for (final reason in ['timeout', 'unreachable', 'tls']) { + expect(shownVerdict('kisskh', dead(reason))?.state, RemoteHealth.dead); + } + }); + + test('a fact about the domain or the site itself stands wherever it was ' + 'found', () { + for (final reason in [ + 'dns', + 'parked', + 'maintainer_down', + 'http_503', + 'http_5xx', + ]) { + expect( + shownVerdict('mn:1', dead(reason))?.state, + RemoteHealth.dead, + reason: reason, + ); + } + }); + + test('a bot wall is not a down verdict and is left alone', () { + final wall = const RemoteVerdict( + state: RemoteHealth.cloudflare, + reason: 'blocked', + ); + expect(shownVerdict('mn:1', wall)?.state, RemoteHealth.cloudflare); + }); + + test('this phone own answer is never second-guessed', () { + final here = const RemoteVerdict( + state: RemoteHealth.dead, + reason: 'timeout', + onDevice: true, + ); + expect(shownVerdict('mn:1', here)?.onDevice, isTrue); + }); + + test('nothing known is no badge', () { + expect(shownVerdict('mn:1', null), isNull); + }); + }); + + group('what counts as a problem', () { + test('an empty catalogue is not health', () { + expect(isProblemVerdict(SourceVerdict.empty), isTrue); + expect(isProblemVerdict(SourceVerdict.failing), isTrue); + expect(isProblemVerdict(SourceVerdict.dead), isTrue); + expect(isProblemVerdict(SourceVerdict.blocked), isTrue); + expect(isProblemVerdict(SourceVerdict.outdated), isTrue); + expect(isProblemVerdict(SourceVerdict.alive), isFalse); + expect(isProblemVerdict(SourceVerdict.unknown), isFalse); + }); + }); + + group('the whole way through the store', _endToEnd); + + group('hiding down sources', () { + final rows = ['a', 'down1', 'b', 'down2', 'current']; + bool isDown(String id) => id.startsWith('down') || id == 'current'; + + test('the count is the number of rows hiding removes', () { + final hidden = partitionDown( + rows, + isDown: isDown, + keep: (r) => r == 'current', + hide: true, + ); + expect(hidden.rows, ['a', 'b', 'current']); + expect(hidden.hidable, rows.length - hidden.rows.length); + expect(hidden.hidable, 2); + }); + + test('and is the same before the switch is flipped', () { + final shown = partitionDown( + rows, + isDown: isDown, + keep: (r) => r == 'current', + ); + expect(shown.hidable, 2); + // Sunk, not gone: the down ones are last and the order within each half + // is the one they arrived in. + expect(shown.rows, ['a', 'b', 'down1', 'down2', 'current']); + }); + + test('a list with nothing down is handed back untouched', () { + final untouched = partitionDown(rows, isDown: (_) => false, hide: true); + expect(identical(untouched.rows, rows), isTrue); + expect(untouched.hidable, 0); + }); + }); +} + +/// The same question asked of the real store rather than of the policy alone: +/// the report the backend actually serves, through `refreshRemote`, out again +/// as the verdict a row is handed. The reason string is the whole hinge of the +/// policy, and it only reaches a badge if the store keeps it — so prove the +/// path end to end rather than the two halves separately. +void _endToEnd() { + late Directory dir; + + setUpAll(() async { + dir = await Directory.systemTemp.createTemp('sozo_verdict_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.settingsBox); + }); + + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + test('the live report, through the store, loses only the verdicts that ' + 'were never about this phone', () async { + // Shaped exactly as `/api/contents/providers/health` answers today: an + // ISO date, `sources` empty, and each extension carrying `state`, `cf` + // and the reason the sweep recorded. The four ids below are the four + // cases the live payload is made of. + final store = SourceHealthStore( + remote: () async => { + 'checkedAt': null, + 'sources': const <String, dynamic>{}, + 'extCheckedAt': DateTime.now().toUtc().toIso8601String(), + 'extensions': const { + 'my:1': {'state': 'dead', 'cf': false, 'reason': 'timeout'}, + 'an:2': {'state': 'dead', 'cf': false, 'reason': 'tls'}, + 'mn:3': {'state': 'dead', 'cf': false, 'reason': 'dns'}, + 'mn:4': {'state': 'dead', 'cf': false, 'reason': 'http_503'}, + 'kisskh': {'state': 'dead', 'cf': false, 'reason': 'unreachable'}, + }, + }, + ); + await store.refreshRemote(); + + // What the store found, before the policy has had a look: all five down. + expect( + [ + for (final id in ['my:1', 'an:2', 'mn:3', 'mn:4', 'kisskh']) + store.badgeOf(id)?.state, + ], + List.filled(5, RemoteHealth.dead), + ); + + // And what a row is entitled to show. The two the datacenter could not + // reach run on the handset, so nothing has been established about them; + // the Sozo source reaches the app through that same backend, so its + // answer stands. + expect(shownVerdict('my:1', store.badgeOf('my:1')), isNull); + expect(shownVerdict('an:2', store.badgeOf('an:2')), isNull); + expect(shownVerdict('mn:3', store.badgeOf('mn:3'))?.reason, 'dns'); + expect(shownVerdict('mn:4', store.badgeOf('mn:4'))?.reason, 'http_503'); + expect( + shownVerdict('kisskh', store.badgeOf('kisskh'))?.state, + RemoteHealth.dead, + ); + }); + + group('a verdict this phone reached itself', () { + test('an empty catalogue gets a badge of its own, because the server\'s ' + 'cannot say "empty"', () { + // kisskh answers the backend 200 with an empty list, which + // `isProblemVerdict` counts — so the row survives the problems-only + // filter — and which `badgeOf` has no vocabulary for, so the row used to + // sit there with nothing on it explaining why. + expect(deviceBadge(SourceVerdict.empty, null), SourceVerdict.empty); + expect(deviceBadge(SourceVerdict.failing, null), SourceVerdict.failing); + }); + + test('a working source, and one nobody has asked, get nothing', () { + expect(deviceBadge(SourceVerdict.alive, null), isNull); + expect(deviceBadge(SourceVerdict.unknown, null), isNull); + }); + + test('nothing is added beside a badge that already speaks', () { + // Dead, outdated and blocked all reach the row as a RemoteVerdict + // already; a second pill next to the first could only disagree with it + // in public. + expect(deviceBadge(SourceVerdict.empty, dead('dns')), isNull); + expect(deviceBadge(SourceVerdict.dead, null), isNull); + expect(deviceBadge(SourceVerdict.outdated, null), isNull); + expect(deviceBadge(SourceVerdict.blocked, null), isNull); + }); + }); + + group('id or health key', () { + test('a CloudStream health key runs on the device too', () { + // `ProviderEntity.healthKey` rewrites `cs:<id>` as `csp:<internalName>`, + // which does not start with the prefix SourceEcosystem classifies by. A + // key passed here used to be called a Sozo cloud source and handed the + // datacenter's verdict on a source that never goes near it. + expect(runsOnDevice('csp:nineanime'), isTrue); + expect(runsOnDevice('cs:nineanime'), isTrue); + expect(runsOnDevice('kisskh'), isFalse); + expect( + shownVerdict('csp:nineanime', dead('timeout')), + isNull, + reason: 'the sweep could not reach the site; the phone may well', + ); + }); + }); +} diff --git a/test/features/support/support_repository_test.dart b/test/features/support/support_repository_test.dart index 88f415c7..0c40d9af 100644 --- a/test/features/support/support_repository_test.dart +++ b/test/features/support/support_repository_test.dart @@ -239,6 +239,14 @@ void main() { }, ); + test('a follow-up carries a newer report when it is given one', () async { + await repo().reply('t1', 'Still not playing', diagnostics: {'log': 'x'}); + expect((seen.single.body as Map)['diagnostics'], {'log': 'x'}); + seen.clear(); + await repo().reply('t1', 'Still not playing'); + expect((seen.single.body as Map).containsKey('diagnostics'), isFalse); + }); + test('screenshots on a message read back with their signed URLs', () { final m = SupportMessage.fromJson({ 'id': 'm',