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 @@
-
+
\ 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