diff --git a/assets/translations/ar.json b/assets/translations/ar.json index f36b9e28..2469a828 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", "settings_title": "إعدادات الغرفة", "room_size": "حجم الغرفة", + "room_size_less": "عدد أقل من الأشخاص", + "room_size_more": "عدد أكبر من الأشخاص", "only_host_setting": "المضيف وحده يتحكم في التشغيل", "lock_room": "قفل الغرفة", "lock_room_hint": "لا يمكن لأحد جديد الانضمام", @@ -3138,7 +3140,9 @@ "error_attachment_type": "صور jpg أو png أو webp فقط.", "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", - "suggest": "تتكرر المشكلة؟ راسل الدعم" + "suggest": "تتكرر المشكلة؟ راسل الدعم", + "error_offline": "أنت غير متصل أو انقطع الاتصال. تحقق من الإنترنت ثم حاول مرة أخرى.", + "not_sent": "لم يُرسل: {}" }, "zangetsu": { "title": "مصادر Zangetsu", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index a7a4ae52..0bd7bf68 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", "settings_title": "ڕێکخستنەکانی ژوور", "room_size": "قەبارەی ژوور", + "room_size_less": "کەسی کەمتر", + "room_size_more": "کەسی زیاتر", "only_host_setting": "تەنها خانەخوێ لێدان کۆنترۆڵ دەکات", "lock_room": "قفڵکردنی ژوور", "lock_room_hint": "کەسی نوێ ناتوانێت بەشداربێت", @@ -3134,7 +3136,9 @@ "error_attachment_type": "تەنها وێنەی jpg یان png یان webp.", "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", - "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە", + "error_offline": "تۆ دەرهێڵیت یان پەیوەندی پچڕا. ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵ بدەرەوە.", + "not_sent": "نەنێردرا: {}" }, "zangetsu": { "title": "سەرچاوەکانی Zangetsu", diff --git a/assets/translations/de.json b/assets/translations/de.json index 328337ec..266f00e4 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut.", "settings_title": "Raumeinstellungen", "room_size": "Raumgröße", + "room_size_less": "Weniger Personen", + "room_size_more": "Mehr Personen", "only_host_setting": "Nur der Host steuert die Wiedergabe", "lock_room": "Raum sperren", "lock_room_hint": "Niemand Neues kann beitreten", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Nur jpg-, png- oder webp-Bilder.", "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", - "suggest": "Passiert das öfter? Schreib dem Support" + "suggest": "Passiert das öfter? Schreib dem Support", + "error_offline": "Du bist offline oder die Verbindung ist abgebrochen. Prüfe dein Internet und versuche es erneut.", + "not_sent": "Nicht gesendet: {}" }, "zangetsu": { "title": "Zangetsu-Quellen", diff --git a/assets/translations/en.json b/assets/translations/en.json index 73e00279..32dda706 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Check your internet connection and try again.", "settings_title": "Room settings", "room_size": "Room size", + "room_size_less": "Fewer people", + "room_size_more": "More people", "only_host_setting": "Only host controls playback", "lock_room": "Lock room", "lock_room_hint": "No one new can join", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Only jpg, png or webp images.", "error_attachment_size": "Each image must be 5 MB or less.", "error_attachment_count": "At most 5 screenshots per message.", - "suggest": "Keeps happening? Write to support" + "suggest": "Keeps happening? Write to support", + "error_offline": "You're offline or the connection dropped. Check your internet and try again.", + "not_sent": "Not sent: {}" }, "zangetsu": { "title": "Zangetsu Sources", diff --git a/assets/translations/es.json b/assets/translations/es.json index 715ac2ba..c464b7de 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo.", "settings_title": "Ajustes de la sala", "room_size": "Tamaño de la sala", + "room_size_less": "Menos personas", + "room_size_more": "Más personas", "only_host_setting": "Solo el anfitrión controla la reproducción", "lock_room": "Bloquear sala", "lock_room_hint": "Nadie nuevo puede unirse", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Solo imágenes jpg, png o webp.", "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", "error_attachment_count": "Como máximo 5 capturas por mensaje.", - "suggest": "¿Sigue pasando? Escribe a soporte" + "suggest": "¿Sigue pasando? Escribe a soporte", + "error_offline": "No tienes conexión o se cortó. Revisa tu internet e inténtalo de nuevo.", + "not_sent": "No se envió: {}" }, "zangetsu": { "title": "Fuentes de Zangetsu", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 615f3a17..e1a47c74 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Vérifie ta connexion internet et réessaie.", "settings_title": "Paramètres du salon", "room_size": "Taille du salon", + "room_size_less": "Moins de personnes", + "room_size_more": "Plus de personnes", "only_host_setting": "Seul l'hôte contrôle la lecture", "lock_room": "Verrouiller le salon", "lock_room_hint": "Personne de nouveau ne peut rejoindre", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Images jpg, png ou webp uniquement.", "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", "error_attachment_count": "5 captures maximum par message.", - "suggest": "Ça se reproduit ? Écris au support" + "suggest": "Ça se reproduit ? Écris au support", + "error_offline": "Vous êtes hors ligne ou la connexion a été coupée. Vérifiez votre connexion et réessayez.", + "not_sent": "Non envoyé : {}" }, "zangetsu": { "title": "Sources Zangetsu", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index edbf007f..83f534df 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।", "settings_title": "रूम सेटिंग्स", "room_size": "रूम का आकार", + "room_size_less": "कम लोग", + "room_size_more": "ज़्यादा लोग", "only_host_setting": "सिर्फ़ होस्ट प्लेबैक नियंत्रित करता है", "lock_room": "रूम लॉक करें", "lock_room_hint": "कोई नया शामिल नहीं हो सकता", @@ -3134,7 +3136,9 @@ "error_attachment_type": "सिर्फ़ jpg, png या webp इमेज।", "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", - "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें" + "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें", + "error_offline": "आप ऑफ़लाइन हैं या कनेक्शन टूट गया। इंटरनेट जाँचें और फिर से कोशिश करें।", + "not_sent": "नहीं भेजा गया: {}" }, "zangetsu": { "title": "Zangetsu स्रोत", diff --git a/assets/translations/id.json b/assets/translations/id.json index 6715e9c1..89ecd4ce 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi.", "settings_title": "Pengaturan ruang", "room_size": "Ukuran ruang", + "room_size_less": "Lebih sedikit orang", + "room_size_more": "Lebih banyak orang", "only_host_setting": "Hanya host yang mengontrol pemutaran", "lock_room": "Kunci ruang", "lock_room_hint": "Tidak ada orang baru yang bisa bergabung", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Hanya gambar jpg, png, atau webp.", "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", - "suggest": "Terus terjadi? Hubungi dukungan" + "suggest": "Terus terjadi? Hubungi dukungan", + "error_offline": "Kamu sedang offline atau koneksi terputus. Periksa internet lalu coba lagi.", + "not_sent": "Tidak terkirim: {}" }, "zangetsu": { "title": "Sumber Zangetsu", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e6fe21a6..56a7ff83 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne.", "settings_title": "Mîhengên odeyê", "room_size": "Mezinahiya odeyê", + "room_size_less": "Kesên kêmtir", + "room_size_more": "Kesên zêdetir", "only_host_setting": "Tenê mazûvan lêdanê kontrol dike", "lock_room": "Odeyê kilît bike", "lock_room_hint": "Kesekî nû nikare tevlî bibe", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Tenê wêneyên jpg, png an webp.", "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", - "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" + "suggest": "Dubare dibe? Ji piştgiriyê re binivîse", + "error_offline": "Tu offline î an girêdan qut bû. Înternetê kontrol bike û dîsa biceribîne.", + "not_sent": "Nehat şandin: {}" }, "zangetsu": { "title": "Çavkaniyên Zangetsu", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 661e14af..b86ec879 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw.", "settings_title": "Kamerinstellingen", "room_size": "Kamergrootte", + "room_size_less": "Minder mensen", + "room_size_more": "Meer mensen", "only_host_setting": "Alleen de host bedient het afspelen", "lock_room": "Kamer vergrendelen", "lock_room_hint": "Niemand nieuw kan meedoen", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Alleen jpg-, png- of webp-afbeeldingen.", "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", - "suggest": "Blijft het gebeuren? Schrijf support" + "suggest": "Blijft het gebeuren? Schrijf support", + "error_offline": "Je bent offline of de verbinding is verbroken. Controleer je internet en probeer het opnieuw.", + "not_sent": "Niet verzonden: {}" }, "zangetsu": { "title": "Zangetsu-bronnen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 96ef5db5..01e96d87 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo.", "settings_title": "Configurações da sala", "room_size": "Tamanho da sala", + "room_size_less": "Menos pessoas", + "room_size_more": "Mais pessoas", "only_host_setting": "Só o anfitrião controla a reprodução", "lock_room": "Trancar sala", "lock_room_hint": "Ninguém novo pode entrar", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Só imagens jpg, png ou webp.", "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", "error_attachment_count": "No máximo 5 capturas por mensagem.", - "suggest": "Continua acontecendo? Fale com o suporte" + "suggest": "Continua acontecendo? Fale com o suporte", + "error_offline": "Você está offline ou a conexão caiu. Verifique a internet e tente novamente.", + "not_sent": "Não enviado: {}" }, "zangetsu": { "title": "Fontes Zangetsu", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 221657e1..9bc900dd 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку.", "settings_title": "Настройки комнаты", "room_size": "Размер комнаты", + "room_size_less": "Меньше участников", + "room_size_more": "Больше участников", "only_host_setting": "Воспроизведением управляет только хост", "lock_room": "Закрыть комнату", "lock_room_hint": "Новые участники не смогут войти", @@ -3135,7 +3137,9 @@ "error_attachment_type": "Только изображения jpg, png или webp.", "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", - "suggest": "Повторяется? Напишите в поддержку" + "suggest": "Повторяется? Напишите в поддержку", + "error_offline": "Нет интернета или соединение прервалось. Проверьте подключение и попробуйте снова.", + "not_sent": "Не отправлено: {}" }, "zangetsu": { "title": "Источники Zangetsu", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 45cdb2d6..701b1e45 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene.", "settings_title": "Oda ayarları", "room_size": "Oda boyutu", + "room_size_less": "Daha az kişi", + "room_size_more": "Daha fazla kişi", "only_host_setting": "Oynatmayı yalnızca host kontrol eder", "lock_room": "Odayı kilitle", "lock_room_hint": "Yeni kimse katılamaz", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Yalnızca jpg, png veya webp görseller.", "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", - "suggest": "Tekrarlıyor mu? Desteğe yaz" + "suggest": "Tekrarlıyor mu? Desteğe yaz", + "error_offline": "Çevrimdışısın veya bağlantı koptu. İnternetini kontrol edip tekrar dene.", + "not_sent": "Gönderilmedi: {}" }, "zangetsu": { "title": "Zangetsu Kaynakları", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 60366c15..dddc90c9 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring.", "settings_title": "Xona sozlamalari", "room_size": "Xona hajmi", + "room_size_less": "Kamroq odam", + "room_size_more": "Ko‘proq odam", "only_host_setting": "Ijroni faqat host boshqaradi", "lock_room": "Xonani qulflash", "lock_room_hint": "Yangi hech kim qo'shila olmaydi", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Faqat jpg, png yoki webp rasm.", "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", - "suggest": "Takrorlanyaptimi? Support’ga yozing" + "suggest": "Takrorlanyaptimi? Support’ga yozing", + "error_offline": "Internet yo'q yoki aloqa uzildi. Internetni tekshirib, qayta urinib ko'ring.", + "not_sent": "Yuborilmadi: {}" }, "zangetsu": { "title": "Zangetsu manbalari", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index a0b6a8f4..54733568 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại.", "settings_title": "Cài đặt phòng", "room_size": "Kích thước phòng", + "room_size_less": "Ít người hơn", + "room_size_more": "Nhiều người hơn", "only_host_setting": "Chỉ chủ phòng điều khiển phát", "lock_room": "Khóa phòng", "lock_room_hint": "Không ai mới có thể tham gia", @@ -3134,7 +3136,9 @@ "error_attachment_type": "Chỉ nhận ảnh jpg, png hoặc webp.", "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", - "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ" + "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ", + "error_offline": "Bạn đang ngoại tuyến hoặc kết nối bị ngắt. Hãy kiểm tra internet rồi thử lại.", + "not_sent": "Chưa gửi được: {}" }, "zangetsu": { "title": "Nguồn Zangetsu", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 62765563..531b9e88 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "檢查下你嘅網絡連線再試一次。", "settings_title": "房間設定", "room_size": "房間人數", + "room_size_less": "少啲人", + "room_size_more": "多啲人", "only_host_setting": "淨係房主可以控制播放", "lock_room": "鎖住房間", "lock_room_hint": "新人唔可以加入", @@ -3134,7 +3136,9 @@ "error_attachment_type": "只接受 jpg、png 或 webp 圖片。", "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", - "suggest": "成日都係咁?搵支援" + "suggest": "成日都係咁?搵支援", + "error_offline": "你而家離線,或者連線斷咗。請檢查網絡再試。", + "not_sent": "未送出:{}" }, "zangetsu": { "title": "Zangetsu 來源", diff --git a/lib/core/widgets/star_prompt_banner.dart b/lib/core/widgets/star_prompt_banner.dart index 46ab792e..a2d4e835 100644 --- a/lib/core/widgets/star_prompt_banner.dart +++ b/lib/core/widgets/star_prompt_banner.dart @@ -130,7 +130,7 @@ class _StarPromptBannerState extends State { children: [ const Icon( Icons.star_rounded, - color: Color(0xFFFFC83D), + color: AppColors.rating, size: 22, ), const SizedBox(width: 10), diff --git a/lib/features/comments/presentation/widgets/episode_comments_sheet.dart b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart index f2975416..8bc919bb 100644 --- a/lib/features/comments/presentation/widgets/episode_comments_sheet.dart +++ b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart @@ -1,4 +1,4 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -38,6 +38,9 @@ Future showEpisodeComments( builder: (_) => SizedBox(height: size.height * 0.75, child: panel), ); } + // The panel sits on the end edge: it slides in from there, and the inset on + // its inner edge is not its concern. + final rtl = Directionality.of(context) == TextDirection.rtl; return showGeneralDialog( context: context, barrierDismissible: true, @@ -51,12 +54,12 @@ Future showEpisodeComments( height: double.infinity, child: Material( color: AppColors.background, - child: SafeArea(left: false, child: panel), + child: SafeArea(left: rtl, right: !rtl, child: panel), ), ), ), transitionBuilder: (context, animation, _, child) => SlideTransition( - position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate( + position: Tween(begin: Offset(rtl ? -1 : 1, 0), end: Offset.zero).animate( CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), ), child: child, diff --git a/lib/features/comments/presentation/widgets/reaction_picker.dart b/lib/features/comments/presentation/widgets/reaction_picker.dart index 29268701..8cb39228 100644 --- a/lib/features/comments/presentation/widgets/reaction_picker.dart +++ b/lib/features/comments/presentation/widgets/reaction_picker.dart @@ -123,6 +123,7 @@ class _ReactionPickerState extends State<_ReactionPicker> { ), ), _RoundButton( + tooltip: 'support.send'.tr(), onTap: _sendText, child: Icon(Icons.send_rounded, color: AppColors.primary, size: 20), ), @@ -152,7 +153,7 @@ class _RoundButton extends StatelessWidget { autofocus: autofocus, customBorder: const CircleBorder(), focusColor: Colors.white24, - child: SizedBox(width: 44, height: 44, child: Center(child: child)), + child: SizedBox(width: 48, height: 48, child: Center(child: child)), ); return tooltip == null ? button : Tooltip(message: tooltip!, child: button); } diff --git a/lib/features/detail/domain/playback/playback_fault.dart b/lib/features/detail/domain/playback/playback_fault.dart index bad2e5fe..cf13bfbc 100644 --- a/lib/features/detail/domain/playback/playback_fault.dart +++ b/lib/features/detail/domain/playback/playback_fault.dart @@ -95,7 +95,11 @@ class PlaybackFault { lower.contains('renderer')) { return PlaybackFault(PlaybackFaultKind.decoder, raw: raw); } + // libmpv's "Failed to open ": the host answered with a refusal + // (often a 403 without the Referer it expects) or not at all. The raw + // line, with its long address, read as the whole message on screen. if (lower.contains('source error') || + lower.contains('failed to open http') || lower.contains('failed to recognize file format') || lower.contains('unrecognizedinputformat') || lower.contains('nodeclaredbrand')) { diff --git a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart index b8b28383..29b3a134 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -15,6 +15,7 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/matching/title_match.dart'; import 'package:soplay/core/network/image_headers.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/features/detail/data/source_choice_store.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; @@ -554,6 +555,9 @@ class _AlternateSourceSheetState extends State { ), const SizedBox(width: 4), TextButton( + // The row it replaces stops being focusable once Auto runs, so a + // remote would otherwise be left with nothing focused. + autofocus: isTvPlatform, onPressed: _stopAuto, style: TextButton.styleFrom(foregroundColor: Colors.white), child: Text('player.alt_auto_stop'.tr()), @@ -562,6 +566,7 @@ class _AlternateSourceSheetState extends State { ); } else if (ended) { trailing = TextButton.icon( + autofocus: isTvPlatform, onPressed: _preparing != null ? null : _runAuto, style: TextButton.styleFrom(foregroundColor: AppColors.primary), icon: const Icon(Icons.refresh_rounded, size: 18), diff --git a/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart index 3a1e943f..cf7500b1 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart @@ -153,17 +153,29 @@ class _MangayomiSourcePickerPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ - TextButton( - onPressed: () => _setAll(visible, true), - child: Text('ext.select_all'.tr()), + Flexible( + child: TextButton( + onPressed: () => _setAll(visible, true), + child: Text( + 'ext.select_all'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), - TextButton( - onPressed: () => _setAll(visible, false), - child: Text('ext.select_none'.tr()), + Flexible( + child: TextButton( + onPressed: () => _setAll(visible, false), + child: Text( + 'ext.select_none'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), const Spacer(), Padding( - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsetsDirectional.only(end: 8), child: Text( '${visible.length}', style: const TextStyle(color: AppColors.textHint), diff --git a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart index 0f772734..be5ecb21 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart @@ -171,7 +171,7 @@ class _MangayomiSourcesPageState extends State { onPressed: () => Navigator.of(ctx).pop(true), child: Text( 'ext.remove'.tr(), - style: const TextStyle(color: Colors.redAccent), + style: const TextStyle(color: AppColors.error), ), ), ], @@ -284,9 +284,21 @@ class _MangayomiSourcesPageState extends State { Widget build(BuildContext context) { if (!MangayomiRuntime.isSupported) { return Scaffold( - appBar: AppBar(title: Text('ext.mangayomi_title'.tr())), + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text('ext.mangayomi_title'.tr()), + ), body: Center( - child: Text('ext.needs_webview'.tr()), + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'ext.needs_webview'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), ), ); } @@ -418,9 +430,9 @@ class _MangayomiSourcesPageState extends State { errorBuilder: (_, _, _) => Container( width: 44, height: 44, - color: Colors.white10, + color: AppColors.surfaceVariant, child: const Icon(Icons.javascript_outlined, - color: Colors.white54), + color: AppColors.textHint), )), ), const SizedBox(width: 12), @@ -493,7 +505,7 @@ class _MangayomiSourcesPageState extends State { ); Widget _statusBanner() { - final color = _statusError ? Colors.redAccent : Colors.green; + final color = _statusError ? AppColors.error : AppColors.success; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( @@ -567,13 +579,13 @@ class _MangayomiSourcesPageState extends State { IconButton( tooltip: 'ext.add_sources'.tr(), icon: const Icon(Icons.playlist_add_rounded, - color: Colors.white70), + color: AppColors.textSecondary), onPressed: _busy ? null : () => _install([url]), ), IconButton( tooltip: 'ext.remove'.tr(), icon: const Icon(Icons.delete_outline, - color: Colors.redAccent), + color: AppColors.error), onPressed: _busy ? null : () => _remove(url), ), ], @@ -583,7 +595,7 @@ class _MangayomiSourcesPageState extends State { ); Widget _updatesBanner(int count) => Container( - padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), + padding: const EdgeInsetsDirectional.fromSTEB(12, 6, 6, 6), decoration: BoxDecoration( color: _accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), @@ -640,9 +652,9 @@ class _MangayomiSourcesPageState extends State { errorBuilder: (_, _, _) => Container( width: 26, height: 26, - color: Colors.white10, + color: AppColors.surfaceVariant, child: const Icon(Icons.public, - size: 14, color: Colors.white54), + size: 14, color: AppColors.textHint), ), ), ), @@ -666,12 +678,12 @@ class _MangayomiSourcesPageState extends State { padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 1), decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.15), + color: AppColors.error.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text('18+', style: TextStyle( - color: Colors.redAccent, + color: AppColors.error, fontSize: 9, fontWeight: FontWeight.w700)), ), @@ -714,13 +726,13 @@ class _MangayomiSourcesPageState extends State { tooltip: 'ext.settings'.tr(), visualDensity: VisualDensity.compact, icon: const Icon(Icons.tune_rounded, - size: 19, color: Colors.white70), + size: 19, color: AppColors.textSecondary), onPressed: () => SourceSettingsSheet.show(context, s), ), PopupMenuButton<_SourceAction>( enabled: !_busy, icon: const Icon(Icons.more_vert_rounded, - size: 19, color: Colors.white70), + size: 19, color: AppColors.textSecondary), color: AppColors.surface, onSelected: (a) => switch (a) { _SourceAction.toggle => _setEnabled(s, !enabled), @@ -736,7 +748,7 @@ class _MangayomiSourcesPageState extends State { PopupMenuItem( value: _SourceAction.remove, child: Text('ext.remove_source'.tr(), - style: const TextStyle(color: Colors.redAccent)), + style: const TextStyle(color: AppColors.error)), ), ], ), diff --git a/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart index 1f029a04..568fd2c7 100644 --- a/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart +++ b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart @@ -9,6 +9,7 @@ import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; import 'package:soplay/features/extensions/presentation/widgets/recommended_repos_section.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/sources/domain/source_scope.dart'; @@ -369,7 +370,7 @@ class _ZangetsuSourcesPageState extends State { ); Widget _statusBanner() { - final color = _statusError ? Colors.redAccent : Colors.green; + final color = _statusError ? AppColors.error : AppColors.success; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( @@ -452,7 +453,7 @@ class _ZangetsuSourcesPageState extends State { ), trailing: IconButton( tooltip: 'general.remove'.tr(), - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + icon: const Icon(Icons.delete_outline, color: AppColors.error), onPressed: _busy ? null : () => _removeRepo(url), ), ), @@ -472,36 +473,37 @@ class _ZangetsuSourcesPageState extends State { prefix: const Icon(Icons.search, color: AppColors.textHint), ), ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ + const SizedBox(height: 4), + // The same chip rows as the Mangayomi page and the catalog. + SourceFilterChipRow( + padding: EdgeInsets.zero, + options: [ for (final (value, label) in [ ('all', 'zangetsu.all_types'.tr()), ('anime', 'ext.type_anime'.tr()), ('movies', 'zangetsu.type_movies'.tr()), ]) - ChoiceChip( - label: Text(label), - selected: _type == value, - selectedColor: _accent.withValues(alpha: 0.25), - onSelected: (_) => setState(() => _type = value), + SourceFilterOption( + label: label, + active: _type == value, + onTap: () => setState(() => _type = value), ), - if (langs.length > 1) - for (final lang in ['all', ...langs]) - ChoiceChip( - label: Text( - lang == 'all' - ? 'zangetsu.all_languages'.tr() - : lang.toUpperCase(), - ), - selected: _lang == lang, - selectedColor: _accent.withValues(alpha: 0.25), - onSelected: (_) => setState(() => _lang = lang), - ), ], ), + if (langs.length > 1) + SourceFilterChipRow( + padding: EdgeInsets.zero, + options: [ + for (final lang in ['all', ...langs]) + SourceFilterOption( + label: lang == 'all' + ? 'zangetsu.all_languages'.tr() + : lang.toUpperCase(), + active: _lang == lang, + onTap: () => setState(() => _lang = lang), + ), + ], + ), ], ); @@ -539,7 +541,7 @@ class _ZangetsuSourcesPageState extends State { ), if (s.nsfw) ...[ const SizedBox(width: 6), - const _Badge(text: '18+', color: Colors.redAccent), + const _Badge(text: '18+', color: AppColors.error), ], ], ), @@ -547,6 +549,8 @@ class _ZangetsuSourcesPageState extends State { '${s.lang.toUpperCase()} · ' '${s.isAnime ? 'ext.type_anime'.tr() : 'zangetsu.type_movies'.tr()}' ' · v${shown.version}', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textHint, fontSize: 10.5, @@ -568,7 +572,7 @@ class _ZangetsuSourcesPageState extends State { ZangetsuSource? update, ) { if (s.builtIn) { - return [_Badge(text: 'zangetsu.built_in'.tr(), color: Colors.green)]; + return [_Badge(text: 'zangetsu.built_in'.tr(), color: AppColors.success)]; } if (installed == null) { return [ @@ -588,13 +592,14 @@ class _ZangetsuSourcesPageState extends State { ), Switch( value: installed.enabled, - activeThumbColor: _accent, + activeThumbColor: Colors.white, + activeTrackColor: _accent, onChanged: _busy ? null : (v) => _toggle(installed, v), ), IconButton( tooltip: 'general.remove'.tr(), visualDensity: VisualDensity.compact, - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + icon: const Icon(Icons.delete_outline, color: AppColors.error), onPressed: _busy ? null : () => _remove(installed), ), ]; @@ -604,8 +609,8 @@ class _ZangetsuSourcesPageState extends State { final fallback = Container( width: 26, height: 26, - color: Colors.white10, - child: const Icon(Icons.bolt_rounded, size: 14, color: Colors.white54), + color: AppColors.surfaceVariant, + child: const Icon(Icons.bolt_rounded, size: 14, color: AppColors.textHint), ); return ClipRRect( borderRadius: BorderRadius.circular(7), diff --git a/lib/features/extensions/presentation/widgets/source_filter_chips.dart b/lib/features/extensions/presentation/widgets/source_filter_chips.dart index a58224dc..4f74cf6a 100644 --- a/lib/features/extensions/presentation/widgets/source_filter_chips.dart +++ b/lib/features/extensions/presentation/widgets/source_filter_chips.dart @@ -48,52 +48,56 @@ class SourceFilterChipRow extends StatelessWidget { } Widget _chip(BuildContext context, SourceFilterOption o) { - return TvFocusable( - onPressed: o.onTap, - borderRadius: 17, - ensureVisibleAlignment: 0.5, - child: AnimatedContainer( - duration: Duration( - milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, - ), - curve: Curves.easeOutCubic, - height: 34, - padding: const EdgeInsets.symmetric(horizontal: 12), - alignment: Alignment.center, - decoration: BoxDecoration( - color: o.active ? AppColors.primary : AppColors.surface, - borderRadius: BorderRadius.circular(17), - border: Border.all( - color: o.active - ? AppColors.primary.withValues(alpha: 0.55) - : Colors.white.withValues(alpha: 0.07), + return Semantics( + button: true, + selected: o.active, + child: TvFocusable( + onPressed: o.onTap, + borderRadius: 17, + ensureVisibleAlignment: 0.5, + child: AnimatedContainer( + duration: Duration( + milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - o.label, - style: TextStyle( - color: o.active ? Colors.white : AppColors.textSecondary, - fontSize: 12.5, - fontWeight: o.active ? FontWeight.w700 : FontWeight.w600, - ), + curve: Curves.easeOutCubic, + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: o.active ? AppColors.primary : AppColors.surface, + borderRadius: BorderRadius.circular(17), + border: Border.all( + color: o.active + ? AppColors.primary.withValues(alpha: 0.55) + : Colors.white.withValues(alpha: 0.07), ), - if (o.count != null) ...[ - const SizedBox(width: 5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ Text( - '${o.count}', + o.label, style: TextStyle( - color: o.active - ? AppColors.primaryLight.withValues(alpha: 0.75) - : AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w600, + color: o.active ? Colors.white : AppColors.textSecondary, + fontSize: 12.5, + fontWeight: o.active ? FontWeight.w700 : FontWeight.w600, ), ), + if (o.count != null) ...[ + const SizedBox(width: 5), + Text( + '${o.count}', + style: TextStyle( + color: o.active + ? AppColors.primaryLight.withValues(alpha: 0.75) + : AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], ], - ], + ), ), ), ); diff --git a/lib/features/manga/presentation/widgets/page_translation_overlay.dart b/lib/features/manga/presentation/widgets/page_translation_overlay.dart index a524c356..5e11f619 100644 --- a/lib/features/manga/presentation/widgets/page_translation_overlay.dart +++ b/lib/features/manga/presentation/widgets/page_translation_overlay.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/manga/domain/translate/page_translation.dart'; @@ -196,31 +197,44 @@ class _PageTranslatePreviewState extends State { top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: Row( - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - 'general.cancel'.tr(), - style: const TextStyle(color: Colors.white70), + // Wraps rather than overflows when the labels are long and the + // phone is narrow. + child: SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'general.cancel'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), ), - ), - const Spacer(), - FilterChip( - selected: _original, - onSelected: (v) => setState(() => _original = v), - label: Text('manga.translate_show_original'.tr()), - selectedColor: accent.withValues(alpha: 0.3), - ), - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: accent), - onPressed: () => Navigator.of(context).pop( - PageTranslatePreviewResult(showOriginal: _original), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilterChip( + selected: _original, + onSelected: (v) => setState(() => _original = v), + label: Text('manga.translate_show_original'.tr()), + selectedColor: accent.withValues(alpha: 0.3), + ), + const SizedBox(width: 10), + FilledButton( + autofocus: isTvPlatform, + style: FilledButton.styleFrom(backgroundColor: accent), + onPressed: () => Navigator.of(context).pop( + PageTranslatePreviewResult(showOriginal: _original), + ), + child: Text('manga.translate_apply'.tr()), + ), + ], ), - child: Text('manga.translate_apply'.tr()), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index 2a0f07da..30076261 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -310,7 +310,7 @@ class _PlayerSettingsPageState extends State { ), const SettingsDivider(), SettingsSwitchTile( - icon: Icons.visibility_off_rounded, + icon: Icons.speaker_notes_off_outlined, title: 'profile.hide_spoilers'.tr(), subtitle: 'profile.hide_spoilers_desc'.tr(), value: _hideSpoilers, diff --git a/lib/features/profile/presentation/pages/profile_page.header.dart b/lib/features/profile/presentation/pages/profile_page.header.dart index fde32c19..227a2244 100644 --- a/lib/features/profile/presentation/pages/profile_page.header.dart +++ b/lib/features/profile/presentation/pages/profile_page.header.dart @@ -271,11 +271,12 @@ class _LevelLink extends StatelessWidget { Widget build(BuildContext context) { final p = view.progress; final title = Cosmetics.titleText(view.title); - return InkWell( + return HoverTap( onTap: () => context.push('/progress'), - borderRadius: BorderRadius.circular(8), + borderRadius: 8, + scale: 1.02, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/profile/presentation/widgets/contributors_section.dart b/lib/features/profile/presentation/widgets/contributors_section.dart index 86875e57..9dc67952 100644 --- a/lib/features/profile/presentation/widgets/contributors_section.dart +++ b/lib/features/profile/presentation/widgets/contributors_section.dart @@ -58,7 +58,7 @@ class _ContributorsSectionState extends State { : ValueListenableBuilder( valueListenable: _service.failed, builder: (_, failed, _) => failed - ? const _QuietError() + ? _QuietError(onRetry: _service.refresh) : const _Skeleton(), ), ), @@ -99,8 +99,8 @@ class _Grid extends StatelessWidget { borderRadius: 12, onTap: () => onOpen(c.htmlUrl), child: Semantics( - button: true, label: c.login, + excludeSemantics: true, child: SizedBox( width: _cell, child: Column( @@ -202,7 +202,9 @@ class _Skeleton extends StatelessWidget { } class _QuietError extends StatelessWidget { - const _QuietError(); + const _QuietError({required this.onRetry}); + + final VoidCallback onRetry; @override Widget build(BuildContext context) { @@ -216,6 +218,7 @@ class _QuietError extends StatelessWidget { style: const TextStyle(color: AppColors.textHint, fontSize: 12), ), ), + TextButton(onPressed: onRetry, child: Text('general.retry'.tr())), ], ); } diff --git a/lib/features/progress/presentation/dialogs/level_up_dialog.dart b/lib/features/progress/presentation/dialogs/level_up_dialog.dart index b99ec033..a48026e1 100644 --- a/lib/features/progress/presentation/dialogs/level_up_dialog.dart +++ b/lib/features/progress/presentation/dialogs/level_up_dialog.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/progress/data/progress_service.dart'; import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; @@ -21,7 +22,7 @@ class LevelUpDialog extends StatelessWidget { return showGeneralDialog( context: context, barrierDismissible: true, - barrierLabel: 'Level up', + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.black.withValues(alpha: 0.8), transitionDuration: const Duration(milliseconds: 320), pageBuilder: (_, _, _) => LevelUpDialog(levelUp: levelUp, onOpen: onOpen), @@ -44,104 +45,109 @@ class LevelUpDialog extends StatelessWidget { return Material( type: MaterialType.transparency, child: Center( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 28), - constraints: const BoxConstraints(maxWidth: 360), - padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.surface, AppColors.background], - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: AppColors.primary.withValues(alpha: 0.3), - width: 0.8, - ), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.18), - blurRadius: 34, - spreadRadius: 2, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 28), + constraints: const BoxConstraints(maxWidth: 360), + padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.surface, AppColors.background], ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'progress.level_up'.tr().toUpperCase(), - style: TextStyle( - color: AppColors.primaryLight, - fontSize: 12, - fontWeight: FontWeight.w800, - letterSpacing: 1.8, - ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + width: 0.8, ), - const SizedBox(height: 18), - LevelRing(level: levelUp.to, fraction: 1, size: 132), - const SizedBox(height: 16), - Text( - 'progress.level_reached'.tr(args: ['${levelUp.to}']), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 20, - fontWeight: FontWeight.w800, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 34, + spreadRadius: 2, ), - ), - if (unlocked.isNotEmpty) ...[ - const SizedBox(height: 14), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Text( - 'progress.unlocked_new'.tr(), + 'progress.level_up'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.8, + ), + ), + const SizedBox(height: 18), + LevelRing(level: levelUp.to, fraction: 1, size: 132), + const SizedBox(height: 16), + Text( + 'progress.level_reached'.tr(args: ['${levelUp.to}']), + textAlign: TextAlign.center, style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12.5, + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 8), - Wrap( - alignment: WrapAlignment.center, - spacing: 6, - runSpacing: 6, + if (unlocked.isNotEmpty) ...[ + const SizedBox(height: 14), + Text( + 'progress.unlocked_new'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 6, + runSpacing: 6, + children: [ + for (final c in unlocked) + Chip( + label: Text(Cosmetics.label(c.slot, c.id)), + visualDensity: VisualDensity.compact, + backgroundColor: Colors.white.withValues(alpha: 0.06), + side: BorderSide.none, + labelStyle: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ], + const SizedBox(height: 18), + Row( children: [ - for (final c in unlocked) - Chip( - label: Text(Cosmetics.label(c.slot, c.id)), - visualDensity: VisualDensity.compact, - backgroundColor: Colors.white.withValues(alpha: 0.06), - side: BorderSide.none, - labelStyle: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12, + if (onOpen != null) + Expanded( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + onOpen!(); + }, + child: Text('progress.open_locker'.tr()), ), ), - ], - ), - ], - const SizedBox(height: 18), - Row( - children: [ - if (onOpen != null) + if (onOpen != null) const SizedBox(width: 8), Expanded( - child: TextButton( - onPressed: () { - Navigator.of(context).pop(); - onOpen!(); - }, - child: Text('progress.open_locker'.tr()), + child: FilledButton( + autofocus: isTvPlatform, + onPressed: () => Navigator.of(context).pop(), + child: Text('achievements.great'.tr()), ), ), - Expanded( - child: FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: Text('achievements.great'.tr()), - ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), diff --git a/lib/features/progress/presentation/pages/progress_page.dart b/lib/features/progress/presentation/pages/progress_page.dart index d2a57b18..996eff86 100644 --- a/lib/features/progress/presentation/pages/progress_page.dart +++ b/lib/features/progress/presentation/pages/progress_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; import 'package:soplay/features/progress/data/progress_service.dart'; @@ -33,6 +34,12 @@ class _ProgressPageState extends State { }); } + Future _retry() async { + setState(() => _loading = true); + await _service.refresh(); + if (mounted) setState(() => _loading = false); + } + void _onChange() { if (mounted) setState(() {}); } @@ -78,10 +85,31 @@ class _ProgressPageState extends State { ? const CircularProgressIndicator() : Padding( padding: const EdgeInsets.all(32), - child: Text( - 'progress.unavailable'.tr(), - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textSecondary), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_rounded, + size: 40, + color: AppColors.textHint, + ), + const SizedBox(height: 12), + Text( + 'progress.unavailable'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 16), + TextButton( + autofocus: true, + onPressed: _retry, + child: Text('general.retry'.tr()), + ), + ], ), ), ) @@ -336,7 +364,7 @@ class _Recent extends StatelessWidget { ), if (g.at != null) Padding( - padding: const EdgeInsets.only(right: 10), + padding: const EdgeInsetsDirectional.only(end: 10), child: Text( DateFormat.MMMd( context.locale.toString(), @@ -465,10 +493,14 @@ class _LockerItem extends StatelessWidget { return FramedAvatar( size: 34, frame: item.id, - child: const CircleAvatar( + child: CircleAvatar( radius: 17, - backgroundColor: Color(0xFF2A2A2A), - child: Icon(Icons.person_rounded, size: 20, color: Colors.white54), + backgroundColor: AppColors.surfaceVariant, + child: const Icon( + Icons.person_rounded, + size: 20, + color: AppColors.textHint, + ), ), ); case CosmeticSlot.nameColor: @@ -495,85 +527,89 @@ class _LockerItem extends StatelessWidget { final label = Cosmetics.label(item.slot, item.id); return Tooltip( message: locked ? unlockHint(item) : label, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - width: 100, - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - decoration: BoxDecoration( - color: equipped - ? AppColors.primary.withValues(alpha: 0.14) - : Colors.white.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(12), - border: Border.all( + child: Semantics( + selected: equipped, + child: HoverTap( + onTap: onTap, + borderRadius: 12, + scale: 1.03, + child: Container( + width: 100, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + decoration: BoxDecoration( color: equipped - ? AppColors.primary.withValues(alpha: 0.6) - : Colors.white.withValues(alpha: 0.06), + ? AppColors.primary.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: equipped + ? AppColors.primary.withValues(alpha: 0.6) + : Colors.white.withValues(alpha: 0.06), + ), ), - ), - child: Opacity( - opacity: locked ? 0.5 : 1, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 44, - child: Center( - child: busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : _preview(), + child: Opacity( + opacity: locked ? 0.5 : 1, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: Center( + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _preview(), + ), ), - ), - const SizedBox(height: 6), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: item.slot == CosmeticSlot.nameColor - ? Cosmetics.nameColor(item.id) - : AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: item.slot == CosmeticSlot.nameColor + ? Cosmetics.nameColor(item.id) + : AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (locked) ...[ - const Icon( - Icons.lock_outline_rounded, - size: 11, - color: AppColors.textHint, - ), - const SizedBox(width: 3), - ], - Flexible( - child: Text( - locked - ? unlockHint(item) - : equipped - ? 'progress.equipped'.tr() - : 'progress.equip'.tr(), - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle( + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (locked) ...[ + const Icon( + Icons.lock_outline_rounded, + size: 11, color: AppColors.textHint, - fontSize: 10.5, + ), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + locked + ? unlockHint(item) + : equipped + ? 'progress.equipped'.tr() + : 'progress.equip'.tr(), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), diff --git a/lib/features/progress/presentation/widgets/level_ring.dart b/lib/features/progress/presentation/widgets/level_ring.dart index 98509181..8f01f191 100644 --- a/lib/features/progress/presentation/widgets/level_ring.dart +++ b/lib/features/progress/presentation/widgets/level_ring.dart @@ -20,45 +20,56 @@ class LevelRing extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - width: size, - height: size, - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: fraction), - duration: const Duration(milliseconds: 700), - curve: Curves.easeOutCubic, - builder: (_, t, child) => CustomPaint( - painter: _RingPainter( - fraction: t, - color: AppColors.primary, - track: Colors.white.withValues(alpha: 0.08), - stroke: size * 0.075, + final still = MediaQuery.disableAnimationsOf(context); + return Semantics( + label: 'progress.level_short'.tr(args: ['$level']), + excludeSemantics: true, + child: SizedBox( + width: size, + height: size, + child: TweenAnimationBuilder( + tween: Tween(begin: still ? fraction : 0, end: fraction), + duration: still ? Duration.zero : const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, t, child) => CustomPaint( + painter: _RingPainter( + fraction: t, + color: AppColors.primary, + track: Colors.white.withValues(alpha: 0.08), + stroke: size * 0.075, + ), + child: child, ), - child: child, - ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'progress.level'.tr().toUpperCase(), - style: TextStyle( - color: AppColors.textSecondary, - fontSize: size * 0.09, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - Text( - '$level', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: size * 0.32, - fontWeight: FontWeight.w900, - height: 1.05, - ), + // Scales down rather than spilling out of the ring at large text + // sizes. + child: Padding( + padding: EdgeInsets.all(size * 0.14), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontSize: size * 0.09, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + Text( + '$level', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.32, + fontWeight: FontWeight.w900, + height: 1.05, + ), + ), + ], ), - ], + ), ), ), ), diff --git a/lib/features/progress/presentation/widgets/user_badge_line.dart b/lib/features/progress/presentation/widgets/user_badge_line.dart index 59399cc2..511eaa0b 100644 --- a/lib/features/progress/presentation/widgets/user_badge_line.dart +++ b/lib/features/progress/presentation/widgets/user_badge_line.dart @@ -68,6 +68,10 @@ class Cosmetics { /// An avatar inside the frame its owner equipped; the avatar alone without /// one. +/// +/// The frame is painted outside the avatar's box rather than added to it, so a +/// framed avatar takes the same space as a plain one and a list of names stays +/// aligned whoever has a frame. class FramedAvatar extends StatelessWidget { const FramedAvatar({ super.key, @@ -96,20 +100,32 @@ class FramedAvatar extends StatelessWidget { final shape = radius == null ? const BoxDecoration(shape: BoxShape.circle) : BoxDecoration(borderRadius: BorderRadius.circular(radius! + ring)); - return Container( - width: size + ring * 2, - height: size + ring * 2, - padding: EdgeInsets.all(ring), - decoration: shape.copyWith( - gradient: SweepGradient(colors: [...colors, colors.first]), - boxShadow: [ - BoxShadow( - color: colors.last.withValues(alpha: 0.35), - blurRadius: ring * 2.5, + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -ring, + top: -ring, + right: -ring, + bottom: -ring, + child: DecoratedBox( + decoration: shape.copyWith( + gradient: SweepGradient(colors: [...colors, colors.first]), + boxShadow: [ + BoxShadow( + color: colors.last.withValues(alpha: 0.35), + blurRadius: ring * 2.5, + ), + ], + ), + ), ), + Positioned.fill(child: child), ], ), - child: child, ); } } diff --git a/lib/features/search/presentation/widgets/voice_search_dialog.dart b/lib/features/search/presentation/widgets/voice_search_dialog.dart index 330494c1..35ee0d43 100644 --- a/lib/features/search/presentation/widgets/voice_search_dialog.dart +++ b/lib/features/search/presentation/widgets/voice_search_dialog.dart @@ -320,7 +320,7 @@ class _VoiceSearchDialogState extends State return Dialog( backgroundColor: AppColors.surface, insetPadding: const EdgeInsets.symmetric(horizontal: 32), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Padding( @@ -389,6 +389,9 @@ class _VoiceSearchDialogState extends State spacing: 4, children: [ TextButton( + // While listening there is nothing else to focus: Done + // stays disabled until a word is heard. + autofocus: isTvPlatform && active, onPressed: _cancel, child: Text('general.cancel'.tr()), ), diff --git a/lib/features/support/data/support_repository.dart b/lib/features/support/data/support_repository.dart index 40e4a5cd..6d0ae039 100644 --- a/lib/features/support/data/support_repository.dart +++ b/lib/features/support/data/support_repository.dart @@ -57,7 +57,12 @@ class SupportRepository { if (type == null) { throw SupportException('support.error_attachment_type'.tr()); } - final size = await file.length(); + final int size; + try { + size = await file.length(); + } on FileSystemException { + throw SupportException('support.error_attachment'.tr()); + } if (size > maxAttachmentBytes) { throw SupportException('support.error_attachment_size'.tr()); } @@ -86,7 +91,11 @@ class SupportRepository { }, ), ); - } on DioException { + } on DioException catch (e) { + throw SupportException( + _offline(e) ? 'support.error_offline'.tr() : 'support.error_attachment'.tr(), + ); + } on FileSystemException { throw SupportException('support.error_attachment'.tr()); } return key; @@ -192,6 +201,7 @@ class SupportRepository { final body = e.response?.data; final code = body is Map ? body['code']?.toString() : null; final key = switch ((e.response?.statusCode, code)) { + _ when _offline(e) => 'support.error_offline', (429, _) => 'support.error_too_many', (_, 'SUPPORT_MESSAGE') => 'support.error_short', (_, 'SUPPORT_THREAD_FULL') => 'support.error_thread_full', @@ -203,6 +213,20 @@ class SupportRepository { _ => 'support.error_generic', }; throw SupportException(key.tr(), code: code); + } on SupportException { + rethrow; + } catch (_) { + // Anything else (a reply this app cannot read, a file that went + // away) still has to end the spinner with a message. + throw SupportException('support.error_generic'.tr()); } } + + static bool _offline(DioException e) => switch (e.type) { + DioExceptionType.connectionError || + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => true, + _ => false, + }; } diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart index f92365ca..8921972b 100644 --- a/lib/features/support/presentation/pages/support_new_page.dart +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -122,14 +122,21 @@ class _SupportNewPageState extends State { context, ).showSnackBar(SnackBar(content: Text('support.sent'.tr()))); context.pushReplacement('/support/${ticket.id}'); - } on SupportException catch (e) { - if (mounted) { - setState(() { - _sending = false; - _progress = null; - _error = e.message; - }); - } + } catch (e) { + if (!mounted) return; + final message = e is SupportException + ? e.message + : 'support.error_generic'.tr(); + setState(() { + _sending = false; + _progress = null; + _error = message; + }); + // The inline line can sit below the keyboard or the fold; say it + // where it is seen too, so nobody wonders whether it went. + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text('support.not_sent'.tr(args: [message])))); } } diff --git a/lib/features/watch_party/presentation/pages/watch_party_page.dart b/lib/features/watch_party/presentation/pages/watch_party_page.dart index afc21c63..bcc0ac5c 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; @@ -420,13 +421,10 @@ class _WatchPartyPageState extends State { if (s.isHost) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => showPartyInviteSheet(context), - icon: const Icon(Icons.person_add_alt_1_rounded), - label: Text('watch_party.invite_friends'.tr()), - ), + child: AppSecondaryButton( + onPressed: () => showPartyInviteSheet(context), + icon: Icons.person_add_alt_1_rounded, + label: 'watch_party.invite_friends'.tr(), ), ), PartyHostSettings(room: room, service: _service), diff --git a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart index fc1b9aff..364bcffc 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -285,19 +285,22 @@ class _Composer extends StatelessWidget { ), ), const SizedBox(width: 4), - Material( - color: AppColors.primary, - shape: const CircleBorder(), - child: InkWell( - onTap: onSend, - customBorder: const CircleBorder(), - child: const SizedBox( - width: 36, - height: 36, - child: Icon( - Icons.send_rounded, - color: Colors.white, - size: 16, + Tooltip( + message: 'support.send'.tr(), + child: Material( + color: AppColors.primary, + shape: const CircleBorder(), + child: InkWell( + onTap: onSend, + customBorder: const CircleBorder(), + child: const SizedBox( + width: 36, + height: 36, + child: Icon( + Icons.send_rounded, + color: Colors.white, + size: 16, + ), ), ), ), diff --git a/lib/features/watch_party/presentation/widgets/party_host_settings.dart b/lib/features/watch_party/presentation/widgets/party_host_settings.dart index 83b00e31..89a619a8 100644 --- a/lib/features/watch_party/presentation/widgets/party_host_settings.dart +++ b/lib/features/watch_party/presentation/widgets/party_host_settings.dart @@ -27,7 +27,7 @@ class PartyHostSettings extends StatelessWidget { final size = room.maxMembers.clamp(minMembers, limit); return Container( margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), - padding: const EdgeInsets.fromLTRB(14, 10, 6, 6), + padding: const EdgeInsetsDirectional.fromSTEB(14, 10, 6, 6), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(14), @@ -58,6 +58,7 @@ class PartyHostSettings extends StatelessWidget { ), ), IconButton( + tooltip: 'watch_party.room_size_less'.tr(), icon: const Icon(Icons.remove_rounded), onPressed: size > minMembers ? () => service.updateSettings(maxMembers: size - 1) @@ -76,6 +77,7 @@ class PartyHostSettings extends StatelessWidget { ), ), IconButton( + tooltip: 'watch_party.room_size_more'.tr(), icon: const Icon(Icons.add_rounded), onPressed: size < limit ? () => service.updateSettings(maxMembers: size + 1) @@ -123,10 +125,12 @@ class _Toggle extends StatelessWidget { Widget build(BuildContext context) { return SwitchListTile( dense: true, - contentPadding: const EdgeInsets.only(right: 4), + contentPadding: const EdgeInsetsDirectional.only(end: 4), value: value, onChanged: onChanged, - activeThumbColor: AppColors.primary, + // Same as the settings switches: a white thumb on an accent track. + activeThumbColor: Colors.white, + activeTrackColor: AppColors.primary, title: Text( title, style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), diff --git a/lib/features/watch_party/presentation/widgets/party_member_bar.dart b/lib/features/watch_party/presentation/widgets/party_member_bar.dart index 3401d331..bc2153ff 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -139,8 +139,8 @@ class PartyMemberBar extends StatelessWidget { isHost && onAction != null && !isMe && !m.isHost; final tile = _MemberTile(member: m, isMe: isMe); if (!actionable) return tile; - return InkWell( - borderRadius: BorderRadius.circular(12), + return HoverTap( + borderRadius: 12, onTap: () => _openMenu(context, m), onLongPress: () => _openMenu(context, m), child: tile, diff --git a/test/features/detail/playback_fault_test.dart b/test/features/detail/playback_fault_test.dart index b922e136..b5b3d002 100644 --- a/test/features/detail/playback_fault_test.dart +++ b/test/features/detail/playback_fault_test.dart @@ -213,6 +213,14 @@ void main() { }); }); + test("mpv's Failed to open is a blocked source, not the raw address", () { + final fault = PlaybackFault.classify( + 'Bad state: Failed to open https://fetch.example/anime/a/b/master.m3u8.', + ); + expect(fault.kind, PlaybackFaultKind.sourceBlocked); + expect(fault.raw, contains('master.m3u8')); + }); + test('the domain layer stays free of Flutter', () { // If this file ever imports material.dart the fault can no longer be // classified in a plain Dart test, which is most of why it exists.