diff --git a/assets/icons/anilist.svg b/assets/icons/anilist.svg index 24b5e0a..7ca1e5f 100644 --- a/assets/icons/anilist.svg +++ b/assets/icons/anilist.svg @@ -1,4 +1 @@ - - - - +AniList logoAnime and manga tracking website \ No newline at end of file diff --git a/assets/js/mangayomi_bridge.js b/assets/js/mangayomi_bridge.js index 6da4be1..ab47bb7 100644 --- a/assets/js/mangayomi_bridge.js +++ b/assets/js/mangayomi_bridge.js @@ -505,7 +505,23 @@ } } + // Kept so switching back to a source already seen is a pointer assignment + // instead of another new Function(code) compile. A cross-search touching + // six sources used to recompile all six on every query. + const registry = globalThis.__sozoProviders || (globalThis.__sozoProviders = {}); + if (source && source.id != null) registry[String(source.id)] = instance; + + globalThis.__sozoProvider = instance; + return true; + }; + + /** Makes an already-loaded extension current. False when it is not loaded. */ + globalThis.__sozoActivateMangayomi = function (id) { + const registry = globalThis.__sozoProviders || {}; + const instance = registry[String(id)]; + if (!instance) return false; globalThis.__sozoProvider = instance; + globalThis.__sozoSource = instance.source || globalThis.__sozoSource; return true; }; diff --git a/assets/translations/en.json b/assets/translations/en.json index 3ac0ff9..0934249 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -51,7 +51,19 @@ "welcome_back": "Welcome back", "create_account": "Create account", "login_subtitle": "Sign in to use your profile and saved data.", - "register_subtitle": "Create an account with email, username, and password." + "register_subtitle": "Create an account with email, username, and password.", + "forgot_password_title": "Reset your password", + "forgot_password_subtitle": "Enter the email on your account and we will send a 6-digit code.", + "reset_password_subtitle": "We sent a code to {email}. Enter it with your new password.", + "send_reset_code": "Send code", + "reset_code_sent": "If that email is registered, a code is on its way", + "reset_code_hint": "6-digit code", + "invalid_reset_code": "Enter the 6-digit code", + "new_password_hint": "New password", + "confirm_password_hint": "Repeat new password", + "reset_password": "Reset password", + "resend_code": "Send another code", + "resend_in": "Send again in {seconds}s" }, "home": { "title": "Home", @@ -178,7 +190,8 @@ "engine_sheet_subtitle": "Pick the engine for this video. You can change it any time in Settings → Player.", "engine_sheet_dont_ask": "Don't ask again", "engine_sheet_play": "Play", - "subtitle_preview_text": "The quick brown fox jumps over the lazy dog" + "subtitle_preview_text": "The quick brown fox jumps over the lazy dog", + "server": "Server" }, "profile": { "title": "Profile", @@ -315,7 +328,31 @@ "local_group": "Local", "search_in": "Search in", "sources_hint": "Search several sources at once. Leave empty to use your current provider.", - "searching": "Searching…" + "searching": "Searching…", + "all_source_search": "All-source search", + "cross_hint": "Search across your sources…", + "pick_sources": "Pick which sources to search across.", + "choose_sources": "Choose sources", + "no_sources_selected": "No sources selected — tap to choose", + "sources_selected": "{} selected — tap to change", + "type_to_search_n": "Type to search {} sources at once.", + "found_in": "Found in {} of {} sources", + "results_n": "{} results", + "waiting_for": "Searching {}…", + "no_results_any": "No results in any selected source.", + "offline_note": "Offline — only on-device sources can be searched.", + "group_by_source": "Group by source", + "merge_titles": "Merge titles", + "open_from": "Open from", + "load_more": "Load more", + "search_failed": "Search failed", + "source_failed": "This source is unavailable right now", + "try_all_sources": "Try all sources", + "search_sources": "Search sources", + "filter_providers": "Filter providers…", + "selected_n": "{} selected", + "apply_n": "Apply ({})", + "many_sources_warning": "Searching {} sources may be slow. The app stays responsive, but fewer is snappier." }, "movie": { "rating": "Rating", @@ -686,7 +723,13 @@ "no_cast": "No cast available", "no_recommendations": "No recommendations available", "untitled": "Untitled", - "read": "Read" + "read": "Read", + "watch_later": "Watch Later", + "watched": "Watched", + "follow_series": "Follow series", + "find_other_sources": "Find other sources", + "anilist_track": "Track on AniList", + "anilist_tracked": "Tracked on AniList" }, "shorts": { "refresh": "Refresh", @@ -934,7 +977,25 @@ "n_episodes_short": "{} ep", "open_anilist": "AniList", "connect_tagline": "Track what you watch automatically", - "connect_short": "Connect" + "connect_short": "Connect", + "calendar_title": "Airing calendar", + "calendar_all": "All", + "calendar_mine": "My list", + "calendar_episode": "Episode {episode}", + "calendar_empty": "Nothing airs on this day.", + "calendar_empty_mine": "Nothing from your list airs on this day.", + "calendar_open": "Airing calendar", + "calendar_error": "Could not load the airing schedule.", + "calendar_rate_limited": "AniList is busy right now. Try again in a moment.", + "calendar_list_error": "Couldn't load your AniList list.", + "calendar_now": "now", + "calendar_count": "{count} airings", + "calendar_count_mine": "{mine} of {total} yours", + "calendar_episode_range": "Episodes {from}-{to}", + "calendar_add_planning": "Add to Planning", + "calendar_added_planning": "Added to your Planning list", + "calendar_add_failed": "Could not add this to your list", + "calendar_already_on_list": "Already on your list" }, "tracker": { "title": "Tracking", @@ -948,5 +1009,25 @@ "unfollow": "Unfollow", "unfollowed": "{} unfollowed", "undo": "Undo" + }, + "remote": { + "title": "TV remote", + "no_devices": "No linked TVs yet. Pair one from the TV app first.", + "load_failed": "Couldn't load your TVs.", + "tv_offline": "TV is offline", + "idle": "Nothing playing", + "command_failed": "That didn't reach the TV", + "search_on_tv": "SEARCH ON TV", + "search_hint": "Type here, search runs on the TV", + "back": "Back", + "home": "Home", + "open_on_tv": "Play on TV", + "sent_to_tv": "Sent to {device}", + "connected_to": "Connected to {device}", + "not_connected_to": "{device} is not connected", + "turn_tv_on": "Open Sozo on the TV to control it", + "retry": "Try again", + "navigation": "NAVIGATION", + "playback": "PLAYBACK" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 3ab29ef..a02a8b9 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -51,7 +51,19 @@ "welcome_back": "С возвращением", "create_account": "Создать аккаунт", "login_subtitle": "Войдите, чтобы использовать профиль и сохраненные данные.", - "register_subtitle": "Создайте аккаунт с email, именем пользователя и паролем." + "register_subtitle": "Создайте аккаунт с email, именем пользователя и паролем.", + "forgot_password_title": "Сброс пароля", + "forgot_password_subtitle": "Введите email вашего аккаунта — мы отправим 6-значный код.", + "reset_password_subtitle": "Код отправлен на {email}. Введите его и новый пароль.", + "send_reset_code": "Отправить код", + "reset_code_sent": "Если такой email зарегистрирован, код уже в пути", + "reset_code_hint": "6-значный код", + "invalid_reset_code": "Введите 6-значный код", + "new_password_hint": "Новый пароль", + "confirm_password_hint": "Повторите новый пароль", + "reset_password": "Сбросить пароль", + "resend_code": "Отправить ещё раз", + "resend_in": "Повторно через {seconds} с" }, "home": { "title": "Главная", @@ -178,7 +190,8 @@ "engine_sheet_subtitle": "Выберите плеер для этого видео. Изменить можно в Настройках → Плеер.", "engine_sheet_dont_ask": "Больше не спрашивать", "engine_sheet_play": "Смотреть", - "subtitle_preview_text": "Съешь ещё этих мягких французских булок" + "subtitle_preview_text": "Съешь ещё этих мягких французских булок", + "server": "Сервер" }, "profile": { "title": "Профиль", @@ -315,7 +328,31 @@ "local_group": "Локальные", "search_in": "Искать в", "sources_hint": "Ищите сразу в нескольких источниках. Оставьте пустым, чтобы использовать текущего провайдера.", - "searching": "Поиск…" + "searching": "Поиск…", + "all_source_search": "Поиск по всем источникам", + "cross_hint": "Поиск по вашим источникам…", + "pick_sources": "Выберите источники для поиска.", + "choose_sources": "Выбрать источники", + "no_sources_selected": "Источники не выбраны — нажмите, чтобы выбрать", + "sources_selected": "Выбрано: {} — нажмите, чтобы изменить", + "type_to_search_n": "Введите запрос — поиск сразу по {} источникам.", + "found_in": "Найдено в {} из {} источников", + "results_n": "{} результатов", + "waiting_for": "Ищем в {}…", + "no_results_any": "Ни в одном выбранном источнике ничего не найдено.", + "offline_note": "Нет сети — доступны только источники на устройстве.", + "group_by_source": "Группировать по источникам", + "merge_titles": "Объединять одинаковые", + "open_from": "Открыть из", + "load_more": "Показать ещё", + "search_failed": "Не удалось выполнить поиск", + "source_failed": "Этот источник сейчас недоступен", + "try_all_sources": "Искать во всех источниках", + "search_sources": "Источники поиска", + "filter_providers": "Фильтр провайдеров…", + "selected_n": "Выбрано: {}", + "apply_n": "Применить ({})", + "many_sources_warning": "Поиск по {} источникам может быть медленным. Приложение не зависнет, но с меньшим числом источников быстрее." }, "movie": { "rating": "Рейтинг", @@ -686,7 +723,13 @@ "no_cast": "Нет информации об актёрах", "no_recommendations": "Нет рекомендаций", "untitled": "Без названия", - "read": "Читать" + "read": "Читать", + "watch_later": "Смотреть позже", + "watched": "Просмотрено", + "follow_series": "Следить за сериалом", + "find_other_sources": "Найти в других источниках", + "anilist_track": "Отслеживать в AniList", + "anilist_tracked": "Отслеживается в AniList" }, "shorts": { "refresh": "Обновить", @@ -934,7 +977,25 @@ "n_episodes_short": "{} ep", "open_anilist": "AniList", "connect_tagline": "Track what you watch automatically", - "connect_short": "Connect" + "connect_short": "Connect", + "calendar_title": "Календарь выхода", + "calendar_all": "Все", + "calendar_mine": "Мой список", + "calendar_episode": "Эпизод {episode}", + "calendar_empty": "В этот день ничего не выходит.", + "calendar_empty_mine": "В этот день ничего из вашего списка не выходит.", + "calendar_open": "Календарь выхода", + "calendar_error": "Не удалось загрузить расписание выхода.", + "calendar_rate_limited": "AniList сейчас перегружен. Попробуйте через минуту.", + "calendar_list_error": "Не удалось загрузить ваш список AniList.", + "calendar_now": "сейчас", + "calendar_count": "серий: {count}", + "calendar_count_mine": "{mine} из {total} ваших", + "calendar_episode_range": "Серии {from}-{to}", + "calendar_add_planning": "В «Запланировано»", + "calendar_added_planning": "Добавлено в «Запланировано»", + "calendar_add_failed": "Не удалось добавить в список", + "calendar_already_on_list": "Уже в вашем списке" }, "tracker": { "title": "Tracking", @@ -948,5 +1009,25 @@ "unfollow": "Unfollow", "unfollowed": "{} unfollowed", "undo": "Undo" + }, + "remote": { + "title": "Пульт для ТВ", + "no_devices": "Нет привязанных ТВ. Сначала свяжите его в приложении на ТВ.", + "load_failed": "Не удалось загрузить ваши ТВ.", + "tv_offline": "ТВ не в сети", + "idle": "Ничего не воспроизводится", + "command_failed": "Команда не дошла до ТВ", + "search_on_tv": "ПОИСК НА ТВ", + "search_hint": "Печатайте здесь — поиск идёт на ТВ", + "back": "Назад", + "home": "Главная", + "open_on_tv": "Смотреть на ТВ", + "sent_to_tv": "Отправлено на {device}", + "connected_to": "Подключено к {device}", + "not_connected_to": "{device} не подключён", + "turn_tv_on": "Откройте Sozo на ТВ, чтобы управлять", + "retry": "Повторить", + "navigation": "НАВИГАЦИЯ", + "playback": "ВОСПРОИЗВЕДЕНИЕ" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 46569e0..06cabc1 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -51,7 +51,19 @@ "welcome_back": "Xush kelibsiz", "create_account": "Akkaunt yaratish", "login_subtitle": "Profilingiz va saqlangan ma'lumotlaringiz uchun kiring.", - "register_subtitle": "Email, username va parol bilan yangi akkaunt oching." + "register_subtitle": "Email, username va parol bilan yangi akkaunt oching.", + "forgot_password_title": "Parolni tiklash", + "forgot_password_subtitle": "Hisobingiz emailini kiriting — 6 xonali kod yuboramiz.", + "reset_password_subtitle": "{email} manziliga kod yubordik. Uni yangi parol bilan kiriting.", + "send_reset_code": "Kod yuborish", + "reset_code_sent": "Agar bu email ro‘yxatdan o‘tgan bo‘lsa, kod yuborildi", + "reset_code_hint": "6 xonali kod", + "invalid_reset_code": "6 xonali kodni kiriting", + "new_password_hint": "Yangi parol", + "confirm_password_hint": "Yangi parolni takrorlang", + "reset_password": "Parolni tiklash", + "resend_code": "Yana kod yuborish", + "resend_in": "{seconds} s dan keyin qayta" }, "home": { "title": "Bosh sahifa", @@ -178,7 +190,8 @@ "engine_sheet_subtitle": "Shu video uchun pleyerni tanlang. Istalgan vaqt Sozlamalar → Pleyer bo‘limidan o‘zgartirasiz.", "engine_sheet_dont_ask": "Boshqa so‘ralmasin", "engine_sheet_play": "Ijro etish", - "subtitle_preview_text": "Tez qo’ng’ir tulki yalqov itning ustidan sakraydi" + "subtitle_preview_text": "Tez qo’ng’ir tulki yalqov itning ustidan sakraydi", + "server": "Server" }, "profile": { "title": "Profil", @@ -315,7 +328,31 @@ "local_group": "Lokal", "search_in": "Qidirish manbalari", "sources_hint": "Bir vaqtning o'zida bir nechta manbada qidiring. Joriy provayderdan foydalanish uchun bo'sh qoldiring.", - "searching": "Qidirilmoqda…" + "searching": "Qidirilmoqda…", + "all_source_search": "Barcha manbalarda qidiruv", + "cross_hint": "Manbalaringiz bo'ylab qidiring…", + "pick_sources": "Qidiriladigan manbalarni tanlang.", + "choose_sources": "Manbalarni tanlash", + "no_sources_selected": "Manba tanlanmagan — tanlash uchun bosing", + "sources_selected": "{} ta tanlandi — o'zgartirish uchun bosing", + "type_to_search_n": "So'rov kiriting — {} ta manbada birdan qidiriladi.", + "found_in": "{} / {} manbada topildi", + "results_n": "{} ta natija", + "waiting_for": "{} da qidirilmoqda…", + "no_results_any": "Tanlangan manbalarning hech birida natija yo'q.", + "offline_note": "Oflayn — faqat qurilmadagi manbalarda qidirish mumkin.", + "group_by_source": "Manba bo'yicha guruhlash", + "merge_titles": "Bir xil nomlarni birlashtirish", + "open_from": "Qayerdan ochish", + "load_more": "Yana yuklash", + "search_failed": "Qidiruv bajarilmadi", + "source_failed": "Bu manba hozir mavjud emas", + "try_all_sources": "Barcha manbalarda qidirish", + "search_sources": "Qidiruv manbalari", + "filter_providers": "Provayderlarni filtrlash…", + "selected_n": "{} ta tanlandi", + "apply_n": "Qo'llash ({})", + "many_sources_warning": "{} ta manbada qidiruv sekin bo'lishi mumkin. Ilova qotmaydi, lekin manbalar kamroq bo'lsa tezroq." }, "movie": { "rating": "Reyting", @@ -686,7 +723,13 @@ "no_cast": "Aktyorlar haqida ma'lumot yo'q", "no_recommendations": "Tavsiyalar mavjud emas", "untitled": "Nomsiz", - "read": "O‘qish" + "read": "O‘qish", + "watch_later": "Keyinroq ko'rish", + "watched": "Ko'rilgan", + "follow_series": "Seriyani kuzatish", + "find_other_sources": "Boshqa manbalardan izlash", + "anilist_track": "AniList'da kuzatish", + "anilist_tracked": "AniList'da kuzatilmoqda" }, "shorts": { "refresh": "Yangilash", @@ -934,7 +977,25 @@ "n_episodes_short": "{} ep", "open_anilist": "AniList", "connect_tagline": "Track what you watch automatically", - "connect_short": "Connect" + "connect_short": "Connect", + "calendar_title": "Efir kalendari", + "calendar_all": "Hammasi", + "calendar_mine": "Mening ro‘yxatim", + "calendar_episode": "{episode}-qism", + "calendar_empty": "Bu kuni hech narsa chiqmaydi.", + "calendar_empty_mine": "Bu kuni ro‘yxatingizdan hech narsa chiqmaydi.", + "calendar_open": "Efir kalendari", + "calendar_error": "Efir jadvalini yuklab bo'lmadi.", + "calendar_rate_limited": "AniList hozir band. Bir ozdan so'ng qayta urinib ko'ring.", + "calendar_list_error": "AniList ro'yxatingizni yuklab bo'lmadi.", + "calendar_now": "hozir", + "calendar_count": "{count} ta epizod", + "calendar_count_mine": "{total} tadan {mine} tasi sizniki", + "calendar_episode_range": "{from}-{to}-epizodlar", + "calendar_add_planning": "«Rejada»ga qo'shish", + "calendar_added_planning": "«Rejada» ro'yxatiga qo'shildi", + "calendar_add_failed": "Ro'yxatga qo'shib bo'lmadi", + "calendar_already_on_list": "Allaqachon ro'yxatingizda" }, "tracker": { "title": "Tracking", @@ -948,5 +1009,25 @@ "unfollow": "Unfollow", "unfollowed": "{} unfollowed", "undo": "Undo" + }, + "remote": { + "title": "TV pulti", + "no_devices": "Bog'langan TV yo'q. Avval TV ilovasidan bog'lang.", + "load_failed": "TVlaringizni yuklab bo‘lmadi.", + "tv_offline": "TV oflayn", + "idle": "Hech narsa o‘ynatilmayapti", + "command_failed": "Buyruq TVga yetmadi", + "search_on_tv": "TVDA QIDIRISH", + "search_hint": "Shu yerga yozing — qidiruv TVda ketadi", + "back": "Orqaga", + "home": "Bosh sahifa", + "open_on_tv": "TVda ko‘rish", + "sent_to_tv": "{device} ga yuborildi", + "connected_to": "{device} ga ulandi", + "not_connected_to": "{device} ulanmagan", + "turn_tv_on": "Boshqarish uchun TVda Sozo’ni oching", + "retry": "Qayta urinish", + "navigation": "NAVIGATSIYA", + "playback": "IJRO" } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 18a0a1e..2b34992 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -163,6 +163,9 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - Flutter + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) - nanopb/encode (= 3.30910.0) @@ -207,6 +210,7 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) @@ -263,6 +267,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/local_auth_darwin/darwin" media_kit_video: :path: ".symlinks/plugins/media_kit_video/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" open_filex: :path: ".symlinks/plugins/open_filex/ios" package_info_plus: @@ -309,6 +315,7 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb media_kit_video: f3b0d035d89def15cfbbcf7dc2ae278f201e2f83 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e3a0816..3cf1bf6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; A1B2C3D40000000000000001 /* RepoFileImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* RepoFileImport.swift */; }; + A1B2C3D40000000000000003 /* SystemControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000004 /* SystemControls.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -58,6 +59,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; A1B2C3D40000000000000002 /* RepoFileImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepoFileImport.swift; sourceTree = ""; }; + A1B2C3D40000000000000004 /* SystemControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemControls.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -143,6 +145,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, A1B2C3D40000000000000002 /* RepoFileImport.swift */, + A1B2C3D40000000000000004 /* SystemControls.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -404,6 +407,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, A1B2C3D40000000000000001 /* RepoFileImport.swift in Sources */, + A1B2C3D40000000000000003 /* SystemControls.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 51b1fa1..fdb9a37 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -67,6 +67,7 @@ import UIKit // "Open with Sozo" on an extension index file. Same channel name and // contract as the Android host, so the Flutter side is platform-agnostic. RepoFileImport.register(messenger: registrar.messenger()) + SystemControls.register(with: registrar) let previewChannel = FlutterMethodChannel( name: "soplay/preview", diff --git a/ios/Runner/SystemControls.swift b/ios/Runner/SystemControls.swift new file mode 100644 index 0000000..88d8e0b --- /dev/null +++ b/ios/Runner/SystemControls.swift @@ -0,0 +1,97 @@ +import AVFoundation +import Flutter +import MediaPlayer +import UIKit + +/// Screen brightness and system volume for the player's swipe gestures. +/// +/// The channel existed on Android only, so on iOS every call threw +/// MissingPluginException straight into a `catch (_) {}` in Dart: the swipe +/// indicator moved and nothing else happened, silently, which is worse than a +/// gesture that visibly does nothing. +/// +/// Brightness is a plain public API. Volume is not — iOS has no supported way +/// for an app to set the system volume, so this drives the slider inside a +/// hidden `MPVolumeView`, which is the long-standing workaround every video app +/// uses. If Apple ever closes it, `setVolume` degrades to reporting the current +/// value rather than failing, and only the gesture stops working. +enum SystemControls { + private static let channelName = "soplay/system_controls" + + /// Kept alive for the process: the slider must be in a window to work, and + /// rebuilding it per call loses the first change of every gesture. + private static var volumeView: MPVolumeView? + + static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: registrar.messenger() + ) + + channel.setMethodCallHandler { call, result in + // UIScreen and UIWindow are main-thread only, and Flutter does not + // promise which thread a call arrives on. + DispatchQueue.main.async { + switch call.method { + case "getBrightness": + result(Double(UIScreen.main.brightness)) + + case "setBrightness": + let value = doubleArg(call, "value") ?? 0.5 + UIScreen.main.brightness = CGFloat(max(0, min(1, value))) + result(Double(UIScreen.main.brightness)) + + case "resetBrightness": + // No per-window override on iOS the way Android has one, so there is + // nothing to hand back to the system — the user's last swipe stands. + result(true) + + case "getVolume": + result(Double(AVAudioSession.sharedInstance().outputVolume)) + + case "setVolume": + let value = doubleArg(call, "value") ?? 1.0 + setSystemVolume(Float(max(0, min(1, value)))) + result(Double(AVAudioSession.sharedInstance().outputVolume)) + + default: + result(FlutterMethodNotImplemented) + } + } + } + } + + private static func doubleArg(_ call: FlutterMethodCall, _ key: String) -> Double? { + guard let args = call.arguments as? [String: Any] else { return nil } + return (args[key] as? NSNumber)?.doubleValue + } + + private static func setSystemVolume(_ value: Float) { + let view = volumeView ?? makeVolumeView() + guard let slider = view.subviews.compactMap({ $0 as? UISlider }).first else { return } + // A tick late: setting `value` inside the same run loop turn the view was + // added in is ignored, and the first swipe of a session would be lost. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + slider.value = value + slider.sendActions(for: .valueChanged) + } + } + + private static func makeVolumeView() -> MPVolumeView { + // Off-screen rather than hidden: `isHidden` stops the slider responding at + // all, so it has to be in the hierarchy and simply out of sight. + let view = MPVolumeView(frame: CGRect(x: -2000, y: -2000, width: 1, height: 1)) + view.alpha = 0.001 + view.isUserInteractionEnabled = false + keyWindow()?.addSubview(view) + volumeView = view + return view + } + + private static func keyWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } +} diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 194aa70..9acb7ed 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -19,6 +19,7 @@ import 'package:soplay/core/network/provider_interceptor.dart'; import 'package:soplay/core/player/local_hls_proxy.dart'; import 'package:soplay/core/player/webview_stream_extractor.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/remote/data/remote_control_service.dart'; import 'package:soplay/features/streak/data/streak_remote_data_source.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; @@ -30,6 +31,7 @@ import 'package:soplay/features/history/data/history_sync_service.dart'; import 'package:soplay/features/anilist/data/anilist_link_store.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; +import 'package:soplay/features/auth/domain/usecases/forgot_password_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/register_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/resend_otp_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/verify_otp_usecase.dart'; @@ -189,6 +191,9 @@ Future configureDependencies() async { // AniList. Rides the authenticated backend client because the link lives on // the Sozo account — the client secret stays on the server, and a TV signed // into the same account inherits the connection without its own sign-in. + getIt.registerLazySingleton( + () => RemoteControlService(dio: getIt()), + ); getIt.registerSingleton(AnilistLinkStore()); getIt.registerSingleton( AnilistService( @@ -512,6 +517,12 @@ Future configureDependencies() async { getIt.registerSingleton( VerifyOtpUseCase(getIt()), ); + getIt.registerSingleton( + RequestPasswordResetUseCase(getIt()), + ); + getIt.registerSingleton( + ResetPasswordUseCase(getIt()), + ); getIt.registerSingleton( ResendOtpUseCase(getIt()), ); @@ -532,6 +543,8 @@ Future configureDependencies() async { registerUseCase: getIt(), verifyOtpUseCase: getIt(), resendOtpUseCase: getIt(), + requestPasswordResetUseCase: getIt(), + resetPasswordUseCase: getIt(), authRepository: getIt(), hiveService: getIt(), notificationService: getIt(), diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index 387d3fa..8d80b1d 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -23,11 +23,21 @@ class JsRuntimeService { InAppWebViewController? _controller; Future? _ready; ExtractorManifest? _manifest; - String? _activeExtractor; - int? _activeVersion; - - // Serializes the extractor-swap + JS call critical section: every provider - // shares one webview and a single mutable globalThis.Provider. + /// Extractors already evaluated into the page, keyed `name@version`. + /// + /// Each is registered ONCE per session. The previous design kept a single + /// mutable `globalThis.Provider` and re-evaluated the whole extractor source + /// whenever a different provider was asked for — so a cross-search over six + /// providers re-parsed six scripts, in sequence, on every query. + final Set _registered = {}; + + /// Registrations in flight, so two legs racing for the same provider fetch + /// and evaluate it once rather than twice. + final Map> _registering = >{}; + + // Serializes registration only. Calls no longer need a gate: they resolve + // their provider from a registry rather than a shared mutable global, so a + // second leg can no longer swap it out mid-flight. Future _jsGate = Future.value(); static const String _runtimeName = '__runtime__'; @@ -159,23 +169,31 @@ class JsRuntimeService { // webview and one globalThis.Provider, so without this a second leg could // swap Provider between this leg's setup and its call — returning one // provider's results under another's name. - final result = await _locked(() async { - await _ensureExtractor(extractor.name, extractor.version); - return _controller!.callAsyncJavaScript( - functionBody: r''' - const __fn = (typeof Provider !== 'undefined') ? Provider[fnName] : null; + await _ensureExtractor(extractor.name, extractor.version); + // Unlocked on purpose. The provider is looked up by name, so several + // cross-search legs can be in flight at once and their network waits + // overlap instead of queueing — which is what made searching several + // sources feel frozen. + final result = await _controller!.callAsyncJavaScript( + functionBody: r''' + const __registry = (globalThis.__sozo || {}).providers || {}; + const __p = __registry[providerName]; + if (!__p) { + throw new Error('Provider "' + providerName + '" is not loaded'); + } + const __fn = __p[fnName]; if (typeof __fn !== 'function') { throw new Error('Provider.' + fnName + ' is not implemented'); } - const __r = await __fn.apply(Provider, fnArgs); + const __r = await __fn.apply(__p, fnArgs); return __r === undefined ? null : __r; ''', - arguments: { - 'fnName': fn, - 'fnArgs': args, - }, - ); - }); + arguments: { + 'providerName': extractor.name, + 'fnName': fn, + 'fnArgs': args, + }, + ); if (result == null) { JsLog.err(tag, '$fn returned null result'); @@ -324,11 +342,22 @@ class JsRuntimeService { await _controller!.evaluateJavascript(source: code); } - Future _ensureExtractor(String name, int wantedVersion) async { + /// Evaluates [name] into the page if it is not already there. + /// + /// Returns as soon as the extractor is registered; repeat calls are free. + Future _ensureExtractor(String name, int wantedVersion) { + final pending = _registering[name]; + if (pending != null) return pending; + final run = _registerExtractor(name, wantedVersion); + _registering[name] = run; + return run.whenComplete(() => _registering.remove(name)); + } + + Future _registerExtractor(String name, int wantedVersion) async { final manifest = _manifest ??= await remote.fetchManifest(); final entry = manifest.byName(name); final version = entry?.version ?? wantedVersion; - if (_activeExtractor == name && _activeVersion == version) return; + if (_registered.contains('$name@$version')) return; final cachedVersion = cache.readVersion(name); String? code; @@ -346,18 +375,24 @@ class JsRuntimeService { ); } if (code.isEmpty) throw StateError('Extractor "$name" JS is empty'); + // Each extractor keeps its own slot. `Provider` stays lexically scoped to + // this IIFE, so an extractor's own methods referring to it by name still + // resolve to their own object rather than to whoever registered last. + final slot = jsonEncode(name); final wrapped = ''' (function(){ - try { delete globalThis.Provider; } catch (e) {} + const __sozo = globalThis.__sozo || (globalThis.__sozo = {}); + const __providers = __sozo.providers || (__sozo.providers = {}); $code if (typeof Provider !== 'undefined') { - globalThis.Provider = Provider; + __providers[$slot] = Provider; } })(); '''; - await _controller!.evaluateJavascript(source: wrapped); - _activeExtractor = name; - _activeVersion = version; + // Evaluation mutates the shared page, so one at a time — but only the + // evaluation, which now happens once per extractor instead of once per call. + await _locked(() => _controller!.evaluateJavascript(source: wrapped)); + _registered.add('$name@$version'); } Future dispose() async { @@ -369,7 +404,7 @@ class JsRuntimeService { _webView = null; _controller = null; _ready = null; - _activeExtractor = null; - _activeVersion = null; + _registered.clear(); + _registering.clear(); } } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 607b4ef..01df312 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import 'package:soplay/features/app_lock/presentation/pages/app_lock_settings_pa import 'package:soplay/features/app_lock/presentation/pages/pin_setup_page.dart'; import 'package:soplay/features/app_lock/presentation/pages/pin_verify_page.dart'; import 'package:soplay/features/desktop_share/presentation/pages/desktop_share_page.dart'; +import 'package:soplay/features/auth/presentation/pages/forgot_password_page.dart'; import 'package:soplay/features/auth/presentation/pages/login_page.dart'; import 'package:soplay/features/auth/presentation/pages/otp_verify_page.dart'; import 'package:soplay/features/auth/presentation/pages/register_page.dart'; @@ -27,6 +28,7 @@ import 'package:soplay/features/manga/presentation/pages/reader_page.dart'; import 'package:soplay/features/main/presentation/pages/main_page.dart'; import 'package:soplay/features/network/presentation/pages/no_internet_page.dart'; import 'package:soplay/features/search/presentation/pages/cross_search_page.dart'; +import 'package:soplay/features/anilist/presentation/pages/airing_calendar_page.dart'; import 'package:soplay/features/anilist/presentation/pages/anilist_library_page.dart'; import 'package:soplay/features/anilist/presentation/pages/anilist_links_page.dart'; import 'package:soplay/features/anilist/presentation/pages/connections_page.dart'; @@ -45,6 +47,8 @@ import 'package:soplay/features/trivia/presentation/trivia_args.dart'; import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; import 'package:soplay/features/user_lists/presentation/pages/user_lists_page.dart'; import 'package:soplay/features/profile/presentation/pages/player_settings_page.dart'; +import 'package:soplay/features/profile/presentation/pages/providers_page.dart'; +import 'package:soplay/features/remote/presentation/pages/tv_remote_page.dart'; import 'package:soplay/features/profile/presentation/pages/profile_page.dart'; import 'package:soplay/features/notifications/presentation/pages/notifications_page.dart'; import 'package:soplay/features/private_list/presentation/pages/private_list_page.dart'; @@ -177,6 +181,10 @@ class AppRouter { path: '/following', builder: (context, state) => const FollowingPage(), ), + GoRoute( + path: '/tv-remote', + builder: (context, state) => const TvRemotePage(), + ), GoRoute( path: '/connections', builder: (context, state) => const ConnectionsPage(), @@ -191,6 +199,10 @@ class AppRouter { path: '/anilist', builder: (context, state) => const AnilistLibraryPage(), ), + GoRoute( + path: '/anilist/calendar', + builder: (context, state) => const AiringCalendarPage(), + ), GoRoute( path: '/anilist/links', builder: (context, state) => const AnilistLinksPage(), @@ -203,6 +215,10 @@ class AppRouter { path: '/player-settings', builder: (context, state) => const PlayerSettingsPage(), ), + GoRoute( + path: '/providers', + builder: (context, state) => const ProvidersPage(), + ), GoRoute( path: '/notifications', builder: (context, state) => const NotificationsPage(), @@ -230,6 +246,11 @@ class AppRouter { GoRoute(path: '/splash', builder: (context, state) => const SplashPage()), GoRoute(path: '/main', builder: (context, state) => const MainPage()), GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + GoRoute( + path: '/forgot-password', + builder: (context, state) => + ForgotPasswordPage(initialEmail: state.extra as String?), + ), GoRoute( path: '/register', builder: (context, state) => const RegisterPage(), diff --git a/lib/core/widgets/app_tab_bar.dart b/lib/core/widgets/app_tab_bar.dart index ade16f4..a11a3fd 100644 --- a/lib/core/widgets/app_tab_bar.dart +++ b/lib/core/widgets/app_tab_bar.dart @@ -151,7 +151,7 @@ class _AppTabBarState extends State if (widget.showDivider) Container( height: AppTabBar._dividerHeight, - color: AppColors.divider, + color: AppColors.divider.withValues(alpha: 0.55), ), ], ), diff --git a/lib/features/anilist/data/anilist_api.dart b/lib/features/anilist/data/anilist_api.dart index d7db437..0122fc4 100644 --- a/lib/features/anilist/data/anilist_api.dart +++ b/lib/features/anilist/data/anilist_api.dart @@ -25,6 +25,30 @@ class AnilistApi { final Dio _dio; + /// When the next request may be sent, after AniList answered 429. + /// + /// AniList's budget is small and enforced hard, and the calendar spends + /// several requests per day tapped — flicking across the strip trips it. + /// Failing fast until the window reopens beats firing a request that is + /// certain to be rejected. + DateTime? _throttledUntil; + + static const String _rateLimitMessage = 'AniList is rate limiting requests'; + + static Duration _retryAfter(Response? response) { + final headers = response?.headers; + final retryAfter = int.tryParse(headers?.value('retry-after') ?? ''); + if (retryAfter != null && retryAfter > 0) { + return Duration(seconds: retryAfter.clamp(1, 300)); + } + final reset = int.tryParse(headers?.value('x-ratelimit-reset') ?? ''); + if (reset != null) { + final left = reset - DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (left > 0) return Duration(seconds: left.clamp(1, 300)); + } + return const Duration(seconds: 60); + } + /// The media selection every query shares. /// /// One constant rather than a copy per query: the entity parses these fields @@ -42,9 +66,28 @@ class AnilistApi { coverImage { large } bannerImage description(asHtml: false) + isAdult nextAiringEpisode { episode airingAt } '''; + /// The lean selection the airing calendar uses. + /// + /// A global day runs to a hundred-odd airings over several pages, and the + /// heavy fields above — description most of all — cost a few hundred KB per + /// day tapped for values the calendar never renders. Named next to + /// [_mediaFields] rather than inlined so both selections stay visible + /// together when a field is added. + static const String _airingMediaFields = ''' + id + episodes + format + status + siteUrl + title { romaji english native } + coverImage { large } + isAdult + '''; + /// Runs [query]. [token] is optional: search and media lookups are public, /// only the viewer's own list and any write need it. Future> _run( @@ -52,13 +95,28 @@ class AnilistApi { Map variables = const {}, String? token, }) async { - final response = await _dio.post( - AnilistConstants.graphqlEndpoint, - data: {'query': query, 'variables': variables}, - options: Options( - headers: {if (token != null) 'Authorization': 'Bearer $token'}, - ), - ); + final until = _throttledUntil; + if (until != null && DateTime.now().isBefore(until)) { + throw const AnilistException(_rateLimitMessage, rateLimited: true); + } + + final Response response; + try { + response = await _dio.post( + AnilistConstants.graphqlEndpoint, + data: {'query': query, 'variables': variables}, + options: Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ), + ); + } on DioException catch (e) { + if (e.response?.statusCode == 429) { + _throttledUntil = DateTime.now().add(_retryAfter(e.response)); + throw const AnilistException(_rateLimitMessage, rateLimited: true); + } + rethrow; + } + _throttledUntil = null; final body = response.data; if (body is! Map) throw const AnilistException('Unexpected AniList reply'); @@ -167,6 +225,68 @@ class AnilistApi { .toList(growable: false); } + /// Everything airing between [from] and [to]. + /// + /// Paged rather than a single large request: a day of global airings runs to + /// a few dozen entries and AniList caps perPage at 50, so asking for one page + /// silently truncates the evening. The cap on pages is a safety net against a + /// window someone widens later, not a real limit — a day never reaches it. + Future> airingSchedule({ + required DateTime from, + required DateTime to, + bool includeAdult = false, + }) async { + final gql = ''' + query (\$start: Int, \$end: Int, \$page: Int) { + Page(page: \$page, perPage: 50) { + pageInfo { hasNextPage } + airingSchedules( + airingAt_greater: \$start + airingAt_lesser: \$end + sort: TIME + ) { + episode + airingAt + media { $_airingMediaFields } + } + } + } + '''; + + // `airingAt_greater` is strictly greater, so the one-second nudge is what + // keeps an episode airing exactly at midnight from falling between two + // days — excluded from this one for equalling its start, and from the + // previous one for that day's inclusive `airingAt_lesser`. + final start = from.toUtc().millisecondsSinceEpoch ~/ 1000 - 1; + final end = to.toUtc().millisecondsSinceEpoch ~/ 1000; + final out = []; + + for (var page = 1; page <= 10; page++) { + final data = await _run( + gql, + variables: {'start': start, 'end': end, 'page': page}, + ); + final pageData = data['Page']; + if (pageData is! Map) break; + + final schedules = pageData['airingSchedules']; + if (schedules is List) { + for (final raw in schedules.whereType()) { + final airing = + AnilistScheduledAiring.fromJson(raw.cast()); + if (airing == null) continue; + if (!includeAdult && airing.media.isAdult) continue; + out.add(airing); + } + } + + final info = pageData['pageInfo']; + final hasNext = info is Map && info['hasNextPage'] == true; + if (!hasNext) break; + } + return out; + } + /// The viewer's own entry for one title, or null if it is not on their list. /// /// Read before every automatic write so progress is never moved BACKWARDS: @@ -196,6 +316,26 @@ class AnilistApi { ); } + /// Puts a title on the viewer's list, leaving an existing entry alone. + /// + /// SaveMediaListEntry is an UPSERT. Sending progress 0 and PLANNING for a + /// media the viewer is already twelve episodes into would reset both — on + /// their real account, with no undo. So this reads first and refuses to + /// write over an entry that exists, and never sends progress at all: the + /// caller's own "is it on the list" check can be stale or, worse, false + /// simply because the library failed to load. + /// + /// Returns null when the title was already there. + Future addToList({ + required String token, + required int mediaId, + AnilistStatus status = AnilistStatus.planning, + }) async { + final existing = await entryState(token: token, mediaId: mediaId); + if (existing != null) return null; + return saveProgress(token: token, mediaId: mediaId, status: status.value); + } + /// Writes progress back to AniList. /// /// [progress] is an episode COUNT, not an index — AniList means "episodes @@ -209,7 +349,10 @@ class AnilistApi { Future saveProgress({ required String token, required int mediaId, - required int progress, + // Optional so a status-only write leaves the viewer's position untouched. + // The mutation omits the argument entirely rather than sending null, which + // AniList would take as "set it to nothing". + int? progress, String? status, }) async { const mutation = ''' @@ -225,15 +368,17 @@ class AnilistApi { mutation, variables: { 'mediaId': mediaId, - 'progress': progress, + 'progress': ?progress, 'status': ?status, }, token: token, ); final saved = data['SaveMediaListEntry']; - if (saved is! Map) throw const AnilistException('AniList saqlamadi'); + if (saved is! Map) { + throw const AnilistException('AniList did not save the change'); + } return AnilistSaveResult( - progress: (saved['progress'] as num?)?.toInt() ?? progress, + progress: (saved['progress'] as num?)?.toInt() ?? progress ?? 0, status: saved['status'] as String? ?? status ?? AnilistStatus.current.value, ); } @@ -262,8 +407,13 @@ class AnilistEntryState { } class AnilistException implements Exception { - const AnilistException(this.message); + const AnilistException(this.message, {this.rateLimited = false}); final String message; + + /// AniList refused on its request budget, not because anything is wrong. + /// Callers can say "try again in a moment" rather than "it failed". + final bool rateLimited; + @override String toString() => message; } diff --git a/lib/features/anilist/domain/entities/anilist_entities.dart b/lib/features/anilist/domain/entities/anilist_entities.dart index 0fb078d..78ca910 100644 --- a/lib/features/anilist/domain/entities/anilist_entities.dart +++ b/lib/features/anilist/domain/entities/anilist_entities.dart @@ -62,6 +62,40 @@ class AnilistAiring { } /// One anime on AniList. +/// One episode of one show, at the minute it goes out. +/// +/// Distinct from [AnilistAiring], which hangs off a media object and only ever +/// describes that show's NEXT episode. The calendar asks the opposite question +/// — what airs in this window — so the airing is the subject and the media is +/// the detail. +class AnilistScheduledAiring { + const AnilistScheduledAiring({ + required this.media, + required this.episode, + required this.airingAt, + }); + + final AnilistMedia media; + final int episode; + final int airingAt; + + DateTime get airsAt => + DateTime.fromMillisecondsSinceEpoch(airingAt * 1000, isUtc: true).toLocal(); + + bool get hasAired => airsAt.isBefore(DateTime.now()); + + static AnilistScheduledAiring? fromJson(Map json) { + final rawMedia = json['media']; + final airingAt = (json['airingAt'] as num?)?.toInt(); + if (rawMedia is! Map || airingAt == null) return null; + return AnilistScheduledAiring( + media: AnilistMedia.fromJson(rawMedia.cast()), + episode: (json['episode'] as num?)?.toInt() ?? 0, + airingAt: airingAt, + ); + } +} + class AnilistMedia { const AnilistMedia({ required this.id, @@ -78,6 +112,7 @@ class AnilistMedia { this.status, this.siteUrl, this.nextAiring, + this.isAdult = false, }); final int id; @@ -94,6 +129,7 @@ class AnilistMedia { final String? status; final String? siteUrl; final AnilistAiring? nextAiring; + final bool isAdult; /// Episodes that exist to watch right now. /// @@ -147,6 +183,7 @@ class AnilistMedia { nextAiring: AnilistAiring.fromJson( (json['nextAiringEpisode'] as Map?)?.cast(), ), + isAdult: json['isAdult'] == true, ); } } diff --git a/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart b/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart new file mode 100644 index 0000000..d67973d --- /dev/null +++ b/lib/features/anilist/presentation/controllers/airing_calendar_controller.dart @@ -0,0 +1,297 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/features/anilist/data/anilist_api.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; + +/// One row of a day's list. +/// +/// Usually one airing. Two airings of the same show in the same minute are how +/// the schedule encodes a double-episode drop, not two events — rendering them +/// as two identical rows differing only in episode number reads as a bug. Two +/// airings at two DIFFERENT times stay two rows, because that is the truth. +class AiringDayRow { + const AiringDayRow({required this.airing, required this.lastEpisode}); + + final AnilistScheduledAiring airing; + final int lastEpisode; + + int get firstEpisode => airing.episode; + bool get isRange => lastEpisode > airing.episode; +} + +/// The airing window, one day at a time. +/// +/// Days are fetched on demand and kept, rather than pulling the window up +/// front: a day of global airings is a hundred-odd entries and most of the +/// window is days the user never looks at. Switching back to a day already +/// seen is then instant. +class AiringCalendarController extends ChangeNotifier { + AiringCalendarController({required AnilistService service}) + : _service = service, + _mineOnly = _readMineOnly(); + + /// Yesterday earns its pill — "did last night's episode actually drop?" is a + /// real question. Two weeks forward is roughly as far ahead as AniList + /// announces. Backwards beyond yesterday is left to the library views. + static const int _daysBefore = 1; + static const int _daysAfter = 13; + + /// How long a fetched day is trusted before a re-select refetches it. Today + /// moves — delays and newly announced episodes land on it — so it is held + /// far more briefly than a day that is already fixed. + static const Duration _todayFreshness = Duration(minutes: 10); + static const Duration _dayFreshness = Duration(hours: 6); + + static const String _mineOnlyKey = 'anilist_calendar_mine_only'; + + final AnilistService _service; + + /// Cache and in-flight state are keyed by the day's midnight, so a rebuild + /// with a fresh DateTime.now() still hits the same entry. + final Map> _byDay = {}; + final Map _fetchedAt = {}; + final Map _errors = {}; + final Map> _inFlight = {}; + + /// Media ids on the viewer's own list, used to mark and to filter. + Set _mine = {}; + + bool _mineOnly; + + /// Today as the strip currently draws it. Held rather than recomputed per + /// build so the strip and the selection can never disagree about which day + /// is which; [syncToToday] is what moves it. + DateTime _anchor = startOfDay(DateTime.now()); + late DateTime _selected = _anchor; + + DateTime get selected => _selected; + DateTime get today => _anchor; + bool get isConnected => _service.isConnected; + + /// The filter only exists while connected, so a disconnect falls back to All + /// without discarding the preference a reconnect should restore. + bool get mineOnly => _mineOnly && _service.isConnected; + + /// The days on offer, anchored to today rather than to the calendar week: + /// "what's on" is read forward, and a Sunday would otherwise offer six days + /// that have already happened. + List get days => List.generate( + _daysBefore + 1 + _daysAfter, + (i) => DateTime(_anchor.year, _anchor.month, _anchor.day - _daysBefore + i), + ); + + int get selectedIndex { + final index = days.indexOf(_selected); + return index < 0 ? _daysBefore : index; + } + + bool isMine(int mediaId) => _mine.contains(mediaId); + + /// The day's airings with the filter applied. + List airingsFor(DateTime day) { + final all = _byDay[startOfDay(day)] ?? const []; + if (!mineOnly) return all; + return all.where((a) => _mine.contains(a.media.id)).toList(growable: false); + } + + List rowsFor(DateTime day) => _collapse(airingsFor(day)); + + /// How many airings the day holds under the current filter, or null when the + /// day has never been fetched — the two are not the same answer, and a strip + /// marker that conflated them would confidently claim a dozen empty days. + int? countFor(DateTime day) { + final all = _byDay[startOfDay(day)]; + if (all == null) return null; + if (!mineOnly) return all.length; + return all.where((a) => _mine.contains(a.media.id)).length; + } + + int totalFor(DateTime day) => (_byDay[startOfDay(day)] ?? const []).length; + + /// Whether the day holds anything at all, regardless of the filter — so the + /// empty state can say "nothing on your list today" instead of "nothing at + /// all today", which would be wrong. + bool hasAnyFor(DateTime day) => totalFor(day) > 0; + + bool loadingFor(DateTime day) => _inFlight.containsKey(startOfDay(day)); + String? errorFor(DateTime day) => _errors[startOfDay(day)]; + + /// The day has never resolved — in flight, or not asked for yet, which is + /// what the page next to the one being swiped away from looks like until + /// the swipe settles and selects it. + bool isPendingFor(DateTime day) { + final key = startOfDay(day); + return !_byDay.containsKey(key) && !_errors.containsKey(key); + } + + /// A failed refresh over a day that still holds data. The list keeps showing + /// what it has, so the failure needs saying somewhere other than the empty + /// state. + bool isStaleFor(DateTime day) => + errorFor(day) != null && hasAnyFor(day) && !loadingFor(day); + + void select(DateTime day) { + final normalised = startOfDay(day); + if (normalised == _selected) return; + _selected = normalised; + notifyListeners(); + load(); + } + + void setMineOnly(bool value) { + if (_mineOnly == value) return; + _mineOnly = value; + _persistMineOnly(value); + notifyListeners(); + } + + /// Records which media the viewer follows, and reports whether that actually + /// changed — a caller listening to the library as well can then skip the + /// rebuild this already sent. + /// + /// Passed in rather than fetched here: the library screens already hold this + /// list, and a second copy would drift from theirs after an edit. + bool setLibrary(Iterable entries) { + final ids = entries.map((e) => e.media.id).toSet(); + if (setEquals(ids, _mine)) return false; + _mine = ids; + notifyListeners(); + return true; + } + + /// Re-anchors the window when the clock has crossed midnight. + /// + /// Needed on resume above all: an app left backgrounded overnight comes back + /// with a selection that is no longer on the strip, showing yesterday's + /// schedule under a strip that starts at today. + void syncToToday() { + final now = startOfDay(DateTime.now()); + if (now == _anchor) return; + + final wasOnToday = _selected == _anchor; + _anchor = now; + final first = days.first; + _byDay.removeWhere((day, _) => day.isBefore(first)); + _fetchedAt.removeWhere((day, _) => day.isBefore(first)); + _errors.removeWhere((day, _) => day.isBefore(first)); + if (wasOnToday || _selected.isBefore(first)) _selected = now; + notifyListeners(); + load(); + } + + Future load({bool force = false}) => loadDay(_selected, force: force); + + Future loadDay(DateTime day, {bool force = false}) { + final key = startOfDay(day); + + // A forced refresh coalesces onto an in-flight fetch rather than being + // swallowed by it: pulling to refresh during the first load then holds the + // indicator until the data actually lands, instead of snapping shut. + final inFlight = _inFlight[key]; + if (inFlight != null) return inFlight; + if (!force && _byDay.containsKey(key) && !_isStale(key)) { + return Future.value(); + } + + final future = _fetch(key); + _inFlight[key] = future; + notifyListeners(); + return future; + } + + Future _fetch(DateTime day) async { + _errors.remove(day); + try { + _byDay[day] = await _service.api.airingSchedule( + from: day, + to: DateTime(day.year, day.month, day.day + 1), + ); + _fetchedAt[day] = DateTime.now(); + _prefetchNext(day); + } catch (e) { + _errors[day] = e is AnilistException + ? (e.rateLimited ? 'anilist.calendar_rate_limited'.tr() : e.message) + : 'anilist.calendar_error'.tr(); + } finally { + _inFlight.remove(day); + // The day may no longer be selected — the user can switch while a fetch + // is in flight — but the result is cached either way, so notifying is + // still correct: it repaints whichever day they landed on. + notifyListeners(); + } + } + + /// Warms tomorrow, and only tomorrow. + /// + /// Forward is the direction this list is read, so one day of lookahead takes + /// the skeleton out of the common swipe. The whole window would be a burst of + /// thirty-odd requests against a budget AniList enforces tightly, to warm + /// days most viewers never open. + void _prefetchNext(DateTime day) { + if (day != _selected) return; + final next = DateTime(day.year, day.month, day.day + 1); + if (next.isAfter(days.last)) return; + if (_byDay.containsKey(next) || _inFlight.containsKey(next)) return; + _inFlight[next] = _fetch(next); + } + + bool _isStale(DateTime day) { + final at = _fetchedAt[day]; + if (at == null) return true; + final age = DateTime.now().difference(at); + return age > (day == _anchor ? _todayFreshness : _dayFreshness); + } + + /// Collapses same-show, same-minute airings into one row. + static List _collapse(List airings) { + final out = []; + final at = {}; + for (final airing in airings) { + final key = '${airing.media.id}@${airing.airingAt ~/ 60}'; + final index = at[key]; + if (index == null) { + at[key] = out.length; + out.add(AiringDayRow(airing: airing, lastEpisode: airing.episode)); + continue; + } + final row = out[index]; + out[index] = AiringDayRow( + airing: airing.episode < row.airing.episode ? airing : row.airing, + lastEpisode: airing.episode > row.lastEpisode + ? airing.episode + : row.lastEpisode, + ); + } + return out; + } + + /// Built arithmetically rather than by adding a Duration: a day is not always + /// 24 hours, and on a DST transition `add(Duration(days: 1))` lands at 23:00 + /// or 01:00 — which would key the cache off a time that is not midnight and + /// hand the query a 23- or 25-hour window. + static DateTime startOfDay(DateTime value) => + DateTime(value.year, value.month, value.day); + + static bool _readMineOnly() { + try { + return Hive.box( + AppConstants.settingsBox, + ).get(_mineOnlyKey, defaultValue: false) == + true; + } catch (_) { + return false; + } + } + + static void _persistMineOnly(bool value) { + try { + Hive.box(AppConstants.settingsBox).put(_mineOnlyKey, value); + } catch (_) { + // Nothing on screen depends on this landing; the filter still works. + } + } +} diff --git a/lib/features/anilist/presentation/controllers/anilist_library_controller.dart b/lib/features/anilist/presentation/controllers/anilist_library_controller.dart index ea194f9..9f35cf1 100644 --- a/lib/features/anilist/presentation/controllers/anilist_library_controller.dart +++ b/lib/features/anilist/presentation/controllers/anilist_library_controller.dart @@ -37,6 +37,17 @@ class AnilistLibraryController extends ChangeNotifier { return out; } + /// The viewer's entry for one title, or null when it is not on their list. + /// + /// Keyed by media rather than entry id because the airing calendar only ever + /// holds a media — it starts from the schedule, not from the library. + AnilistListEntry? entryForMedia(int mediaId) { + for (final e in _entries) { + if (e.media.id == mediaId) return e; + } + return null; + } + int countOf(AnilistStatus status) => _entries.where((e) => e.status == status.value).length; diff --git a/lib/features/anilist/presentation/pages/airing_calendar_page.dart b/lib/features/anilist/presentation/pages/airing_calendar_page.dart new file mode 100644 index 0000000..ee815b6 --- /dev/null +++ b/lib/features/anilist/presentation/pages/airing_calendar_page.dart @@ -0,0 +1,1019 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/data/anilist_api.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/anilist/domain/entities/anilist_entities.dart'; +import 'package:soplay/features/anilist/presentation/controllers/airing_calendar_controller.dart'; +import 'package:soplay/features/anilist/presentation/controllers/anilist_library_controller.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_entry_sheet.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; + +/// What airs over the next two weeks, across all of AniList. +/// +/// The complement to Upcoming, which is deliberately narrow — only the shows +/// you already follow. This is the wide view, with a filter back down to yours, +/// so discovering a new season doesn't mean leaving the app. +/// +/// Works signed out: the schedule is public. Connecting only adds the "on your +/// list" marks, the filter, and the per-entry actions. +class AiringCalendarPage extends StatefulWidget { + const AiringCalendarPage({ + super.key, + this.showAppBar = true, + this.controller, + }); + + final bool showAppBar; + + /// The library, when a host already holds one. Lent rather than built here so + /// a "+1" made on another AniList screen is reflected by the marks on this + /// one, and so opening the calendar does not re-fetch the whole collection. + final AnilistLibraryController? controller; + + @override + State createState() => _AiringCalendarPageState(); +} + +class _AiringCalendarPageState extends State + with WidgetsBindingObserver { + final AnilistService _service = getIt(); + late final AiringCalendarController _calendar; + late final AnilistLibraryController _library; + late final bool _ownsLibrary = widget.controller == null; + + late final PageController _pages; + final ScrollController _strip = ScrollController(); + + /// Redraws what the clock decides: the aired fade, the time pill colour and + /// the "now" rule all move on their own, and nothing else on this page would + /// notice. Also the cheapest place to catch midnight passing. + Timer? _ticker; + + bool _libraryLoading = false; + String? _libraryError; + + @override + void initState() { + super.initState(); + _calendar = AiringCalendarController(service: _service); + _library = widget.controller ?? AnilistLibraryController(service: _service); + _pages = PageController(initialPage: _calendar.selectedIndex); + + _calendar.addListener(_onChange); + _library.addListener(_onLibraryChange); + _service.addListener(_onServiceChange); + WidgetsBinding.instance.addObserver(this); + + _calendar.setLibrary(_library.entries); + _calendar.load(); + if (_service.isConnected) _library.load(); + + _ticker = Timer.periodic(const Duration(seconds: 30), (_) { + if (!mounted) return; + _calendar.syncToToday(); + setState(() {}); + }); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _centreStrip(jump: true), + ); + } + + @override + void dispose() { + _ticker?.cancel(); + WidgetsBinding.instance.removeObserver(this); + _calendar.removeListener(_onChange); + _library.removeListener(_onLibraryChange); + _service.removeListener(_onServiceChange); + _pages.dispose(); + _strip.dispose(); + _calendar.dispose(); + if (_ownsLibrary) _library.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + _calendar.syncToToday(); + if (mounted) setState(() {}); + } + + void _onChange() { + if (!mounted) return; + setState(() {}); + _syncPager(); + } + + void _onLibraryChange() { + // setLibrary notifies the calendar, which this page already listens to, so + // rebuilding again here would double every optimistic write the library + // makes — and the library notifies on each one. + if (_calendar.setLibrary(_library.entries)) return; + if (_library.loading == _libraryLoading && + _library.error == _libraryError) { + return; + } + _libraryLoading = _library.loading; + _libraryError = _library.error; + _onChange(); + } + + void _onServiceChange() { + if (_service.isConnected) { + _library.load(); + } else { + _calendar.setLibrary(const []); + } + _onChange(); + } + + void _syncPager() { + if (!_pages.hasClients) return; + final index = _calendar.selectedIndex; + final current = (_pages.page ?? _pages.initialPage.toDouble()).round(); + if (current != index) { + _pages.animateToPage( + index, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + _centreStrip(); + } + + /// Keeps the selected pill on screen. The strip runs a fortnight now, so the + /// day being shown can easily sit past the end of it. + void _centreStrip({bool jump = false}) { + if (!_strip.hasClients) return; + const extent = _DayStrip.pillWidth + _DayStrip.gap; + final viewport = _strip.position.viewportDimension; + final target = + (_calendar.selectedIndex * extent + + _DayStrip.padding + + _DayStrip.pillWidth / 2 - + viewport / 2) + .clamp(0.0, _strip.position.maxScrollExtent); + if ((target - _strip.offset).abs() < 1) return; + if (jump) { + _strip.jumpTo(target); + return; + } + _strip.animateTo( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final body = Column( + children: [ + _DayStrip(controller: _calendar, scrollController: _strip), + if (_service.isConnected) _MineFilter(controller: _calendar), + _DayHeader(controller: _calendar), + Expanded( + child: PageView.builder( + controller: _pages, + itemCount: _calendar.days.length, + onPageChanged: (i) => _calendar.select(_calendar.days[i]), + itemBuilder: (context, i) => _buildDay(context, _calendar.days[i]), + ), + ), + ], + ); + + if (!widget.showAppBar) return body; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Row( + children: [ + const AnilistLogo(size: 20), + const SizedBox(width: 9), + Text( + 'anilist.calendar_title'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + ], + ), + ), + body: body, + ); + } + + Widget _buildDay(BuildContext context, DateTime day) { + if (_calendar.isPendingFor(day)) return const _DaySkeleton(); + + Future refresh() => _calendar.loadDay(day, force: true); + final rows = _calendar.rowsFor(day); + if (rows.isEmpty) return _buildEmpty(context, day, refresh); + + final nowAt = _nowMarker(day, rows); + + return RefreshIndicator( + color: kAnilistBlue, + backgroundColor: AppColors.surface, + onRefresh: refresh, + child: Column( + children: [ + // A failed refresh over a day that still holds entries has nowhere + // else to show: the list branch never reaches the empty state. + if (_calendar.isStaleFor(day)) + _StaleBanner( + message: _calendar.errorFor(day)!, + onRetry: () => _calendar.loadDay(day, force: true), + ), + Expanded( + child: ListView.separated( + key: PageStorageKey('anilist-day-${day.toIso8601String()}'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + itemCount: rows.length, + separatorBuilder: (_, _) => const SizedBox(height: 9), + itemBuilder: (context, i) { + final row = rows[i]; + final card = _AiringCard( + row: row, + mine: _calendar.isMine(row.airing.media.id), + onTap: () => _openRow(row), + ); + if (i != nowAt) return card; + return Column( + children: [ + const _NowDivider(), + const SizedBox(height: 9), + card, + ], + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildEmpty( + BuildContext context, + DateTime day, + Future Function() refresh, + ) { + final error = _calendar.errorFor(day); + // Three cases, not two: nothing airs, nothing OF YOURS airs, and "your list + // never loaded" — which the second would report as an empty week. + final listFailed = _calendar.mineOnly && _library.error != null; + final String message; + if (error != null) { + message = error; + } else if (listFailed) { + message = 'anilist.calendar_list_error'.tr(); + } else { + message = _calendar.mineOnly && _calendar.hasAnyFor(day) + ? 'anilist.calendar_empty_mine'.tr() + : 'anilist.calendar_empty'.tr(); + } + final failed = error != null || listFailed; + + return RefreshIndicator( + color: kAnilistBlue, + backgroundColor: AppColors.surface, + onRefresh: refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox(height: MediaQuery.sizeOf(context).height * 0.12), + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + failed ? Icons.cloud_off_rounded : Icons.event_busy_rounded, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), + const SizedBox(height: 14), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13.5, + height: 1.5, + ), + ), + if (failed) ...[ + const SizedBox(height: 10), + TextButton( + onPressed: listFailed + ? () => _library.load(force: true) + : refresh, + style: TextButton.styleFrom( + foregroundColor: kAnilistBlue, + ), + child: Text('anilist.retry'.tr()), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + /// Where "now" falls in the day, or -1 when a rule would say nothing — any + /// other day, or a day entirely behind or entirely ahead of this moment. + int _nowMarker(DateTime day, List rows) { + if (day != _calendar.today) return -1; + final index = rows.indexWhere((r) => !r.airing.hasAired); + return index <= 0 ? -1 : index; + } + + void _openRow(AiringDayRow row) { + final media = row.airing.media; + final entry = _library.entryForMedia(media.id); + if (entry != null) { + AnilistEntrySheet.show(context, entryId: entry.id, controller: _library); + return; + } + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => _MediaActions( + media: media, + onAdd: _service.isConnected ? () => _addToPlanning(media) : null, + ), + ); + } + + Future _addToPlanning(AnilistMedia media) async { + final token = _service.token; + if (token == null) return; + final messenger = ScaffoldMessenger.of(context); + String message; + try { + final saved = await _service.api.addToList(token: token, mediaId: media.id); + // Re-read rather than patch a synthetic entry in: the mark on this card + // is driven by the library, and AniList decides the entry's id. + await _library.load(force: true); + // null means the title was already there — which is what we see whenever + // the library failed to load, so say so instead of claiming an add. + message = saved == null + ? 'anilist.calendar_already_on_list'.tr() + : 'anilist.calendar_added_planning'.tr(); + } catch (e) { + message = e is AnilistException + ? e.message + : 'anilist.calendar_add_failed'.tr(); + } + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } +} + +/// The day selector: yesterday, today, and a fortnight ahead. +class _DayStrip extends StatelessWidget { + const _DayStrip({required this.controller, required this.scrollController}); + + static const double pillWidth = 54; + static const double gap = 8; + static const double padding = 14; + + final AiringCalendarController controller; + final ScrollController scrollController; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + final days = controller.days; + + return SizedBox( + height: 76, + child: ListView.separated( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: padding), + itemCount: days.length, + separatorBuilder: (_, _) => const SizedBox(width: gap), + itemBuilder: (context, i) { + final day = days[i]; + final selected = day == controller.selected; + final isToday = day == controller.today; + // Null means "not fetched": a marker on every day would claim an + // empty schedule for a fortnight nobody has looked at yet. + final count = controller.countFor(day); + + return Semantics( + button: true, + selected: selected, + label: DateFormat.yMMMMEEEEd(locale).format(day), + child: ExcludeSemantics( + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: pillWidth, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: selected ? kAnilistBlue : AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isToday && !selected + ? kAnilistBlue.withValues(alpha: 0.55) + : Colors.transparent, + width: 1.4, + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => controller.select(day), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + DateFormat.E(locale).format(day).toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: selected + ? Colors.white.withValues(alpha: 0.85) + : AppColors.textHint, + ), + ), + const SizedBox(height: 4), + Text( + '${day.day}', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w800, + color: selected + ? Colors.white + : AppColors.textPrimary, + ), + ), + const SizedBox(height: 5), + Container( + width: 5, + height: 5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (count ?? 0) > 0 + ? (selected + ? Colors.white.withValues(alpha: 0.9) + : kAnilistBlue) + : Colors.transparent, + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +/// The selected date in full, plus what the day actually holds. +/// +/// The strip only shows a weekday and a bare number, and the list below carries +/// times and nothing else, so past the first week there is otherwise nothing on +/// screen saying which day is being read. +class _DayHeader extends StatelessWidget { + const _DayHeader({required this.controller}); + + final AiringCalendarController controller; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + final day = controller.selected; + final date = DateFormat.MMMEd(locale).format(day); + final count = controller.countFor(day); + final total = controller.totalFor(day); + + final label = count == null + ? date + : controller.mineOnly + ? '$date · ${'anilist.calendar_count_mine'.tr(namedArgs: {'mine': '$count', 'total': '$total'})}' + : '$date · ${'anilist.calendar_count'.tr(namedArgs: {'count': '$count'})}'; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 2), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ), + ); + } +} + +/// All / my list toggle. Only offered while connected, since it needs a list. +class _MineFilter extends StatelessWidget { + const _MineFilter({required this.controller}); + + final AiringCalendarController controller; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 0), + child: Row( + children: [ + _FilterPill( + label: 'anilist.calendar_all'.tr(), + selected: !controller.mineOnly, + onTap: () => controller.setMineOnly(false), + ), + const SizedBox(width: 8), + _FilterPill( + label: 'anilist.calendar_mine'.tr(), + selected: controller.mineOnly, + onTap: () => controller.setMineOnly(true), + ), + ], + ), + ); + } +} + +class _FilterPill extends StatelessWidget { + const _FilterPill({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: selected + ? kAnilistBlue.withValues(alpha: 0.16) + : AppColors.surface, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? kAnilistBlue : Colors.transparent, + width: 1.2, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: selected ? kAnilistBlue : AppColors.textSecondary, + ), + ), + ), + ), + ); + } +} + +/// The line between what has already aired and what is still to come. +class _NowDivider extends StatelessWidget { + const _NowDivider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4, bottom: 2), + child: Row( + children: [ + Text( + 'anilist.calendar_now'.tr().toUpperCase(), + style: const TextStyle( + color: kAnilistBlue, + fontSize: 10, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 1, + color: kAnilistBlue.withValues(alpha: 0.35), + ), + ), + ], + ), + ); + } +} + +class _StaleBanner extends StatelessWidget { + const _StaleBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(14, 6, 14, 0), + padding: const EdgeInsets.fromLTRB(11, 8, 6, 8), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(11), + border: Border.all(color: AppColors.border, width: 0.6), + ), + child: Row( + children: [ + const Icon( + Icons.cloud_off_rounded, + size: 16, + color: AppColors.textHint, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + height: 1.35, + ), + ), + ), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + foregroundColor: kAnilistBlue, + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: const Size(0, 32), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'anilist.retry'.tr(), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} + +/// Placeholder rows in the shape of the real ones. +/// +/// A spinner in the middle of a blank page reads as a navigation, which is +/// wrong for something that happens on every swipe between days — and the row +/// geometry here is fixed, which is exactly when a skeleton beats a spinner. +class _DaySkeleton extends StatelessWidget { + const _DaySkeleton(); + + @override + Widget build(BuildContext context) { + Widget bar(double width, double height) => Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + ); + + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 28), + itemCount: 6, + separatorBuilder: (_, _) => const SizedBox(height: 9), + itemBuilder: (context, i) => Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Container( + width: 44, + height: 66, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + bar(double.infinity, 11), + const SizedBox(height: 8), + bar(90, 9), + ], + ), + ), + const SizedBox(width: 10), + bar(42, 24), + ], + ), + ), + ); + } +} + +class _AiringCard extends StatelessWidget { + const _AiringCard({ + required this.row, + required this.mine, + required this.onTap, + }); + + final AiringDayRow row; + final bool mine; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final airing = row.airing; + final media = airing.media; + final time = DateFormat.Hm(context.locale.toString()).format(airing.airsAt); + final aired = airing.hasAired; + final episode = row.isRange + ? 'anilist.calendar_episode_range'.tr( + namedArgs: {'from': '${row.firstEpisode}', 'to': '${row.lastEpisode}'}, + ) + : 'anilist.calendar_episode'.tr( + namedArgs: {'episode': '${airing.episode}'}, + ); + + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: mine + ? kAnilistBlue.withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Row( + children: [ + // Faded once it has gone out, so a glance down the day separates + // what is still to come from what already aired. + Opacity( + opacity: aired ? 0.55 : 1, + child: AnilistCover(url: media.coverImage, width: 44, radius: 8), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + media.displayTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5, + height: 1.25, + fontWeight: FontWeight.w700, + color: aired + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ), + if (mine) ...[ + const SizedBox(width: 6), + const AnilistLogo(size: 15, radius: 4), + ], + ], + ), + const SizedBox(height: 5), + Text( + episode, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textHint, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 5, + ), + decoration: BoxDecoration( + color: aired + ? AppColors.surfaceVariant + : kAnilistBlue.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + time, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: aired ? AppColors.textHint : kAnilistBlue, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// What can be done with a title that is not on the viewer's list. +/// +/// Deliberately short: the calendar's job is to hand a discovery over to the +/// rest of the app, and finding sources for it is the reason to be here at all. +class _MediaActions extends StatelessWidget { + const _MediaActions({required this.media, required this.onAdd}); + + final AnilistMedia media; + final VoidCallback? onAdd; + + @override + Widget build(BuildContext context) { + final siteUrl = media.siteUrl; + + return SafeArea( + top: false, + child: Container( + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + padding: const EdgeInsets.fromLTRB(18, 10, 18, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 38, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnilistCover(url: media.coverImage, width: 54), + const SizedBox(width: 13), + Expanded( + child: Text( + media.displayTitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15.5, + fontWeight: FontWeight.w800, + height: 1.25, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(color: AppColors.divider, height: 1), + _SheetAction( + icon: Icons.travel_explore_rounded, + label: 'anilist.find_in_sources'.tr(), + subtitle: 'anilist.find_in_sources_hint'.tr(), + onTap: () { + Navigator.of(context).pop(); + context.push('/cross-search', extra: media.displayTitle); + }, + ), + if (onAdd != null) + _SheetAction( + icon: Icons.bookmark_add_outlined, + label: 'anilist.calendar_add_planning'.tr(), + onTap: () { + Navigator.of(context).pop(); + onAdd!(); + }, + ), + _SheetAction( + icon: Icons.open_in_new_rounded, + label: 'anilist.open_on_anilist'.tr(), + onTap: siteUrl == null + ? null + : () => launchUrl( + Uri.parse(siteUrl), + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ), + ); + } +} + +class _SheetAction extends StatelessWidget { + const _SheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.subtitle, + }); + + final IconData icon; + final String label; + final String? subtitle; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return ListTile( + contentPadding: EdgeInsets.zero, + enabled: enabled, + leading: Icon( + icon, + color: enabled ? kAnilistBlue : AppColors.textHint, + size: 22, + ), + title: Text( + label, + style: TextStyle( + color: enabled ? AppColors.textPrimary : AppColors.textHint, + fontSize: 14.5, + fontWeight: FontWeight.w600, + ), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: onTap, + ); + } +} diff --git a/lib/features/anilist/presentation/pages/anilist_library_page.dart b/lib/features/anilist/presentation/pages/anilist_library_page.dart index 0afa1ff..c239b04 100644 --- a/lib/features/anilist/presentation/pages/anilist_library_page.dart +++ b/lib/features/anilist/presentation/pages/anilist_library_page.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -130,6 +131,13 @@ class _AnilistLibraryPageState extends State ], ), actions: [ + // Offered signed out too: the schedule is public, and it is the one + // part of AniList that is worth something without an account. + IconButton( + tooltip: 'anilist.calendar_title'.tr(), + onPressed: () => context.push('/anilist/calendar'), + icon: const Icon(Icons.calendar_month_rounded), + ), if (connected) IconButton( tooltip: 'anilist.refresh'.tr(), diff --git a/lib/features/anilist/presentation/pages/connections_page.dart b/lib/features/anilist/presentation/pages/connections_page.dart index 5da0391..40f38f9 100644 --- a/lib/features/anilist/presentation/pages/connections_page.dart +++ b/lib/features/anilist/presentation/pages/connections_page.dart @@ -237,8 +237,15 @@ class _ConnectionsPageState extends State { ], ), ), + // Outside the `connected` block on purpose: the schedule is public. + const SizedBox(height: 12), + _Row( + icon: Icons.calendar_month_rounded, + title: 'anilist.calendar_open'.tr(), + onTap: () => context.push('/anilist/calendar'), + ), if (connected) ...[ - const SizedBox(height: 12), + const SizedBox(height: 8), _Row( icon: Icons.auto_awesome_motion_rounded, title: 'anilist.open_library'.tr(), @@ -302,7 +309,7 @@ class _Avatar extends StatelessWidget { color: AppColors.textHint, ), ) - : const Center(child: AnilistLogo(size: 24, color: Colors.white)), + : const Center(child: AnilistLogo(size: 30)), ); } } diff --git a/lib/features/anilist/presentation/widgets/anilist_linked_badge.dart b/lib/features/anilist/presentation/widgets/anilist_linked_badge.dart new file mode 100644 index 0000000..b59cb14 --- /dev/null +++ b/lib/features/anilist/presentation/widgets/anilist_linked_badge.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/extractor/provider_manager.dart'; +import 'package:soplay/features/anilist/data/anilist_link_store.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; + +/// The AniList mark on a title that is already linked to an AniList entry. +/// +/// Linking is what makes tracking work at all — a source title rarely matches +/// AniList exactly — but until now the link was invisible outside the AniList +/// screens, so there was no way to tell from a result whether watching it would +/// count. This is that signal. +/// +/// Renders nothing when there is no link, so it costs a search grid nothing in +/// the common case. +class AnilistLinkedBadge extends StatelessWidget { + const AnilistLinkedBadge({ + super.key, + required this.contentUrl, + this.provider, + this.size = 15, + }); + + final String contentUrl; + + /// The source the result came from. Falls back to the active one, which is + /// correct for a single-source search and wrong for a cross-source grid — + /// hence cross search passes each hit's own provider. + final String? provider; + + final double size; + + @override + Widget build(BuildContext context) { + if (contentUrl.trim().isEmpty) return const SizedBox.shrink(); + if (!getIt.isRegistered()) return const SizedBox.shrink(); + + final source = provider ?? _activeProvider(); + if (source == null || source.isEmpty) return const SizedBox.shrink(); + + final mediaId = getIt().mediaIdFor(source, contentUrl); + if (mediaId == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(6), + ), + child: AnilistLogo(size: size, radius: 4), + ); + } + + static String? _activeProvider() { + if (!getIt.isRegistered()) return null; + return getIt().currentProviderId; + } +} diff --git a/lib/features/anilist/presentation/widgets/anilist_logo.dart b/lib/features/anilist/presentation/widgets/anilist_logo.dart index 4c22b38..30fc5c5 100644 --- a/lib/features/anilist/presentation/widgets/anilist_logo.dart +++ b/lib/features/anilist/presentation/widgets/anilist_logo.dart @@ -1,30 +1,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; - -/// The AniList mark. +/// The official AniList logo, from +/// https://commons.wikimedia.org/wiki/File:AniList_logo.svg /// -/// Drawn from `assets/icons/anilist.svg`, which is a reconstruction of the -/// official logo rather than the file from AniList's brand kit. Dropping the -/// real SVG in at that path replaces it everywhere with no code change. +/// Rendered in its own colours — the artwork includes its dark tile, so tinting +/// it to a single colour would erase the mark it exists to show. class AnilistLogo extends StatelessWidget { - const AnilistLogo({super.key, this.size = 20, this.color = kAnilistBlue}); + const AnilistLogo({super.key, this.size = 20, this.radius = 5}); final double size; - final Color color; + final double radius; @override - Widget build(BuildContext context) => SvgPicture.asset( - 'assets/icons/anilist.svg', - width: size, - height: size, - colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SvgPicture.asset('assets/icons/anilist.svg', width: size, height: size), ); } -/// The mark on its brand-blue tile, for places that need a service badge — -/// a settings row, an empty state, an account card. +/// The logo at badge size, for a settings row or an empty state. class AnilistLogoBadge extends StatelessWidget { const AnilistLogoBadge({super.key, this.size = 44, this.radius = 12}); @@ -32,28 +27,6 @@ class AnilistLogoBadge extends StatelessWidget { final double radius; @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(radius), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [kAnilistBlue, kAnilistBlueDeep], - ), - boxShadow: [ - BoxShadow( - color: kAnilistBlue.withValues(alpha: 0.3), - blurRadius: size * 0.4, - spreadRadius: -size * 0.08, - ), - ], - ), - child: Center( - child: AnilistLogo(size: size * 0.52, color: Colors.white), - ), - ); - } + Widget build(BuildContext context) => + AnilistLogo(size: size, radius: radius); } diff --git a/lib/features/auth/data/datasources/auth_remote_data_source.dart b/lib/features/auth/data/datasources/auth_remote_data_source.dart index e018cd6..ef44565 100644 --- a/lib/features/auth/data/datasources/auth_remote_data_source.dart +++ b/lib/features/auth/data/datasources/auth_remote_data_source.dart @@ -44,6 +44,32 @@ class AuthRemoteDataSource { return AuthModel.fromJson(response.data as Map); } + /// Asks the server to email a reset code. + /// + /// Answers the same way whether or not the address exists — the server + /// decides that, and it deliberately does not say, so this must not treat a + /// missing account as an error either. + Future requestPasswordReset(String email) async { + await dio.post('/auth/forgot-password', data: {'email': email}); + } + + /// Sets the new password and returns the session it issues. + /// + /// The server signs the user in as part of the reset, so there is no second + /// login round trip — and no window where they know the new password but the + /// app still holds the old session. + Future resetPassword({ + required String email, + required String code, + required String newPassword, + }) async { + final response = await dio.post( + '/auth/reset-password', + data: {'email': email, 'otp': code, 'newPassword': newPassword}, + ); + return AuthModel.fromJson(response.data as Map); + } + Future getProfile() async { final response = await dio.get('/auth/profile'); final data = response.data as Map; diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index 7ee7dbd..fdb0e3b 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -93,6 +93,49 @@ class AuthRepositoryImpl implements AuthRepository { } } + @override + Future> requestPasswordReset(String email) async { + try { + await _remoteDataSource.requestPasswordReset(email); + return const Success(null); + } on DioException catch (e) { + return Failure(Exception(_messageFrom(e))); + } catch (e) { + return Failure(Exception(e.toString())); + } + } + + @override + Future> resetPassword({ + required String email, + required String code, + required String newPassword, + }) async { + try { + final model = await _remoteDataSource.resetPassword( + email: email, + code: code, + newPassword: newPassword, + ); + if (model.accessToken.isEmpty) { + return Failure(Exception('Access token topilmadi')); + } + // Stored exactly like a verified registration: the reset issues a real + // session, and not saving it would leave the user staring at a login + // screen straight after proving who they are. + await _hiveService.saveAuth( + accessToken: model.accessToken, + refreshToken: model.refreshToken, + user: model.user as UserModel, + ); + return Success(model); + } on DioException catch (e) { + return Failure(Exception(_messageFrom(e))); + } catch (e) { + return Failure(Exception(e.toString())); + } + } + @override Future> getProfile() async { try { diff --git a/lib/features/auth/domain/repositories/auth_repository.dart b/lib/features/auth/domain/repositories/auth_repository.dart index 5c9aef9..61b9a9c 100644 --- a/lib/features/auth/domain/repositories/auth_repository.dart +++ b/lib/features/auth/domain/repositories/auth_repository.dart @@ -19,6 +19,14 @@ abstract class AuthRepository { required String code, }); + Future> requestPasswordReset(String email); + + Future> resetPassword({ + required String email, + required String code, + required String newPassword, + }); + Future> getProfile(); Future logout(); diff --git a/lib/features/auth/domain/usecases/forgot_password_usecase.dart b/lib/features/auth/domain/usecases/forgot_password_usecase.dart new file mode 100644 index 0000000..a50905b --- /dev/null +++ b/lib/features/auth/domain/usecases/forgot_password_usecase.dart @@ -0,0 +1,31 @@ +import '../../../../core/error/result.dart'; +import '../entities/auth_token.dart'; +import '../repositories/auth_repository.dart'; + +class RequestPasswordResetUseCase { + final AuthRepository _authRepository; + + RequestPasswordResetUseCase(this._authRepository); + + Future> call(String email) { + return _authRepository.requestPasswordReset(email); + } +} + +class ResetPasswordUseCase { + final AuthRepository _authRepository; + + ResetPasswordUseCase(this._authRepository); + + Future> call({ + required String email, + required String code, + required String newPassword, + }) { + return _authRepository.resetPassword( + email: email, + code: code, + newPassword: newPassword, + ); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index a3c5cb0..a3167f1 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -5,6 +5,7 @@ import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/auth/domain/entities/auth_token.dart'; import 'package:soplay/features/auth/domain/repositories/auth_repository.dart'; +import 'package:soplay/features/auth/domain/usecases/forgot_password_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/login_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/register_usecase.dart'; import 'package:soplay/features/auth/domain/usecases/resend_otp_usecase.dart'; @@ -23,6 +24,8 @@ class AuthBloc extends Bloc { final RegisterUseCase registerUseCase; final VerifyOtpUseCase verifyOtpUseCase; final ResendOtpUseCase resendOtpUseCase; + final RequestPasswordResetUseCase requestPasswordResetUseCase; + final ResetPasswordUseCase resetPasswordUseCase; final AuthRepository authRepository; final HiveService hiveService; final NotificationService notificationService; @@ -35,6 +38,8 @@ class AuthBloc extends Bloc { required this.registerUseCase, required this.verifyOtpUseCase, required this.resendOtpUseCase, + required this.requestPasswordResetUseCase, + required this.resetPasswordUseCase, required this.authRepository, required this.hiveService, required this.notificationService, @@ -46,6 +51,8 @@ class AuthBloc extends Bloc { on(_onVerifyOtp); on(_onResendOtp); on((_, emit) => emit(AuthInitial())); + on(_onPasswordResetRequested); + on(_onPasswordResetSubmitted); on(_onLogout); on(_onSessionExpired); on(_onProfileRefresh); @@ -154,6 +161,82 @@ class AuthBloc extends Bloc { } } + Future _onPasswordResetRequested( + AuthPasswordResetRequested event, + Emitter emit, + ) async { + final current = state; + if (event.isResend && current is AuthPasswordResetPending) { + if (DateTime.now().isBefore(current.cooldownUntil)) return; + emit(current.copyWith(resending: true, clearError: true)); + } else if (!event.isResend) { + emit(AuthLoading()); + } + + final result = await requestPasswordResetUseCase(event.email); + final pending = state; + switch (result) { + case Success(): + // The server answers the same way whether or not the address exists, so + // this screen must too — telling the user "no such account" here is how + // an app leaks which emails are registered. + emit( + pending is AuthPasswordResetPending + ? pending.copyWith( + resending: false, + justSent: true, + cooldownUntil: DateTime.now().add(_resendCooldown), + clearError: true, + ) + : AuthPasswordResetPending( + email: event.email, + cooldownUntil: DateTime.now().add(_resendCooldown), + justSent: true, + ), + ); + case Failure(:final error): + final msg = _friendlyError(error); + emit( + pending is AuthPasswordResetPending + ? pending.copyWith(resending: false, error: msg) + : AuthError(message: msg), + ); + } + } + + Future _onPasswordResetSubmitted( + AuthPasswordResetSubmitted event, + Emitter emit, + ) async { + final current = state; + if (current is AuthPasswordResetPending) { + emit(current.copyWith(submitting: true, clearError: true)); + } + + final result = await resetPasswordUseCase( + email: event.email, + code: event.code, + newPassword: event.newPassword, + ); + // Re-read after the await, exactly as the register flow does: a resend may + // have replaced the pending state while this was in flight. + final pending = state; + switch (result) { + case Success(:final value): + emit(AuthLoaded(token: value)); + unawaited(syncFavorites()); + unawaited(_syncHistory()); + unawaited(notificationService.setup()); + case Failure(:final error): + final msg = _friendlyError(error); + emit( + pending is AuthPasswordResetPending + ? pending.copyWith(submitting: false, error: msg) + : AuthError(message: msg), + ); + } + } + Future _onResendOtp( AuthOtpResendRequested event, Emitter emit, diff --git a/lib/features/auth/presentation/bloc/auth_event.dart b/lib/features/auth/presentation/bloc/auth_event.dart index 19db63a..e91aeb6 100644 --- a/lib/features/auth/presentation/bloc/auth_event.dart +++ b/lib/features/auth/presentation/bloc/auth_event.dart @@ -62,6 +62,34 @@ class AuthOtpReset extends AuthEvent { const AuthOtpReset(); } +/// Ask for a reset code. Also the resend, since the server treats a second +/// forgot-password call as one — there is no separate resend endpoint for this +/// flow and inventing a client-side one would just drift. +class AuthPasswordResetRequested extends AuthEvent { + final String email; + final bool isResend; + + const AuthPasswordResetRequested({required this.email, this.isResend = false}); + + @override + List get props => [email, isResend]; +} + +class AuthPasswordResetSubmitted extends AuthEvent { + final String email; + final String code; + final String newPassword; + + const AuthPasswordResetSubmitted({ + required this.email, + required this.code, + required this.newPassword, + }); + + @override + List get props => [email, code, newPassword]; +} + class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); } diff --git a/lib/features/auth/presentation/bloc/auth_state.dart b/lib/features/auth/presentation/bloc/auth_state.dart index 771f826..14c5111 100644 --- a/lib/features/auth/presentation/bloc/auth_state.dart +++ b/lib/features/auth/presentation/bloc/auth_state.dart @@ -78,3 +78,55 @@ class AuthOtpPending extends AuthState { error, ]; } + +/// Waiting on the code emailed by a password reset. +/// +/// Separate from [AuthOtpPending] even though both hold an email and a +/// cooldown: that one means "finish signing up" and the screen behind it only +/// asks for a code, while this one also collects the new password. Sharing it +/// would leave the OTP screen unable to tell which it is looking at. +class AuthPasswordResetPending extends AuthState { + final String email; + final DateTime cooldownUntil; + final bool justSent; + final bool submitting; + final bool resending; + final String? error; + + AuthPasswordResetPending({ + required this.email, + required this.cooldownUntil, + this.justSent = false, + this.submitting = false, + this.resending = false, + this.error, + }); + + AuthPasswordResetPending copyWith({ + DateTime? cooldownUntil, + bool? justSent, + bool? submitting, + bool? resending, + String? error, + bool clearError = false, + }) { + return AuthPasswordResetPending( + email: email, + cooldownUntil: cooldownUntil ?? this.cooldownUntil, + justSent: justSent ?? this.justSent, + submitting: submitting ?? this.submitting, + resending: resending ?? this.resending, + error: clearError ? null : (error ?? this.error), + ); + } + + @override + List get props => [ + email, + cooldownUntil, + justSent, + submitting, + resending, + error, + ]; +} diff --git a/lib/features/auth/presentation/pages/forgot_password_page.dart b/lib/features/auth/presentation/pages/forgot_password_page.dart new file mode 100644 index 0000000..dea78cf --- /dev/null +++ b/lib/features/auth/presentation/pages/forgot_password_page.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:soplay/features/auth/presentation/bloc/auth_event.dart'; +import 'package:soplay/features/auth/presentation/bloc/auth_state.dart'; + +/// Resetting a forgotten password. +/// +/// One page, two steps: ask for the address, then take the emailed code and the +/// new password together. Splitting the second step across two screens would +/// mean holding a verified code while navigating, and the code is only useful +/// alongside the password anyway. +/// +/// The server signs the user in as part of the reset, so a success here lands +/// in [AuthLoaded] and the router takes them into the app — there is no window +/// where they know the new password but still face a login form. +class ForgotPasswordPage extends StatefulWidget { + const ForgotPasswordPage({super.key, this.initialEmail}); + + final String? initialEmail; + + @override + State createState() => _ForgotPasswordPageState(); +} + +class _ForgotPasswordPageState extends State { + final _emailForm = GlobalKey(); + final _resetForm = GlobalKey(); + + late final _email = TextEditingController(text: widget.initialEmail ?? ''); + final _code = TextEditingController(); + final _password = TextEditingController(); + final _confirm = TextEditingController(); + + bool _obscure = true; + Timer? _ticker; + Duration _remaining = Duration.zero; + + @override + void dispose() { + _ticker?.cancel(); + _email.dispose(); + _code.dispose(); + _password.dispose(); + _confirm.dispose(); + super.dispose(); + } + + void _startCooldown(DateTime until) { + _ticker?.cancel(); + void tick() { + if (!mounted) return; + final left = until.difference(DateTime.now()); + setState(() => _remaining = left.isNegative ? Duration.zero : left); + if (left.isNegative) _ticker?.cancel(); + } + + tick(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) => tick()); + } + + void _sendCode({bool resend = false}) { + if (!resend && !(_emailForm.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + context.read().add( + AuthPasswordResetRequested(email: _email.text.trim(), isResend: resend), + ); + } + + void _submitReset() { + if (!(_resetForm.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + context.read().add( + AuthPasswordResetSubmitted( + email: _email.text.trim(), + code: _code.text.trim(), + newPassword: _password.text, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => context.canPop() + ? context.pop() + : context.pushReplacement('/login'), + ), + ), + body: SafeArea( + child: BlocConsumer( + listenWhen: (a, b) => b is AuthPasswordResetPending || b is AuthError, + listener: (context, state) { + if (state is AuthPasswordResetPending) { + _startCooldown(state.cooldownUntil); + if (state.justSent) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('auth.reset_code_sent'.tr()), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + }, + builder: (context, state) { + final pending = state is AuthPasswordResetPending ? state : null; + final sending = state is AuthLoading; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'auth.forgot_password_title'.tr(), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + pending == null + ? 'auth.forgot_password_subtitle'.tr() + : 'auth.reset_password_subtitle'.tr( + namedArgs: {'email': pending.email}, + ), + style: const TextStyle( + fontSize: 13.5, + height: 1.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 26), + + if (pending == null) + _EmailStep( + formKey: _emailForm, + controller: _email, + busy: sending, + onSubmit: _sendCode, + ) + else + _ResetStep( + formKey: _resetForm, + code: _code, + password: _password, + confirm: _confirm, + obscure: _obscure, + onToggleObscure: () => + setState(() => _obscure = !_obscure), + state: pending, + remaining: _remaining, + onResend: () => _sendCode(resend: true), + onSubmit: _submitReset, + ), + + if (state is AuthError) ...[ + const SizedBox(height: 14), + Text( + state.message, + style: const TextStyle( + color: AppColors.error, + fontSize: 12.5, + ), + ), + ], + ], + ), + ); + }, + ), + ), + ); + } +} + +class _EmailStep extends StatelessWidget { + const _EmailStep({ + required this.formKey, + required this.controller, + required this.busy, + required this.onSubmit, + }); + + final GlobalKey formKey; + final TextEditingController controller; + final bool busy; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + return Form( + key: formKey, + child: Column( + children: [ + _Field( + controller: controller, + hint: 'auth.email_hint'.tr(), + icon: Icons.mail_outline_rounded, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onSubmit(), + validator: (v) { + final value = (v ?? '').trim(); + if (value.isEmpty) return 'auth.email'.tr(); + if (!value.contains('@') || !value.contains('.')) { + return 'auth.invalid_email'.tr(); + } + return null; + }, + ), + const SizedBox(height: 20), + _PrimaryButton( + label: 'auth.send_reset_code'.tr(), + busy: busy, + onPressed: onSubmit, + ), + ], + ), + ); + } +} + +class _ResetStep extends StatelessWidget { + const _ResetStep({ + required this.formKey, + required this.code, + required this.password, + required this.confirm, + required this.obscure, + required this.onToggleObscure, + required this.state, + required this.remaining, + required this.onResend, + required this.onSubmit, + }); + + final GlobalKey formKey; + final TextEditingController code; + final TextEditingController password; + final TextEditingController confirm; + final bool obscure; + final VoidCallback onToggleObscure; + final AuthPasswordResetPending state; + final Duration remaining; + final VoidCallback onResend; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final canResend = remaining == Duration.zero && !state.resending; + + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Field( + controller: code, + hint: 'auth.reset_code_hint'.tr(), + icon: Icons.pin_outlined, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + validator: (v) { + final value = (v ?? '').trim(); + if (value.length != 6) return 'auth.invalid_reset_code'.tr(); + return null; + }, + ), + const SizedBox(height: 12), + _Field( + controller: password, + hint: 'auth.new_password_hint'.tr(), + icon: Icons.lock_outline_rounded, + obscureText: obscure, + textInputAction: TextInputAction.next, + suffix: IconButton( + onPressed: onToggleObscure, + icon: Icon( + obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + color: AppColors.textHint, + ), + ), + validator: (v) { + if ((v ?? '').isEmpty) return 'auth.password'.tr(); + if ((v ?? '').length < 6) return 'auth.invalid_password'.tr(); + return null; + }, + ), + const SizedBox(height: 12), + _Field( + controller: confirm, + hint: 'auth.confirm_password_hint'.tr(), + icon: Icons.lock_reset_rounded, + obscureText: obscure, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onSubmit(), + validator: (v) { + // Checked here rather than only on the server: a typo would + // otherwise burn the one-time code and force another email. + if (v != password.text) return 'auth.passwords_not_match'.tr(); + return null; + }, + ), + + if (state.error != null) ...[ + const SizedBox(height: 12), + Text( + state.error!, + style: const TextStyle(color: AppColors.error, fontSize: 12.5), + ), + ], + + const SizedBox(height: 20), + _PrimaryButton( + label: 'auth.reset_password'.tr(), + busy: state.submitting, + onPressed: onSubmit, + ), + const SizedBox(height: 10), + Center( + child: TextButton( + onPressed: canResend ? onResend : null, + child: Text( + canResend + ? 'auth.resend_code'.tr() + : 'auth.resend_in'.tr( + namedArgs: {'seconds': '${remaining.inSeconds}'}, + ), + style: const TextStyle(fontSize: 12.5), + ), + ), + ), + ], + ), + ); + } +} + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.busy, + required this.onPressed, + }); + + final String label; + final bool busy; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: busy ? null : onPressed, + child: busy + ? const SizedBox( + width: 21, + height: 21, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.2, + ), + ) + : Text(label), + ), + ); + } +} + +class _Field extends StatelessWidget { + const _Field({ + required this.controller, + required this.hint, + required this.icon, + this.keyboardType, + this.textInputAction, + this.obscureText = false, + this.suffix, + this.validator, + this.onFieldSubmitted, + this.inputFormatters, + }); + + final TextEditingController controller; + final String hint; + final IconData icon; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final bool obscureText; + final Widget? suffix; + final String? Function(String?)? validator; + final void Function(String)? onFieldSubmitted; + final List? inputFormatters; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: keyboardType, + textInputAction: textInputAction, + obscureText: obscureText, + validator: validator, + onFieldSubmitted: onFieldSubmitted, + inputFormatters: inputFormatters, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + decoration: InputDecoration( + hintText: hint, + prefixIcon: Icon(icon, size: 20, color: AppColors.textHint), + suffixIcon: suffix, + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index a9e88ee..2f032d0 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -117,7 +117,24 @@ class _LoginPageState extends State { ), validator: _validatePassword, ), - const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: TextButton( + // Carries whatever was typed, so the reset page + // does not ask for an address the user just gave. + onPressed: () => context.push( + '/forgot-password', + extra: _identifierController.text.contains('@') + ? _identifierController.text.trim() + : null, + ), + child: Text( + 'auth.forgot_password'.tr(), + style: const TextStyle(fontSize: 12.5), + ), + ), + ), + const SizedBox(height: 8), BlocBuilder( builder: (context, state) { final loading = state is AuthLoading; diff --git a/lib/features/detail/domain/video_option_groups.dart b/lib/features/detail/domain/video_option_groups.dart new file mode 100644 index 0000000..707529e --- /dev/null +++ b/lib/features/detail/domain/video_option_groups.dart @@ -0,0 +1,122 @@ +/// Splits the flat source list into servers and the qualities each one offers. +/// +/// A provider returns one list mixing both — "SubsPlease · 1080p", +/// "Erai-raws · 720p" — and the player showed it raw, so picking a quality +/// could silently move you to another host. Servers and qualities are +/// independent choices and get one control each. +/// +/// Everything here works on the source labels alone, so it stays testable +/// without a player or a widget tree. +class VideoOptionGroups { + const VideoOptionGroups._(); + + static final RegExp _resolution = RegExp(r'\d{3,4}'); + static final RegExp _separator = RegExp(r'\s*[·•|]\s*'); + static final RegExp _spaces = RegExp(r'\s+'); + + /// Labels that name no host at all still need something to group under. + static const String _fallbackServer = 'Default'; + + static List servers(List labels) { + final out = []; + for (final label in labels) { + final name = serverOf(label); + if (!out.contains(name)) out.add(name); + } + return out; + } + + /// Positions in the ORIGINAL list, so the caller's selected index stays + /// valid. + static List indicesFor(List labels, String server) => [ + for (var i = 0; i < labels.length; i++) + if (serverOf(labels[i]) == server) i, + ]; + + /// The distinct qualities [server] offers, so a picker can say what choosing + /// it gets you. + static List qualitiesFor(List labels, String server) { + final out = []; + for (final i in indicesFor(labels, server)) { + final quality = qualityOf(labels[i]); + if (quality.isNotEmpty && !out.contains(quality)) out.add(quality); + } + return out; + } + + static String serverOf(String label) => _split(label).server; + + /// The label with the host stripped off — empty when the label carries no + /// quality of its own. + static String qualityOf(String label) => _split(label).quality; + + static int? resolutionOf(String label) => + int.tryParse(_resolution.firstMatch(qualityOf(label))?.group(0) ?? ''); + + /// The index to land on when switching to [server], keeping the current + /// resolution where that server has it. + /// + /// Falls back to the server's highest resolution rather than its first entry: + /// providers list sources in arbitrary order, and dropping someone from 1080p + /// to 360p because that entry happened to come first reads as a bug. + static int switchTo(List labels, int currentIndex, String server) { + final candidates = indicesFor(labels, server); + if (candidates.isEmpty) return currentIndex; + + final wanted = currentIndex >= 0 && currentIndex < labels.length + ? resolutionOf(labels[currentIndex]) + : null; + if (wanted != null) { + for (final i in candidates) { + if (resolutionOf(labels[i]) == wanted) return i; + } + } + var best = candidates.first; + for (final i in candidates) { + if ((resolutionOf(labels[i]) ?? 0) > (resolutionOf(labels[best]) ?? 0)) { + best = i; + } + } + return best; + } + + static ({String server, String quality}) _split(String label) { + final text = label.trim(); + if (text.isEmpty) return (server: _fallbackServer, quality: ''); + + final parts = [ + for (final part in text.split(_separator)) + if (part.trim().isNotEmpty) part.trim(), + ]; + if (parts.length > 1) { + // Which part is the quality is decided by what it LOOKS like, not by + // where it sits. Assuming the host comes first put "480p · 1-server" in + // backwards — the settings sheet offered a server called 480p and a + // quality called 1-server. + final at = parts.indexWhere(_resolution.hasMatch); + if (at >= 0) { + final host = [...parts.take(at), ...parts.skip(at + 1)].join(' · '); + return ( + server: host.isEmpty ? _fallbackServer : host, + quality: parts[at], + ); + } + // Nothing resolution-shaped anywhere: the whole label names a host, the + // way "SUB · Mp4Upload" does. Taking the first part as the server would + // make a language tag into a host and the host into a quality. + return (server: text, quality: ''); + } + + final words = text.split(_spaces); + final at = words.indexWhere(_resolution.hasMatch); + // Nothing resolution-shaped in it: the whole label names a server, the way + // "Server 1" or "SUB Mp4Upload" does. + if (at < 0) return (server: text, quality: ''); + + final host = [...words.take(at), ...words.skip(at + 1)].join(' '); + return ( + server: host.isEmpty ? _fallbackServer : host, + quality: words[at], + ); + } +} diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 7ea9271..f990b8b 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -33,20 +33,17 @@ import 'package:soplay/features/tracker/domain/entities/followed_title.dart'; import 'package:soplay/features/my_list/data/datasources/my_list_local_data_source.dart'; import 'package:soplay/features/my_list/data/private_list_service.dart'; import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; -import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; -import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; import 'package:soplay/features/private_list/presentation/private_unlock.dart'; import 'package:share_plus/share_plus.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_cast_tab.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_comments_tab.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_hero.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_info.dart'; +import 'package:soplay/features/detail/presentation/widgets/detail_more_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_related.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_screenshots.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_skeleton.dart'; import 'package:showcaseview/showcaseview.dart'; -import 'package:soplay/features/anilist/data/anilist_service.dart'; -import 'package:soplay/features/anilist/presentation/widgets/anilist_track_button.dart'; class DetailPage extends StatelessWidget { const DetailPage({super.key, required this.args}); @@ -546,6 +543,24 @@ class _DetailViewState extends State<_DetailView> Share.share('${widget.detail.title}\n$link'); } + void _showMoreSheet() { + final detail = widget.detail; + showDetailMoreSheet( + context, + entity: FavoriteEntity( + provider: detail.provider, + contentUrl: detail.contentUrl, + title: detail.title, + thumbnail: detail.thumbnail ?? '', + ), + showFollow: detail.isSerial, + isFollowing: _isFollowing, + onToggleFollow: _toggleFollow, + onFindSources: _onFindOtherSources, + onShare: _onShare, + ); + } + void _handlePlayback(PlaybackEntity playback) { // A reading source ALWAYS goes through the chapter list, even when it // reports a single chapter (one-shots, and Mangayomi novels that expose one @@ -903,15 +918,6 @@ class _DetailViewState extends State<_DetailView> showPill: showPill, title: detail.title, reader: detail.provider.opensReader, - // Identity for the Watch Later / Watched buttons. - // Those talk to UserListsRepository themselves, so - // they need the content, not another callback pair. - listEntity: FavoriteEntity( - provider: detail.provider, - contentUrl: detail.contentUrl, - title: detail.title, - thumbnail: detail.thumbnail ?? '', - ), isInList: isInList, inPrivate: inPrivate, isLoading: state is EpisodesLoading, @@ -924,11 +930,7 @@ class _DetailViewState extends State<_DetailView> onAddToList: _toggleMyList, onMoveToPrivate: _onMoveToPrivate, onPrivateActions: _showPrivateActions, - onFindSources: _onFindOtherSources, - showFollow: detail.isSerial, - isFollowing: _isFollowing, - onFollow: _toggleFollow, - onShare: _onShare, + onMore: _showMoreSheet, ), ), ), @@ -979,7 +981,6 @@ class _AnimatedTopBar extends StatelessWidget { required this.showPill, required this.title, required this.reader, - required this.listEntity, required this.isInList, required this.inPrivate, required this.isLoading, @@ -992,11 +993,7 @@ class _AnimatedTopBar extends StatelessWidget { required this.onAddToList, required this.onMoveToPrivate, required this.onPrivateActions, - required this.onFindSources, - required this.showFollow, - required this.isFollowing, - required this.onFollow, - required this.onShare, + required this.onMore, }); final double collapse; @@ -1005,9 +1002,6 @@ class _AnimatedTopBar extends StatelessWidget { /// Reading source — the pill says "Read" rather than "Play". final bool reader; - - /// Content identity for the Watch Later / Watched buttons. - final FavoriteEntity listEntity; final bool isInList; final bool inPrivate; final bool isLoading; @@ -1020,11 +1014,7 @@ class _AnimatedTopBar extends StatelessWidget { final VoidCallback onAddToList; final VoidCallback onMoveToPrivate; final VoidCallback onPrivateActions; - final VoidCallback onFindSources; - final bool showFollow; - final bool isFollowing; - final VoidCallback onFollow; - final VoidCallback onShare; + final VoidCallback onMore; @override Widget build(BuildContext context) { @@ -1130,47 +1120,9 @@ class _AnimatedTopBar extends StatelessWidget { ), const SizedBox(width: 8), ], - _UserListButton( - kind: UserListKind.watchLater, - entity: listEntity, - activeIcon: Icons.watch_later_rounded, - inactiveIcon: Icons.watch_later_outlined, - ), - const SizedBox(width: 8), - _UserListButton( - kind: UserListKind.watched, - entity: listEntity, - activeIcon: Icons.visibility_rounded, - inactiveIcon: Icons.visibility_outlined, - ), - const SizedBox(width: 8), - if (showFollow) ...[ - _CircleIconButton( - icon: isFollowing - ? Icons.notifications_active_rounded - : Icons.notifications_none_rounded, - iconColor: isFollowing ? AppColors.rating : Colors.white, - onTap: onFollow, - ), - const SizedBox(width: 8), - ], - AnilistTrackButton( - provider: listEntity.provider, - contentUrl: listEntity.contentUrl, - title: listEntity.title, - ), - // Zero-width when AniList is not connected, so the spacer would - // otherwise leave a visible gap for most users. - if (getIt().isConnected) - const SizedBox(width: 8), - _CircleIconButton( - icon: Icons.travel_explore_rounded, - onTap: onFindSources, - ), - const SizedBox(width: 8), _CircleIconButton( - icon: Icons.ios_share_rounded, - onTap: onShare, + icon: Icons.more_vert_rounded, + onTap: onMore, ), ], ), @@ -1181,71 +1133,6 @@ class _AnimatedTopBar extends StatelessWidget { } } -/// Toggle for one user-curated list (Watch Later / Watched). -/// -/// Owns its state and talks to [UserListsRepository] directly instead of adding -/// another callback pair to [_AnimatedTopBar]: the two buttons behave -/// identically, differ only by [kind], and a third list would be one more -/// instance rather than more plumbing. -/// -/// The repository writes its cache before the network, so the flip is immediate -/// and survives a failed request — no spinner, no rollback dance. -class _UserListButton extends StatefulWidget { - const _UserListButton({ - required this.kind, - required this.entity, - required this.activeIcon, - required this.inactiveIcon, - }); - - final UserListKind kind; - final FavoriteEntity entity; - final IconData activeIcon; - final IconData inactiveIcon; - - @override - State<_UserListButton> createState() => _UserListButtonState(); -} - -class _UserListButtonState extends State<_UserListButton> { - late bool _active = _read(); - - bool _read() => getIt() - .contains(widget.kind, widget.entity.contentUrl); - - @override - void didUpdateWidget(covariant _UserListButton oldWidget) { - super.didUpdateWidget(oldWidget); - // The same button is reused when the page rebinds to another title. - if (oldWidget.entity.contentUrl != widget.entity.contentUrl) { - _active = _read(); - } - } - - Future _toggle() async { - final repo = getIt(); - final next = !_active; - setState(() => _active = next); - if (next) { - await repo.add(widget.kind, widget.entity); - // Marking Watched evicts the title from Watch Later (server and cache - // both), so the sibling button must re-read rather than stay lit. - if (widget.kind == UserListKind.watched && mounted) setState(() {}); - } else { - await repo.remove(widget.kind, widget.entity.contentUrl); - } - } - - @override - Widget build(BuildContext context) { - return _CircleIconButton( - icon: _active ? widget.activeIcon : widget.inactiveIcon, - iconColor: _active ? AppColors.rating : Colors.white, - onTap: _toggle, - ); - } -} - class _CircleIconButton extends StatelessWidget { const _CircleIconButton({ required this.icon, diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 476e937..5c6a04b 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -683,7 +683,8 @@ extension _PlayerControls on _PlayerPageState { final c = _controller; final initialized = c != null && c.value.isInitialized; final hasEpisodes = widget.args.isSerial && widget.args.episodes.isNotEmpty; - final hasQualities = _videoSources.length > 1; + final hasServers = _sourceServers.length > 1; + final hasQualities = _currentServerSources.length > 1; final hasLangSwitcher = _availableLangsForCurrentEpisode().length > 1; // Same gate the settings-sheet entry uses (player_page.panels.dart): the // top bar only promotes the action, it does not widen who can download. @@ -772,6 +773,13 @@ extension _PlayerControls on _PlayerPageState { icon: Icons.settings_outlined, onTap: _openSettingsSheet, ), + if (hasServers) ...[ + const SizedBox(width: 8), + _IconButton( + icon: Icons.dns_outlined, + onTap: _openServerSheet, + ), + ], if (hasEpisodes || hasQualities) ...[ const SizedBox(width: 8), _IconButton( @@ -856,7 +864,8 @@ extension _PlayerControls on _PlayerPageState { ), ), ), - if (initialized) _buildBottomBar(c, hasEpisodes, hasQualities), + if (initialized) + _buildBottomBar(c, hasEpisodes, hasServers, hasQualities), ], ), ), @@ -911,6 +920,7 @@ extension _PlayerControls on _PlayerPageState { Widget _buildDesktopControlRow( PlayerController c, bool hasEpisodes, + bool hasServers, bool hasQualities, bool hasPrev, bool hasNext, @@ -1006,6 +1016,13 @@ extension _PlayerControls on _PlayerPageState { icon: Icons.settings_outlined, onTap: _openSettingsSheet, ), + if (hasServers) ...[ + const SizedBox(width: 4), + _IconButton( + icon: Icons.dns_outlined, + onTap: _openServerSheet, + ), + ], if (hasQualities) ...[ const SizedBox(width: 4), _IconButton( @@ -1040,6 +1057,7 @@ extension _PlayerControls on _PlayerPageState { Widget _buildBottomBar( PlayerController c, bool hasEpisodes, + bool hasServers, bool hasQualities, ) { final hasNext = @@ -1215,7 +1233,7 @@ extension _PlayerControls on _PlayerPageState { const SizedBox(height: 4), if (isDesktopPlatform) _buildDesktopControlRow( - c, hasEpisodes, hasQualities, hasPrev, hasNext) + c, hasEpisodes, hasServers, hasQualities, hasPrev, hasNext) else SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -1242,10 +1260,19 @@ extension _PlayerControls on _PlayerPageState { enabled: true, onTap: _openSpeedSheet, ), + if (hasServers) + _BottomTextButton( + icon: Icons.dns_outlined, + label: _currentServer ?? '—', + enabled: true, + onTap: _openServerSheet, + ), if (hasQualities) _BottomTextButton( icon: Icons.high_quality_rounded, - label: _currentQuality ?? 'player.quality'.tr(), + label: _currentQuality == null + ? 'player.quality'.tr() + : _qualityLabel(_currentQuality!), enabled: true, onTap: () => _openPanel(_SidePanel.quality), ), diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 5e7a60a..3b36b1b 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -34,6 +34,7 @@ import 'package:soplay/features/detail/domain/entities/subtitle_style.dart'; import 'package:soplay/features/detail/domain/entities/thumbnails_entity.dart'; import 'package:soplay/core/preview/frame_preview_service.dart'; import 'package:soplay/features/detail/domain/entities/video_source_entity.dart'; +import 'package:soplay/features/detail/domain/video_option_groups.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index 2145c52..246e2b2 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -69,8 +69,116 @@ extension _PlayerPanels on _PlayerPageState { ); } + List get _sourceLabels => [for (final s in _videoSources) s.quality]; + + List get _sourceServers => VideoOptionGroups.servers(_sourceLabels); + + /// The host currently playing, or null before the first source resolves. + /// + /// Nullable on purpose: serverOf('') answers with the "Default" placeholder, + /// which the bar and the settings sheet were painting as if it were a real + /// host name. + String? get _currentServer { + final quality = _currentQuality; + if (quality == null) return null; + return VideoOptionGroups.serverOf(quality); + } + + /// Positions in [_videoSources] the current server offers. + List get _currentServerSources { + final labels = _sourceLabels; + final indices = VideoOptionGroups.indicesFor(labels, _currentServer ?? ''); + // Until the first source resolves there is no current server; offering + // everything beats offering nothing. + return indices.isEmpty + ? [for (var i = 0; i < labels.length; i++) i] + : indices; + } + + String _qualityLabel(String label) { + final quality = VideoOptionGroups.qualityOf(label); + return quality.isEmpty ? label : quality; + } + + /// [_QualityRow] paints the label verbatim, and every label in a one-server + /// list starts with that server's name — so hand it a display-only copy. + VideoSourceEntity _resolutionOnly(VideoSourceEntity source) => + VideoSourceEntity( + quality: _qualityLabel(source.quality), + videoUrl: source.videoUrl, + isDefault: source.isDefault, + accessible: source.accessible, + ); + + void _openServerSheet() { + final labels = _sourceLabels; + final servers = _sourceServers; + if (servers.length < 2) return; + showAdaptiveModal( + context: context, + backgroundColor: const Color(0xFF111111), + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + const Icon( + Icons.dns_outlined, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 10), + Text( + 'player.server'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + const Divider(color: Colors.white12, height: 1), + for (final server in servers) + _ServerTile( + label: server, + qualities: VideoOptionGroups.qualitiesFor(labels, server) + .join(' · '), + selected: server == _currentServer, + onTap: () { + Navigator.of(sheetContext).pop(); + _switchServer(server); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Future _switchServer(String server) async { + final labels = _sourceLabels; + final current = + _currentQuality == null ? -1 : labels.indexOf(_currentQuality!); + final target = VideoOptionGroups.switchTo(labels, current, server); + if (target < 0 || target >= _videoSources.length) return; + await _switchQuality(_videoSources[target]); + } + void _openSettingsSheet() { - final hasQualities = _videoSources.length > 1; + final hasServers = _sourceServers.length > 1; + final hasQualities = _currentServerSources.length > 1; final langs = _availableLangsForCurrentEpisode(); final hasLangs = langs.length > 1; showAdaptiveModal( @@ -126,11 +234,23 @@ extension _PlayerPanels on _PlayerPageState { _openFitSheet(); }, ), + if (hasServers) + _SettingsTile( + icon: Icons.dns_outlined, + label: 'player.server'.tr(), + value: _currentServer ?? '—', + onTap: () { + Navigator.of(sheetContext).pop(); + _openServerSheet(); + }, + ), if (hasQualities) _SettingsTile( icon: Icons.high_quality_rounded, label: 'player.quality'.tr(), - value: _currentQuality ?? '—', + value: _currentQuality == null + ? '—' + : _qualityLabel(_currentQuality!), onTap: () { Navigator.of(sheetContext).pop(); _openPanel(_SidePanel.quality); @@ -485,6 +605,7 @@ extension _PlayerPanels on _PlayerPageState { Widget _buildSidePanel() { final isQuality = _panel == _SidePanel.quality; + final sources = _currentServerSources; return Positioned( top: 0, bottom: 0, @@ -529,15 +650,15 @@ extension _PlayerPanels on _PlayerPageState { child: isQuality ? ListView.separated( controller: _tvPanelScroll, - itemCount: _videoSources.length, + itemCount: sources.length, separatorBuilder: (_, _) => Divider( color: Colors.white.withValues(alpha: 0.06), height: 1, ), itemBuilder: (_, i) { - final src = _videoSources[i]; + final src = _videoSources[sources[i]]; return _QualityRow( - source: src, + source: _resolutionOnly(src), isActive: src.quality == _currentQuality, onTap: () => _switchQuality(src), ); @@ -567,3 +688,72 @@ extension _PlayerPanels on _PlayerPageState { ); } } + +class _ServerTile extends StatelessWidget { + const _ServerTile({ + required this.label, + required this.qualities, + required this.selected, + required this.onTap, + }); + + final String label; + final String qualities; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + autofocus: isTvPlatform && selected, + focusColor: _kTvFocusFill, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + color: selected ? AppColors.primary : Colors.white54, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? Colors.white : Colors.white70, + fontSize: 14, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + ), + ), + if (qualities.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + qualities, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/detail/presentation/widgets/detail_more_sheet.dart b/lib/features/detail/presentation/widgets/detail_more_sheet.dart new file mode 100644 index 0000000..4c804b5 --- /dev/null +++ b/lib/features/detail/presentation/widgets/detail_more_sheet.dart @@ -0,0 +1,460 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/features/remote/data/remote_control_service.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/data/anilist_link_store.dart'; +import 'package:soplay/features/anilist/data/anilist_service.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_link_sheet.dart'; +import 'package:soplay/features/my_list/domain/entities/favorite_entity.dart'; +import 'package:soplay/features/user_lists/domain/entities/user_list_kind.dart'; +import 'package:soplay/features/user_lists/domain/repositories/user_lists_repository.dart'; + +/// The overflow menu behind the title screen's three-dot button. +/// +/// A sheet rather than a [PopupMenuButton] because half of these entries are +/// toggles: the row has to say whether the title is already in Watch Later or +/// tracked on AniList, which a bare menu of labels cannot. +Future showDetailMoreSheet( + BuildContext context, { + required FavoriteEntity entity, + required bool showFollow, + required bool isFollowing, + required VoidCallback onToggleFollow, + required VoidCallback onFindSources, + required VoidCallback onShare, +}) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _DetailMoreSheet( + entity: entity, + showFollow: showFollow, + isFollowing: isFollowing, + onToggleFollow: onToggleFollow, + onFindSources: onFindSources, + onShare: onShare, + ), + ); +} + +class _DetailMoreSheet extends StatefulWidget { + const _DetailMoreSheet({ + required this.entity, + required this.showFollow, + required this.isFollowing, + required this.onToggleFollow, + required this.onFindSources, + required this.onShare, + }); + + final FavoriteEntity entity; + final bool showFollow; + final bool isFollowing; + final VoidCallback onToggleFollow; + final VoidCallback onFindSources; + final VoidCallback onShare; + + @override + State<_DetailMoreSheet> createState() => _DetailMoreSheetState(); +} + +class _DetailMoreSheetState extends State<_DetailMoreSheet> { + /// Hands this title to a linked TV. + /// + /// The device list is fetched on tap rather than when the sheet opens: most + /// people never press this, and the ones who do can wait for one request + /// instead of everyone paying for it on every open. + /// + /// The TITLE is what travels. The two apps do not share a source registry, so + /// this app's contentUrl means nothing to a TV without that provider — the TV + /// resolves the title against its own sources. + Future _playOnTv() async { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + final service = getIt(); + + void say(String message) => messenger.showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + + try { + final devices = await service.devices(); + final online = devices.where((d) => d.online).toList(); + + if (devices.isEmpty) { + say('remote.no_devices'.tr()); + return; + } + if (online.isEmpty) { + say('remote.tv_offline'.tr()); + return; + } + + // One reachable TV is the normal case; asking which would be a dialog + // with a single button in it. + final target = online.length == 1 + ? online.first + : await _pickDevice(online); + if (target == null) return; + + await service.openOnTv( + target.id, + title: widget.entity.title, + contentUrl: widget.entity.contentUrl, + provider: widget.entity.provider, + ); + if (!mounted) return; + navigator.pop(); + say('remote.sent_to_tv'.tr(namedArgs: {'device': target.name})); + } on RemoteOfflineException { + say('remote.tv_offline'.tr()); + } catch (_) { + say('remote.command_failed'.tr()); + } + } + + Future _pickDevice(List devices) { + return showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final device in devices) + ListTile( + leading: const Icon(Icons.tv_rounded, color: AppColors.textSecondary), + title: Text(device.name), + onTap: () => Navigator.of(sheetContext).pop(device), + ), + ], + ), + ), + ); + } + + final UserListSync _listSync = UserListSync(); + late bool _following = widget.isFollowing; + + @override + void dispose() { + _listSync.dispose(); + super.dispose(); + } + + void _toggleFollow() { + setState(() => _following = !_following); + widget.onToggleFollow(); + } + + void _run(VoidCallback action) { + Navigator.of(context).pop(); + action(); + } + + @override + Widget build(BuildContext context) { + final anilistReady = getIt().isConnected && + widget.entity.contentUrl.isNotEmpty; + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.textHint, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 10), + child: Text( + widget.entity.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + UserListToggle( + kind: UserListKind.watchLater, + entity: widget.entity, + sync: _listSync, + builder: (_, active, toggle) => _SheetRow( + icon: active + ? Icons.watch_later_rounded + : Icons.watch_later_outlined, + label: 'detail.watch_later'.tr(), + active: active, + onTap: toggle, + ), + ), + UserListToggle( + kind: UserListKind.watched, + entity: widget.entity, + sync: _listSync, + builder: (_, active, toggle) => _SheetRow( + icon: active ? Icons.visibility_rounded : Icons.visibility_outlined, + label: 'detail.watched'.tr(), + active: active, + onTap: toggle, + ), + ), + if (widget.showFollow) + _SheetRow( + icon: _following + ? Icons.notifications_active_rounded + : Icons.notifications_none_rounded, + label: 'detail.follow_series'.tr(), + active: _following, + onTap: _toggleFollow, + ), + if (anilistReady) _AnilistRow(entity: widget.entity), + const Divider( + color: AppColors.divider, + height: 17, + indent: 18, + endIndent: 18, + ), + _SheetRow( + icon: Icons.travel_explore_rounded, + label: 'detail.find_other_sources'.tr(), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: () => _run(widget.onFindSources), + ), + _SheetRow( + icon: Icons.cast_rounded, + label: 'remote.open_on_tv'.tr(), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: _playOnTv, + ), + _SheetRow( + icon: Icons.ios_share_rounded, + label: 'movie.share'.tr(), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + onTap: () => _run(widget.onShare), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +/// Fires after any [UserListToggle] writes, so sibling toggles re-read the +/// cache: marking a title Watched evicts it from Watch Later. +class UserListSync extends ChangeNotifier { + void ping() => notifyListeners(); +} + +/// Holds the membership state of one user-curated list and hands it to +/// [builder], so a circle button and a sheet row share the same logic. +/// +/// The repository writes its cache before the network, so the flip is immediate +/// and survives a failed request — no spinner, no rollback dance. +class UserListToggle extends StatefulWidget { + const UserListToggle({ + super.key, + required this.kind, + required this.entity, + required this.builder, + this.sync, + }); + + final UserListKind kind; + final FavoriteEntity entity; + final UserListSync? sync; + final Widget Function(BuildContext context, bool active, VoidCallback toggle) + builder; + + @override + State createState() => _UserListToggleState(); +} + +class _UserListToggleState extends State { + late bool _active = _read(); + + bool _read() => getIt() + .contains(widget.kind, widget.entity.contentUrl); + + @override + void initState() { + super.initState(); + widget.sync?.addListener(_reread); + } + + @override + void didUpdateWidget(covariant UserListToggle oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.sync != widget.sync) { + oldWidget.sync?.removeListener(_reread); + widget.sync?.addListener(_reread); + } + // The same toggle is reused when the page rebinds to another title. + if (oldWidget.entity.contentUrl != widget.entity.contentUrl) { + _active = _read(); + } + } + + @override + void dispose() { + widget.sync?.removeListener(_reread); + super.dispose(); + } + + void _reread() { + final active = _read(); + if (mounted && active != _active) setState(() => _active = active); + } + + Future _toggle() async { + final repo = getIt(); + final next = !_active; + setState(() => _active = next); + if (next) { + await repo.add(widget.kind, widget.entity); + } else { + await repo.remove(widget.kind, widget.entity.contentUrl); + } + if (mounted) widget.sync?.ping(); + } + + @override + Widget build(BuildContext context) => + widget.builder(context, _active, _toggle); +} + +class _AnilistRow extends StatefulWidget { + const _AnilistRow({required this.entity}); + + final FavoriteEntity entity; + + @override + State<_AnilistRow> createState() => _AnilistRowState(); +} + +class _AnilistRowState extends State<_AnilistRow> { + late AnilistLink? _link = getIt() + .get(widget.entity.provider, widget.entity.contentUrl); + + Future _open() async { + await AnilistLinkSheet.show( + context, + provider: widget.entity.provider, + contentUrl: widget.entity.contentUrl, + title: widget.entity.title, + ); + if (!mounted) return; + setState(() => _link = getIt() + .get(widget.entity.provider, widget.entity.contentUrl)); + } + + @override + Widget build(BuildContext context) { + final linked = _link != null; + return _SheetRow( + icon: linked ? Icons.bookmark_added_rounded : Icons.bookmark_add_outlined, + label: linked + ? 'detail.anilist_tracked'.tr() + : 'detail.anilist_track'.tr(), + active: linked, + accent: kAnilistBlue, + onTap: _open, + ); + } +} + +class _SheetRow extends StatelessWidget { + const _SheetRow({ + required this.icon, + required this.label, + required this.onTap, + this.active = false, + this.accent = AppColors.rating, + this.trailing, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool active; + final Color accent; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: active + ? accent.withValues(alpha: 0.16) + : Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + icon, + size: 19, + color: active ? accent : AppColors.textPrimary, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w600, + ), + ), + ), + trailing ?? + (active + ? Icon(Icons.check_rounded, size: 20, color: accent) + : const SizedBox.shrink()), + ], + ), + ), + ); + } +} diff --git a/lib/features/extensions/data/mangayomi_runtime.dart b/lib/features/extensions/data/mangayomi_runtime.dart index bb136b4..cfe0ab4 100644 --- a/lib/features/extensions/data/mangayomi_runtime.dart +++ b/lib/features/extensions/data/mangayomi_runtime.dart @@ -37,6 +37,9 @@ class MangayomiRuntime { String? _activeId; String? _activeVersion; + /// Version of each extension compiled into the page this session, by id. + final Map _loaded = {}; + /// Serialises "swap the extension, then call it". Without this, two /// concurrent cross-search legs would race on the single `__sozoProvider` /// global and one source's results would be returned under another's name. @@ -153,6 +156,25 @@ class MangayomiRuntime { await _seedPrefs(source); return; } + + // Already compiled this session — swapping back is a pointer assignment. + // Reloading meant re-running new Function(code) on every source switch, + // which a cross-search does once per source per query. + if (_loaded[source.id] == source.version) { + await _seedPrefs(source); + final activated = await _controller!.callAsyncJavaScript( + functionBody: r'return __sozoActivateMangayomi(id);', + arguments: {'id': source.id}, + ); + if (activated?.value == true) { + _activeId = source.id; + _activeVersion = source.version; + return; + } + // The page was reloaded underneath us; fall through and compile again. + _loaded.remove(source.id); + } + final code = await store.code(source); await _seedPrefs(source); final result = await _controller!.callAsyncJavaScript( @@ -165,6 +187,7 @@ class MangayomiRuntime { } _activeId = source.id; _activeVersion = source.version; + _loaded[source.id] = source.version; } Future _seedPrefs(MangayomiSource source) async { @@ -265,6 +288,14 @@ class MangayomiRuntime { /// Drops the loaded extension so the next call re-reads its code. Used after /// an update or a preference change that alters the base url. void invalidate([String? sourceId]) { + // Must drop the compiled instance too, not just the active pointer: the + // whole point of this call is to make the next one re-read the code, and a + // cached instance would be handed straight back instead. + if (sourceId == null) { + _loaded.clear(); + } else { + _loaded.remove(sourceId); + } if (sourceId == null || sourceId == _activeId) { _activeId = null; _activeVersion = null; @@ -282,5 +313,6 @@ class MangayomiRuntime { _ready = null; _activeId = null; _activeVersion = null; + _loaded.clear(); } } diff --git a/lib/features/link_tv/presentation/pages/link_tv_page.dart b/lib/features/link_tv/presentation/pages/link_tv_page.dart index 783efe9..d8b1990 100644 --- a/lib/features/link_tv/presentation/pages/link_tv_page.dart +++ b/lib/features/link_tv/presentation/pages/link_tv_page.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -112,6 +113,15 @@ class _LinkTvViewState extends State<_LinkTvView> { title: Text('link_tv.title'.tr(), style: const TextStyle(color: AppColors.textPrimary)), iconTheme: const IconThemeData(color: AppColors.textPrimary), + actions: [ + // Reached from here because this is where the TVs are: pairing + // one and then driving it are the same errand. + IconButton( + tooltip: 'remote.title'.tr(), + onPressed: () => context.push('/tv-remote'), + icon: const Icon(Icons.settings_remote_rounded), + ), + ], ), body: RefreshIndicator( color: AppColors.primary, diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index fc836eb..1560283 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -769,34 +769,49 @@ class _SoplayFloatingNav extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(8), + const radius = BorderRadius.all(Radius.circular(9999)); + + return DecoratedBox( decoration: BoxDecoration( - color: AppColors.navBackground, - borderRadius: BorderRadius.circular(9999), + borderRadius: radius, boxShadow: [ + // `outer` keeps the lift without darkening the content that now + // shows through the translucent fill. BoxShadow( - color: Colors.black.withValues(alpha: 0.25), - blurRadius: 55, - offset: const Offset(0, 20), - ), - BoxShadow( - color: Colors.black.withValues(alpha: 0.17), - blurRadius: 13, - offset: const Offset(0, 12), + color: Colors.black.withValues(alpha: 0.32), + blurRadius: 28, + offset: const Offset(0, 10), + blurStyle: BlurStyle.outer, ), ], ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < items.length; i++) - _NavCircle( - item: items[i], - selected: index == i, - onTap: () => onTap(i), + child: ClipRRect( + borderRadius: radius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF262626).withValues(alpha: 0.72), + borderRadius: radius, + border: Border.all( + color: Colors.white.withValues(alpha: 0.10), + width: 0.5, + ), ), - ], + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < items.length; i++) + _NavCircle( + item: items[i], + selected: index == i, + onTap: () => onTap(i), + ), + ], + ), + ), + ), ), ); } diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 957a2b8..099ed26 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -10,7 +10,6 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/features/link_tv/presentation/pages/link_tv_page.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/features/profile/presentation/widgets/tab_customizer_sheet.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:soplay/core/aniyomi/aniyomi_channel.dart'; import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; @@ -25,7 +24,6 @@ import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/desktop_window.dart'; import 'package:soplay/core/system/nav_prefs.dart'; import 'package:soplay/core/system/responsive.dart'; -import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; import 'package:soplay/features/manga/presentation/pages/manga_sources_page.dart'; import 'package:soplay/features/cloudstream/presentation/pages/cloudstream_sources_page.dart'; import 'package:soplay/features/app_lock/domain/repositories/app_lock_repository.dart'; @@ -36,11 +34,11 @@ import 'package:soplay/features/auth/presentation/bloc/auth_event.dart'; import 'package:soplay/features/auth/presentation/bloc/auth_state.dart'; import 'package:soplay/features/download/data/download_service.dart'; import 'package:soplay/features/history/data/history_service.dart'; -import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/streak/presentation/widgets/streak_card.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; +import 'package:soplay/features/profile/presentation/pages/providers_page.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_brand.dart'; import 'package:soplay/features/anilist/presentation/widgets/anilist_logo.dart'; @@ -146,7 +144,11 @@ class _ProfileViewState extends State<_ProfileView> { ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 2, child: _ProvidersSection()), + child: _Reveal(order: 2, child: _ConnectionsSection()), + ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + const SliverToBoxAdapter( + child: _Reveal(order: 3, child: _ProvidersSection()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), if (BridgeControl.canHost && CloudStreamChannel.isSupported) ...[ @@ -320,23 +322,23 @@ class _ProfileViewState extends State<_ProfileView> { ), ), const SliverToBoxAdapter( - child: _Reveal(order: 3, child: _WatchHistorySection()), + child: _Reveal(order: 4, child: _WatchHistorySection()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 4, child: _SecuritySection()), + child: _Reveal(order: 5, child: _SecuritySection()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 5, child: _AppearanceEntry()), + child: _Reveal(order: 6, child: _AppearanceEntry()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 6, child: _PlayerEntry()), + child: _Reveal(order: 7, child: _PlayerEntry()), ), const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter( - child: _Reveal(order: 7, child: _AboutSection()), + child: _Reveal(order: 8, child: _AboutSection()), ), SliverToBoxAdapter( child: SizedBox( @@ -488,6 +490,8 @@ class _ProfileViewState extends State<_ProfileView> { ), const SizedBox(height: 8), const StreakCard(), + const SizedBox(height: 16), + const _ConnectionsSection(), ], ); case 1: @@ -904,6 +908,7 @@ class _LogoutButton extends StatelessWidget { } } +/// Compact summary of the active provider; the picker itself is [ProvidersPage]. class _ProvidersSection extends StatelessWidget { const _ProvidersSection(); @@ -918,12 +923,11 @@ class _ProvidersSection extends StatelessWidget { const SizedBox(height: 8), BlocBuilder( builder: (context, state) { - final currentName = state is ProviderLoaded - ? state.currentProvider?.name ?? state.currentProviderId - : '—'; - final currentProvider = state is ProviderLoaded - ? state.currentProvider - : null; + final loaded = state is ProviderLoaded ? state : null; + final currentProvider = loaded?.currentProvider; + final currentName = + currentProvider?.name ?? loaded?.currentProviderId ?? '—'; + final total = loaded?.providers.length ?? 0; return _SectionCard( children: [ @@ -949,13 +953,28 @@ class _ProvidersSection extends StatelessWidget { ), ), ), - Text( - currentName, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 14, + Flexible( + child: Text( + currentName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + ), ), ), + if (total > 0) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + '· $total', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + ), + ), + ), const SizedBox(width: 4), const Icon( Icons.chevron_right_rounded, @@ -964,9 +983,7 @@ class _ProvidersSection extends StatelessWidget { ), ], ), - onTap: () { - _ProvidersPage.open(context, context.read()); - }, + onTap: () => context.push('/providers'), ), ], ); @@ -978,1045 +995,8 @@ class _ProvidersSection extends StatelessWidget { } } -String providerGroup(ProviderEntity p) { - if (p.category == 'cloudstream') return 'cloudstream'; - if (p.category == 'aniyomi') return 'aniyomi'; - if (p.category == 'manga') return 'manga'; - if (p.category == 'mangayomi') return 'mangayomi'; - return switch (p.mode) { - 'hybrid' => 'hybrid', - 'client' => 'local', - _ => 'cloud', - }; -} - -String _providerSheetFilter = 'all'; - void openProviderPicker(BuildContext context, ProviderBloc bloc) { - _ProvidersPage.open(context, bloc); -} - -class _ProvidersPage extends StatefulWidget { - const _ProvidersPage(); - - static void open(BuildContext context, ProviderBloc bloc) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BlocProvider.value( - value: bloc, - child: const _ProvidersPage(), - ), - ), - ); - } - - @override - State<_ProvidersPage> createState() => _ProvidersPageState(); -} - -class _ProvidersPageState extends State<_ProvidersPage> { - late String _selectedCategory; - final _searchController = TextEditingController(); - String _query = ''; - Timer? _searchDebounce; - - @override - void initState() { - super.initState(); - final hasFavorites = - getIt().getFavoriteProviders().isNotEmpty; - var initial = _providerSheetFilter; - if (initial == 'favorites' && !hasFavorites) initial = 'all'; - if (initial == 'all' && hasFavorites) initial = 'favorites'; - _selectedCategory = initial; - _providerSheetFilter = initial; - } - - @override - void dispose() { - _searchDebounce?.cancel(); - _searchController.dispose(); - super.dispose(); - } - - Future _toggleFavorite(String id) async { - await getIt().toggleFavoriteProvider(id); - if (!mounted) return; - if (_selectedCategory == 'favorites' && - getIt().getFavoriteProviders().isEmpty) { - _selectedCategory = 'all'; - _providerSheetFilter = 'all'; - } - setState(() {}); - } - - @override - Widget build(BuildContext context) { - final bottomPad = MediaQuery.paddingOf(context).bottom; - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( - backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - elevation: 0, - title: Text('profile.choose_provider'.tr()), - actions: [ - BlocBuilder( - builder: (context, state) => state is ProviderLoaded - ? Padding( - padding: const EdgeInsets.only(right: 6), - child: _CategoryFilterButton( - providers: state.providers, - selected: _selectedCategory, - onSelected: (cat) => setState(() { - _selectedCategory = cat; - _providerSheetFilter = cat; - }), - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - final filtered = state is ProviderLoaded - ? _filteredProviders(state.providers) - : const []; - final favorites = - getIt().getFavoriteProviders().toSet(); - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), - child: TextField( - controller: _searchController, - onChanged: (v) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 200), - () { - if (mounted) setState(() => _query = v.trim()); - }, - ); - }, - style: const TextStyle(color: Colors.white, fontSize: 14), - textInputAction: TextInputAction.search, - decoration: InputDecoration( - isDense: true, - hintText: 'profile.search_providers_hint'.tr(), - hintStyle: const TextStyle(color: AppColors.textHint), - prefixIcon: const Icon(Icons.search, - color: AppColors.textHint, size: 20), - suffixIcon: _query.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear, - color: AppColors.textHint, size: 20), - onPressed: () => setState(() { - _searchController.clear(); - _query = ''; - }), - ), - filled: true, - fillColor: AppColors.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), - ), - if (state is ProviderLoaded && state.offline) - _ProvidersOfflineBanner( - usableCount: state.usableProviders.length, - cachedAt: state.cachedAt, - onRetry: () => - context.read().add(const ProviderLoad()), - ), - if (state is ProviderLoaded) - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 16, 6), - child: Text( - _query.isEmpty - ? 'profile.count_of_total_shown'.tr(args: [ - '${filtered.length}', - '${state.providers.length}' - ]) - // A query ignores the category chip, so say so — - // otherwise a hit from a hidden group looks like a bug. - : '${filtered.length} / ${state.providers.length} · ' - '${'profile.searching_all_sources'.tr()}', - style: const TextStyle( - color: AppColors.textHint, fontSize: 12), - ), - ), - ), - Expanded( - child: switch (state) { - ProviderLoaded() => filtered.isEmpty - ? const _ProvidersEmpty() - : _ProvidersList( - providers: filtered, - currentProviderId: state.currentProviderId, - bottomPad: bottomPad, - favorites: favorites, - onToggleFavorite: _toggleFavorite, - unavailableIds: { - for (final p in state.providers) - if (!state.isUsable(p)) p.id, - }, - ), - ProviderError() => _ProvidersError( - onRetry: () => - context.read().add(const ProviderLoad()), - ), - _ => const _ProvidersLoading(), - }, - ), - ], - ); - }, - ), - ); - } - - /// Providers matching the current category + query. - /// - /// **A query searches every provider, not just the active category.** The - /// category chips are a browsing aid; "All" deliberately hides the 260+ - /// CloudStream/Aniyomi/Manga extension sources so the default list stays - /// short. Scoping the search box to that same subset meant typing an - /// installed extension's name in the default view found nothing at all — - /// the one place a user with hundreds of sources actually needs search. - List _filteredProviders(List all) { - final q = _query.trim().toLowerCase(); - if (q.isNotEmpty) { - return all - .where((p) => - p.name.toLowerCase().contains(q) || p.id.toLowerCase().contains(q)) - .toList(); - } - - Iterable list; - if (_selectedCategory == 'favorites') { - final favs = getIt().getFavoriteProviders().toSet(); - list = all.where((p) => favs.contains(p.id)); - } else if (_selectedCategory == 'all') { - list = all.where((p) => - providerGroup(p) != 'cloudstream' && - providerGroup(p) != 'aniyomi' && - providerGroup(p) != 'manga' && - providerGroup(p) != 'mangayomi'); - } else if (_selectedCategory.startsWith('repo:')) { - final repo = _selectedCategory.substring(5); - list = all.where( - (p) => providerGroup(p) == 'cloudstream' && p.description == repo); - } else { - list = all.where((p) => providerGroup(p) == _selectedCategory); - } - return list.toList(); - } -} - -class _CategoryFilterButton extends StatelessWidget { - const _CategoryFilterButton({ - required this.providers, - required this.selected, - required this.onSelected, - }); - - final List providers; - final String selected; - final ValueChanged onSelected; - - static const _canonicalOrder = [ - 'favorites', - 'cloud', - 'hybrid', - 'local', - 'cloudstream', - 'aniyomi', - 'manga', - 'mangayomi', - ]; - - static const _meta = { - 'all': ('All', Icons.apps_rounded), - 'favorites': ('Favorites', Icons.star), - 'cloud': ('Cloud', Icons.cloud_outlined), - 'hybrid': ('Hybrid', Icons.sync_rounded), - 'local': ('Local', Icons.smartphone_outlined), - 'cloudstream':('CloudStream', Icons.extension_outlined), - 'aniyomi': ('Aniyomi', Icons.play_circle_outline), - 'manga': ('Manga', Icons.menu_book_outlined), - 'mangayomi': ('Mangayomi', Icons.javascript_outlined), - }; - - String _label(String key) => - key == 'favorites' ? 'profile.favorites'.tr() : (_meta[key]?.$1 ?? key); - - String _repoShort(String repo) { - final seg = repo.contains('/') ? repo.split('/').last : repo; - return seg.length > 18 ? '${seg.substring(0, 17)}…' : seg; - } - - @override - Widget build(BuildContext context) { - final counts = {}; - for (final p in providers) { - final g = providerGroup(p); - counts[g] = (counts[g] ?? 0) + 1; - } - final favIds = getIt().getFavoriteProviders().toSet(); - final favoriteCount = providers.where((p) => favIds.contains(p.id)).length; - if (favoriteCount > 0) counts['favorites'] = favoriteCount; - final repoCounts = {}; - for (final p in providers) { - if (providerGroup(p) != 'cloudstream') continue; - final r = p.description; - if (r.isEmpty || r == 'CloudStream') continue; - repoCounts[r] = (repoCounts[r] ?? 0) + 1; - } - final available = _canonicalOrder.where(counts.containsKey).toList(); - final repos = repoCounts.keys.toList()..sort(); - if (available.length < 2 && repos.isEmpty) return const SizedBox.shrink(); - - final (String, IconData) selectedMeta = selected.startsWith('repo:') - ? (_repoShort(selected.substring(5)), Icons.folder_outlined) - : (_label(selected), (_meta[selected] ?? _meta['all']!).$2); - final selectedCount = selected == 'all' - ? providers.length - : selected.startsWith('repo:') - ? (repoCounts[selected.substring(5)] ?? 0) - : (counts[selected] ?? 0); - - final entries = <(String, String, IconData, int)>[ - ('all', _meta['all']!.$1, _meta['all']!.$2, providers.length), - ...available.map((cat) { - final meta = _meta[cat] ?? (cat, Icons.label_outline); - return (cat, _label(cat), meta.$2, counts[cat] ?? 0); - }), - ...repos.map((r) => - ('repo:$r', _repoShort(r), Icons.folder_outlined, repoCounts[r] ?? 0)), - ]; - - return PopupMenuButton( - tooltip: 'search.filter'.tr(), - offset: const Offset(0, 44), - color: AppColors.surfaceVariant, - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: Colors.white.withValues(alpha: 0.06)), - ), - onSelected: onSelected, - itemBuilder: (_) => [ - for (final (id, label, icon, count) in entries) - PopupMenuItem( - value: id, - height: 42, - child: Row( - children: [ - Icon( - icon, - size: 16, - color: selected == id - ? AppColors.primary - : AppColors.textSecondary, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - color: selected == id - ? AppColors.primary - : AppColors.textPrimary, - fontSize: 13.5, - fontWeight: selected == id - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - ), - const SizedBox(width: 12), - Text( - '$count', - style: const TextStyle( - color: AppColors.textHint, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - child: Container( - padding: const EdgeInsets.fromLTRB(10, 7, 8, 7), - decoration: BoxDecoration( - color: AppColors.surfaceVariant.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.white.withValues(alpha: 0.06)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(selectedMeta.$2, size: 14, color: AppColors.textSecondary), - const SizedBox(width: 6), - Text( - selectedMeta.$1, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12.5, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 5), - Text( - '$selectedCount', - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.keyboard_arrow_down_rounded, - size: 16, - color: AppColors.textHint, - ), - ], - ), - ), - ); - } -} - -class _ProvidersList extends StatefulWidget { - const _ProvidersList({ - required this.providers, - required this.currentProviderId, - required this.bottomPad, - required this.favorites, - required this.onToggleFavorite, - this.unavailableIds = const {}, - }); - - final List providers; - final String currentProviderId; - final double bottomPad; - final Set favorites; - final ValueChanged onToggleFavorite; - - /// Providers that exist in the list but cannot serve content right now - /// (server-backed entries while the backend is unreachable). - final Set unavailableIds; - - @override - State<_ProvidersList> createState() => _ProvidersListState(); -} - -class _ProvidersListState extends State<_ProvidersList> { - static const double _estItemExtent = 72.0; - late final ScrollController _controller; - - @override - void initState() { - super.initState(); - final i = widget.providers.indexWhere((p) => p.id == widget.currentProviderId); - final offset = i > 2 ? (i * _estItemExtent - 80).clamp(0.0, double.infinity) : 0.0; - _controller = ScrollController(initialScrollOffset: offset); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ListView.builder( - controller: _controller, - padding: EdgeInsets.fromLTRB(16, 4, 16, widget.bottomPad + 16), - addAutomaticKeepAlives: false, - itemExtent: _estItemExtent, - itemCount: widget.providers.length, - itemBuilder: (context, i) { - final provider = widget.providers[i]; - final selected = provider.id == widget.currentProviderId; - final unavailable = widget.unavailableIds.contains(provider.id); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _ProviderListTile( - provider: provider, - selected: selected, - isFavorite: widget.favorites.contains(provider.id), - unavailable: unavailable, - onToggleFavorite: () => widget.onToggleFavorite(provider.id), - onTap: () { - // Refuse the selection outright rather than letting it fail - // three screens later inside a home or detail request. - if (unavailable) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('profile.provider_needs_server'.tr()), - backgroundColor: AppColors.surface, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 3), - ), - ); - return; - } - context.read().add(ProviderSelect(provider.id)); - Navigator.of(context).pop(); - }, - ), - ); - }, - ); - } -} - -class _ProvidersEmpty extends StatelessWidget { - const _ProvidersEmpty(); - - @override - Widget build(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_off_rounded, - size: 44, - color: AppColors.textHint.withValues(alpha: 0.7), - ), - const SizedBox(height: 12), - Text( - 'profile.no_providers_in_category'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - 'profile.try_select_all'.tr(), - textAlign: TextAlign.center, - style: TextStyle( - color: AppColors.textHint.withValues(alpha: 0.85), - fontSize: 12, - ), - ), - ], - ), - ), - ); - } -} - -class _ProviderListTile extends StatelessWidget { - const _ProviderListTile({ - required this.provider, - required this.selected, - required this.isFavorite, - required this.onToggleFavorite, - required this.onTap, - this.unavailable = false, - }); - - final ProviderEntity provider; - final bool selected; - final bool isFavorite; - final bool unavailable; - final VoidCallback onToggleFavorite; - final VoidCallback onTap; - - bool get _canSolveCloudflare => - provider.id.startsWith('an:') || - provider.id.startsWith('mn:') || - provider.id.startsWith('cs:'); - - Future _solveCloudflare(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); - final ok = await requestCloudflareSolve(context, provider.id); - messenger.showSnackBar( - SnackBar( - content: Text(ok ? '${'general.done'.tr()} ✓' : 'general.cancel'.tr()), - backgroundColor: AppColors.surface, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: unavailable ? 0.45 : 1, - child: _tile(context), - ); - } - - Widget _tile(BuildContext context) { - return Material( - color: selected - ? AppColors.primary.withValues(alpha: 0.10) - : AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: selected - ? Border.all(color: AppColors.primary, width: 1.2) - : null, - ), - child: InkWell( - onTap: onTap, - onLongPress: - _canSolveCloudflare ? () => _solveCloudflare(context) : null, - onSecondaryTap: - _canSolveCloudflare ? () => _solveCloudflare(context) : null, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - _ProviderLogo(provider: provider, size: 40), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Flexible( - child: Text( - provider.name, - style: TextStyle( - color: selected - ? AppColors.textPrimary - : AppColors.textSecondary, - fontSize: 14, - fontWeight: selected - ? FontWeight.w700 - : FontWeight.w500, - height: 1.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - if (unavailable) - const _ServerDownBadge() - else - _ProviderModeBadge(mode: provider.mode), - if (provider.requiresCfBypass) ...[ - const SizedBox(width: 4), - const _CfBypassBadge(), - ], - if (provider.nsfw) ...[ - const SizedBox(width: 4), - const _NsfwBadge(), - ], - ], - ), - if (provider.description.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - provider.description, - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - height: 1.25, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - const SizedBox(width: 4), - IconButton( - onPressed: onToggleFavorite, - icon: Icon( - isFavorite ? Icons.star : Icons.star_border, - color: isFavorite ? Colors.amber : AppColors.textHint, - size: 20, - ), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 34, - minHeight: 34, - ), - tooltip: 'profile.add_favorite'.tr(), - ), - const SizedBox(width: 2), - if (selected) - Container( - width: 20, - height: 20, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.check_rounded, - color: Colors.white, - size: 12, - ), - ) - else - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textHint, - size: 20, - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Replaces the mode badge on a server-backed provider while the API is down, -/// so the reason it is greyed out is readable at a glance. -class _ServerDownBadge extends StatelessWidget { - const _ServerDownBadge(); - - @override - Widget build(BuildContext context) { - const color = AppColors.error; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.cloud_off_rounded, size: 9, color: color), - const SizedBox(width: 3), - Text( - 'profile.offline_badge'.tr(), - style: const TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ], - ), - ); - } -} - -/// Explains the outage in place, above a list that is still partly usable. -class _ProvidersOfflineBanner extends StatelessWidget { - const _ProvidersOfflineBanner({ - required this.usableCount, - required this.cachedAt, - required this.onRetry, - }); - - final int usableCount; - final DateTime? cachedAt; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.fromLTRB(16, 2, 16, 10), - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.border, width: 0.6), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.cloud_off_rounded, - color: AppColors.textSecondary, size: 20), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'profile.offline_title'.tr(), - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - usableCount > 0 - ? 'profile.offline_local_available' - .tr(args: ['$usableCount']) - : 'profile.offline_no_local'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12, - height: 1.4, - ), - ), - if (cachedAt != null) ...[ - const SizedBox(height: 2), - Text( - 'profile.offline_cached_at'.tr(args: [_stamp(cachedAt!)]), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 11, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: onRetry, - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 10), - ), - child: Text('general.retry'.tr()), - ), - ], - ), - ); - } - - static String _stamp(DateTime at) { - final l = at.toLocal(); - String two(int v) => v.toString().padLeft(2, '0'); - return '${two(l.day)}.${two(l.month)} ${two(l.hour)}:${two(l.minute)}'; - } -} - -class _ProviderModeBadge extends StatelessWidget { - const _ProviderModeBadge({required this.mode}); - final String mode; - - @override - Widget build(BuildContext context) { - final normalized = mode.toLowerCase(); - final (label, color) = switch (normalized) { - 'client' => ('Local', const Color(0xFF34A853)), - 'hybrid' => ('Hybrid', const Color(0xFFF59E0B)), - 'server' => ('Cloud', const Color(0xFF6B7280)), - _ => (mode.isEmpty ? 'Cloud' : mode, const Color(0xFF6B7280)), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: Text( - label, - style: TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ); - } -} - -class _CfBypassBadge extends StatelessWidget { - const _CfBypassBadge(); - - @override - Widget build(BuildContext context) { - const color = Color(0xFFF38020); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.shield_outlined, size: 9, color: color), - SizedBox(width: 3), - Text( - 'CF', - style: TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ], - ), - ); - } -} - -class _NsfwBadge extends StatelessWidget { - const _NsfwBadge(); - - @override - Widget build(BuildContext context) { - const color = Color(0xFFE53935); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), - ), - child: const Text( - '18+', - style: TextStyle( - color: color, - fontSize: 9, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ); - } -} - -class _ProvidersLoading extends StatelessWidget { - const _ProvidersLoading(); - - @override - Widget build(BuildContext context) { - return const Center( - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textHint, - ), - ); - } -} - -/// Reached only when the backend is unreachable, there is no cached list *and* -/// no plugin is installed — i.e. there is genuinely no working path left. The -/// copy therefore points at installing plugins, which needs no server. -class _ProvidersError extends StatelessWidget { - const _ProvidersError({required this.onRetry}); - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), - child: Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white.withValues(alpha: 0.09)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.06), - ), - child: const Icon( - Icons.cloud_off_rounded, - color: AppColors.textSecondary, - size: 28, - ), - ), - const SizedBox(height: 18), - Text( - 'profile.offline_title'.tr(), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 18, - fontWeight: FontWeight.w800, - height: 1.2, - ), - ), - const SizedBox(height: 8), - Text( - 'profile.offline_no_local'.tr(), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 13, - height: 1.5, - ), - ), - const SizedBox(height: 22), - SizedBox( - height: 46, - width: double.infinity, - child: ElevatedButton( - onPressed: onRetry, - child: Text('general.retry'.tr()), - ), - ), - if (CloudStreamChannel.isSupported) ...[ - const SizedBox(height: 10), - SizedBox( - height: 46, - width: double.infinity, - child: OutlinedButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const CloudStreamSourcesPage(), - ), - ), - child: Text('profile.offline_install_plugins'.tr()), - ), - ), - ], - ], - ), - ), - ), - ), - ), - ); - } + ProvidersPage.open(context, bloc); } class _WatchHistorySection extends StatefulWidget { @@ -2127,8 +1107,6 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { onTap: () => context.push('/following'), ), const Divider(color: AppColors.divider, height: 1), - _ConnectionsTile(), - const Divider(color: AppColors.divider, height: 1), _Tile( icon: Icons.devices_rounded, title: BridgeControl.canHost @@ -2151,6 +1129,20 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { } } +/// Sits directly under the streak card: the offer to connect a tracker only +/// works if it is seen, and it was previously buried in the Activity list. +class _ConnectionsSection extends StatelessWidget { + const _ConnectionsSection(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _SectionCard(children: [_ConnectionsTile()]), + ); + } +} + /// The Connections row, which shows AniList's state inline. /// /// A tile of its own rather than a plain link: whether a tracker is connected @@ -2158,6 +1150,8 @@ class _WatchHistorySectionState extends State<_WatchHistorySection> { /// navigate to find out defeats the point. Listens to the service so connecting /// elsewhere updates it without a manual refresh. class _ConnectionsTile extends StatefulWidget { + const _ConnectionsTile(); + @override State<_ConnectionsTile> createState() => _ConnectionsTileState(); } @@ -3059,60 +2053,6 @@ class _Initials extends StatelessWidget { } } -class _ProviderLogo extends StatelessWidget { - const _ProviderLogo({required this.provider, this.size = 42}); - final ProviderEntity provider; - final double size; - - @override - Widget build(BuildContext context) { - final cache = (size * MediaQuery.devicePixelRatioOf(context)).round(); - return ClipRRect( - borderRadius: BorderRadius.circular(10), - child: provider.image.isEmpty - ? _ProviderFallback(name: provider.name, size: size) - : CachedNetworkImage( - imageUrl: provider.image, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: cache, - memCacheHeight: cache, - fadeInDuration: const Duration(milliseconds: 120), - placeholder: (_, _) => - _ProviderFallback(name: provider.name, size: size), - errorWidget: (_, _, _) => - _ProviderFallback(name: provider.name, size: size), - ), - ); - } -} - -class _ProviderFallback extends StatelessWidget { - const _ProviderFallback({required this.name, required this.size}); - final String name; - final double size; - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - color: AppColors.surfaceVariant, - alignment: Alignment.center, - child: Text( - name.isEmpty ? '?' : name[0].toUpperCase(), - style: TextStyle( - color: AppColors.textSecondary, - fontWeight: FontWeight.w800, - fontSize: size * 0.38, - ), - ), - ); - } -} - - class _ServerCountdownTile extends StatefulWidget { const _ServerCountdownTile(); diff --git a/lib/features/profile/presentation/pages/providers_page.dart b/lib/features/profile/presentation/pages/providers_page.dart new file mode 100644 index 0000000..6c9b282 --- /dev/null +++ b/lib/features/profile/presentation/pages/providers_page.dart @@ -0,0 +1,1111 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:soplay/core/cloudstream/cloudstream_channel.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/cloudflare/cloudflare_solver.dart'; +import 'package:soplay/features/cloudstream/presentation/pages/cloudstream_sources_page.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; + +String providerGroup(ProviderEntity p) { + if (p.category == 'cloudstream') return 'cloudstream'; + if (p.category == 'aniyomi') return 'aniyomi'; + if (p.category == 'manga') return 'manga'; + if (p.category == 'mangayomi') return 'mangayomi'; + return switch (p.mode) { + 'hybrid' => 'hybrid', + 'client' => 'local', + _ => 'cloud', + }; +} + +String _providerSheetFilter = 'all'; + +/// Full-screen provider picker, routed at `/providers` and also pushed +/// imperatively from the home top bar and the outage banner. +class ProvidersPage extends StatefulWidget { + const ProvidersPage({super.key}); + + /// Push carrying an explicit [ProviderBloc], for callers that cannot rely on + /// an ambient one. + static void open(BuildContext context, ProviderBloc bloc) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: bloc, + child: const ProvidersPage(), + ), + ), + ); + } + + @override + State createState() => _ProvidersPageState(); +} + +class _ProvidersPageState extends State { + late String _selectedCategory; + final _searchController = TextEditingController(); + String _query = ''; + Timer? _searchDebounce; + + @override + void initState() { + super.initState(); + final hasFavorites = + getIt().getFavoriteProviders().isNotEmpty; + var initial = _providerSheetFilter; + if (initial == 'favorites' && !hasFavorites) initial = 'all'; + if (initial == 'all' && hasFavorites) initial = 'favorites'; + _selectedCategory = initial; + _providerSheetFilter = initial; + } + + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + Future _toggleFavorite(String id) async { + await getIt().toggleFavoriteProvider(id); + if (!mounted) return; + if (_selectedCategory == 'favorites' && + getIt().getFavoriteProviders().isEmpty) { + _selectedCategory = 'all'; + _providerSheetFilter = 'all'; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final bottomPad = MediaQuery.paddingOf(context).bottom; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('profile.choose_provider'.tr()), + actions: [ + BlocBuilder( + builder: (context, state) => state is ProviderLoaded + ? Padding( + padding: const EdgeInsets.only(right: 6), + child: _CategoryFilterButton( + providers: state.providers, + selected: _selectedCategory, + onSelected: (cat) => setState(() { + _selectedCategory = cat; + _providerSheetFilter = cat; + }), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + final filtered = state is ProviderLoaded + ? _filteredProviders(state.providers) + : const []; + final favorites = + getIt().getFavoriteProviders().toSet(); + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), + child: TextField( + controller: _searchController, + onChanged: (v) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 200), + () { + if (mounted) setState(() => _query = v.trim()); + }, + ); + }, + style: const TextStyle(color: Colors.white, fontSize: 14), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + isDense: true, + hintText: 'profile.search_providers_hint'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint), + prefixIcon: const Icon(Icons.search, + color: AppColors.textHint, size: 20), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear, + color: AppColors.textHint, size: 20), + onPressed: () => setState(() { + _searchController.clear(); + _query = ''; + }), + ), + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (state is ProviderLoaded && state.offline) + _ProvidersOfflineBanner( + usableCount: state.usableProviders.length, + cachedAt: state.cachedAt, + onRetry: () => + context.read().add(const ProviderLoad()), + ), + if (state is ProviderLoaded) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 16, 6), + child: Text( + _query.isEmpty + ? 'profile.count_of_total_shown'.tr(args: [ + '${filtered.length}', + '${state.providers.length}' + ]) + // A query ignores the category chip, so say so — + // otherwise a hit from a hidden group looks like a bug. + : '${filtered.length} / ${state.providers.length} · ' + '${'profile.searching_all_sources'.tr()}', + style: const TextStyle( + color: AppColors.textHint, fontSize: 12), + ), + ), + ), + Expanded( + child: switch (state) { + ProviderLoaded() => filtered.isEmpty + ? const _ProvidersEmpty() + : _ProvidersList( + providers: filtered, + currentProviderId: state.currentProviderId, + bottomPad: bottomPad, + favorites: favorites, + onToggleFavorite: _toggleFavorite, + unavailableIds: { + for (final p in state.providers) + if (!state.isUsable(p)) p.id, + }, + ), + ProviderError() => _ProvidersError( + onRetry: () => + context.read().add(const ProviderLoad()), + ), + _ => const _ProvidersLoading(), + }, + ), + ], + ); + }, + ), + ); + } + + /// Providers matching the current category + query. + /// + /// **A query searches every provider, not just the active category.** The + /// category chips are a browsing aid; "All" deliberately hides the 260+ + /// CloudStream/Aniyomi/Manga extension sources so the default list stays + /// short. Scoping the search box to that same subset meant typing an + /// installed extension's name in the default view found nothing at all — + /// the one place a user with hundreds of sources actually needs search. + List _filteredProviders(List all) { + final q = _query.trim().toLowerCase(); + if (q.isNotEmpty) { + return all + .where((p) => + p.name.toLowerCase().contains(q) || p.id.toLowerCase().contains(q)) + .toList(); + } + + Iterable list; + if (_selectedCategory == 'favorites') { + final favs = getIt().getFavoriteProviders().toSet(); + list = all.where((p) => favs.contains(p.id)); + } else if (_selectedCategory == 'all') { + list = all.where((p) => + providerGroup(p) != 'cloudstream' && + providerGroup(p) != 'aniyomi' && + providerGroup(p) != 'manga' && + providerGroup(p) != 'mangayomi'); + } else if (_selectedCategory.startsWith('repo:')) { + final repo = _selectedCategory.substring(5); + list = all.where( + (p) => providerGroup(p) == 'cloudstream' && p.description == repo); + } else { + list = all.where((p) => providerGroup(p) == _selectedCategory); + } + return list.toList(); + } +} + +class _CategoryFilterButton extends StatelessWidget { + const _CategoryFilterButton({ + required this.providers, + required this.selected, + required this.onSelected, + }); + + final List providers; + final String selected; + final ValueChanged onSelected; + + static const _canonicalOrder = [ + 'favorites', + 'cloud', + 'hybrid', + 'local', + 'cloudstream', + 'aniyomi', + 'manga', + 'mangayomi', + ]; + + static const _meta = { + 'all': ('All', Icons.apps_rounded), + 'favorites': ('Favorites', Icons.star), + 'cloud': ('Cloud', Icons.cloud_outlined), + 'hybrid': ('Hybrid', Icons.sync_rounded), + 'local': ('Local', Icons.smartphone_outlined), + 'cloudstream':('CloudStream', Icons.extension_outlined), + 'aniyomi': ('Aniyomi', Icons.play_circle_outline), + 'manga': ('Manga', Icons.menu_book_outlined), + 'mangayomi': ('Mangayomi', Icons.javascript_outlined), + }; + + String _label(String key) => + key == 'favorites' ? 'profile.favorites'.tr() : (_meta[key]?.$1 ?? key); + + String _repoShort(String repo) { + final seg = repo.contains('/') ? repo.split('/').last : repo; + return seg.length > 18 ? '${seg.substring(0, 17)}…' : seg; + } + + @override + Widget build(BuildContext context) { + final counts = {}; + for (final p in providers) { + final g = providerGroup(p); + counts[g] = (counts[g] ?? 0) + 1; + } + final favIds = getIt().getFavoriteProviders().toSet(); + final favoriteCount = providers.where((p) => favIds.contains(p.id)).length; + if (favoriteCount > 0) counts['favorites'] = favoriteCount; + final repoCounts = {}; + for (final p in providers) { + if (providerGroup(p) != 'cloudstream') continue; + final r = p.description; + if (r.isEmpty || r == 'CloudStream') continue; + repoCounts[r] = (repoCounts[r] ?? 0) + 1; + } + final available = _canonicalOrder.where(counts.containsKey).toList(); + final repos = repoCounts.keys.toList()..sort(); + if (available.length < 2 && repos.isEmpty) return const SizedBox.shrink(); + + final (String, IconData) selectedMeta = selected.startsWith('repo:') + ? (_repoShort(selected.substring(5)), Icons.folder_outlined) + : (_label(selected), (_meta[selected] ?? _meta['all']!).$2); + final selectedCount = selected == 'all' + ? providers.length + : selected.startsWith('repo:') + ? (repoCounts[selected.substring(5)] ?? 0) + : (counts[selected] ?? 0); + + final entries = <(String, String, IconData, int)>[ + ('all', _meta['all']!.$1, _meta['all']!.$2, providers.length), + ...available.map((cat) { + final meta = _meta[cat] ?? (cat, Icons.label_outline); + return (cat, _label(cat), meta.$2, counts[cat] ?? 0); + }), + ...repos.map((r) => + ('repo:$r', _repoShort(r), Icons.folder_outlined, repoCounts[r] ?? 0)), + ]; + + return PopupMenuButton( + tooltip: 'search.filter'.tr(), + offset: const Offset(0, 44), + color: AppColors.surfaceVariant, + elevation: 8, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + onSelected: onSelected, + itemBuilder: (_) => [ + for (final (id, label, icon, count) in entries) + PopupMenuItem( + value: id, + height: 42, + child: Row( + children: [ + Icon( + icon, + size: 16, + color: selected == id + ? AppColors.primary + : AppColors.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: selected == id + ? AppColors.primary + : AppColors.textPrimary, + fontSize: 13.5, + fontWeight: selected == id + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Text( + '$count', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.fromLTRB(10, 7, 8, 7), + decoration: BoxDecoration( + color: AppColors.surfaceVariant.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(selectedMeta.$2, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + selectedMeta.$1, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 5), + Text( + '$selectedCount', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.keyboard_arrow_down_rounded, + size: 16, + color: AppColors.textHint, + ), + ], + ), + ), + ); + } +} + +class _ProvidersList extends StatefulWidget { + const _ProvidersList({ + required this.providers, + required this.currentProviderId, + required this.bottomPad, + required this.favorites, + required this.onToggleFavorite, + this.unavailableIds = const {}, + }); + + final List providers; + final String currentProviderId; + final double bottomPad; + final Set favorites; + final ValueChanged onToggleFavorite; + + /// Providers that exist in the list but cannot serve content right now + /// (server-backed entries while the backend is unreachable). + final Set unavailableIds; + + @override + State<_ProvidersList> createState() => _ProvidersListState(); +} + +class _ProvidersListState extends State<_ProvidersList> { + static const double _estItemExtent = 72.0; + late final ScrollController _controller; + + @override + void initState() { + super.initState(); + final i = widget.providers.indexWhere((p) => p.id == widget.currentProviderId); + final offset = i > 2 ? (i * _estItemExtent - 80).clamp(0.0, double.infinity) : 0.0; + _controller = ScrollController(initialScrollOffset: offset); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView.builder( + controller: _controller, + padding: EdgeInsets.fromLTRB(16, 4, 16, widget.bottomPad + 16), + addAutomaticKeepAlives: false, + itemExtent: _estItemExtent, + itemCount: widget.providers.length, + itemBuilder: (context, i) { + final provider = widget.providers[i]; + final selected = provider.id == widget.currentProviderId; + final unavailable = widget.unavailableIds.contains(provider.id); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ProviderListTile( + provider: provider, + selected: selected, + isFavorite: widget.favorites.contains(provider.id), + unavailable: unavailable, + onToggleFavorite: () => widget.onToggleFavorite(provider.id), + onTap: () { + // Refuse the selection outright rather than letting it fail + // three screens later inside a home or detail request. + if (unavailable) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('profile.provider_needs_server'.tr()), + backgroundColor: AppColors.surface, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + ), + ); + return; + } + context.read().add(ProviderSelect(provider.id)); + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + } +} + +class _ProvidersEmpty extends StatelessWidget { + const _ProvidersEmpty(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off_rounded, + size: 44, + color: AppColors.textHint.withValues(alpha: 0.7), + ), + const SizedBox(height: 12), + Text( + 'profile.no_providers_in_category'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'profile.try_select_all'.tr(), + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textHint.withValues(alpha: 0.85), + fontSize: 12, + ), + ), + ], + ), + ), + ); + } +} + +class _ProviderListTile extends StatelessWidget { + const _ProviderListTile({ + required this.provider, + required this.selected, + required this.isFavorite, + required this.onToggleFavorite, + required this.onTap, + this.unavailable = false, + }); + + final ProviderEntity provider; + final bool selected; + final bool isFavorite; + final bool unavailable; + final VoidCallback onToggleFavorite; + final VoidCallback onTap; + + bool get _canSolveCloudflare => + provider.id.startsWith('an:') || + provider.id.startsWith('mn:') || + provider.id.startsWith('cs:'); + + Future _solveCloudflare(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final ok = await requestCloudflareSolve(context, provider.id); + messenger.showSnackBar( + SnackBar( + content: Text(ok ? '${'general.done'.tr()} ✓' : 'general.cancel'.tr()), + backgroundColor: AppColors.surface, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: unavailable ? 0.45 : 1, + child: _tile(context), + ); + } + + Widget _tile(BuildContext context) { + return Material( + color: selected + ? AppColors.primary.withValues(alpha: 0.10) + : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: selected + ? Border.all(color: AppColors.primary, width: 1.2) + : null, + ), + child: InkWell( + onTap: onTap, + onLongPress: + _canSolveCloudflare ? () => _solveCloudflare(context) : null, + onSecondaryTap: + _canSolveCloudflare ? () => _solveCloudflare(context) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + _ProviderLogo(provider: provider, size: 40), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + provider.name, + style: TextStyle( + color: selected + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 14, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + if (unavailable) + const _ServerDownBadge() + else + _ProviderModeBadge(mode: provider.mode), + if (provider.requiresCfBypass) ...[ + const SizedBox(width: 4), + const _CfBypassBadge(), + ], + if (provider.nsfw) ...[ + const SizedBox(width: 4), + const _NsfwBadge(), + ], + ], + ), + if (provider.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + provider.description, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + height: 1.25, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const SizedBox(width: 4), + IconButton( + onPressed: onToggleFavorite, + icon: Icon( + isFavorite ? Icons.star : Icons.star_border, + color: isFavorite ? Colors.amber : AppColors.textHint, + size: 20, + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 34, + minHeight: 34, + ), + tooltip: 'profile.add_favorite'.tr(), + ), + const SizedBox(width: 2), + if (selected) + Container( + width: 20, + height: 20, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + color: Colors.white, + size: 12, + ), + ) + else + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textHint, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Replaces the mode badge on a server-backed provider while the API is down, +/// so the reason it is greyed out is readable at a glance. +class _ServerDownBadge extends StatelessWidget { + const _ServerDownBadge(); + + @override + Widget build(BuildContext context) { + const color = AppColors.error; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off_rounded, size: 9, color: color), + const SizedBox(width: 3), + Text( + 'profile.offline_badge'.tr(), + style: const TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +/// Explains the outage in place, above a list that is still partly usable. +class _ProvidersOfflineBanner extends StatelessWidget { + const _ProvidersOfflineBanner({ + required this.usableCount, + required this.cachedAt, + required this.onRetry, + }); + + final int usableCount; + final DateTime? cachedAt; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 2, 16, 10), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border, width: 0.6), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.cloud_off_rounded, + color: AppColors.textSecondary, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'profile.offline_title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + usableCount > 0 + ? 'profile.offline_local_available' + .tr(args: ['$usableCount']) + : 'profile.offline_no_local'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + height: 1.4, + ), + ), + if (cachedAt != null) ...[ + const SizedBox(height: 2), + Text( + 'profile.offline_cached_at'.tr(args: [_stamp(cachedAt!)]), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onRetry, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: Text('general.retry'.tr()), + ), + ], + ), + ); + } + + static String _stamp(DateTime at) { + final l = at.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(l.day)}.${two(l.month)} ${two(l.hour)}:${two(l.minute)}'; + } +} + +class _ProviderModeBadge extends StatelessWidget { + const _ProviderModeBadge({required this.mode}); + final String mode; + + @override + Widget build(BuildContext context) { + final normalized = mode.toLowerCase(); + final (label, color) = switch (normalized) { + 'client' => ('Local', const Color(0xFF34A853)), + 'hybrid' => ('Hybrid', const Color(0xFFF59E0B)), + 'server' => ('Cloud', const Color(0xFF6B7280)), + _ => (mode.isEmpty ? 'Cloud' : mode, const Color(0xFF6B7280)), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ); + } +} + +class _CfBypassBadge extends StatelessWidget { + const _CfBypassBadge(); + + @override + Widget build(BuildContext context) { + const color = Color(0xFFF38020); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield_outlined, size: 9, color: color), + SizedBox(width: 3), + Text( + 'CF', + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ], + ), + ); + } +} + +class _NsfwBadge extends StatelessWidget { + const _NsfwBadge(); + + @override + Widget build(BuildContext context) { + const color = Color(0xFFE53935); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.45), width: 0.8), + ), + child: const Text( + '18+', + style: TextStyle( + color: color, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ); + } +} + +class _ProvidersLoading extends StatelessWidget { + const _ProvidersLoading(); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textHint, + ), + ); + } +} + +/// Reached only when the backend is unreachable, there is no cached list *and* +/// no plugin is installed — i.e. there is genuinely no working path left. The +/// copy therefore points at installing plugins, which needs no server. +class _ProvidersError extends StatelessWidget { + const _ProvidersError({required this.onRetry}); + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 40), + child: Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Colors.white.withValues(alpha: 0.09)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.06), + ), + child: const Icon( + Icons.cloud_off_rounded, + color: AppColors.textSecondary, + size: 28, + ), + ), + const SizedBox(height: 18), + Text( + 'profile.offline_title'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + const SizedBox(height: 8), + Text( + 'profile.offline_no_local'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: 22), + SizedBox( + height: 46, + width: double.infinity, + child: ElevatedButton( + onPressed: onRetry, + child: Text('general.retry'.tr()), + ), + ), + if (CloudStreamChannel.isSupported) ...[ + const SizedBox(height: 10), + SizedBox( + height: 46, + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CloudStreamSourcesPage(), + ), + ), + child: Text('profile.offline_install_plugins'.tr()), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _ProviderLogo extends StatelessWidget { + const _ProviderLogo({required this.provider, this.size = 42}); + final ProviderEntity provider; + final double size; + + @override + Widget build(BuildContext context) { + final cache = (size * MediaQuery.devicePixelRatioOf(context)).round(); + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: provider.image.isEmpty + ? _ProviderFallback(name: provider.name, size: size) + : CachedNetworkImage( + imageUrl: provider.image, + width: size, + height: size, + fit: BoxFit.cover, + memCacheWidth: cache, + memCacheHeight: cache, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => + _ProviderFallback(name: provider.name, size: size), + errorWidget: (_, _, _) => + _ProviderFallback(name: provider.name, size: size), + ), + ); + } +} + +class _ProviderFallback extends StatelessWidget { + const _ProviderFallback({required this.name, required this.size}); + final String name; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + color: AppColors.surfaceVariant, + alignment: Alignment.center, + child: Text( + name.isEmpty ? '?' : name[0].toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontWeight: FontWeight.w800, + fontSize: size * 0.38, + ), + ), + ); + } +} diff --git a/lib/features/remote/data/remote_control_service.dart b/lib/features/remote/data/remote_control_service.dart new file mode 100644 index 0000000..352fe8b --- /dev/null +++ b/lib/features/remote/data/remote_control_service.dart @@ -0,0 +1,172 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +/// A TV this account can drive, and whether it is actually reachable. +@immutable +class RemoteDevice { + const RemoteDevice({ + required this.id, + required this.name, + required this.online, + this.lastSeenAt, + }); + + final String id; + final String name; + + /// Holding the channel open — not "signed in recently". A linked TV that is + /// switched off is not something you can press buttons at. + final bool online; + + final DateTime? lastSeenAt; + + static RemoteDevice? fromJson(Map json) { + final id = json['id']?.toString(); + if (id == null || id.isEmpty) return null; + return RemoteDevice( + id: id, + name: (json['name'] as String?)?.trim().isNotEmpty == true + ? json['name'] as String + : 'TV', + online: json['online'] == true, + lastSeenAt: DateTime.tryParse(json['lastSeenAt']?.toString() ?? ''), + ); + } +} + +/// What the TV last reported it was doing. +@immutable +class RemoteTvState { + const RemoteTvState({ + this.screen, + this.title, + this.episode, + this.playing = false, + this.positionMs, + this.durationMs, + }); + + final String? screen; + final String? title; + final String? episode; + final bool playing; + final int? positionMs; + final int? durationMs; + + bool get hasPlayback => (durationMs ?? 0) > 0; + + static RemoteTvState? fromJson(Map? json) { + if (json == null) return null; + return RemoteTvState( + screen: json['screen'] as String?, + title: json['title'] as String?, + episode: json['episode']?.toString(), + playing: json['playing'] == true, + positionMs: (json['positionMs'] as num?)?.toInt(), + durationMs: (json['durationMs'] as num?)?.toInt(), + ); + } +} + +/// Raised when the TV is not holding the channel open. +/// +/// Its own type because it is the one failure worth saying out loud: every +/// other error here means something went wrong, this one means "turn the TV on". +class RemoteOfflineException implements Exception { + const RemoteOfflineException(); +} + +/// The phone half of the remote control. +/// +/// Commands go through the server rather than the local network. The two +/// devices are frequently not on the same one, and a remote that only works at +/// home is not the feature people want. +class RemoteControlService { + const RemoteControlService({required Dio dio}) : _dio = dio; + + final Dio _dio; + + Future> devices() async { + final response = await _dio.get('/remote/devices'); + final items = (response.data as Map?)?['items']; + if (items is! List) return const []; + return items + .whereType() + .map((e) => RemoteDevice.fromJson(e.cast())) + .whereType() + .toList(growable: false); + } + + Future<({bool online, RemoteTvState? state})> state(String deviceId) async { + final response = await _dio.get( + '/remote/state', + queryParameters: {'deviceId': deviceId}, + ); + final data = response.data as Map?; + return ( + online: data?['online'] == true, + state: RemoteTvState.fromJson( + (data?['state'] as Map?)?.cast(), + ), + ); + } + + /// Sends one command. + /// + /// A 409 is the server saying nothing is listening, which is the normal state + /// of a TV that is off — surfaced as [RemoteOfflineException] so the UI can + /// say so rather than showing a stack of failures. + Future send( + String deviceId, + String type, { + Map args = const {}, + }) async { + try { + await _dio.post( + '/remote/command', + data: {'deviceId': deviceId, 'type': type, ...args}, + ); + } on DioException catch (e) { + if (e.response?.statusCode == 409) throw const RemoteOfflineException(); + rethrow; + } + } + + Future play(String id) => send(id, 'play'); + Future pause(String id) => send(id, 'pause'); + Future playPause(String id) => send(id, 'playpause'); + Future next(String id) => send(id, 'next'); + Future previous(String id) => send(id, 'prev'); + Future back(String id) => send(id, 'back'); + Future home(String id) => send(id, 'home'); + + Future seekTo(String id, int positionMs) => + send(id, 'seek', args: {'positionMs': positionMs}); + + Future seekBy(String id, int deltaMs) => + send(id, 'seekBy', args: {'deltaMs': deltaMs}); + + Future dpad(String id, String direction) => + send(id, 'dpad', args: {'direction': direction}); + + /// Types into the TV's search. The point of the whole feature. + Future type(String id, String text) => + send(id, 'text', args: {'text': text}); + + /// Plays something on the TV. + /// + /// The TITLE travels, not the url: the two apps do not share a source + /// registry, so a link from a provider installed here is meaningless to a TV + /// that does not have it. The TV resolves the title against its own sources. + Future openOnTv( + String id, { + required String title, + String? contentUrl, + String? provider, + }) => + send(id, 'open', args: { + 'title': title, + if (contentUrl != null && contentUrl.isNotEmpty) 'contentUrl': contentUrl, + if (provider != null && provider.isNotEmpty) 'provider': provider, + }); +} diff --git a/lib/features/remote/presentation/pages/tv_remote_page.dart b/lib/features/remote/presentation/pages/tv_remote_page.dart new file mode 100644 index 0000000..c346d80 --- /dev/null +++ b/lib/features/remote/presentation/pages/tv_remote_page.dart @@ -0,0 +1,636 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/remote/data/remote_control_service.dart'; +import 'package:soplay/features/remote/presentation/remote_controller.dart'; + +/// The phone as a remote for the TV. +/// +/// Two halves earn the screen: a keyboard, because entering text with a d-pad +/// is the worst thing about any TV app, and transport controls, because the +/// physical remote is always on the other sofa. +class TvRemotePage extends StatefulWidget { + const TvRemotePage({super.key}); + + @override + State createState() => _TvRemotePageState(); +} + +class _TvRemotePageState extends State + with WidgetsBindingObserver { + late final RemoteController _controller = RemoteController( + service: getIt(), + ); + final _search = TextEditingController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _controller.addListener(_onChange); + _controller.load(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.removeListener(_onChange); + _controller.dispose(); + _search.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Nobody is looking at the remote from the app switcher, and polling a TV + // from the background is how a remote left open drains a battery. + _controller.setPaused(state != AppLifecycleState.resumed); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + Future _run(Future Function(String id) action) async { + HapticFeedback.selectionClick(); + final problem = await _controller.send(action); + if (problem == null || !mounted) return; + _say(switch (problem) { + 'offline' => 'remote.tv_offline'.tr(), + 'no-device' => 'remote.no_devices'.tr(), + _ => 'remote.command_failed'.tr(), + }); + } + + void _say(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + 'remote.title'.tr(), + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), + ), + actions: [ + IconButton( + onPressed: _controller.load, + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + body: SafeArea(child: _body()), + ); + } + + Widget _body() { + switch (_controller.status) { + case RemoteStatus.loading: + return const Center(child: CircularProgressIndicator(strokeWidth: 2.5)); + + case RemoteStatus.failed: + return _Message( + icon: Icons.cloud_off_rounded, + text: 'remote.load_failed'.tr(), + actionLabel: 'remote.retry'.tr(), + onAction: _controller.load, + ); + + case RemoteStatus.noDevices: + return _Message( + icon: Icons.tv_off_rounded, + text: 'remote.no_devices'.tr(), + actionLabel: 'remote.retry'.tr(), + onAction: _controller.load, + ); + + case RemoteStatus.ready: + return _ready(); + } + } + + Widget _ready() { + final canControl = _controller.canControl; + + return RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _controller.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), + children: [ + _ConnectionBanner( + device: _controller.selected, + online: _controller.online, + state: _controller.tvState, + ), + const SizedBox(height: 12), + + // Shown even for a single TV: "which one am I driving" is the first + // question a remote has to answer, and hiding the answer when there + // is exactly one is how it stops being obvious. + _DeviceList( + devices: _controller.devices, + selected: _controller.selected, + onSelect: _controller.select, + ), + + const SizedBox(height: 18), + _Disabled( + disabled: !canControl, + child: _KeyboardCard( + controller: _search, + onSubmit: (text) { + final query = text.trim(); + if (query.isEmpty) return; + _run((id) => getIt().type(id, query)); + }, + ), + ), + + const SizedBox(height: 14), + _Disabled( + disabled: !canControl, + child: _DpadCard( + onDirection: (d) => + _run((id) => getIt().dpad(id, d)), + onBack: () => _run(getIt().back), + onHome: () => _run(getIt().home), + ), + ), + + const SizedBox(height: 14), + _Disabled( + disabled: !canControl, + child: _TransportCard( + playing: _controller.tvState?.playing ?? false, + onPlayPause: () => _run(getIt().playPause), + onSeekBack: () => + _run((id) => getIt().seekBy(id, -10000)), + onSeekForward: () => + _run((id) => getIt().seekBy(id, 10000)), + onPrevious: () => _run(getIt().previous), + onNext: () => _run(getIt().next), + ), + ), + ], + ), + ); + } +} + +/// Says, in one line, which TV this is and whether it is listening. +class _ConnectionBanner extends StatelessWidget { + const _ConnectionBanner({ + required this.device, + required this.online, + required this.state, + }); + + final RemoteDevice? device; + final bool online; + final RemoteTvState? state; + + String _clock(int ms) { + final d = Duration(milliseconds: ms); + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final name = device?.name ?? ''; + final playing = state; + final accent = online ? AppColors.success : AppColors.textHint; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: accent.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + online ? Icons.cast_connected_rounded : Icons.tv_off_rounded, + color: accent, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + online + ? 'remote.connected_to'.tr(namedArgs: {'device': name}) + : 'remote.not_connected_to'.tr(namedArgs: {'device': name}), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + !online + ? 'remote.turn_tv_on'.tr() + : playing?.title ?? 'remote.idle'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11.5, + color: AppColors.textHint, + ), + ), + if (online && (playing?.hasPlayback ?? false)) ...[ + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + minHeight: 3, + value: (playing!.positionMs ?? 0) / playing.durationMs!, + backgroundColor: AppColors.surfaceVariant, + valueColor: const AlwaysStoppedAnimation(AppColors.primary), + ), + ), + const SizedBox(height: 4), + Text( + '${_clock(playing.positionMs ?? 0)} / ${_clock(playing.durationMs!)}', + style: const TextStyle( + fontSize: 10.5, + color: AppColors.textHint, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _DeviceList extends StatelessWidget { + const _DeviceList({ + required this.devices, + required this.selected, + required this.onSelect, + }); + + final List devices; + final RemoteDevice? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 38, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: devices.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final device = devices[i]; + final active = device.id == selected?.id; + return GestureDetector( + onTap: () => onSelect(device), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13), + alignment: Alignment.center, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.16) + : AppColors.surface, + borderRadius: BorderRadius.circular(19), + border: Border.all( + color: active ? AppColors.primary : Colors.transparent, + width: 1.2, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: device.online + ? AppColors.success + : AppColors.textHint, + ), + ), + const SizedBox(width: 7), + Text( + device.name, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: active + ? AppColors.primary + : AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// Dims and blocks a control group rather than letting every tap fail. +class _Disabled extends StatelessWidget { + const _Disabled({required this.disabled, required this.child}); + + final bool disabled; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 160), + opacity: disabled ? 0.4 : 1, + child: IgnorePointer(ignoring: disabled, child: child), + ); + } +} + +class _KeyboardCard extends StatelessWidget { + const _KeyboardCard({required this.controller, required this.onSubmit}); + + final TextEditingController controller; + final ValueChanged onSubmit; + + @override + Widget build(BuildContext context) { + return _Card( + label: 'remote.search_on_tv'.tr(), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.search, + onSubmitted: onSubmit, + decoration: InputDecoration( + hintText: 'remote.search_hint'.tr(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: () => onSubmit(controller.text), + icon: const Icon(Icons.send_rounded, size: 18), + ), + ], + ), + ); + } +} + +class _DpadCard extends StatelessWidget { + const _DpadCard({ + required this.onDirection, + required this.onBack, + required this.onHome, + }); + + final ValueChanged onDirection; + final VoidCallback onBack; + final VoidCallback onHome; + + @override + Widget build(BuildContext context) { + return _Card( + label: 'remote.navigation'.tr(), + child: Column( + children: [ + _Key( + icon: Icons.keyboard_arrow_up_rounded, + onTap: () => onDirection('up'), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _Key( + icon: Icons.keyboard_arrow_left_rounded, + onTap: () => onDirection('left'), + ), + const SizedBox(width: 10), + _Key( + icon: Icons.circle_outlined, + primary: true, + onTap: () => onDirection('center'), + ), + const SizedBox(width: 10), + _Key( + icon: Icons.keyboard_arrow_right_rounded, + onTap: () => onDirection('right'), + ), + ], + ), + _Key( + icon: Icons.keyboard_arrow_down_rounded, + onTap: () => onDirection('down'), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + onPressed: onBack, + icon: const Icon(Icons.arrow_back_rounded, size: 17), + label: Text('remote.back'.tr()), + ), + const SizedBox(width: 12), + TextButton.icon( + onPressed: onHome, + icon: const Icon(Icons.home_rounded, size: 17), + label: Text('remote.home'.tr()), + ), + ], + ), + ], + ), + ); + } +} + +class _TransportCard extends StatelessWidget { + const _TransportCard({ + required this.playing, + required this.onPlayPause, + required this.onSeekBack, + required this.onSeekForward, + required this.onPrevious, + required this.onNext, + }); + + final bool playing; + final VoidCallback onPlayPause; + final VoidCallback onSeekBack; + final VoidCallback onSeekForward; + final VoidCallback onPrevious; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + return _Card( + label: 'remote.playback'.tr(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _Key(icon: Icons.skip_previous_rounded, onTap: onPrevious), + _Key(icon: Icons.replay_10_rounded, onTap: onSeekBack), + _Key( + icon: playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + primary: true, + onTap: onPlayPause, + ), + _Key(icon: Icons.forward_10_rounded, onTap: onSeekForward), + _Key(icon: Icons.skip_next_rounded, onTap: onNext), + ], + ), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w800, + letterSpacing: 0.7, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 10), + child, + ], + ), + ); + } +} + +class _Key extends StatelessWidget { + const _Key({required this.icon, required this.onTap, this.primary = false}); + + final IconData icon; + final VoidCallback onTap; + final bool primary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(4), + child: Material( + color: primary ? AppColors.primary : AppColors.surfaceVariant, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: SizedBox( + width: primary ? 58 : 48, + height: primary ? 58 : 48, + child: Icon(icon, color: Colors.white, size: primary ? 27 : 23), + ), + ), + ), + ); + } +} + +class _Message extends StatelessWidget { + const _Message({ + required this.icon, + required this.text, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String text; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 46, + color: AppColors.textHint.withValues(alpha: 0.6), + ), + const SizedBox(height: 14), + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 13.5, + height: 1.5, + ), + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 14), + OutlinedButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/remote/presentation/remote_controller.dart b/lib/features/remote/presentation/remote_controller.dart new file mode 100644 index 0000000..6827c08 --- /dev/null +++ b/lib/features/remote/presentation/remote_controller.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:soplay/features/remote/data/remote_control_service.dart'; + +enum RemoteStatus { loading, noDevices, failed, ready } + +/// Owns the connection to one TV: which one, whether it is reachable, and what +/// it is doing. +/// +/// Pulled out of the page because the polling rules are the part that is easy +/// to get wrong, and a widget rebuilding is a bad place to keep a timer. +class RemoteController extends ChangeNotifier { + RemoteController({required RemoteControlService service}) : _service = service; + + final RemoteControlService _service; + + // Adaptive on purpose. A remote showing a moving position needs seconds; a + // TV that is switched off needs almost nothing, and asking it every two + // seconds is how a remote left open flattens a battery. + static const _whilePlaying = Duration(seconds: 2); + static const _whileIdle = Duration(seconds: 5); + static const _whileOffline = Duration(seconds: 15); + + RemoteStatus _status = RemoteStatus.loading; + List _devices = const []; + RemoteDevice? _selected; + RemoteTvState? _tvState; + bool _online = false; + + /// True only between a command leaving and its answer, so a button can show + /// it was pressed without the whole screen flickering. + bool _busy = false; + + Timer? _timer; + + /// Guards against overlapping polls. A periodic timer firing into an async + /// body does not wait for the previous one, so a slow network stacks requests + /// until the page appears frozen — the exact failure this exists to prevent. + bool _polling = false; + + bool _disposed = false; + bool _paused = false; + + RemoteStatus get status => _status; + List get devices => _devices; + RemoteDevice? get selected => _selected; + RemoteTvState? get tvState => _tvState; + bool get online => _online; + bool get busy => _busy; + + /// Whether commands can do anything right now. The UI disables on this rather + /// than letting every tap turn into a failed request. + bool get canControl => _selected != null && _online; + + Future load() async { + _status = RemoteStatus.loading; + _notify(); + try { + final devices = await _service.devices(); + if (_disposed) return; + _devices = devices; + + if (devices.isEmpty) { + _selected = null; + _status = RemoteStatus.noDevices; + _stopTimer(); + _notify(); + return; + } + + // Keep the current pick across a refresh; otherwise prefer one that is + // actually reachable, since the most recently used TV is often the one + // that is now switched off. + final keep = _selected; + _selected = keep != null && devices.any((d) => d.id == keep.id) + ? devices.firstWhere((d) => d.id == keep.id) + : devices.firstWhere((d) => d.online, orElse: () => devices.first); + + _status = RemoteStatus.ready; + _notify(); + await _refresh(); + _schedule(); + } catch (_) { + if (_disposed) return; + _status = RemoteStatus.failed; + _stopTimer(); + _notify(); + } + } + + void select(RemoteDevice device) { + if (device.id == _selected?.id) return; + _selected = device; + // Clear rather than carry: showing the previous TV's title under a new one + // is worse than showing nothing for a second. + _tvState = null; + _online = device.online; + _notify(); + unawaited(_refresh()); + _schedule(); + } + + /// Stops polling while the app is not in front of the user. + void setPaused(bool paused) { + if (_paused == paused) return; + _paused = paused; + if (paused) { + _stopTimer(); + } else { + unawaited(_refresh()); + _schedule(); + } + } + + /// Runs a command and reports what happened. + /// + /// Returns null on success, or a reason to show. The controller never throws + /// at the UI: a remote whose buttons can crash the page is worse than one + /// that says a button did not land. + Future send(Future Function(String id) action) async { + final device = _selected; + if (device == null) return 'no-device'; + _busy = true; + _notify(); + try { + await action(device.id); + await _refresh(); + return null; + } on RemoteOfflineException { + _online = false; + _schedule(); + return 'offline'; + } catch (_) { + return 'failed'; + } finally { + _busy = false; + _notify(); + } + } + + Future _refresh() async { + final device = _selected; + if (device == null || _polling || _disposed) return; + _polling = true; + try { + final result = await _service.state(device.id); + if (_disposed) return; + final wasOnline = _online; + _online = result.online; + _tvState = result.state; + _notify(); + // Coming back online should tighten the cadence immediately rather than + // after one more slow tick. + if (wasOnline != _online) _schedule(); + } catch (_) { + // A dropped poll is not worth surfacing; the next one is seconds away. + } finally { + _polling = false; + } + } + + void _schedule() { + _stopTimer(); + if (_disposed || _paused || _selected == null) return; + final interval = !_online + ? _whileOffline + : (_tvState?.playing ?? false) + ? _whilePlaying + : _whileIdle; + _timer = Timer.periodic(interval, (_) => unawaited(_refresh())); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _stopTimer(); + super.dispose(); + } +} diff --git a/lib/features/search/data/datasources/search_data_source.dart b/lib/features/search/data/datasources/search_data_source.dart index a78f30e..455abd7 100644 --- a/lib/features/search/data/datasources/search_data_source.dart +++ b/lib/features/search/data/datasources/search_data_source.dart @@ -31,10 +31,21 @@ class SearchDataSource { return SearchModel.fromJson(response.data); } - Future searchMovies(String query, {int page = 1}) async { + /// [provider] overrides the interceptor's "current provider", which is what + /// lets cross-search treat each selected server provider as its own leg + /// instead of collapsing them into one. + Future searchMovies( + String query, { + int page = 1, + String? provider, + }) async { var response = await dio.get( '/contents/search', - queryParameters: {'q': query, "page": page}, + queryParameters: { + 'q': query, + 'page': page, + if (provider != null && provider.isNotEmpty) 'provider': provider, + }, ); return SearchModel.fromJson(response.data); } diff --git a/lib/features/search/data/repositories/search_repository_imp.dart b/lib/features/search/data/repositories/search_repository_imp.dart index d9d0999..e0c97e4 100644 --- a/lib/features/search/data/repositories/search_repository_imp.dart +++ b/lib/features/search/data/repositories/search_repository_imp.dart @@ -84,7 +84,11 @@ class SearchRepositoryImp extends SearchRepository { } @override - Future> searchMovies(String query, {int page = 1}) async { + Future> searchMovies( + String query, { + int page = 1, + String? genre, + }) async { final js = jsRuntime; final provider = _currentProvider; if (provider != null && provider.startsWith('cs:')) { diff --git a/lib/features/search/data/search_recents_store.dart b/lib/features/search/data/search_recents_store.dart new file mode 100644 index 0000000..f55b7e8 --- /dev/null +++ b/lib/features/search/data/search_recents_store.dart @@ -0,0 +1,53 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; + +/// The last queries the user actually ran, so the idle search screen has +/// something on it. Every operation is best-effort: search must keep working +/// even if the settings box is unavailable (tests, first run). +class SearchRecentsStore { + static const String _key = 'search_recent_queries'; + static const int maxEntries = 8; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + List load() { + final raw = _box?.get(_key); + if (raw is! List) return const []; + return raw.map((e) => e.toString()).where((e) => e.isNotEmpty).toList(); + } + + Future> add(String query) async { + final q = query.trim(); + if (q.isEmpty) return load(); + final list = load() + ..removeWhere((e) => e.toLowerCase() == q.toLowerCase()) + ..insert(0, q); + final trimmed = list.take(maxEntries).toList(); + await _write(trimmed); + return trimmed; + } + + Future> remove(String query) async { + final list = load() + ..removeWhere((e) => e.toLowerCase() == query.trim().toLowerCase()); + await _write(list); + return list; + } + + Future> clear() async { + await _write(const []); + return const []; + } + + Future _write(List list) async { + try { + await _box?.put(_key, list); + } catch (_) {} + } +} diff --git a/lib/features/search/domain/entities/cross_search_result.dart b/lib/features/search/domain/entities/cross_search_result.dart index 88375da..9cab46c 100644 --- a/lib/features/search/domain/entities/cross_search_result.dart +++ b/lib/features/search/domain/entities/cross_search_result.dart @@ -57,11 +57,119 @@ class ProviderSearchResult { required this.provider, required this.items, required this.status, + this.page = 1, + this.totalPages = 1, + this.message = '', }); final ProviderRef provider; final List items; final ProviderSearchStatus status; + final int page; + final int totalPages; + + /// Why this leg failed, when it did. Shown next to the source's name instead + /// of being reduced to a count of anonymous failures. + final String message; bool get hasItems => items.isNotEmpty; + bool get hasMore => page < totalPages; + + ProviderSearchResult copyWith({ + List? items, + ProviderSearchStatus? status, + int? page, + int? totalPages, + String? message, + }) => + ProviderSearchResult( + provider: provider, + items: items ?? this.items, + status: status ?? this.status, + page: page ?? this.page, + totalPages: totalPages ?? this.totalPages, + message: message ?? this.message, + ); +} + +/// One source's copy of a title. +class TitleHit { + const TitleHit({required this.provider, required this.item}); + + final ProviderRef provider; + final MovieEntity item; +} + +/// The same title as carried by one or more sources. +/// +/// This is the whole point of cross-search: without it the user has to scroll +/// N per-source rails to discover that one title is on N sources. +class MergedSearchTitle { + MergedSearchTitle({required this.key, required this.hits, this.year}); + + final String key; + final List hits; + final int? year; + + MovieEntity get primary => hits.first.item; + ProviderRef get primaryProvider => hits.first.provider; + int get sourceCount => hits.map((h) => h.provider.id).toSet().length; +} + +/// Title key used to merge hits across sources: lowercased, punctuation and a +/// trailing "(year)" removed, leading article dropped, whitespace collapsed. +String normalizedTitleKey(String title) { + var t = title.toLowerCase().trim(); + t = t.replaceAll(RegExp(r'[\(\[]\s*(19|20)\d{2}\s*[\)\]]'), ' '); + t = t.replaceAll(RegExp(r"[^a-z0-9\u0400-\u04ff\u0600-\u06ff ]+"), ' '); + t = t.replaceAll(RegExp(r'\s+'), ' ').trim(); + for (final article in const ['the ', 'a ', 'an ']) { + if (t.startsWith(article)) { + t = t.substring(article.length); + break; + } + } + return t; +} + +/// Groups every leg's items into one list of titles. +/// +/// Two hits merge when their [normalizedTitleKey] matches and their years are +/// compatible — equal, or missing on at least one side, because extension hosts +/// almost never populate a year. +List mergeSearchResults(List legs) { + final groups = >{}; + final ordered = []; + + for (final leg in legs) { + for (final item in leg.items) { + final key = normalizedTitleKey(item.title); + if (key.isEmpty) continue; + final bucket = groups.putIfAbsent(key, () => []); + final match = bucket + .where((g) => g.year == null || item.year == null || g.year == item.year) + .firstOrNull; + if (match == null) { + final group = MergedSearchTitle( + key: key, + year: item.year, + hits: [TitleHit(provider: leg.provider, item: item)], + ); + bucket.add(group); + ordered.add(group); + } else { + match.hits.add(TitleHit(provider: leg.provider, item: item)); + } + } + } + + // Ties keep arrival order: a rail that reorders under the user's finger is + // how a tap lands on a different title than the one that was under it. + final indexed = [ + for (var i = 0; i < ordered.length; i++) (index: i, group: ordered[i]), + ]..sort((a, b) { + final byCount = b.group.sourceCount.compareTo(a.group.sourceCount); + return byCount != 0 ? byCount : a.index.compareTo(b.index); + }); + return [for (final e in indexed) e.group]; } diff --git a/lib/features/search/domain/repositories/search_repository.dart b/lib/features/search/domain/repositories/search_repository.dart index 7f9ff3b..b1c818b 100644 --- a/lib/features/search/domain/repositories/search_repository.dart +++ b/lib/features/search/domain/repositories/search_repository.dart @@ -7,5 +7,9 @@ abstract class SearchRepository { Future> getMoviesByGenre(String genre, {int page = 1}); + /// Text search. Genre is NOT a parameter: the server's /contents/search + /// reads only q, page and provider, so a genre passed here was silently + /// dropped and the caller got an unfiltered search back. Browsing a genre + /// goes through [getMoviesByGenre]. Future> searchMovies(String query, {int page = 1}); } diff --git a/lib/features/search/domain/services/cross_search_engine.dart b/lib/features/search/domain/services/cross_search_engine.dart index 25d0053..ca62d11 100644 --- a/lib/features/search/domain/services/cross_search_engine.dart +++ b/lib/features/search/domain/services/cross_search_engine.dart @@ -10,6 +10,8 @@ import 'package:soplay/features/search/data/datasources/search_data_source.dart' import 'package:soplay/features/search/data/model/search_model.dart'; import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; +typedef _Leg = ({List items, int page, int totalPages}); + /// Fans a query out across a set of providers with **bounded concurrency**, a /// **per-provider timeout**, and **incremental** emission — the core reason the /// feature never freezes even with a large provider set: @@ -48,34 +50,18 @@ class CrossSearchEngine { /// this ceiling is only ever paid once per source. static const Duration channelTimeout = Duration(seconds: 45); - /// A synthetic id used for the single collapsed backend ("Sozo") search leg. - static const String serverId = '__server__'; - + /// Every selected provider is its own leg, server providers included: the + /// backend takes an explicit `provider`, so collapsing them into one call was + /// both a lie in the summary ("1 of 1 sources") and a silent no-op for every + /// server provider the user picked beyond the first. Stream search({ required List set, required String query, + int page = 1, int concurrency = defaultConcurrency, Duration perProviderTimeout = defaultTimeout, }) { - // Collapse every server provider into a single backend call — the backend - // search endpoint is provider-agnostic, so N server providers = 1 leg. - final tasks = []; - var hasServer = false; - for (final p in set) { - if (p.kind == ProviderKind.server) { - hasServer = true; - } else { - tasks.add(p); - } - } - if (hasServer) { - tasks.add(const ProviderRef( - id: serverId, - name: 'Sozo', - kind: ProviderKind.server, - )); - } - + final tasks = List.of(set); final controller = StreamController(); var cancelled = false; controller.onCancel = () => cancelled = true; @@ -87,7 +73,12 @@ class CrossSearchEngine { while (!cancelled) { final i = next++; if (i >= tasks.length) return; - final result = await _searchOne(tasks[i], query, perProviderTimeout); + final result = await searchProvider( + tasks[i], + query, + page: page, + timeout: perProviderTimeout, + ); if (cancelled || controller.isClosed) return; controller.add(result); } @@ -97,28 +88,33 @@ class CrossSearchEngine { if (!controller.isClosed) await controller.close(); } - // Fire-and-forget; every error is captured inside [_searchOne]. + // Fire-and-forget; every error is captured inside [searchProvider]. unawaited(drain()); return controller.stream; } - Future _searchOne( + /// One leg on its own — used to retry a single failed source and to page it. + Future searchProvider( ProviderRef ref, - String query, - Duration timeout, - ) async { + String query, { + int page = 1, + Duration timeout = defaultTimeout, + }) async { // Extension hosts get the longer budget — see [channelTimeout]. final effective = ref.kind == ProviderKind.channel && timeout < channelTimeout ? channelTimeout : timeout; try { - final items = await _dispatch(ref, query).timeout(effective); + final leg = await _dispatch(ref, query, page).timeout(effective); return ProviderSearchResult( provider: ref, - items: items, - status: - items.isEmpty ? ProviderSearchStatus.empty : ProviderSearchStatus.ok, + items: leg.items, + page: leg.page, + totalPages: leg.totalPages, + status: leg.items.isEmpty + ? ProviderSearchStatus.empty + : ProviderSearchStatus.ok, ); } on TimeoutException { return ProviderSearchResult( @@ -126,11 +122,12 @@ class CrossSearchEngine { items: const [], status: ProviderSearchStatus.timeout, ); - } catch (_) { + } catch (e) { return ProviderSearchResult( provider: ref, items: const [], status: ProviderSearchStatus.error, + message: e.toString().replaceFirst('Exception: ', ''), ); } } @@ -141,40 +138,55 @@ class CrossSearchEngine { /// or an `error` field from the host) so the leg is reported as `error` /// rather than `empty` — "this source is down" and "no match here" look /// identical to the user otherwise, and only one of them is worth retrying. - List _unwrap(Map map, String label) { + _Leg _unwrap(Map map, String label) { if (map.isEmpty) throw Exception('$label: source unavailable'); final model = SearchModel.fromJson(map); final error = (map['error'] as String?)?.trim(); if (model.items.isEmpty && error != null && error.isNotEmpty) { throw Exception('$label: $error'); } - return model.items; + return (items: model.items, page: model.page, totalPages: model.totalPages); } - Future> _dispatch(ProviderRef ref, String query) async { + Future<_Leg> _dispatch(ProviderRef ref, String query, int page) async { final id = ref.id; if (id.startsWith('cs:')) { return _unwrap( - await CloudStreamChannel.search(id.substring(3), query), ref.name); + await CloudStreamChannel.search(id.substring(3), query, page: page), + ref.name, + ); } if (id.startsWith('an:')) { return _unwrap( - await AniyomiChannel.search(id.substring(3), query), ref.name); + await AniyomiChannel.search(id.substring(3), query, page: page), + ref.name, + ); } if (id.startsWith('mn:')) { return _unwrap( - await MangaChannel.search(id.substring(3), query), ref.name); + await MangaChannel.search(id.substring(3), query, page: page), + ref.name, + ); } if (id.startsWith('my:')) { return _unwrap( - await mangayomi.search(id.substring(3), query), ref.name); + await mangayomi.search(id.substring(3), query, page: page), + ref.name, + ); } if (ref.kind == ProviderKind.js) { - final map = await jsRuntime.trySearch(id, query, 1); - return map == null ? const [] : SearchModel.fromJson(map).items; + final map = await jsRuntime.trySearch(id, query, page); + // A null response means the extractor is missing or failed to load. That + // is a broken source, not "no match here" — reporting it as empty is the + // one place this engine used to invert its own distinction. + if (map == null) throw Exception('${ref.name}: source unavailable'); + return _unwrap(map, ref.name); } - // server — provider-agnostic backend search. - final model = await dataSource.searchMovies(query); - return model.items; + final model = await dataSource.searchMovies( + query, + page: page, + provider: ref.id, + ); + return (items: model.items, page: model.page, totalPages: model.totalPages); } } diff --git a/lib/features/search/presentation/blocs/cross_search_controller.dart b/lib/features/search/presentation/blocs/cross_search_controller.dart index cf60892..d135d57 100644 --- a/lib/features/search/presentation/blocs/cross_search_controller.dart +++ b/lib/features/search/presentation/blocs/cross_search_controller.dart @@ -1,10 +1,18 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; +import 'package:soplay/features/search/presentation/blocs/search_query_policy.dart'; -/// Drives one cross-search page: debounces input, runs the engine, collects +/// Where a cross-search run is, so the UI can never claim a finished search it +/// has not started: [pending] is "the query changed, the fan-out has not begun", +/// which is exactly the window in which the old query's counts used to be +/// presented as the new query's answer. +enum CrossSearchPhase { idle, pending, running, done } + +/// Drives one cross-search surface: debounces input, runs the engine, collects /// results incrementally, and cancels the previous run on every new query. class CrossSearchController extends ChangeNotifier { CrossSearchController({required this.engine, required List set}) @@ -13,89 +21,191 @@ class CrossSearchController extends ChangeNotifier { final CrossSearchEngine engine; List _set; - Timer? _debounce; + final QueryDebouncer _debouncer = QueryDebouncer(); StreamSubscription? _sub; int _token = 0; String _query = ''; - bool _searching = false; + CrossSearchPhase _phase = CrossSearchPhase.idle; + + /// Keyed by provider id, but read back in [providerSet] order so a leg that + /// lands late cannot make the sections jump around under the user's finger. final Map _results = {}; + final Set _retrying = {}; + List _merged = const []; + + static const int _cacheEntries = 6; + static const Duration _cacheTtl = Duration(minutes: 5); + final LinkedHashMap _cache = LinkedHashMap(); String get query => _query; - bool get searching => _searching; + CrossSearchPhase get phase => _phase; + bool get searching => + _phase == CrossSearchPhase.pending || _phase == CrossSearchPhase.running; List get providerSet => _set; - /// Number of search legs (server providers collapse into one). - int get expectedLegs { - var legs = 0; - var hasServer = false; - for (final p in _set) { - if (p.kind == ProviderKind.server) { - hasServer = true; - } else { - legs++; - } - } - return legs + (hasServer ? 1 : 0); - } - + int get expectedLegs => _set.length; int get completedLegs => _results.length; int get totalItems => _results.values.fold(0, (s, r) => s + r.items.length); - int get sourcesWithResults => - _results.values.where((r) => r.hasItems).length; - - /// Results ordered so sources with hits come first, then empty/timeout/error. - List get results { - final out = _results.values.toList(); - int rank(ProviderSearchResult r) => switch (r.status) { - ProviderSearchStatus.ok => 0, - ProviderSearchStatus.empty => 1, - ProviderSearchStatus.timeout => 2, - ProviderSearchStatus.error => 3, - }; - out.sort((a, b) => rank(a).compareTo(rank(b))); - return out; - } + int get sourcesWithResults => _results.values.where((r) => r.hasItems).length; + + /// Every leg in the selected order — answered or not. + List get results => [ + for (final ref in _set) + if (_results[ref.id] != null) _results[ref.id]!, + ]; + + List get legsWithItems => + results.where((r) => r.hasItems).toList(); + + List get failedLegs => results + .where((r) => + r.status == ProviderSearchStatus.timeout || + r.status == ProviderSearchStatus.error) + .toList(); - /// How many distinct providers returned a title (normalized title + year). - /// Only exact normalized-key matches count — no fuzzy merging. - Map _crossSourceCounts = const {}; - int crossSourceCount(String title, int? year) => - _crossSourceCounts[_key(title, year)] ?? 0; + /// Sources that have not answered yet, by name — a progress line that names + /// what it is waiting for instead of counting anonymous legs. + List get pendingSources => [ + for (final ref in _set) + if (!_results.containsKey(ref.id)) ref.name, + ]; - static String _key(String title, int? year) => - '${title.trim().toLowerCase()}|${year ?? ''}'; + bool isRetrying(String providerId) => _retrying.contains(providerId); - void onQueryChanged(String q) { - _debounce?.cancel(); - final trimmed = q.trim(); + /// One card per title, contributed by one or more sources. + List get merged => _merged; + + bool get hasMoreAnywhere => results.any((r) => r.hasMore); + + void onQueryChanged(String raw) { + final trimmed = SearchQueryPolicy.normalize(raw); + if (trimmed == _query) return; _query = trimmed; + + _cancel(); + _results.clear(); + _retrying.clear(); + _merged = const []; + if (trimmed.isEmpty) { - _cancel(); - _results.clear(); - _crossSourceCounts = const {}; - _searching = false; + _phase = CrossSearchPhase.idle; notifyListeners(); return; } - _debounce = Timer(const Duration(milliseconds: 450), () => _run(trimmed)); + + if (_restoreFromCache(trimmed)) return; + + _debouncer.reset(); + // A query below the minimum length arms nothing, so claiming "pending" + // would leave the page spinning against a request that never happens. + _phase = _debouncer.schedule(trimmed, _run) + ? CrossSearchPhase.pending + : CrossSearchPhase.idle; + notifyListeners(); + } + + /// The keyboard's Search key: no debounce, no minimum length. + void submit(String raw) { + final trimmed = SearchQueryPolicy.normalize(raw); + if (trimmed.isEmpty) return; + _query = trimmed; + _debouncer.reset(); + _run(trimmed); } void setProviderSet(List set) { _set = set; - if (_query.isNotEmpty) _run(_query); + _cache.clear(); + if (_query.isEmpty) { + notifyListeners(); + return; + } + _run(_query); } - void retry() { - if (_query.isNotEmpty) _run(_query); + /// Re-runs a single source. A first-ever extension search has to download and + /// dex-load an APK, so a timeout here is expected and needs to be actionable + /// per source rather than as one anonymous "2 timed out". + Future retryProvider(String providerId) async { + final ref = _set.where((p) => p.id == providerId).firstOrNull; + if (ref == null || _query.isEmpty || _retrying.contains(providerId)) return; + + final token = _token; + _retrying.add(providerId); + notifyListeners(); + + final result = await engine.searchProvider(ref, _query); + _retrying.remove(providerId); + if (token != _token) return; + + _results[ref.id] = result; + _remerge(); + notifyListeners(); + } + + /// Whether a [loadMore] is in flight, so the button can say so and refuse a + /// second tap. Without it two taps both page from the same snapshot: the + /// same page is fetched twice and the second write clobbers the first. + bool get loadingMore => _loadingMore; + bool _loadingMore = false; + + /// Next page from every source that reported one. + Future loadMore() async { + if (_loadingMore) return; + final token = _token; + final pending = results.where((r) => r.hasMore).toList(); + if (pending.isEmpty || _query.isEmpty) return; + + _loadingMore = true; + notifyListeners(); + try { + await _loadMoreLegs(pending, token); + } finally { + // A superseded run must not clear the flag for the one that replaced it. + if (token == _token) { + _loadingMore = false; + notifyListeners(); + } + } + } + + Future _loadMoreLegs( + List pending, + int token, + ) async { + for (final leg in pending) { + final next = await engine.searchProvider( + leg.provider, + _query, + page: leg.page + 1, + ); + if (token != _token) return; + if (next.status != ProviderSearchStatus.ok) continue; + final seen = {for (final m in leg.items) '${m.provider}::${m.url}'}; + _results[leg.provider.id] = leg.copyWith( + items: [ + ...leg.items, + for (final m in next.items) + if (seen.add('${m.provider}::${m.url}')) m, + ], + page: next.page, + totalPages: next.totalPages, + ); + _remerge(); + notifyListeners(); + } } void _run(String q) { _cancel(); final token = ++_token; + _loadingMore = false; _results.clear(); - _crossSourceCounts = const {}; - _searching = _set.isNotEmpty; + _retrying.clear(); + _merged = const []; + _phase = + _set.isEmpty ? CrossSearchPhase.done : CrossSearchPhase.running; notifyListeners(); if (_set.isEmpty) return; @@ -103,40 +213,68 @@ class CrossSearchController extends ChangeNotifier { (result) { if (token != _token) return; _results[result.provider.id] = result; - _recomputeCrossCounts(); + _remerge(); notifyListeners(); }, onDone: () { if (token != _token) return; - _searching = false; + _phase = CrossSearchPhase.done; + _store(q); notifyListeners(); }, ); } - void _recomputeCrossCounts() { - final counts = >{}; - for (final r in _results.values) { - for (final m in r.items) { - counts - .putIfAbsent(_key(m.title, m.year), () => {}) - .add(r.provider.id); - } + void _remerge() => _merged = mergeSearchResults(results); + + void _store(String q) { + _cache.remove(_cacheKey(q)); + _cache[_cacheKey(q)] = _CachedRun( + at: DateTime.now(), + results: List.of(results), + ); + while (_cache.length > _cacheEntries) { + _cache.remove(_cache.keys.first); } - _crossSourceCounts = { - for (final e in counts.entries) e.key: e.value.length, - }; } + bool _restoreFromCache(String q) { + final hit = _cache[_cacheKey(q)]; + if (hit == null) return false; + if (DateTime.now().difference(hit.at) > _cacheTtl) { + _cache.remove(_cacheKey(q)); + return false; + } + ++_token; + for (final r in hit.results) { + _results[r.provider.id] = r; + } + _remerge(); + _phase = CrossSearchPhase.done; + notifyListeners(); + return true; + } + + String _cacheKey(String q) => + '${_set.map((p) => p.id).join(',')}|${q.toLowerCase()}'; + void _cancel() { + _debouncer.cancel(); _sub?.cancel(); _sub = null; } @override void dispose() { - _debounce?.cancel(); + _debouncer.dispose(); _cancel(); super.dispose(); } } + +class _CachedRun { + const _CachedRun({required this.at, required this.results}); + + final DateTime at; + final List results; +} diff --git a/lib/features/search/presentation/blocs/search_bloc.dart b/lib/features/search/presentation/blocs/search_bloc.dart index 8f72280..793dccd 100644 --- a/lib/features/search/presentation/blocs/search_bloc.dart +++ b/lib/features/search/presentation/blocs/search_bloc.dart @@ -4,131 +4,281 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/search/data/search_recents_store.dart'; import 'package:soplay/features/search/domain/entities/genre_entity.dart'; +import 'package:soplay/features/search/domain/entities/search_entity.dart'; import 'package:soplay/features/search/domain/usecases/genre_usecase.dart'; import 'package:soplay/features/search/domain/usecases/search_usecase.dart'; +import 'package:soplay/features/search/presentation/blocs/search_query_policy.dart'; part 'search_event.dart'; part 'search_state.dart'; +/// Single-source search. +/// +/// Every handler that awaits carries the run token: a response that lands after +/// a newer run has started is dropped instead of overwriting it. Genres are a +/// field on the state, not a state of their own, so a failed genres fetch can +/// never repaint the results as a network error. class SearchBloc extends Bloc { final SearchUseCase _searchUseCase; final GenreUseCase _genreUseCase; + final SearchRecentsStore _recents; - Timer? _debounce; + final QueryDebouncer _debouncer = QueryDebouncer(); - int _execToken = 0; + int _runToken = 0; + int _genreToken = 0; + + /// What is in the box right now. Kept off the state on purpose: a keystroke + /// must not rebuild the results grid. + String _pendingText = ''; SearchBloc({ required SearchUseCase searchUseCase, required GenreUseCase genreUseCase, + SearchRecentsStore? recentsStore, }) : _searchUseCase = searchUseCase, _genreUseCase = genreUseCase, - super(const SearchInitial()) { + _recents = recentsStore ?? SearchRecentsStore(), + super(const SearchState()) { on(_onLoad); on(_onQueryChanged); - on<_SearchExecute>(_onExecute); + on(_onSubmitted); + on(_onGenreSelected); + on<_SearchRun>(_onRun); on(_onLoadMore); - on(_onByGenre); + on(_onRetry); + on(_onRecentRemoved); + on(_onRecentsCleared); + + add(const SearchLoad()); } Future _onLoad(SearchLoad event, Emitter emit) async { - ++_execToken; // invalidate any in-flight text search (box cleared → genres) - emit(const SearchGenresLoading()); + final genreToken = ++_genreToken; + ++_runToken; + _debouncer.reset(); + + // Genres belong to the provider that is being left behind, and so do the + // results; the text in the box does not. + final criteria = SearchCriteria(text: state.criteria.text); + emit(state.copyWith( + criteria: criteria, + status: criteria.isEmpty ? SearchStatus.idle : SearchStatus.loading, + items: const [], + page: 1, + totalPages: 1, + isLoadingMore: false, + genres: const [], + genresLoading: true, + genresFailed: false, + recent: _recents.load(), + clearError: true, + )); + + if (criteria.isNotEmpty) add(_SearchRun(criteria)); + final result = await _genreUseCase(); - if (result.isSuccess) { - emit(SearchGenresLoaded(result.getOrNull()!)); - } else { - emit(SearchError(result.getErrorOrNull()!.toString())); - } + if (genreToken != _genreToken || isClosed) return; + emit(state.copyWith( + genres: result.isSuccess ? result.getOrNull()! : const [], + genresLoading: false, + genresFailed: result.isError, + )); } void _onQueryChanged(SearchQueryChanged event, Emitter emit) { - _debounce?.cancel(); - final q = event.query.trim(); + final q = SearchQueryPolicy.normalize(event.query); + _pendingText = q; + if (q.isEmpty) { - add(const SearchLoad()); + _debouncer.reset(); + ++_runToken; + final criteria = state.criteria.copyWith(text: ''); + if (criteria.isEmpty) { + emit(state.copyWith( + criteria: criteria, + status: SearchStatus.idle, + items: const [], + page: 1, + totalPages: 1, + isLoadingMore: false, + recent: _recents.load(), + clearError: true, + )); + } else { + add(_SearchRun(criteria)); + } return; } - _debounce = Timer(const Duration(milliseconds: 450), () { - if (!isClosed) add(_SearchExecute(q)); + + _debouncer.schedule(q, (value) { + if (isClosed) return; + // Same reason as _onGenreSelected: the two cannot be combined, so the + // one the user just acted on wins. + add(_SearchRun(SearchCriteria(text: value, genre: ''))); }); } - Future _onExecute(_SearchExecute event, Emitter emit) async { - final token = ++_execToken; - emit(const SearchLoading()); - final result = await _searchUseCase(event.query); - if (token != _execToken) return; - if (result.isSuccess) { - final data = result.getOrNull()!; - emit(SearchLoaded( - query: event.query, - items: data.items, - page: data.page, - totalPages: data.totalPages, - )); - } else { - emit(SearchError(result.getErrorOrNull()!.toString())); - } + void _onSubmitted(SearchSubmitted event, Emitter emit) { + _pendingText = SearchQueryPolicy.normalize(event.query); + _debouncer.runNow(_pendingText, (value) { + if (isClosed) return; + add(_SearchRun(state.criteria.copyWith(text: value))); + }); } - Future _onLoadMore(SearchLoadMore event, Emitter emit) async { - final current = state; - if (current is! SearchLoaded || current.isLoadingMore || !current.hasMore) return; - - final loading = current.copyWith(isLoadingMore: true); - emit(loading); - final nextPage = current.page + 1; - - final Result result = current.query.isNotEmpty - ? await _searchUseCase(current.query, page: nextPage) - : await _genreUseCase.callByGenre(current.genre, page: nextPage); - - // A newer search/genre may have replaced the state while paging was in - // flight — discard this stale page instead of clobbering current results. - if (state != loading) return; - - if (result.isSuccess) { - final data = result.getOrNull()!; - final seen = { - for (final m in current.items) '${m.provider}::${m.url}', - }; - final fresh = []; - for (final m in data.items) { - if (seen.add('${m.provider}::${m.url}')) fresh.add(m); - } - emit(current.copyWith( - items: [...current.items, ...fresh], - page: data.page, - totalPages: data.totalPages, + void _onGenreSelected(SearchGenreSelected event, Emitter emit) { + _debouncer.reset(); + // Genre and text do not compose: the server's /contents/search takes only + // q, page and provider, so a genre sent alongside a query is dropped on the + // floor and the user gets a plain text search under an active filter chip. + // Picking a genre therefore browses that genre instead. + final genre = event.genre.trim(); + if (genre.isNotEmpty) _pendingText = ''; + final text = genre.isNotEmpty + ? '' + : (SearchQueryPolicy.runnable(_pendingText) ? _pendingText : ''); + final criteria = SearchCriteria(text: text, genre: genre); + if (criteria.isEmpty) { + ++_runToken; + emit(state.copyWith( + criteria: criteria, + status: SearchStatus.idle, + items: const [], + page: 1, + totalPages: 1, isLoadingMore: false, + recent: _recents.load(), + clearError: true, )); - } else { - emit(current.copyWith(isLoadingMore: false)); + return; } + add(_SearchRun(criteria)); } - Future _onByGenre(SearchByGenre event, Emitter emit) async { - ++_execToken; // invalidate any in-flight text search (genre filter applied) - emit(const SearchLoading()); - final result = await _genreUseCase.callByGenre(event.genre); - if (result.isSuccess) { - final data = result.getOrNull()!; - emit(SearchLoaded( - genre: event.genre, - items: data.items, - page: data.page, - totalPages: data.totalPages, + Future _onRun(_SearchRun event, Emitter emit) async { + final token = ++_runToken; + final criteria = event.criteria; + + // Something is already on screen: keep it and show progress on top of it + // rather than blanking the grid on every keystroke. + final keepItems = state.items.isNotEmpty; + emit(state.copyWith( + criteria: criteria, + status: keepItems ? SearchStatus.refreshing : SearchStatus.loading, + isLoadingMore: false, + clearError: true, + )); + + final result = await _fetch(criteria, 1); + if (token != _runToken || isClosed) return; + + if (result.isError) { + final raw = result.getErrorOrNull()!.toString(); + _debouncer.forget(); + emit(state.copyWith( + status: SearchStatus.error, + items: const [], + page: 1, + totalPages: 1, + errorMessage: cleanFailureMessage(raw), + errorKind: classifySearchFailure(raw), )); - } else { - emit(SearchError(result.getErrorOrNull()!.toString())); + return; + } + + final data = result.getOrNull()!; + final recent = criteria.text.isEmpty + ? state.recent + : await _recents.add(criteria.text); + if (token != _runToken || isClosed) return; + + emit(state.copyWith( + items: data.items, + page: data.page, + totalPages: data.totalPages, + status: data.items.isEmpty ? SearchStatus.empty : SearchStatus.loaded, + recent: recent, + clearError: true, + )); + } + + Future _onLoadMore( + SearchLoadMore event, + Emitter emit, + ) async { + if (state.status != SearchStatus.loaded || + state.isLoadingMore || + !state.hasMore) { + return; + } + + final token = ++_runToken; + final criteria = state.criteria; + final nextPage = state.page + 1; + emit(state.copyWith(isLoadingMore: true)); + + final result = await _fetch(criteria, nextPage); + if (token != _runToken || isClosed) return; + + if (result.isError) { + emit(state.copyWith(isLoadingMore: false)); + return; + } + + final data = result.getOrNull()!; + final seen = {for (final m in state.items) '${m.provider}::${m.url}'}; + final fresh = [ + for (final m in data.items) + if (seen.add('${m.provider}::${m.url}')) m, + ]; + emit(state.copyWith( + items: [...state.items, ...fresh], + page: data.page, + totalPages: data.totalPages, + isLoadingMore: false, + )); + } + + void _onRetry(SearchRetry event, Emitter emit) { + _debouncer.reset(); + if (state.criteria.isEmpty) { + add(const SearchLoad()); + return; + } + add(_SearchRun(state.criteria)); + } + + Future _onRecentRemoved( + SearchRecentRemoved event, + Emitter emit, + ) async { + final recent = await _recents.remove(event.query); + if (isClosed) return; + emit(state.copyWith(recent: recent)); + } + + Future _onRecentsCleared( + SearchRecentsCleared event, + Emitter emit, + ) async { + final recent = await _recents.clear(); + if (isClosed) return; + emit(state.copyWith(recent: recent)); + } + + Future> _fetch(SearchCriteria criteria, int page) { + if (criteria.text.isNotEmpty) { + return _searchUseCase(criteria.text, page: page); } + return _genreUseCase.callByGenre(criteria.genre, page: page); } @override Future close() { - _debounce?.cancel(); + _debouncer.dispose(); return super.close(); } } diff --git a/lib/features/search/presentation/blocs/search_event.dart b/lib/features/search/presentation/blocs/search_event.dart index 3866a1c..e486873 100644 --- a/lib/features/search/presentation/blocs/search_event.dart +++ b/lib/features/search/presentation/blocs/search_event.dart @@ -4,6 +4,8 @@ abstract class SearchEvent { const SearchEvent(); } +/// First mount and every provider switch: genres belong to the provider, so +/// they are dropped and refetched, and whatever is in the box is re-run. class SearchLoad extends SearchEvent { const SearchLoad(); } @@ -13,16 +15,37 @@ class SearchQueryChanged extends SearchEvent { final String query; } -class _SearchExecute extends SearchEvent { - const _SearchExecute(this.query); +/// The keyboard's Search key: run now, no debounce, no dedupe. +class SearchSubmitted extends SearchEvent { + const SearchSubmitted(this.query); final String query; } +/// Applies or clears the genre filter. Combines with whatever text is present. +class SearchGenreSelected extends SearchEvent { + const SearchGenreSelected(this.genre); + final String genre; +} + class SearchLoadMore extends SearchEvent { const SearchLoadMore(); } -class SearchByGenre extends SearchEvent { - const SearchByGenre(this.genre); - final String genre; +/// Re-runs the operation that actually failed, not the genres call. +class SearchRetry extends SearchEvent { + const SearchRetry(); +} + +class SearchRecentRemoved extends SearchEvent { + const SearchRecentRemoved(this.query); + final String query; +} + +class SearchRecentsCleared extends SearchEvent { + const SearchRecentsCleared(); +} + +class _SearchRun extends SearchEvent { + const _SearchRun(this.criteria); + final SearchCriteria criteria; } diff --git a/lib/features/search/presentation/blocs/search_query_policy.dart b/lib/features/search/presentation/blocs/search_query_policy.dart new file mode 100644 index 0000000..a9fce22 --- /dev/null +++ b/lib/features/search/presentation/blocs/search_query_policy.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +/// The single debounce + run policy for every search surface. +/// +/// Both the single-source bloc and the cross-source controller used to carry +/// their own copy of this timer, which is how they drifted apart: same delay, +/// different behaviour on a re-typed query and on a run triggered from outside +/// the field (retry, provider-set change) while a debounce was still pending. +class SearchQueryPolicy { + const SearchQueryPolicy._(); + + static const Duration debounce = Duration(milliseconds: 450); + + /// A one-character query fans out to every selected source for almost no + /// signal, so it never auto-runs. Submitting from the keyboard still does. + static const int minLength = 2; + + static String normalize(String raw) => raw.trim(); + + static bool runnable(String query) => normalize(query).length >= minLength; +} + +class QueryDebouncer { + Timer? _timer; + String? _lastDispatched; + + bool get isPending => _timer?.isActive ?? false; + + /// Schedules [run] unless the query is too short or identical to the last one + /// dispatched. Always cancels a pending run first. + /// + /// Returns whether a run was actually armed. Callers that show a spinner have + /// to know: setting one before calling this left the UI searching forever on + /// a one-character query, because nothing was ever dispatched to end it. + bool schedule(String query, void Function(String query) run) { + _timer?.cancel(); + final q = SearchQueryPolicy.normalize(query); + if (!SearchQueryPolicy.runnable(q)) return false; + if (q == _lastDispatched) return false; + _timer = Timer(SearchQueryPolicy.debounce, () { + _lastDispatched = q; + run(q); + }); + return true; + } + + /// Runs [query] now, dropping any pending debounce. Used by the keyboard's + /// Search key, which must not be swallowed by the dedupe. + void runNow(String query, void Function(String query) run) { + _timer?.cancel(); + final q = SearchQueryPolicy.normalize(query); + if (q.isEmpty) return; + _lastDispatched = q; + run(q); + } + + void cancel() => _timer?.cancel(); + + /// Drops the dedupe key but leaves a pending run alone — used when a run + /// failed, so retyping the same query is not swallowed as a duplicate. + void forget() => _lastDispatched = null; + + /// Forgets the dedupe key so the same query runs again — a retry, a provider + /// switch or a filter change all mean "the previous answer is void". + void reset() { + _timer?.cancel(); + _lastDispatched = null; + } + + void dispose() => _timer?.cancel(); +} diff --git a/lib/features/search/presentation/blocs/search_state.dart b/lib/features/search/presentation/blocs/search_state.dart index 11db3d8..cefd895 100644 --- a/lib/features/search/presentation/blocs/search_state.dart +++ b/lib/features/search/presentation/blocs/search_state.dart @@ -1,73 +1,164 @@ part of 'search_bloc.dart'; -abstract class SearchState extends Equatable { - const SearchState(); - @override - List get props => []; -} +enum SearchStatus { + /// Nothing asked for yet — recents, genres and the hint live here. + idle, -class SearchInitial extends SearchState { - const SearchInitial(); -} + /// First results for the current criteria; nothing to keep on screen. + loading, -class SearchGenresLoading extends SearchState { - const SearchGenresLoading(); + /// New results for criteria that already have something on screen. + refreshing, + loaded, + empty, + error, } -class SearchGenresLoaded extends SearchState { - const SearchGenresLoaded(this.genres); - final List genres; - @override - List get props => [genres]; -} +/// Why a search failed, so the view can stop blaming the user's wifi for a +/// broken extension source. +enum SearchFailureKind { network, source, unknown } + +/// What the user is asking for. Text and genre are composable: either one, both +/// or neither, and every change re-runs through the same path. +class SearchCriteria extends Equatable { + const SearchCriteria({this.text = '', this.genre = ''}); + + final String text; + final String genre; + + bool get isEmpty => text.isEmpty && genre.isEmpty; + bool get isNotEmpty => !isEmpty; + + String get label => text.isNotEmpty ? text : genre; + + SearchCriteria copyWith({String? text, String? genre}) => SearchCriteria( + text: text ?? this.text, + genre: genre ?? this.genre, + ); -class SearchLoading extends SearchState { - const SearchLoading(); + @override + List get props => [text, genre]; } -class SearchLoaded extends SearchState { - const SearchLoaded({ - required this.items, - required this.page, - required this.totalPages, - this.query = '', - this.genre = '', +class SearchState extends Equatable { + const SearchState({ + this.criteria = const SearchCriteria(), + this.status = SearchStatus.idle, + this.items = const [], + this.page = 1, + this.totalPages = 1, this.isLoadingMore = false, + this.errorMessage = '', + this.errorKind = SearchFailureKind.unknown, + this.genres = const [], + this.genresLoading = false, + this.genresFailed = false, + this.recent = const [], }); + final SearchCriteria criteria; + final SearchStatus status; final List items; final int page; final int totalPages; - final String query; - final String genre; final bool isLoadingMore; + final String errorMessage; + final SearchFailureKind errorKind; + + /// Genres are a *field*, not a state: a failed genre fetch must never be able + /// to paint the search screen as broken, and clearing the box must never be + /// able to wipe results. + final List genres; + final bool genresLoading; + final bool genresFailed; + + final List recent; + bool get hasMore => page < totalPages; + bool get hasGenres => genres.isNotEmpty; + bool get isBusy => + status == SearchStatus.loading || status == SearchStatus.refreshing; - SearchLoaded copyWith({ + SearchState copyWith({ + SearchCriteria? criteria, + SearchStatus? status, List? items, int? page, int? totalPages, - String? query, - String? genre, bool? isLoadingMore, + String? errorMessage, + SearchFailureKind? errorKind, + List? genres, + bool? genresLoading, + bool? genresFailed, + List? recent, + bool clearError = false, }) => - SearchLoaded( + SearchState( + criteria: criteria ?? this.criteria, + status: status ?? this.status, items: items ?? this.items, page: page ?? this.page, totalPages: totalPages ?? this.totalPages, - query: query ?? this.query, - genre: genre ?? this.genre, isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: clearError ? '' : (errorMessage ?? this.errorMessage), + errorKind: clearError + ? SearchFailureKind.unknown + : (errorKind ?? this.errorKind), + genres: genres ?? this.genres, + genresLoading: genresLoading ?? this.genresLoading, + genresFailed: genresFailed ?? this.genresFailed, + recent: recent ?? this.recent, ); @override - List get props => [items, page, totalPages, query, genre, isLoadingMore]; + List get props => [ + criteria, + status, + items, + page, + totalPages, + isLoadingMore, + errorMessage, + errorKind, + genres, + genresLoading, + genresFailed, + recent, + ]; } -class SearchError extends SearchState { - const SearchError(this.message); - final String message; - @override - List get props => [message]; +/// Best-effort classification of a repository failure. +/// +/// The repository and the extension hosts already distinguish "source +/// unavailable" from "no match"; this keeps that distinction alive up to the +/// view instead of collapsing everything into "check your connection". +SearchFailureKind classifySearchFailure(String raw) { + final m = raw.toLowerCase(); + if (m.contains('socketexception') || + m.contains('failed host lookup') || + m.contains('connection error') || + m.contains('connection refused') || + m.contains('connection closed') || + m.contains('network is unreachable') || + m.contains('timeout') || + m.contains('timed out')) { + return SearchFailureKind.network; + } + if (m.contains('source unavailable') || + m.contains('platformexception') || + m.contains('extension') || + m.contains('extractor')) { + return SearchFailureKind.source; + } + return SearchFailureKind.unknown; +} + +String cleanFailureMessage(String raw) { + var m = raw.trim(); + while (m.startsWith('Exception:')) { + m = m.substring('Exception:'.length).trim(); + } + return m; } diff --git a/lib/features/search/presentation/pages/cross_search_page.dart b/lib/features/search/presentation/pages/cross_search_page.dart index d774dde..e6af6cb 100644 --- a/lib/features/search/presentation/pages/cross_search_page.dart +++ b/lib/features/search/presentation/pages/cross_search_page.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -5,25 +6,17 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; -import 'package:soplay/features/home/domain/entities/movie.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/search/domain/entities/cross_search_result.dart'; import 'package:soplay/features/search/domain/services/cross_search_engine.dart'; import 'package:soplay/features/search/presentation/blocs/cross_search_controller.dart'; +import 'package:soplay/features/search/presentation/widgets/search_result_card.dart'; import 'package:soplay/features/search/presentation/widgets/search_set_sheet.dart'; - -/// Search a curated set of providers at once. Results stream in, grouped by -/// provider — freeze-proof (bounded concurrency + per-provider timeout in the -/// engine), so a large or partly-broken set never blocks the UI. class CrossSearchPage extends StatefulWidget { const CrossSearchPage({super.key, this.initialQuery}); - - /// When set (e.g. opened from a title's "find on other sources"), the page - /// starts searching for this immediately and does not steal focus. final String? initialQuery; @override @@ -33,21 +26,25 @@ class CrossSearchPage extends StatefulWidget { class _CrossSearchPageState extends State { final _textController = TextEditingController(); late final CrossSearchController _controller; + Set _selectedIds = {}; + List _providers = const []; + bool _offline = false; + bool _grouped = false; @override void initState() { super.initState(); - final providers = _allProviders(); - _selectedIds = _initialSelection(providers); + _providers = _providersOf(context.read().state); + _selectedIds = _initialSelection(_providers); _controller = CrossSearchController( engine: getIt(), - set: _buildRefs(providers, _selectedIds), + set: _buildRefs(_providers, _selectedIds), ); final q = widget.initialQuery?.trim() ?? ''; if (q.isNotEmpty) { _textController.text = q; - _controller.onQueryChanged(q); + _controller.submit(q); } } @@ -60,9 +57,31 @@ class _CrossSearchPageState extends State { /// Offline this yields only the on-device plugins: fanning out to server /// providers with the API down would just add a row of failed legs. - List _allProviders() { - final state = context.read().state; - return state is ProviderLoaded ? state.usableProviders : const []; + List _providersOf(ProviderState state) => + state is ProviderLoaded ? state.usableProviders : const []; + + /// The page used to snapshot ProviderBloc once in initState, so opening it + /// before the providers had loaded left it permanently empty. + void _syncProviders(ProviderState state) { + final providers = _providersOf(state); + final offline = state is ProviderLoaded && state.offline; + if (providers.length == _providers.length && + offline == _offline && + providers.every((p) => _providers.any((e) => e.id == p.id))) { + return; + } + final wasEmpty = _providers.isEmpty; + _providers = providers; + _offline = offline; + if (wasEmpty || _selectedIds.isEmpty) { + _selectedIds = _initialSelection(providers); + } else { + _selectedIds = _selectedIds + .where((id) => providers.any((p) => p.id == id)) + .toSet(); + } + setState(() {}); + _controller.setProviderSet(_buildRefs(providers, _selectedIds)); } Set _initialSelection(List providers) { @@ -87,44 +106,130 @@ class _CrossSearchPageState extends State { } Future _openSetSheet() async { - final providers = _allProviders(); final result = await SearchSetSheet.show( context, - providers: providers, + providers: _providers, initialSelected: _selectedIds, ); if (result == null || !mounted) return; _selectedIds = result; await getIt().setCrossSearchProviders(result.toList()); - _controller.setProviderSet(_buildRefs(providers, result)); + _controller.setProviderSet(_buildRefs(_providers, result)); setState(() {}); } + void _openDetail(MergedSearchTitle title) { + if (title.sourceCount <= 1) { + _push(title.hits.first); + return; + } + showModalBottomSheet( + context: context, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 6), + child: Text( + 'search.open_from'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + for (final hit in title.hits) + ListTile( + dense: true, + leading: const Icon(Icons.play_circle_outline, + color: AppColors.primary, size: 20), + title: Text(hit.provider.name, + style: const TextStyle(color: Colors.white, fontSize: 14)), + subtitle: Text(hit.item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, fontSize: 12)), + onTap: () { + Navigator.of(context).pop(); + _push(hit); + }, + ), + ], + ), + ), + ); + } + + void _push(TitleHit hit) { + if (hit.item.url.isEmpty) return; + context.push( + '/detail', + extra: DetailArgs( + contentUrl: hit.item.url, + preview: hit.item, + provider: _providerIdOf(hit), + ), + ); + } + + /// The source this hit came from, never the app's "current" provider. + String? _providerIdOf(TitleHit hit) { + if (hit.provider.kind == ProviderKind.server && + hit.item.provider.isNotEmpty) { + return hit.item.provider; + } + return hit.provider.id; + } + @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar( + return BlocListener( + listener: (_, state) => _syncProviders(state), + child: Scaffold( backgroundColor: AppColors.background, - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - elevation: 0, - title: const Text('All-source search', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _searchField(), - _sourcesBar(), - const Divider(height: 1, color: Colors.white10), - Expanded( - child: AnimatedBuilder( - animation: _controller, - builder: (_, _) => _body(), + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('search.all_source_search'.tr(), + style: + const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + actions: [ + IconButton( + tooltip: _grouped + ? 'search.merge_titles'.tr() + : 'search.group_by_source'.tr(), + onPressed: () => setState(() => _grouped = !_grouped), + icon: Icon( + _grouped ? Icons.grid_view_rounded : Icons.view_agenda_outlined, + size: 20, + ), ), - ), - ], + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _searchField(), + _sourcesBar(), + const Divider(height: 1, color: Colors.white10), + Expanded( + child: AnimatedBuilder( + animation: _controller, + builder: (_, _) => _body(), + ), + ), + ], + ), ), ); } @@ -138,22 +243,30 @@ class _CrossSearchPageState extends State { style: const TextStyle(color: Colors.white), textInputAction: TextInputAction.search, onChanged: _controller.onQueryChanged, + onSubmitted: (value) { + FocusScope.of(context).unfocus(); + _controller.submit(value); + }, decoration: InputDecoration( isDense: true, - hintText: 'Search across your sources…', + hintText: 'search.cross_hint'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), prefixIcon: const Icon(Icons.search, color: AppColors.textHint, size: 20), - suffixIcon: _textController.text.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.close, color: AppColors.textHint), - onPressed: () { - _textController.clear(); - _controller.onQueryChanged(''); - setState(() {}); - }, - ), + // Driven by the controller: nothing else subscribes to it, so the + // clear button used to appear only on an unrelated rebuild. + suffixIcon: ValueListenableBuilder( + valueListenable: _textController, + builder: (_, value, _) => value.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + icon: const Icon(Icons.close, color: AppColors.textHint), + onPressed: () { + _textController.clear(); + _controller.onQueryChanged(''); + }, + ), + ), filled: true, fillColor: AppColors.surface, border: OutlineInputBorder( @@ -167,8 +280,8 @@ class _CrossSearchPageState extends State { Widget _sourcesBar() { final label = _selectedIds.isEmpty - ? 'No sources selected — tap to choose' - : '${_selectedIds.length} source(s) selected — tap to change'; + ? 'search.no_sources_selected'.tr() + : 'search.sources_selected'.tr(args: ['${_selectedIds.length}']); return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), child: Material( @@ -207,48 +320,114 @@ class _CrossSearchPageState extends State { if (_selectedIds.isEmpty) { return _centered( icon: Icons.tune, - text: 'Pick which sources to search across.', + text: 'search.pick_sources'.tr(), action: FilledButton( onPressed: _openSetSheet, - child: const Text('Choose sources'), + child: Text('search.choose_sources'.tr()), ), ); } if (_controller.query.isEmpty) { return _centered( icon: Icons.travel_explore, - text: 'Type to search all ${_selectedIds.length} selected sources ' - 'at once.', + text: 'search.type_to_search_n' + .tr(args: ['${_controller.expectedLegs}']), ); } - final results = _controller.results; - final withItems = results.where((r) => r.hasItems).toList(); - final withoutItems = results.where((r) => !r.hasItems).toList(); - final pending = _controller.expectedLegs - _controller.completedLegs; + final merged = _controller.merged; + final done = _controller.phase == CrossSearchPhase.done; - return ListView( - padding: const EdgeInsets.only(bottom: 24), - children: [ - _summary(pending), - for (final r in withItems) _providerSection(r), - if (withoutItems.isNotEmpty) _noResultFooter(withoutItems), - if (withItems.isEmpty && pending <= 0) - _centered( - icon: Icons.search_off, - text: 'No results in any selected source.', - padded: false, + return CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverToBoxAdapter(child: _summary()), + if (_offline) + SliverToBoxAdapter(child: _note('search.offline_note'.tr())), + if (merged.isEmpty && done) + SliverToBoxAdapter( + child: _centered( + icon: Icons.search_off, + text: 'search.no_results_any'.tr(), + padded: false, + ), + ) + else if (_grouped) + SliverList( + delegate: SliverChildBuilderDelegate( + (_, i) => _providerSection(_controller.legsWithItems[i]), + childCount: _controller.legsWithItems.length, + ), + ) + else + _mergedGrid(merged), + if (_controller.hasMoreAnywhere && done) + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: OutlinedButton( + onPressed: + _controller.loadingMore ? null : _controller.loadMore, + child: _controller.loadingMore + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text('search.load_more'.tr()), + ), + ), + ), ), + SliverToBoxAdapter(child: _sourceStatusList()), + const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ); } - Widget _summary(int pending) { + Widget _mergedGrid(List merged) { + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (_, i) { + final title = merged[i]; + return SearchResultCard( + key: ValueKey('${title.key}|${title.year ?? ''}'), + movie: title.primary, + provider: _providerIdOf(title.hits.first), + sourceLabel: title.sourceCount > 1 + ? 'search.sources_n'.tr(args: ['${title.sourceCount}']) + : title.primaryProvider.name, + sourceCount: title.sourceCount, + onTap: () => _openDetail(title), + ); + }, + childCount: merged.length, + ), + gridDelegate: searchGridDelegate(context), + ), + ); + } + + Widget _summary() { + final pending = _controller.pendingSources; + final searching = _controller.searching; + final text = searching + ? (pending.isEmpty + ? 'search.searching'.tr() + : 'search.waiting_for'.tr(args: [pending.take(2).join(', ')])) + : 'search.found_in'.tr(args: [ + '${_controller.sourcesWithResults}', + '${_controller.expectedLegs}', + ]); + return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ - if (_controller.searching && pending > 0) ...[ + if (searching) ...[ const SizedBox( width: 14, height: 14, @@ -258,10 +437,7 @@ class _CrossSearchPageState extends State { ], Expanded( child: Text( - 'Found in ${_controller.sourcesWithResults} of ' - '${_controller.expectedLegs} sources · ' - '${_controller.totalItems} results' - '${pending > 0 ? ' · searching…' : ''}', + '$text · ${'search.results_n'.tr(args: ['${_controller.totalItems}'])}', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -275,6 +451,7 @@ class _CrossSearchPageState extends State { Widget _providerSection(ProviderSearchResult r) { return Column( + key: ValueKey('section-${r.provider.id}'), crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( @@ -308,25 +485,22 @@ class _CrossSearchPageState extends State { ), ), SizedBox( - height: 188, + height: searchCardHeight(116), child: ListView.separated( + key: PageStorageKey('cross-rail-${r.provider.id}'), scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: r.items.length, separatorBuilder: (_, _) => const SizedBox(width: 10), itemBuilder: (_, i) { final m = r.items[i]; - // Open the detail with the SOURCE this result came from — not the - // app's "current" provider. Server legs are collapsed under the - // synthetic __server__ ref, so use the item's own backend provider - // there; channel/js legs carry the real id on the section ref. - final prov = r.provider.kind == ProviderKind.server - ? (m.provider.isNotEmpty ? m.provider : null) - : r.provider.id; - return _MovieCard( + final hit = TitleHit(provider: r.provider, item: m); + return SearchResultCard( + width: 116, movie: m, - alsoOn: _controller.crossSourceCount(m.title, m.year), - provider: prov, + provider: _providerIdOf(hit), + sourceLabel: m.year != null ? '${m.year}' : '', + onTap: () => _push(hit), ); }, ), @@ -335,23 +509,75 @@ class _CrossSearchPageState extends State { ); } - Widget _noResultFooter(List list) { - final noResults = list.where((r) => r.status == ProviderSearchStatus.empty).length; - final timeouts = list.where((r) => r.status == ProviderSearchStatus.timeout).length; - final errors = list.where((r) => r.status == ProviderSearchStatus.error).length; - final parts = [ - if (noResults > 0) '$noResults with no results', - if (timeouts > 0) '$timeouts timed out', - if (errors > 0) '$errors failed', - ]; - if (parts.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 4), - child: Text(parts.join(' · '), - style: const TextStyle(color: AppColors.textHint, fontSize: 12)), + /// Every leg by name with what it did, and a Retry for the ones that failed. + Widget _sourceStatusList() { + final legs = _controller.results; + if (legs.isEmpty) return const SizedBox.shrink(); + return Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: _controller.failedLegs.isNotEmpty, + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + title: Text( + 'search.sources'.tr(), + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + children: [ + for (final leg in legs) + ListTile( + dense: true, + contentPadding: const EdgeInsets.only(left: 16, right: 8), + title: Text(leg.provider.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13)), + subtitle: Text( + _statusLabel(leg), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 11.5), + ), + trailing: leg.status == ProviderSearchStatus.ok + ? null + : _controller.isRetrying(leg.provider.id) + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : TextButton( + onPressed: () => + _controller.retryProvider(leg.provider.id), + child: Text('general.retry'.tr()), + ), + ), + ], + ), ); } + String _statusLabel(ProviderSearchResult leg) => switch (leg.status) { + ProviderSearchStatus.ok => + 'search.results_n'.tr(args: ['${leg.items.length}']), + ProviderSearchStatus.empty => 'search.no_results'.tr(), + ProviderSearchStatus.timeout => 'errors.timeout'.tr(), + ProviderSearchStatus.error => leg.message.isEmpty + ? 'search.source_failed'.tr() + : leg.message, + }; + + Widget _note(String text) => Container( + margin: const EdgeInsets.fromLTRB(16, 6, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text(text, + style: const TextStyle( + color: Colors.orange, fontSize: 11.5, height: 1.3)), + ); + Widget _centered({ required IconData icon, required String text, @@ -371,93 +597,11 @@ class _CrossSearchPageState extends State { ); if (!padded) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 60), child: Center(child: content)); + padding: const EdgeInsets.symmetric(vertical: 60), + child: Center(child: content)); } return Center( child: Padding(padding: const EdgeInsets.all(32), child: content), ); } } - -class _MovieCard extends StatelessWidget { - const _MovieCard({required this.movie, required this.alsoOn, this.provider}); - - final MovieEntity movie; - final int alsoOn; - final String? provider; - - @override - Widget build(BuildContext context) { - // Android TV: these cards are every result of a cross-provider search, so a - // bare GestureDetector made the whole results grid unreachable by the D-pad. - // Off TV TvFocusable collapses to exactly the GestureDetector that was here - // — same onTap, same explicit HitTestBehavior.opaque, nothing else added. - return TvFocusable( - behavior: HitTestBehavior.opaque, - onPressed: () { - if (movie.url.isEmpty) return; - context.push('/detail', - extra: DetailArgs( - contentUrl: movie.url, preview: movie, provider: provider)); - }, - child: SizedBox( - width: 116, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - SizedBox( - width: 116, - height: 150, - child: movie.thumbnail != null - ? Image.network( - movie.thumbnail!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _placeholder(), - ) - : _placeholder(), - ), - if (alsoOn > 1) - Positioned( - top: 6, - left: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(6), - ), - child: Text('$alsoOn sources', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.w700)), - ), - ), - ], - ), - ), - const SizedBox(height: 6), - Text( - movie.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, fontSize: 11.5, height: 1.2), - ), - ], - ), - ), - ); - } - - Widget _placeholder() => Container( - color: AppColors.surfaceVariant, - child: const Icon(Icons.movie_rounded, - color: AppColors.textHint, size: 30), - ); -} diff --git a/lib/features/search/presentation/pages/search_page.dart b/lib/features/search/presentation/pages/search_page.dart index 0622e5f..fafb202 100644 --- a/lib/features/search/presentation/pages/search_page.dart +++ b/lib/features/search/presentation/pages/search_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; -import 'package:soplay/features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'package:soplay/features/search/presentation/widgets/search_filter_sheet.dart'; import 'package:soplay/features/search/presentation/widgets/search_header.dart'; @@ -29,19 +28,14 @@ class _SearchViewState extends State<_SearchView> { final _scrollController = ScrollController(); final _blurProgress = ValueNotifier(0); - List _cachedGenres = []; - SearchFilterSelection _filter = const SearchFilterSelection(); - - bool get _hasActiveFilter => _filter.hasActiveFilter; - @override void initState() { super.initState(); _scrollController.addListener(_onScroll); - final bloc = context.read(); - if (bloc.state is SearchInitial) { - bloc.add(const SearchLoad()); - } + // Coming back to the tab: the bloc still holds the last query, so the box + // must show it instead of looking empty over a full grid of results. The + // bloc loads itself, so re-entering the tab never clears the results. + _controller.text = context.read().state.criteria.text; } @override @@ -67,7 +61,11 @@ class _SearchViewState extends State<_SearchView> { void _maybeAutoFill(SearchState state) { if (!isDesktopPlatform) return; - if (state is! SearchLoaded || !state.hasMore || state.isLoadingMore) return; + if (state.status != SearchStatus.loaded || + !state.hasMore || + state.isLoadingMore) { + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) return; if (_scrollController.position.maxScrollExtent <= 0) { @@ -81,6 +79,17 @@ class _SearchViewState extends State<_SearchView> { context.read().add(const SearchQueryChanged('')); } + void _runQuery(String query) { + _controller.text = query; + _controller.selection = TextSelection.collapsed(offset: query.length); + context.read().add(SearchSubmitted(query)); + } + + void _openCrossSearch([String? query]) { + final q = (query ?? _controller.text).trim(); + context.push('/cross-search', extra: q.isEmpty ? null : q); + } + void _openFilter() { final bloc = context.read(); showAdaptiveModal( @@ -88,19 +97,11 @@ class _SearchViewState extends State<_SearchView> { backgroundColor: Colors.transparent, isScrollControlled: true, builder: (_) => SearchFilterSheet( - initialSelection: _filter, - genres: _cachedGenres, - onApply: (selection) { - if (!mounted) return; - setState(() => _filter = selection); - - final query = _controller.text.trim(); - if (selection.genre.isNotEmpty && query.isEmpty) { - bloc.add(SearchByGenre(selection.genre)); - } else if (!selection.hasActiveFilter && query.isEmpty) { - bloc.add(const SearchLoad()); - } - }, + initialSelection: SearchFilterSelection(genre: bloc.state.criteria.genre), + genres: bloc.state.genres, + // Always dispatch: a genre picked or cleared while text is in the box + // used to change nothing but the button's active dot. + onApply: (selection) => bloc.add(SearchGenreSelected(selection.genre)), ), ); } @@ -113,39 +114,45 @@ class _SearchViewState extends State<_SearchView> { return Scaffold( backgroundColor: AppColors.background, - body: Stack( - children: [ - BlocConsumer( - listener: (context, state) { - if (state is SearchGenresLoaded) { - _cachedGenres = state.genres; - } - _maybeAutoFill(state); - }, - builder: (context, state) => SearchContentView( + body: BlocConsumer( + listener: (context, state) => _maybeAutoFill(state), + builder: (context, state) => Stack( + children: [ + SearchContentView( state: state, scrollController: _scrollController, topPad: headerHeight, bottomPad: bottomPad, - onRetry: () => context.read().add(const SearchLoad()), + onRetry: () => context.read().add(const SearchRetry()), + onSuggestion: _runQuery, + onGenre: (genre) => + context.read().add(SearchGenreSelected(genre)), + onRemoveRecent: (query) => + context.read().add(SearchRecentRemoved(query)), + onClearRecents: () => + context.read().add(const SearchRecentsCleared()), + onTryAllSources: () => _openCrossSearch(state.criteria.text), ), - ), - ValueListenableBuilder( - valueListenable: _blurProgress, - builder: (context, progress, _) => SearchStickyHeader( - progress: progress, - topPad: topPad, - controller: _controller, - focus: _focus, - hasActiveFilter: _hasActiveFilter, - onFilterTap: _openFilter, - onMultiSearchTap: () => context.push('/cross-search'), - onQueryChanged: (q) => - context.read().add(SearchQueryChanged(q)), - onClear: _clearSearch, + ValueListenableBuilder( + valueListenable: _blurProgress, + builder: (context, progress, _) => SearchStickyHeader( + progress: progress, + topPad: topPad, + controller: _controller, + focus: _focus, + hasActiveFilter: state.criteria.genre.isNotEmpty, + showFilter: state.hasGenres, + onFilterTap: _openFilter, + onMultiSearchTap: _openCrossSearch, + onQueryChanged: (q) => + context.read().add(SearchQueryChanged(q)), + onSubmitted: (q) => + context.read().add(SearchSubmitted(q)), + onClear: _clearSearch, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/search/presentation/widgets/search_filter_sheet.dart b/lib/features/search/presentation/widgets/search_filter_sheet.dart index ffc6f1c..1f728de 100644 --- a/lib/features/search/presentation/widgets/search_filter_sheet.dart +++ b/lib/features/search/presentation/widgets/search_filter_sheet.dart @@ -91,7 +91,9 @@ class _SearchFilterSheetState extends State { options: widget.genres .map( (genre) => SearchFilterOption( - label: genre.slug, + label: genre.name.isNotEmpty + ? genre.name + : genre.slug, value: genre.slug, ), ) diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 1e3d2d9..3fb4431 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -13,9 +13,11 @@ class SearchStickyHeader extends StatelessWidget { required this.controller, required this.focus, required this.hasActiveFilter, + required this.showFilter, required this.onFilterTap, required this.onMultiSearchTap, required this.onQueryChanged, + required this.onSubmitted, required this.onClear, }); @@ -24,9 +26,14 @@ class SearchStickyHeader extends StatelessWidget { final TextEditingController controller; final FocusNode focus; final bool hasActiveFilter; + + /// Hidden when the current provider exposes no genres — an empty filter + /// sheet is worse than no button. + final bool showFilter; final VoidCallback onFilterTap; final VoidCallback onMultiSearchTap; final ValueChanged onQueryChanged; + final ValueChanged onSubmitted; final VoidCallback onClear; @override @@ -38,9 +45,10 @@ class SearchStickyHeader extends StatelessWidget { final titleHeight = lerpDouble(28, 0, compactProgress)!; final titleGap = lerpDouble(14, 8, compactProgress)!; final bottomGap = lerpDouble(16, 10, compactProgress)!; - final backgroundColor = progress < 0.01 - ? AppColors.background - : const Color(0xFF181818).withValues(alpha: 0.82); + final blurred = progress > 0.01; + final backgroundColor = blurred + ? const Color(0xFF181818).withValues(alpha: 0.82) + : AppColors.background; final inner = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -80,6 +88,7 @@ class SearchStickyHeader extends StatelessWidget { controller: controller, focus: focus, onChanged: onQueryChanged, + onSubmitted: onSubmitted, onClear: onClear, ), ), @@ -87,148 +96,150 @@ class SearchStickyHeader extends StatelessWidget { _HeaderIconButton( icon: Icons.travel_explore_rounded, onTap: onMultiSearchTap, - tooltip: 'Search all sources', + tooltip: 'search.all_source_search'.tr(), ), - const SizedBox(width: 10), - _FilterButton(active: hasActiveFilter, onTap: onFilterTap), + if (showFilter) ...[ + const SizedBox(width: 10), + _FilterButton(active: hasActiveFilter, onTap: onFilterTap), + ], ], ), ), ], ); + final surface = Container( + decoration: BoxDecoration( + color: backgroundColor, + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.06 * progress), + ), + ), + ), + child: inner, + ); + + // An unscrolled page needs no save layer: a BackdropFilter at sigma 0 still + // allocates one, on the same frames as the debounce and the poster decode. + if (!blurred) return surface; + return ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20 * progress, sigmaY: 20 * progress), - child: Container( - decoration: BoxDecoration( - color: backgroundColor, - border: Border( - bottom: BorderSide( - color: Colors.white.withValues(alpha: 0.06 * progress), - ), - ), - ), - child: inner, - ), + child: surface, ), ); } } -class _SearchField extends StatefulWidget { +/// Only the pieces that actually change rebuild on a keystroke: the clear icon +/// listens to the controller, the border listens to the focus node, and the +/// [TextField] itself is passed through untouched. +class _SearchField extends StatelessWidget { const _SearchField({ required this.controller, required this.focus, required this.onChanged, + required this.onSubmitted, required this.onClear, }); final TextEditingController controller; final FocusNode focus; final ValueChanged onChanged; + final ValueChanged onSubmitted; final VoidCallback onClear; - @override - State<_SearchField> createState() => _SearchFieldState(); -} - -class _SearchFieldState extends State<_SearchField> { - @override - void initState() { - super.initState(); - widget.controller.addListener(_rebuild); - widget.focus.addListener(_rebuild); - } - - void _rebuild() => setState(() {}); - - @override - void dispose() { - widget.controller.removeListener(_rebuild); - widget.focus.removeListener(_rebuild); - super.dispose(); - } - @override Widget build(BuildContext context) { - final focused = widget.focus.hasFocus; + final field = TextField( + controller: controller, + focusNode: focus, + cursorRadius: const Radius.circular(14), + textInputAction: TextInputAction.search, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + height: 1, + ), + decoration: InputDecoration( + hintText: 'search.hint'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint, fontSize: 15), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.textHint, + size: 20, + ), + suffixIcon: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + const icon = Icon( + Icons.close_rounded, + color: AppColors.textHint, + size: 18, + ); + // Android TV: the clear (X) is the only way to drop a query + // without a hardware keyboard, so it needs to be a focus stop. + if (isTvPlatform) { + return TvFocusable( + onPressed: onClear, + borderRadius: 9, + child: icon, + ); + } + return GestureDetector(onTap: onClear, child: icon); + }, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: onChanged, + onSubmitted: (value) { + focus.unfocus(); + onSubmitted(value); + }, + onTapOutside: (_) => focus.unfocus(), + ); return ClipRRect( borderRadius: BorderRadius.circular(14), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - height: 46, - decoration: BoxDecoration( - color: focused - ? AppColors.surfaceVariant.withValues(alpha: 0.96) - : AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( + child: ListenableBuilder( + listenable: focus, + child: field, + builder: (context, child) { + final focused = focus.hasFocus; + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + height: 46, + // Focus reads as lit, not as outlined. A saturated red rectangle + // around a text field is louder than anything else on the screen + // and fights the dark surface it sits on; the brand colour carries + // in a soft glow instead, where it says the same thing quietly. + decoration: BoxDecoration( color: focused - ? AppColors.primary.withValues(alpha: 0.58) - : Colors.white.withValues(alpha: 0.08), - width: focused ? 1.2 : 1, - ), - ), - child: TextField( - controller: widget.controller, - focusNode: widget.focus, - cursorRadius: const Radius.circular(14), - textInputAction: TextInputAction.search, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 15, - height: 1, - ), - decoration: InputDecoration( - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(14)), + ? AppColors.surfaceVariant.withValues(alpha: 0.96) + : AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: Colors.white.withValues(alpha: focused ? 0.16 : 0.08), ), - hintText: 'search.hint'.tr(), - hintStyle: const TextStyle( - color: AppColors.textHint, - fontSize: 15, - ), - prefixIcon: const Icon( - Icons.search_rounded, - color: AppColors.textHint, - size: 20, - ), - // Android TV: the clear (X) is the only way to drop a query - // without a hardware keyboard, so it needs to be a focus stop. - // Off TV both branches below are the original GestureDetector. - suffixIcon: widget.controller.text.isEmpty - ? null - : (isTvPlatform - ? TvFocusable( - onPressed: widget.onClear, - borderRadius: 9, - child: const Icon( - Icons.close_rounded, - color: AppColors.textHint, - size: 18, - ), - ) - : GestureDetector( - onTap: widget.onClear, - child: const Icon( - Icons.close_rounded, - color: AppColors.textHint, - size: 18, - ), - )), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, + boxShadow: focused + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.22), + blurRadius: 14, + spreadRadius: -2, + ), + ] + : null, ), - onChanged: widget.onChanged, - onTapOutside: (_) => widget.focus.unfocus(), - ), - ), + child: child, + ); + }, ), ); } @@ -247,23 +258,15 @@ class _HeaderIconButton extends StatelessWidget { @override Widget build(BuildContext context) { - final content = ClipRRect( - borderRadius: BorderRadius.circular(14), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - height: 46, - width: 46, - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: Colors.white.withValues(alpha: 0.08), - ), - ), - child: Icon(icon, size: 20, color: AppColors.textSecondary), - ), + final content = Container( + height: 46, + width: 46, + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), + child: Icon(icon, size: 20, color: AppColors.textSecondary), ); // Android TV: the search header's action buttons were unreachable by the // D-pad. Off TV this is the GestureDetector that was always here. @@ -282,48 +285,42 @@ class _FilterButton extends StatelessWidget { @override Widget build(BuildContext context) { - final content = ClipRRect( - borderRadius: BorderRadius.circular(14), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - height: 46, - width: 46, - decoration: BoxDecoration( - color: active - ? AppColors.primary.withValues(alpha: 0.18) - : AppColors.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: active - ? AppColors.primary.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.08), - ), + final content = Container( + height: 46, + width: 46, + decoration: BoxDecoration( + color: active + ? AppColors.primary.withValues(alpha: 0.18) + : AppColors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active + ? AppColors.primary.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.08), + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.tune_rounded, + size: 20, + color: active ? AppColors.primary : AppColors.textSecondary, ), - child: Stack( - alignment: Alignment.center, - children: [ - Icon( - Icons.tune_rounded, - size: 20, - color: active ? AppColors.primary : AppColors.textSecondary, - ), - if (active) - Positioned( - top: 9, - right: 9, - child: Container( - width: 7, - height: 7, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - ), + if (active) + Positioned( + top: 9, + right: 9, + child: Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, ), - ], - ), - ), + ), + ), + ], ), ); diff --git a/lib/features/search/presentation/widgets/search_result_card.dart b/lib/features/search/presentation/widgets/search_result_card.dart new file mode 100644 index 0000000..8dc22b1 --- /dev/null +++ b/lib/features/search/presentation/widgets/search_result_card.dart @@ -0,0 +1,193 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/anilist/presentation/widgets/anilist_linked_badge.dart'; +import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; + +const double _posterRatio = 2 / 3; +const double _captionHeight = 48; + +/// Poster grid shared by single-source and cross-source search. +/// +/// Column count comes from the actual width, not the platform: an Android +/// tablet, an iPad and a TV are all "mobile" to [isDesktopPlatform] and used to +/// get three enormous columns. The extent is computed rather than expressed as +/// an aspect ratio so the poster keeps a true 2:3 and the caption strip is the +/// same height everywhere. +SliverGridDelegate searchGridDelegate( + BuildContext context, { + double horizontalPadding = 32, + double spacing = 10, +}) { + final width = MediaQuery.sizeOf(context).width; + final columns = searchGridColumns(width); + final tile = (width - horizontalPadding - spacing * (columns - 1)) / columns; + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: spacing, + mainAxisSpacing: 16, + mainAxisExtent: tile / _posterRatio + _captionHeight, + ); +} + +int searchGridColumns(double width) { + if (width < 420) return 3; + if (width < 620) return 4; + if (width < 900) return 5; + if (width < 1200) return 6; + return 7; +} + +double searchCardHeight(double tileWidth) => + tileWidth / _posterRatio + _captionHeight; + +class SearchResultCard extends StatelessWidget { + const SearchResultCard({ + super.key, + required this.movie, + required this.onTap, + this.provider, + this.sourceLabel, + this.sourceCount = 1, + this.width, + }); + + final MovieEntity movie; + final VoidCallback onTap; + + /// The source this hit came from — passed on to the detail page so a result + /// never opens against the app's "current" provider by accident. + final String? provider; + + /// Human-readable source name, shown when several sources are on screen. + final String? sourceLabel; + + /// How many sources carry this title. > 1 draws the merge pill. + final int sourceCount; + + final double? width; + + @override + Widget build(BuildContext context) { + final subtitle = sourceLabel ?? (movie.year != null ? '${movie.year}' : ''); + + final card = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + HomeNetworkImage( + url: movie.thumbnail, + borderRadius: BorderRadius.circular(10), + placeholderIcon: Icons.movie_rounded, + ), + Positioned( + top: 6, + left: 6, + child: AnilistLinkedBadge( + contentUrl: movie.url, + provider: provider, + ), + ), + if (movie.rating != null) + Positioned( + top: 6, + right: 6, + child: _Pill( + color: Colors.black.withValues(alpha: 0.72), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.star_rounded, + color: AppColors.rating, + size: 10, + ), + const SizedBox(width: 2), + Text( + '${movie.rating}', + style: const TextStyle( + color: Colors.white, + fontSize: 9.5, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + if (sourceCount > 1) + Positioned( + left: 6, + bottom: 6, + child: _Pill( + color: AppColors.primary.withValues(alpha: 0.92), + child: Text( + 'search.sources_n'.tr(args: ['$sourceCount']), + style: const TextStyle( + color: Colors.white, + fontSize: 9.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + Text( + movie.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + height: 1.15, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + height: 1.2, + ), + ), + ], + ); + + final tappable = HoverTap( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: card, + ); + + return width == null ? tappable : SizedBox(width: width, child: tappable); + } +} + +class _Pill extends StatelessWidget { + const _Pill({required this.color, required this.child}); + + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(5), + ), + child: child, + ); +} diff --git a/lib/features/search/presentation/widgets/search_set_sheet.dart b/lib/features/search/presentation/widgets/search_set_sheet.dart index d6a8681..bc71eb1 100644 --- a/lib/features/search/presentation/widgets/search_set_sheet.dart +++ b/lib/features/search/presentation/widgets/search_set_sheet.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -98,12 +99,13 @@ class _SearchSetSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Search sources', - style: TextStyle( + Text('search.search_sources'.tr(), + style: const TextStyle( color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.w800)), - Text('${_selected.length} selected', + Text('search.selected_n' + .tr(args: ['${_selected.length}']), style: const TextStyle( color: AppColors.textHint, fontSize: 12)), ], @@ -113,7 +115,7 @@ class _SearchSetSheetState extends State { onPressed: _selected.isEmpty ? null : () => setState(_selected.clear), - child: const Text('Clear'), + child: Text('search.clear_filter'.tr()), ), ], ), @@ -125,7 +127,7 @@ class _SearchSetSheetState extends State { onChanged: (v) => setState(() => _query = v), decoration: InputDecoration( isDense: true, - hintText: 'Filter providers…', + hintText: 'search.filter_providers'.tr(), hintStyle: const TextStyle(color: AppColors.textHint), prefixIcon: const Icon(Icons.search, color: AppColors.textHint, size: 20), @@ -149,8 +151,8 @@ class _SearchSetSheetState extends State { borderRadius: BorderRadius.circular(8), ), child: Text( - 'Searching ${_selected.length} sources may be slow. ' - 'The app stays responsive, but fewer is snappier.', + 'search.many_sources_warning' + .tr(args: ['${_selected.length}']), style: const TextStyle( color: Colors.orange, fontSize: 11.5, height: 1.3), ), @@ -214,7 +216,8 @@ class _SearchSetSheetState extends State { style: FilledButton.styleFrom( backgroundColor: AppColors.primary), onPressed: () => Navigator.of(context).pop(_selected), - child: Text('Apply (${_selected.length})'), + child: Text('search.apply_n' + .tr(args: ['${_selected.length}'])), ), ), ), diff --git a/lib/features/search/presentation/widgets/search_state_views.dart b/lib/features/search/presentation/widgets/search_state_views.dart index 34453b1..70d55c3 100644 --- a/lib/features/search/presentation/widgets/search_state_views.dart +++ b/lib/features/search/presentation/widgets/search_state_views.dart @@ -1,12 +1,14 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; import 'package:soplay/features/home/domain/entities/movie.dart'; +import 'package:soplay/features/home/presentation/widgets/home_shared_widgets.dart'; +import 'package:soplay/features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; +import 'package:soplay/features/search/presentation/widgets/search_result_card.dart'; class SearchContentView extends StatelessWidget { const SearchContentView({ @@ -16,6 +18,11 @@ class SearchContentView extends StatelessWidget { required this.topPad, required this.bottomPad, required this.onRetry, + required this.onSuggestion, + required this.onGenre, + required this.onRemoveRecent, + required this.onClearRecents, + required this.onTryAllSources, }); final SearchState state; @@ -23,119 +30,203 @@ class SearchContentView extends StatelessWidget { final double topPad; final double bottomPad; final VoidCallback onRetry; + final ValueChanged onSuggestion; + final ValueChanged onGenre; + final ValueChanged onRemoveRecent; + final VoidCallback onClearRecents; + final VoidCallback onTryAllSources; @override Widget build(BuildContext context) { - final currentState = state; - if (currentState is SearchLoaded) { - return _SearchResultsView( - state: currentState, - scrollController: scrollController, - topPad: topPad, - bottomPad: bottomPad, - ); - } - if (currentState is SearchLoading) { - return _SearchLoadingView(topPad: topPad); - } - if (currentState is SearchError) { - return _SearchErrorView(topPad: topPad, onRetry: onRetry); + return CustomScrollView( + controller: scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: topPad)), + if (state.status == SearchStatus.refreshing) + const SliverToBoxAdapter( + child: SizedBox( + height: 2, + child: LinearProgressIndicator( + minHeight: 2, + color: AppColors.primary, + backgroundColor: Colors.transparent, + ), + ), + ), + ..._body(context), + SliverToBoxAdapter(child: SizedBox(height: bottomPad + 90)), + ], + ); + } + + List _body(BuildContext context) { + switch (state.status) { + case SearchStatus.loading: + return [const _SearchSkeletonGrid()]; + case SearchStatus.error: + return [ + SliverFillRemaining( + hasScrollBody: false, + child: _SearchErrorView( + kind: state.errorKind, + message: state.errorMessage, + onRetry: onRetry, + ), + ), + ]; + case SearchStatus.empty: + return [ + SliverFillRemaining( + hasScrollBody: false, + child: _SearchEmptyView( + criteria: state.criteria, + onTryAllSources: onTryAllSources, + ), + ), + ]; + case SearchStatus.idle: + return [ + SliverToBoxAdapter( + child: _SearchIdleView( + recent: state.recent, + genres: state.genres, + genresLoading: state.genresLoading, + onSuggestion: onSuggestion, + onGenre: onGenre, + onRemoveRecent: onRemoveRecent, + onClearRecents: onClearRecents, + ), + ), + ]; + case SearchStatus.loaded: + case SearchStatus.refreshing: + return [ + SearchResultsGrid(items: state.items), + if (state.isLoadingMore) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center( + child: CircularProgressIndicator( + color: AppColors.primary, + strokeWidth: 2, + ), + ), + ), + ), + ]; } - return _SearchPlaceholder(topPad: topPad); } } -class _SearchPlaceholder extends StatelessWidget { - const _SearchPlaceholder({required this.topPad}); +/// The one results grid for the feature. Cross-search's merged view uses it too. +class SearchResultsGrid extends StatelessWidget { + const SearchResultsGrid({super.key, required this.items}); - final double topPad; + final List items; @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: topPad), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.search_rounded, - color: AppColors.textHint.withValues(alpha: 0.45), - size: 68, - ), - const SizedBox(height: 16), - Text( - 'search.hint'.tr(), - style: const TextStyle( - color: AppColors.textHint, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ], + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, i) { + final movie = items[i]; + return SearchResultCard( + movie: movie, + // Results can carry a provider of their own — opening them + // against the app's "current" provider is how a result that + // looked fine in the grid failed to load its detail page. + provider: movie.provider.isEmpty ? null : movie.provider, + onTap: () { + if (movie.url.isEmpty) return; + context.push( + '/detail', + extra: DetailArgs( + contentUrl: movie.url, + preview: movie, + provider: movie.provider.isEmpty ? null : movie.provider, + ), + ); + }, + ); + }, + childCount: items.length, ), + gridDelegate: searchGridDelegate(context), ), ); } } -class _SearchLoadingView extends StatelessWidget { - const _SearchLoadingView({required this.topPad}); - - final double topPad; +class _SearchSkeletonGrid extends StatelessWidget { + const _SearchSkeletonGrid(); @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: topPad), - child: const Center( - child: CircularProgressIndicator( - color: AppColors.primary, - strokeWidth: 2, + final width = MediaQuery.sizeOf(context).width; + final columns = searchGridColumns(width); + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, i) => const ShimmerWrapper( + child: HomeSkeletonBox( + width: double.infinity, + height: double.infinity, + radius: 10, + ), + ), + childCount: columns * 3, ), + gridDelegate: searchGridDelegate(context), ), ); } } -class _SearchResultsView extends StatelessWidget { - const _SearchResultsView({ - required this.state, - required this.scrollController, - required this.topPad, - required this.bottomPad, +class _SearchIdleView extends StatelessWidget { + const _SearchIdleView({ + required this.recent, + required this.genres, + required this.genresLoading, + required this.onSuggestion, + required this.onGenre, + required this.onRemoveRecent, + required this.onClearRecents, }); - final SearchLoaded state; - final ScrollController scrollController; - final double topPad; - final double bottomPad; + final List recent; + final List genres; + final bool genresLoading; + final ValueChanged onSuggestion; + final ValueChanged onGenre; + final ValueChanged onRemoveRecent; + final VoidCallback onClearRecents; @override Widget build(BuildContext context) { - if (state.items.isEmpty) { + if (recent.isEmpty && genres.isEmpty) { return Padding( - padding: EdgeInsets.only(top: topPad), + padding: const EdgeInsets.only(top: 80), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon( - Icons.search_off_rounded, - color: AppColors.textHint, - size: 52, + Icon( + Icons.search_rounded, + color: AppColors.textHint.withValues(alpha: 0.45), + size: 68, ), - const SizedBox(height: 14), + const SizedBox(height: 16), Text( - 'search.no_results_for'.tr( - namedArgs: { - 'query': state.query.isNotEmpty ? state.query : state.genre, - }, - ), + 'search.hint'.tr(), style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 15, + color: AppColors.textHint, + fontSize: 16, + fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), @@ -145,213 +236,330 @@ class _SearchResultsView extends StatelessWidget { ); } - return CustomScrollView( - controller: scrollController, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - SliverPadding( - padding: EdgeInsets.fromLTRB(16, topPad + 8, 16, 8), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, i) => _SearchMovieCard(movie: state.items[i]), - childCount: state.items.length, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (recent.isNotEmpty) ...[ + Row( + children: [ + Expanded(child: _SectionTitle('search.recent'.tr())), + _TextAction( + label: 'search.clear_filter'.tr(), + onTap: onClearRecents, + ), + ], ), - gridDelegate: responsiveGridDelegate( - mobileCrossAxisCount: 3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.62, + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final q in recent) + _Chip( + label: q, + icon: Icons.history_rounded, + onTap: () => onSuggestion(q), + onRemove: () => onRemoveRecent(q), + ), + ], ), - ), - ), - if (state.isLoadingMore) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 20), - child: Center( - child: CircularProgressIndicator( - color: AppColors.primary, - strokeWidth: 2, - ), + const SizedBox(height: 26), + ], + if (genres.isNotEmpty) ...[ + _SectionTitle('search.categories'.tr()), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final g in genres) + _Chip( + label: g.name.isNotEmpty ? g.name : g.slug, + onTap: () => onGenre(g.slug), + ), + ], + ), + ] else if (genresLoading) + const ShimmerWrapper( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + HomeSkeletonBox(width: 92, height: 34, radius: 10), + HomeSkeletonBox(width: 68, height: 34, radius: 10), + HomeSkeletonBox(width: 110, height: 34, radius: 10), + HomeSkeletonBox(width: 80, height: 34, radius: 10), + ], ), ), - ), - SliverToBoxAdapter(child: SizedBox(height: bottomPad + 90)), - ], + ], + ), ); } } -class _SearchMovieCard extends StatelessWidget { - const _SearchMovieCard({required this.movie}); +class _SearchEmptyView extends StatelessWidget { + const _SearchEmptyView({ + required this.criteria, + required this.onTryAllSources, + }); - final MovieEntity movie; + final SearchCriteria criteria; + final VoidCallback onTryAllSources; @override Widget build(BuildContext context) { - return HoverTap( - behavior: HitTestBehavior.opaque, - onTap: () { - if (movie.url.isEmpty) return; - context.push( - '/detail', - extra: DetailArgs(contentUrl: movie.url, preview: movie), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, + return Padding( + padding: const EdgeInsets.fromLTRB(32, 60, 32, 32), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - movie.thumbnail != null - ? Image.network( - movie.thumbnail!, - fit: BoxFit.cover, - errorBuilder: (context, error, stack) => _placeholder(), - ) - : _placeholder(), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Colors.black.withValues(alpha: 0.85), - Colors.transparent, - ], - ), - ), - padding: const EdgeInsets.fromLTRB(6, 22, 6, 6), - child: Text( - movie.title, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600, - height: 1.2, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), + const Icon( + Icons.search_off_rounded, + color: AppColors.textHint, + size: 52, ), - if (movie.rating != null) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star_rounded, - color: AppColors.rating, - size: 9, - ), - const SizedBox(width: 2), - Text( - '${movie.rating}', - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), + const SizedBox(height: 14), + Text( + 'search.no_results_for'.tr(namedArgs: {'query': criteria.label}), + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), + textAlign: TextAlign.center, + ), + if (criteria.text.isNotEmpty) ...[ + const SizedBox(height: 20), + _ActionChip( + icon: Icons.travel_explore_rounded, + label: 'search.try_all_sources'.tr(), + onTap: onTryAllSources, ), + ], ], ), - ), ); } +} + +class _SearchErrorView extends StatelessWidget { + const _SearchErrorView({ + required this.kind, + required this.message, + required this.onRetry, + }); + + final SearchFailureKind kind; + final String message; + final VoidCallback onRetry; - Widget _placeholder() { - return Container( - color: AppColors.surfaceVariant, - child: const Icon( - Icons.movie_rounded, - color: AppColors.textHint, - size: 32, + @override + Widget build(BuildContext context) { + final (icon, title) = switch (kind) { + SearchFailureKind.network => ( + Icons.wifi_off_rounded, + 'errors.network'.tr(), + ), + SearchFailureKind.source => ( + Icons.extension_off_rounded, + 'search.source_failed'.tr(), + ), + SearchFailureKind.unknown => ( + Icons.error_outline_rounded, + 'search.search_failed'.tr(), + ), + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(32, 60, 32, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.textHint, size: 52), + const SizedBox(height: 14), + Text( + title, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 15), + textAlign: TextAlign.center, + ), + if (message.isNotEmpty && kind != SearchFailureKind.network) ...[ + const SizedBox(height: 8), + Text( + message, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 12.5), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 20), + _ActionChip( + icon: Icons.refresh_rounded, + label: 'general.retry'.tr(), + onTap: onRetry, + autofocus: true, + ), + ], ), ); } } -class _SearchErrorView extends StatelessWidget { - const _SearchErrorView({required this.topPad, required this.onRetry}); +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); - final double topPad; - final VoidCallback onRetry; + final String text; + + @override + Widget build(BuildContext context) => Text( + text.toUpperCase(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ); +} + +class _TextAction extends StatelessWidget { + const _TextAction({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final retryChip = Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), - ), + final child = Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: Text( - 'general.retry'.tr(), + label, style: const TextStyle( - color: AppColors.primary, - fontSize: 14, + color: AppColors.textSecondary, + fontSize: 12.5, fontWeight: FontWeight.w600, ), ), ); + if (isTvPlatform) { + return TvFocusable(onPressed: onTap, borderRadius: 8, child: child); + } + return GestureDetector(onTap: onTap, child: child); + } +} - return Padding( - padding: EdgeInsets.only(top: topPad), - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.wifi_off_rounded, - color: AppColors.textHint, - size: 52, +class _Chip extends StatelessWidget { + const _Chip({ + required this.label, + required this.onTap, + this.icon, + this.onRemove, + }); + + final String label; + final VoidCallback onTap; + final IconData? icon; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final chip = Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.surfaceVariant.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: AppColors.textHint), + const SizedBox(width: 6), + ], + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + fontWeight: FontWeight.w500, ), - const SizedBox(height: 14), - Text( - 'errors.network'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 15, - ), - textAlign: TextAlign.center, + ), + ), + if (onRemove != null) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: onRemove, + child: const Icon( + Icons.close_rounded, + size: 13, + color: AppColors.textHint, ), - const SizedBox(height: 20), - // Android TV: Retry is the only control on this screen; on a bare - // GestureDetector a network blip left the remote with no way out. - // Off TV the else branch is the original GestureDetector. - if (isTvPlatform) - TvFocusable( - onPressed: onRetry, - borderRadius: 10, - autofocus: true, - child: retryChip, - ) - else - GestureDetector(onTap: onRetry, child: retryChip), - ], + ), + ], + ], + ), + ); + + if (isTvPlatform) { + return TvFocusable(onPressed: onTap, borderRadius: 10, child: chip); + } + return GestureDetector(onTap: onTap, child: chip); + } +} + +class _ActionChip extends StatelessWidget { + const _ActionChip({ + required this.icon, + required this.label, + required this.onTap, + this.autofocus = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool autofocus; + + @override + Widget build(BuildContext context) { + final content = Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + color: AppColors.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), - ), + ], ), ); + + // Android TV: this is often the only control on the screen, so it has to be + // a focus stop or the remote has no way out. + if (isTvPlatform) { + return TvFocusable( + onPressed: onTap, + borderRadius: 10, + autofocus: autofocus, + child: content, + ); + } + return GestureDetector(onTap: onTap, child: content); } } diff --git a/lib/features/tracker/presentation/pages/following_page.dart b/lib/features/tracker/presentation/pages/following_page.dart index cc65530..3266ad1 100644 --- a/lib/features/tracker/presentation/pages/following_page.dart +++ b/lib/features/tracker/presentation/pages/following_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_tab_bar.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/anilist/presentation/controllers/anilist_library_controller.dart'; import 'package:soplay/features/anilist/presentation/pages/anilist_library_page.dart'; @@ -65,35 +66,13 @@ class _FollowingPageState extends State icon: const Icon(Icons.link_rounded), ), ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(46), - child: Container( - alignment: Alignment.centerLeft, - decoration: const BoxDecoration( - border: - Border(bottom: BorderSide(color: AppColors.divider, width: 0.5)), - ), - child: TabBar( - controller: _tabs, - isScrollable: true, - tabAlignment: TabAlignment.start, - dividerColor: Colors.transparent, - indicatorColor: AppColors.primary, - indicatorSize: TabBarIndicatorSize.label, - indicatorWeight: 2.5, - labelColor: AppColors.textPrimary, - unselectedLabelColor: AppColors.textSecondary, - labelStyle: - const TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800), - unselectedLabelStyle: - const TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600), - tabs: [ - Tab(height: 44, text: 'tracker.tab_following'.tr()), - Tab(height: 44, text: 'tracker.tab_upcoming'.tr()), - const Tab(height: 44, text: 'AniList'), - ], - ), - ), + bottom: AppTabBar( + controller: _tabs, + labels: [ + 'tracker.tab_following'.tr(), + 'tracker.tab_upcoming'.tr(), + 'AniList', + ], ), ), body: TabBarView( diff --git a/lib/features/user_lists/presentation/pages/user_lists_page.dart b/lib/features/user_lists/presentation/pages/user_lists_page.dart index 2bb3a14..7f89ba2 100644 --- a/lib/features/user_lists/presentation/pages/user_lists_page.dart +++ b/lib/features/user_lists/presentation/pages/user_lists_page.dart @@ -46,10 +46,18 @@ class _UserListsPageState extends State backgroundColor: AppColors.background, appBar: AppBar( backgroundColor: AppColors.background, - title: const Text('My Lists'), + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + titleSpacing: 16, + title: const Text( + 'My Lists', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800), + ), bottom: AppTabBar( - controller: _tabs, + // Two tabs: split the bar rather than hugging the left edge. isScrollable: false, + controller: _tabs, labels: [for (final k in _kinds) k.label], ), ), diff --git a/test/features/detail/video_option_groups_test.dart b/test/features/detail/video_option_groups_test.dart new file mode 100644 index 0000000..8205701 --- /dev/null +++ b/test/features/detail/video_option_groups_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/video_option_groups.dart'; + +void main() { + group('server and quality are told apart by shape, not by order', () { + test('host first', () { + expect(VideoOptionGroups.serverOf('SubsPlease · 1080p'), 'SubsPlease'); + expect(VideoOptionGroups.qualityOf('SubsPlease · 1080p'), '1080p'); + }); + + test('quality first — the case that shipped backwards', () { + // The settings sheet showed Server "480p" and Quality "1-server". + expect(VideoOptionGroups.serverOf('480p · 1-server'), '1-server'); + expect(VideoOptionGroups.qualityOf('480p · 1-server'), '480p'); + }); + + test('space separated, either order', () { + expect(VideoOptionGroups.serverOf('Vidstream 720p'), 'Vidstream'); + expect(VideoOptionGroups.qualityOf('Vidstream 720p'), '720p'); + expect(VideoOptionGroups.serverOf('1080p Doodstream'), 'Doodstream'); + expect(VideoOptionGroups.qualityOf('1080p Doodstream'), '1080p'); + }); + + test('no resolution at all means the whole label is a host', () { + expect(VideoOptionGroups.serverOf('Server 1'), 'Server 1'); + expect(VideoOptionGroups.qualityOf('Server 1'), ''); + expect(VideoOptionGroups.serverOf('SUB · Mp4Upload'), 'SUB · Mp4Upload'); + }); + + test('an empty label still groups somewhere', () { + expect(VideoOptionGroups.serverOf(''), 'Default'); + expect(VideoOptionGroups.serverOf(' '), 'Default'); + }); + }); + + group('grouping', () { + final labels = [ + 'Vidstream · 1080p', + 'Vidstream · 720p', + '480p · Doodstream', + 'Doodstream · 1080p', + ]; + + test('servers are listed once, in the order they appear', () { + expect(VideoOptionGroups.servers(labels), ['Vidstream', 'Doodstream']); + }); + + test('a host collects its entries whichever way its labels are written', () { + // Doodstream appears once with the quality first and once with it last. + expect(VideoOptionGroups.indicesFor(labels, 'Doodstream'), [2, 3]); + expect(VideoOptionGroups.qualitiesFor(labels, 'Doodstream'), ['480p', '1080p']); + }); + }); + + group('switching host', () { + final labels = ['A · 720p', 'A · 1080p', 'B · 360p', 'B · 720p', 'B · 1080p']; + + test('keeps the resolution you were on', () { + expect(VideoOptionGroups.switchTo(labels, 0, 'B'), 3); + }); + + test('falls back to the host\'s best, not its first', () { + // Arbitrary provider ordering must not drop someone from 1080p to 360p. + expect(VideoOptionGroups.switchTo(labels, 1, 'B'), 4); + }); + + test('an unknown host leaves the selection alone', () { + expect(VideoOptionGroups.switchTo(labels, 1, 'C'), 1); + }); + }); +}