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/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/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("` + 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/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..e5c50599 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -557,7 +557,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": "حسابي", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index f97db42b..a8224252 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -557,7 +557,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": "پرۆفایل", diff --git a/assets/translations/de.json b/assets/translations/de.json index 8cc3ee27..8f765fda 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -557,7 +557,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", diff --git a/assets/translations/en.json b/assets/translations/en.json index b7828f39..e889ba40 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -557,7 +557,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", diff --git a/assets/translations/es.json b/assets/translations/es.json index e45868f1..e12283c8 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -557,7 +557,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", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index e1a4c3dd..e3c91f53 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -557,7 +557,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", diff --git a/assets/translations/id.json b/assets/translations/id.json index d3996d02..ebfb5729 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -557,7 +557,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", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 556dc6e5..8829b346 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -557,7 +557,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", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 20aed06f..f897ea80 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -557,7 +557,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", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 7e4d1f17..40e823ec 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -557,7 +557,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", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index efe8a882..c8ffb626 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -557,7 +557,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": "Профиль", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index e2324ce5..35122614 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -557,7 +557,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", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 1716e906..f23d7d17 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -557,7 +557,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", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 6ce1e7a4..414e9f29 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -557,7 +557,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": "個人檔案", 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/lib/app.dart b/lib/app.dart index 10c2d06e..60891803 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,8 @@ 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 +195,17 @@ 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 routed = child ?? const SizedBox.shrink(); final app = AppLockOverlay( child: ProfileGate( child: AchievementCelebrationHost( - child: child ?? const SizedBox.shrink(), + // 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/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/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..8439349b 100644 --- a/lib/core/player/download_media_probe.dart +++ b/lib/core/player/download_media_probe.dart @@ -1,14 +1,47 @@ +import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.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 @@ -18,45 +51,76 @@ class DownloadMediaProbe { options: Options( headers: {...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/media_controller.dart b/lib/core/player/media_controller.dart index 5924ac01..7830dbc5 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -14,6 +14,10 @@ import 'package:soplay/core/player/playback_readiness.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 +237,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 +479,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 +664,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 +766,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 +830,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 +890,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 +912,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 +1024,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 +1068,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 +1216,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 +1252,7 @@ class _MediaKitController extends PlayerController { _player.stream.error, ], canceled: _closed.future, + pictureTimeout: _pictureTimeout, ); @override @@ -1251,6 +1483,7 @@ class _MediaKitController extends PlayerController { Future _disposeOnce() async { if (!_closed.isCompleted) _closed.complete(); await _teardownMpv(); + await _backendSwitches.close(); await _fallback?.dispose(); } @@ -1266,6 +1499,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/preview/frame_preview_service.dart b/lib/core/preview/frame_preview_service.dart index 6f08f42d..e9ad3044 100644 --- a/lib/core/preview/frame_preview_service.dart +++ b/lib/core/preview/frame_preview_service.dart @@ -741,6 +741,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 +750,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 +785,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/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/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/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/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 5934a74e..d6d61a81 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'; @@ -66,7 +67,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'; @@ -713,7 +713,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 +748,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; diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index af1cca41..98d29e98 100644 --- a/lib/features/detail/presentation/pages/episodes_page.dart +++ b/lib/features/detail/presentation/pages/episodes_page.dart @@ -962,11 +962,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; } diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index d580c97d..5d3723ce 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 { @@ -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, @@ -1417,14 +1478,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( @@ -1886,11 +1950,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; @@ -2082,7 +2150,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( diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index a38d82b3..942057ae 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'; @@ -221,6 +222,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 +249,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. /// @@ -609,6 +634,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 +808,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 +828,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 +857,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 +943,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 +953,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 +997,7 @@ class _PlayerPageState extends State behavior: HitTestBehavior.opaque, onTap: _locked ? null - : _desktopMini + : _compact ? _togglePlay : _toggleControls, onDoubleTapDown: _locked || isDesktopPlatform @@ -951,6 +1005,8 @@ class _PlayerPageState extends State : (d) => _onDoubleTapDown(d, constraints), onDoubleTap: _locked ? null + : _docked + ? _expandFromDock : _desktopMini ? _toggleDesktopMini : isDesktopPlatform @@ -961,20 +1017,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 +1038,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 +1077,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 +1110,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 +1147,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 +1309,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 +1623,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.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..03af9865 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -263,6 +263,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 +276,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 +357,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; } @@ -637,6 +651,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. @@ -968,6 +1013,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 +1026,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 +1083,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 +1134,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 +1167,8 @@ extension _PlayerMedia on _PlayerPageState { type: effType, resumeAt: resumeAt, party: party, + sniffed: sniffedStream, + reconnect: reconnect, ); } @@ -1360,7 +1410,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 +1446,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 +1463,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 +1506,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 +1622,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 +1803,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 +1906,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 +1931,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 +1998,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 +2070,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 +2241,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 +2312,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 +2323,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 +2564,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 +2584,7 @@ extension _PlayerMedia on _PlayerPageState { // back into the sniffer. url: _playSourceUrl ?? _videoUrl!, headers: _playSourceUrl != null ? _playSourceHeaders : _headers, - type: _mediaType, + type: _playSourceType, resumeAt: keepPosition, ); } 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.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index d0c206de..5dbee7f9 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 @@ -312,7 +316,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, @@ -344,6 +348,92 @@ class _IconButton extends StatelessWidget { ), ), ); + 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), + ), + ); + }, + ), + ), + ), + ), + ), + ), + ); } } 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/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/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/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/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/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/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/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/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;