mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
Merge branch 'pr/232' into dev
This commit is contained in:
commit
55a818dd0a
24 changed files with 1999 additions and 301 deletions
28
README.md
28
README.md
|
|
@ -53,15 +53,31 @@ You can download Sora using Xcode or using the .ipa file, which you can find in
|
|||
|
||||
## Acknowledgements
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Frameworks:
|
||||
- [Drops](https://github.com/omaralbeik/Drops) - MIT License
|
||||
- [NukeUI](https://github.com/kean/NukeUI) - MIT License
|
||||
- [SoraCore](https://github.com/cranci1/SoraCore) - Custom License
|
||||
- [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) - MIT License
|
||||
- [Drops](https://github.com/omaralbeik/Drops) – MIT License
|
||||
- [NukeUI](https://github.com/kean/NukeUI) – MIT License
|
||||
- [SoraCore](https://github.com/cranci1/SoraCore) – Custom License
|
||||
- [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) – MIT License
|
||||
|
||||
Misc:
|
||||
- [50/50](https://github.com/50n50) for the app icon
|
||||
- [Ciro](https://github.com/CiroHoodLove) for the episodes banners
|
||||
- [50/50](https://github.com/50n50) for the app icon
|
||||
- [Ciro](https://github.com/CiroHoodLove) for the episode banners
|
||||
|
||||
Translators:
|
||||
- [paul](https://github.com/50n50) – Dutch
|
||||
- Utopia – Bosnian
|
||||
- simplymox – Italian
|
||||
- [ibro](https://github.com/xibrox) – Russian, Czech, Kazakh
|
||||
- [Ciro](https://github.com/CiroHoodLove) – Arabic, French
|
||||
- [storm](https://github.com/stormfjeld) – Norwegian, Swedish
|
||||
- VastSector0 – Spanish
|
||||
- [Seiike](https://github.com/Seeike) – Slovak
|
||||
- [Cufiy](https://github.com/JMcrafter26) – German
|
||||
- yoshi1780 – Mongolian
|
||||
- Perju – Romanian
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<string>kk</string>
|
||||
<string>mn</string>
|
||||
<string>nn</string>
|
||||
<string>ru</string>
|
||||
<string>ro</string>
|
||||
<string>sk</string>
|
||||
<string>es</string>
|
||||
<string>sv</string>
|
||||
|
|
|
|||
467
Sora/Localization/mn-Cyrl.lproj/Localizable.strings
Normal file
467
Sora/Localization/mn-Cyrl.lproj/Localizable.strings
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
/* General */
|
||||
"About" = "Бидний тухайд";
|
||||
"About Sora" = "Sora аппын тухай";
|
||||
"Active" = "Идэвхтэй";
|
||||
"Active Downloads" = "Татаж байна";
|
||||
"Actively downloading media can be tracked from here." = "Татаж байгаа үзвэрүүдийг эндээс харж болно";
|
||||
"Add Module" = "Модул нэмэх";
|
||||
"Adjust the number of media items per row in portrait and landscape modes." = "Хэвтээ болон босоо загварын нэг мөрөн харуулах үзвэрийн тоо";
|
||||
"Advanced" = "Нарийн тохиргоо";
|
||||
"AKA Sulfur" = "өөрөөр Sulfur";
|
||||
"All Bookmarks" = "Бүх хадгалсан үзвэрүүд";
|
||||
"All Watching" = "Үзэж буй үзвэрүүд";
|
||||
"Also known as Sulfur" = "өөрөөр Sulfur гэж нэрлэдэг";
|
||||
"AniList" = "AniList";
|
||||
"AniList ID" = "AniList ХД";
|
||||
"AniList Match" = "AniList тохирол";
|
||||
"AniList.co" = "AniList.co";
|
||||
"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Аппыг сайжруулах зорилгоор мэдээллийг нууцалж цуглуулдаг. Таны хувийн мэдээллийг цуглуулдаггүй болно. Мөн хүссэн үедээ мэдээлэл цуглуулахыг цуцалж болно.";
|
||||
"App Info" = "Аппын мэдээлэл ";
|
||||
"App Language" = "Хэл";
|
||||
"App Storage" = "Багтаамж";
|
||||
"Appearance" = "Харагдах байдал";
|
||||
|
||||
/* Alerts and Actions */
|
||||
"Are you sure you want to clear all cached data? This will help free up storage space." = "Та хадгалагдсан өгөгдлийн устгахдаа итгэлтэй байна уу? Устгасан тохиолдолд багтаамж чөлөөлөгдөнө.";
|
||||
"Are you sure you want to delete '%@'?" = "Та '%@' үзвэрийг устгахдаа итгэлтэй байна уу?";
|
||||
"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Та '%2$@' үзвэрийн %1$d ангиудыг устгахдаа итгэлтэй байна уу?";
|
||||
"Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." = "Та бүх татаж авсан үзвэрийг устгахдаа итгэлтэй байна уу? Зөвхөн сангаа цэврэлсэнээр, татаж авсан үзвэрүүдээ устгахгүй байж болно.";
|
||||
"Are you sure you want to erase all app data? This action cannot be undone." = "Та аппын бүх өгөгдлийг утгахдаа итгэлтэй байна уу? Энэ үйлдлийг буцаах боломжгүй.";
|
||||
|
||||
/* Features */
|
||||
"Background Enabled" = "Аппыг идэвхгүй үед татах";
|
||||
"Bookmark items for an easier access later." = "Үзвэрийг хадгалсанаар дараа нь олоход хялбар болно";
|
||||
"Bookmarks" = "Хадгалсан үзвэр";
|
||||
"Bottom Padding" = "Доод зай";
|
||||
"Cancel" = "Цуцлах";
|
||||
"Cellular Quality" = "Утасны дата бичлэгийн чанар";
|
||||
"Check out some community modules here!" = "Илүү олон модулиудыг эндээс олоорой!";
|
||||
"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." = "Өөрийн WiFi болон утасны датанд тааруулж бичлэгийн чанарыг сонгоорой. Өндөр чанартай бичлэг нь илүү их дата ашиглана. Хэрэв таны сонгосон бичлэгийн чанар байхгүй бол хамгийн ойролцоо чанарыг сонгож тоглуулна.\n\nТэмдэглэл: Бүх үзвэрүүд болон бичлэг тоглуулагч нь чанар сонгох үйлдэлгүй байдаг. Бичлэгийн чанар сонгох үйлдлийг HLS төрлийн үзвэрийг Sora тоглуулагч ашиглан үзэж байгаа тохиолдолд ашиглахад хамгийн тохиромжтой байдаг.";
|
||||
"Clear" = "Устгах";
|
||||
"Clear All Downloads" = "Бүх таталтыг устгах";
|
||||
"Clear Cache" = "Кэш цэвэрлэх";
|
||||
"Clear Library Only" = "Зөвхөн санг цэвэрлэх";
|
||||
"Clear Logs" = "Лог цэвэрлэх";
|
||||
"Click the plus button to add a module!" = "Нэмэх тэмдэг дээр дарж шинэ модуль нэмнэ үү!";
|
||||
"Continue Watching" = "Үргэлжлүүлж үзэх";
|
||||
"Continue Watching Episode %d" = "%d ангийг үргэлжлүүлж үзэх";
|
||||
"Contributors" = "Хувь нэмэр оруулсан";
|
||||
"Copied to Clipboard" = "Хуулсан";
|
||||
"Copy to Clipboard" = "Хуулсан";
|
||||
"Copy URL" = "Холбоосыг хуулах";
|
||||
|
||||
/* Episodes */
|
||||
"%lld Episodes" = "%lld анги";
|
||||
"%lld of %lld" = "%lld-ийн %lld";
|
||||
"%lld-%lld" = "%lld-%lld";
|
||||
"%lld%% seen" = "%lld%% үзсэн";
|
||||
"Episode %lld" = "%lld-р анги";
|
||||
"Episodes" = "Ангиуд";
|
||||
"Episodes might not be available yet or there could be an issue with the source." = " ";
|
||||
"Episodes Range" = " ";
|
||||
|
||||
/* System */
|
||||
"cranci1" = "cranci1";
|
||||
"Dark" = "Хар";
|
||||
"DATA & LOGS" = "Өгөгдөл ба лог";
|
||||
"Debug" = "Алдаа илрүүлэх";
|
||||
"Debugging and troubleshooting." = "Алдааг илрүүлэх ба асуудал олох";
|
||||
|
||||
/* Actions */
|
||||
"Delete" = "Устгах";
|
||||
"Delete All" = "Бүгдийг устгах";
|
||||
"Delete All Downloads" = "Бүх таталтыг устгах";
|
||||
"Delete All Episodes" = "Бүх ангийг устгах";
|
||||
"Delete Download" = "Таталт устгах";
|
||||
"Delete Episode" = "Анги устгах";
|
||||
|
||||
/* Player */
|
||||
"Double Tap to Seek" = "Хоёр дарж гүйлгэх";
|
||||
"Double tapping the screen on it's sides will skip with the short tap setting." = "Дэлгэцийн хоёр талд хоёр удаа хурдан дарвал богино хугацаагаар бичлэгийг гүйлгэнэ.";
|
||||
|
||||
/* Downloads */
|
||||
"Download" = "Татах";
|
||||
"Download Episode" = "Анги татах";
|
||||
"Download Summary" = "Таталтын түүх";
|
||||
"Download This Episode" = "Энэ ангийг татах";
|
||||
"Downloaded" = "Татсан";
|
||||
"Downloaded Shows" = "Татсан үзвэрүүд";
|
||||
"Downloading" = "Татаж байна";
|
||||
"Downloads" = "Таталтууд";
|
||||
|
||||
/* Settings */
|
||||
"Enable Analytics" = "Аналитик ажилуулах";
|
||||
"Enable Subtitles" = "Хадмал харуулах";
|
||||
|
||||
/* Data Management */
|
||||
"Erase" = "Арилгах";
|
||||
"Erase all App Data" = "Аппын бүх өгөгдлийг арилгах";
|
||||
"Erase App Data" = "Аппын өгөгдлийг арилгах";
|
||||
|
||||
/* Errors */
|
||||
"Error" = "Алдаа";
|
||||
"Error Fetching Results" = "Илэрцийг олоход гарсан алдаа";
|
||||
"Errors and critical issues." = "Алдаанууд болон ноцтой асуудлууд";
|
||||
"Failed to load contributors" = "Контрибуторуудыг ачааллаж чадсангүй";
|
||||
|
||||
/* Features */
|
||||
"Fetch Episode metadata" = "Ангийн мета мэдээллийг татах";
|
||||
"Files Downloaded" = "Татаж авсан файлууд";
|
||||
"Font Size" = "Үсгийн хэмжээ";
|
||||
|
||||
/* Interface */
|
||||
"Force Landscape" = "Байнга хэвтээ байлгах";
|
||||
"General" = "Ерөнхий";
|
||||
"General events and activities." = "Ерөнхий эвэнт ба үйл ажиллагаанууд";
|
||||
"General Preferences" = "Ерөнхий тохиргоо";
|
||||
"Hide Splash Screen" = "Эхлэлийн дэлгэцийг нуух";
|
||||
"HLS video downloading." = "HLS бичлэг таталт";
|
||||
"Hold Speed" = "Дарах хурд";
|
||||
|
||||
/* Info */
|
||||
"Info" = "Мэдээлэл";
|
||||
"INFOS" = "МЭДЭЭЛЛҮҮД";
|
||||
"Installed Modules" = "Суулгасан модулиуд";
|
||||
"Interface" = "Харилцах хэсэг";
|
||||
|
||||
/* Social */
|
||||
"Join the Discord" = "Дискорд сувагт нэгдэх";
|
||||
|
||||
/* Layout */
|
||||
"Landscape Columns" = "Хэвтээ багана";
|
||||
"Language" = "Хэл";
|
||||
"LESS" = "БАГАСГАХ";
|
||||
|
||||
/* Library */
|
||||
"Library" = "Сан";
|
||||
"License (GPLv3.0)" = "Лиценз (GPLх3.0)";
|
||||
"Light" = "Цагаан";
|
||||
|
||||
/* Loading States */
|
||||
"Loading Episode %lld..." = "%lld-р ангийг ачаалж байна...";
|
||||
"Loading logs..." = "Логийг ачаалж байна...";
|
||||
"Loading module information..." = "Модулийн мэдээллийг ачаалж байна...";
|
||||
"Loading Stream" = "Үзвэрийг ачаалж байна";
|
||||
|
||||
/* Logging */
|
||||
"Log Debug Info" = "Дибаг мэдээллийг бичих";
|
||||
"Log Filters" = "Лог шүүлтүүрүүд";
|
||||
"Log In with AniList" = "AniList-ээр нэвтрэх";
|
||||
"Log In with Trakt" = "Trakt-аар нэвтрэх";
|
||||
"Log Out from AniList" = "AniList-ээс гарах";
|
||||
"Log Out from Trakt" = "Trakt-аас гарах";
|
||||
"Log Types" = "Логийн төрлүүд";
|
||||
"Logged in as" = "Нэвтэрсэн байна";
|
||||
"Logged in as " = " нэвтэрсэн байна";
|
||||
|
||||
/* Logs and Settings */
|
||||
"Logs" = "Логууд";
|
||||
"Long press Skip" = "Удаан дарж алгасах";
|
||||
"MAIN" = "ҮНДСЭН";
|
||||
"Main Developer" = "Үндсэн Хөгжүүлэгч";
|
||||
"MAIN SETTINGS" = "ҮНДСЭН ТОХИРГООНУУД";
|
||||
|
||||
/* Media Actions */
|
||||
"Mark All Previous Watched" = "Өмнөх бүгдийг үзсэнээр тэмдэглэх";
|
||||
"Mark as Watched" = "Үзсэнээр тэмдэглэх";
|
||||
"Mark Episode as Watched" = "Ангийг үзсэнээр тэмдэглэх";
|
||||
"Mark Previous Episodes as Watched" = "Өмнөх бүх ангийг үзсэнээр тэмдэглэх";
|
||||
"Mark watched" = "Үзсэнийг тэмдэглэх";
|
||||
"Match with AniList" = "Anilist-тэй тааруулах";
|
||||
"Match with TMDB" = "TMDB-тэй тааруулах";
|
||||
"Matched ID: %lld" = "Тааруулсан ХД: %lld";
|
||||
"Matched with: %@" = "Тааруулсан: %@";
|
||||
"Max Concurrent Downloads" = "Зэрэг татах дээд хэмжээ";
|
||||
|
||||
/* Media Interface */
|
||||
"Media Grid Layout" = "Медиа грид байршил";
|
||||
"Media Player" = "Медиа тоглуулагч";
|
||||
"Media View" = "Медиа харагдац";
|
||||
"Metadata Provider" = "Нэмэлт мэдээлэл нийлүүлэгч";
|
||||
"Metadata Providers Order" = "Нэмэлт мэдээлэл нийлүүлэгчид";
|
||||
"Module Removed" = "Модуль устсан";
|
||||
"Modules" = "Модулиуд";
|
||||
|
||||
/* Headers */
|
||||
"MODULES" = "МОДУЛИУД";
|
||||
"MORE" = "ИЛҮҮ";
|
||||
|
||||
/* Status Messages */
|
||||
"No Active Downloads" = "Идэвхтэй таталт байхгүй байна";
|
||||
"No AniList matches found" = "Anilist дээр олдсонгүй";
|
||||
"No Data Available" = "Мэдээлэл байхгүй байна";
|
||||
"No Downloads" = "Таталт байхгүй байна";
|
||||
"No episodes available" = "Анги олдсонгүй";
|
||||
"No Episodes Available" = "Анги Олдсонгүй";
|
||||
"No items to continue watching." = "Үргэлүүлж үзэх зүйл байхгүй";
|
||||
"No matches found" = "Илэрц олдсонгүй";
|
||||
"No Module Selected" = "Модуль сонгоогүй байна";
|
||||
"No Modules" = "Модуль байгүй";
|
||||
"No Results Found" = "Хайлт олдсонгүй";
|
||||
"No Search Results Found" = "Хайлтын Үр Дүн Олдсонгүй";
|
||||
"Nothing to Continue Watching" = "Үргэлжлүүлж Үзэх Зүйл Байхгүй";
|
||||
|
||||
/* Notes and Messages */
|
||||
"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Модулийн JSON файл доторх хувилбарийн нэр өөрчлөгдсөн тохиолдолд л модуль шинэчлэгдэнэ.";
|
||||
|
||||
/* Actions */
|
||||
"OK" = "ЗА";
|
||||
"Open Community Library" = "Нийтлэг Санг Нээх";
|
||||
|
||||
/* External Services */
|
||||
"Open in AniList" = "Anilist дотор нээх";
|
||||
"Original Poster" = "Жинхэнэ Постлогч";
|
||||
|
||||
/* Playback */
|
||||
"Paused" = "Зогсоосон";
|
||||
"Play" = "Тоглуулах";
|
||||
"Player" = "Тоглуулагч";
|
||||
|
||||
/* System Messages */
|
||||
"Please restart the app to apply the language change." = "Аппаас гарч дахин орсноор хэл солигдоно";
|
||||
"Please select a module from settings" = "Тохиргооны хэсгээс модуль сонгоно уу";
|
||||
|
||||
/* Interface */
|
||||
"Portrait Columns" = "Босоо Баганууд";
|
||||
"Progress bar Marker Color" = "Явцын зурвасын тэмдэглэгээний өнгө";
|
||||
"Provider: %@" = "Нийлүүлэгч: %@";
|
||||
|
||||
/* Queue */
|
||||
"Queue" = "Дараалал";
|
||||
"Queued" = "Хүлээлтэд орсон";
|
||||
|
||||
/* Content */
|
||||
"Recently watched content will appear here." = "Сүүлд үзсэн үзвэрүүд энд харагдана";
|
||||
|
||||
/* Settings */
|
||||
"Refresh Modules on Launch" = "Апп нээгдэх болгонд модуль шинэчлэх";
|
||||
"Refresh Storage Info" = "Багтаамжийн мэдээллийг шинэчлэх";
|
||||
"Remember Playback speed" = "Тоглуулах хурдыг сануулах";
|
||||
|
||||
/* Actions */
|
||||
"Remove" = "Устгах";
|
||||
"Remove All Cache" = "Бүх Кэшийг Устгах";
|
||||
|
||||
/* File Management */
|
||||
"Remove All Documents" = "Бүх мэдээлийг устгах";
|
||||
"Remove Documents" = "Мэдээллийг Устгах";
|
||||
"Remove Downloaded Media" = "Татаж авсан үзвэрийг устгах";
|
||||
"Remove Downloads" = "Таталтуудыг Устгах";
|
||||
"Remove from Bookmarks" = "Хадгалахаа болих";
|
||||
"Remove Item" = "Анги Устгах";
|
||||
|
||||
/* Support */
|
||||
"Report an Issue" = "Алдаа мэдээлэх";
|
||||
|
||||
/* Reset Options */
|
||||
"Reset" = "Анхны төлөвт оруулах";
|
||||
"Reset AniList ID" = "AniList ХД анхны төлөвт оруулах";
|
||||
"Reset Episode Progress" = "Эхнээс нь үзэх";
|
||||
"Reset progress" = "Анхны төлөвт оруулах явц";
|
||||
"Reset Progress" = "Явцыг ахний төлөвт оруулах";
|
||||
|
||||
/* System */
|
||||
"Restart Required" = "Дахин ачааллах шаардлагатай";
|
||||
"Running Sora %@ - cranci1" = "Sora %@ ачаалж байна - cranci1";
|
||||
|
||||
/* Actions */
|
||||
"Save" = "Хадгалах";
|
||||
"Search" = "Хайа";
|
||||
|
||||
/* Search */
|
||||
"Search downloads" = "Татсан үзвэр хайх";
|
||||
"Search for something..." = "Үзвэр хайх...";
|
||||
"Search..." = "Хайх...";
|
||||
|
||||
/* Content */
|
||||
"Season %d" = "%d-р Улирал";
|
||||
"Season %lld" = "%lld-р Улирал";
|
||||
"Segments Color" = "Ерөнхий өнгө";
|
||||
|
||||
/* Modules */
|
||||
"Select Module" = "Модуль сонгох";
|
||||
"Set Custom AniList ID" = "AniList ХД харуулах";
|
||||
|
||||
/* Interface */
|
||||
"Settings" = "Тохиргоо";
|
||||
"Shadow" = "Сүүдэр";
|
||||
"Show More (%lld more characters)" = "Илүү харуулах (%lld тэмдэгт харагдана)";
|
||||
"Show PiP Button" = "PiP товч харуулах";
|
||||
"Show Skip 85s Button" = "85с алгасах товч харуулах";
|
||||
"Show Skip Intro / Outro Buttons" = "Эхлэл/Төгсгөлийн дууг алгасах точ хөруулах";
|
||||
"Shows" = "Харуулах";
|
||||
"Size (%@)" = "Хэмжээ (%d)";
|
||||
"Skip Settings" = "Алгасах тохиргоо";
|
||||
|
||||
/* Player Features */
|
||||
"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Зарим үйлдлүүд нь зөвхөн Sora болон Үндсэн тоглуулагч дээр ажилладаг, тухайлбал Үргэлж хэвтээ байдлаар үзэх, удаан дарж хурд удирдах болон алгасах хугацаа нь тохиргоо";
|
||||
|
||||
/* App Info */
|
||||
"Sora" = "Sora";
|
||||
"Sora %@ by cranci1" = "Sora %@ by cranci1";
|
||||
"Sora and cranci1 are not affiliated with AniList or Trakt in any way.
|
||||
|
||||
Also note that progress updates may not be 100% accurate." = "
|
||||
Sora ба cranci1 нь AniList эсвэл Trakt-тэй ямар ч хамааралгүй болно.
|
||||
|
||||
Мөн явцын шинэчлэлтүүд 100% үнэн зөв байж чадахгүй гэдгийг анхаарна уу.";
|
||||
"Sora GitHub Repository" = "Sora GitHub хуудас";
|
||||
"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur нь үргэлж үнэ төлбөргүй, зар сурталчилгаагүй байх болно!";
|
||||
|
||||
/* Interface */
|
||||
"Sort" = "Эрэмблэх";
|
||||
"Speed Settings" = "Тоглуулах хурд";
|
||||
|
||||
/* Playback */
|
||||
"Start Watching" = "Үзэх";
|
||||
"Start Watching Episode %d" = "%d ангийг үзэх";
|
||||
"Storage Used" = "Ашигласан багтаамж";
|
||||
"Stream" = "Үзвэр";
|
||||
"Streaming and video playback." = "Үзвэр ба бичлэг";
|
||||
|
||||
/* Subtitles */
|
||||
"Subtitle Color" = "Хадмалын өнгө";
|
||||
"Subtitle Settings" = "Хадмалын тохиргоо";
|
||||
|
||||
/* Sync */
|
||||
"Sync anime progress" = "Аниме үзсэн ангиудыг тэмдэглэх";
|
||||
"Sync TV shows progress" = "Цувралын үзсэн ангиудыг тэмдэглэх";
|
||||
|
||||
/* System */
|
||||
"System" = "Систем";
|
||||
|
||||
/* Instructions */
|
||||
"Tap a title to override the current match." = "Нэр дээр дарж одоогийн хайлтыг солино уу";
|
||||
"Tap Skip" = "Энд дарж гүйлгэнэ үү";
|
||||
"Tap to manage your modules" = "Энд дарж модуль солино уу";
|
||||
"Tap to select a module" = "Энд дарж модуль сонгоно уу";
|
||||
|
||||
/* App Information */
|
||||
"The app cache helps the app load images faster. Clearing the Documents folder will delete all downloaded modules. Do not erase App Data unless you understand the consequences — it may cause the app to malfunction." = " Аппын кэш нь зургуудийг илүү хурдан ачаалахад тусалдаг. Documents хавтасыг устгавал бүх татсан үзвэрүүдийг утгана. Гарах үр дагаврыг нь ойлголгүйгээр Апп датаг бүү устга - Энэ нь дараа нь аппыг буруу ажиллахад нөлөөлөх боломжтой";
|
||||
"The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily. For episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." = " Ангийн хязгаар нь нэг хуудсанд хэдэн харагдахыг тохируулдаг. Ингэснээр ангиуд нь багцлагдаж (Жишээ нь 1-25, 26-50 гэх мэт), үзэх ангиа сонгоход илүү хялбар болгоно. Ангийн мета өгөгдөл нь тухайн ангийн харагдац зураг болон нэрийг хадгалдаг тул зарим тохиолдолд спойлер агуулдаг.";
|
||||
|
||||
|
||||
|
||||
"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "Модуль зөвхөн нэг анги агуулсан байсан тул, кино байх боломжтой гэж үзээд тусгай дэлгэц хийхээр шийдсэн.";
|
||||
|
||||
/* Interface */
|
||||
"Thumbnails Width" = "Үзвэрийн зургийн урт";
|
||||
"TMDB Match" = "ТМДБ тохирол";
|
||||
"Trackers" = "Тракерууд";
|
||||
"Trakt" = "Trakt";
|
||||
"Trakt.tv" = "Trakt.tv";
|
||||
|
||||
/* Search */
|
||||
"Try different keywords" = "Өөр үгээр хайж үзнэ үү";
|
||||
"Try different search terms" = "Өөр төрлөөр хайж үзнэ үү";
|
||||
|
||||
/* Player Controls */
|
||||
"Two Finger Hold for Pause" = "Хоёр хуруугаар дарж бичлэгийг зогсоох";
|
||||
"Unable to fetch matches. Please try again later." = "Илэрц олж чадсангүй. Дараа дахин оролдоно уу?";
|
||||
"Use TMDB Poster Image" = "ТМДБ нүүр зураг ашиглах";
|
||||
|
||||
/* Version */
|
||||
"v%@" = "х%@";
|
||||
"Video Player" = "Бичлэг тоглуулагч";
|
||||
|
||||
/* Video Settings */
|
||||
"Video Quality Preferences" = "Бичлэгийн чанарын тохиргоо";
|
||||
"View All" = "Бүгдийг харах";
|
||||
"Watched" = "Үзсэн";
|
||||
"Why am I not seeing any episodes?" = "Яагаад нэг ч анги байхгүй байна?";
|
||||
"WiFi Quality" = "WiFi чанар";
|
||||
|
||||
/* User Status */
|
||||
"You are not logged in" = "Та нэвтрээгүй байна";
|
||||
"You have no items saved." = "Танд хадгалсан үзвэр байхгүй байна";
|
||||
"Your downloaded episodes will appear here" = "Таны татсан үзвэрийн ангиуд энд харагдана";
|
||||
"Your recently watched content will appear here" = "Таны сүүлд үзсэн үзвэрүүд энд харагдана";
|
||||
|
||||
/* Download Settings */
|
||||
"Download Settings" = "Таталтын тохиргоо";
|
||||
"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Зэрэг таталт хийх хязгаар нь зэрэг татах ангийн тоо юм. Өндөр байх тусам илүү их дата болон утасны нөөцийг ашиглана.";
|
||||
"Quality" = "Чанар";
|
||||
"Max Concurrent Downloads" = "Зэрэг таталт хийх хязгаар";
|
||||
"Allow Cellular Downloads" = "Утасны датагаар татах";
|
||||
"Quality Information" = "Чанарын мэдээлэл";
|
||||
|
||||
/* Storage */
|
||||
"Storage Management" = "Багтаамж удирдах";
|
||||
"Storage Used" = "Ашигласан багтаамж";
|
||||
"Library cleared successfully" = "Санг амжилттай цэвэрлэлээ";
|
||||
"All downloads deleted successfully" = "Бүх татсан үзвэрүүдийг амжилттай устлаа";
|
||||
|
||||
/* New additions */
|
||||
"Recent searches" = "Сүүлд хайсан";
|
||||
"me frfr" = "me frfr";
|
||||
"Data" = "Мэдээлэл";
|
||||
"Maximum Quality Available" = "Хамгийн өндөр чанартай";
|
||||
"All Reading" = "Бүх унших зүйл";
|
||||
"No Reading History" = "Унших түүх байхгүй";
|
||||
"Books you're reading will appear here" = "Таны уншиж байгаа номууд энд харагдана";
|
||||
"All Watching" = "Бүх үзэх зүйл";
|
||||
"Continue Reading" = "Унших үргэлжлүүлэх";
|
||||
"Nothing to Continue Reading" = "Үргэлжлүүлж унших зүйл байхгүй";
|
||||
"Your recently read novels will appear here" = "Таны саяхан уншсан зохиолууд энд харагдана";
|
||||
"No Bookmarks" = "Хадгалсан зүйл байхгүй";
|
||||
"Add bookmarks to this collection" = "Энэ цуглуулгад хадгалсан зүйл нэмэх";
|
||||
"items" = "зүйл";
|
||||
"Chapter %d" = "Бүлэг %d";
|
||||
"Episode %d" = "Анги %d";
|
||||
"%d%%" = "%d%%";
|
||||
"%d%% seen" = "%d%% үзсэн";
|
||||
"DownloadCountFormat" = "Татаж авсан: %d";
|
||||
"Error loading chapter" = "Бүлэг ачаалахад алдаа гарлаа";
|
||||
"Font Size: %dpt" = "Фонтын хэмжээ: %dpt";
|
||||
"Line Spacing: %.1f" = "Мөр хоорондын зай: %.1f";
|
||||
"Line Spacing" = "Мөр хоорондын зай";
|
||||
"Margin: %dpx" = "Захын зай: %dpx";
|
||||
"Margin" = "Захын зай";
|
||||
"Auto Scroll Speed" = "Автомат гүйлгэх хурд";
|
||||
"Speed" = "Хурд";
|
||||
"Speed: %.1fx" = "Хурд: %.1fx";
|
||||
"Matched %@: %@" = "Таарсан %@: %@";
|
||||
"Enter the AniList ID for this series" = "Энэ цувралын AniList ID-г оруулна уу";
|
||||
|
||||
/* New additions */
|
||||
"Create Collection" = "Цуглуулга үүсгэх";
|
||||
"Collection Name" = "Цуглуулгын нэр";
|
||||
"Rename Collection" = "Цуглуулгын нэр солих";
|
||||
"Rename" = "Нэр солих";
|
||||
"All Reading" = "Бүх унших зүйл";
|
||||
"Recently Added" = "Саяхан нэмэгдсэн";
|
||||
"Novel Title" = "Зохиолын гарчиг";
|
||||
"Read Progress" = "Уншсан явц";
|
||||
"Date Created" = "Үүсгэсэн огноо";
|
||||
"Name" = "Нэр";
|
||||
"Item Count" = "Зүйлийн тоо";
|
||||
"Date Added" = "Нэмсэн огноо";
|
||||
"Title" = "Гарчиг";
|
||||
"Source" = "Эх сурвалж";
|
||||
"Search reading..." = "Унж байгаа зүйл хайх...";
|
||||
"Search collections..." = "Цуглуулга хайх...";
|
||||
"Search bookmarks..." = "Хадгалсан зүйл хайх...";
|
||||
"%d items" = "%d зүйл";
|
||||
"Fetching Data" = "Өгөгдөл татаж байна";
|
||||
"Please wait while fetching." = "Татаж байна, хүлээнэ үү.";
|
||||
"Start Reading" = "Унж эхлэх";
|
||||
"Chapters" = "Бүлгүүд";
|
||||
"Completed" = "Дууссан";
|
||||
"Drag to reorder" = "Дарааллаар байрлуулахын тулд чирнэ үү";
|
||||
"Drag to reorder sections" = "Хэсгүүдийг дарааллаар байрлуулахын тулд чирнэ үү";
|
||||
"Library View" = "Сангийн харагдац";
|
||||
"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Сангийн харагдах хэсгүүдийг тохируулна уу. Хэсгүүдийг дахин эрэмбэлж эсвэл бүрэн идэвхгүй болгож болно.";
|
||||
"Library Sections Order" = "Сангийн хэсгүүдийн дараалал";
|
||||
"Completion Percentage" = "Дуусгах хувь";
|
||||
"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Зарим үйлдлүүд нь зөвхөн Sora болон Үндсэн тоглуулагч дээр ажилладаг, тухайлбал Үргэлж хэвтээ байдлаар үзэх, удаан дарж хурд удирдах болон алгасах хугацаа нь тохиргоо\n\nДуусгах хувь нь бичлэгийн төгсгөлөөс хэдэн хувийн өмнө AniList болон Trakt дээр үзсэнээр тэмдэглэхээ тодорхойлно.";
|
||||
"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Аппын кэш нь зургуудийг илүү хурдан ачаалахад тусалдаг.\n\nDocuments хавтасыг устгавал бүх татсан үзвэрүүдийг утгана.\n\nАпп датаг устгавал аппын бүх тохиргоо болон өгөгдөл устана.";
|
||||
"Translators" = "Орчуулагчид";
|
||||
"Paste URL" = "Холбоосыг буулгах";
|
||||
|
||||
/* Added missing localizations */
|
||||
"Series Title" = "Цувралын гарчиг";
|
||||
"Content Source" = "Агуулгын эх сурвалж";
|
||||
"Watch Progress" = "Үзсэн явц";
|
||||
"Recent searches" = "Саяхны хайлт";
|
||||
"Collections" = "Цуглуулгууд";
|
||||
"Continue Reading" = "Унших үргэлжлүүлэх";
|
||||
227
Sora/Localization/ro.lproj/Localizable.strings
Normal file
227
Sora/Localization/ro.lproj/Localizable.strings
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"About" = "Despre";
|
||||
"About Sora" = "Despre Sora";
|
||||
"Active" = "Activ";
|
||||
"Active Downloads" = "Descărcări Active";
|
||||
"Actively downloading media can be tracked from here." = "Descărcările pot fii urmarite de aici";
|
||||
"Add Module" = "Adaugă Modul";
|
||||
"Adjust the number of media items per row in portrait and landscape modes." = "Ajustează numărul de elemente media pe rand in modurile portret si peisaj";
|
||||
"Advanced" = "Avansat";
|
||||
"AKA Sulfur" = "AKA Sulfur";
|
||||
"All Bookmarks" = "Toate Marcajele";
|
||||
"All Watching" = "Watching";
|
||||
"Also known as Sulfur" = "AKA Sulfur";
|
||||
"AniList" = "AniList";
|
||||
"AniList ID" = "ID AniList";
|
||||
"AniList Match" = "Potrivire AniList";
|
||||
"AniList.co" = "AniList.co";
|
||||
"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Se colectează date anonime pentru a îmbunătăți aplicația. Nu sunt colectate informații personale. Acestea pot fi dezactivate oricând.";
|
||||
"App Info" = "Informații Aplicație";
|
||||
"App Language" = "Limbă aplicație";
|
||||
"App Storage" = "Stocare Aplicație";
|
||||
"Appearance" = "Aspect";
|
||||
/* Alerts and Actions */
|
||||
"Are you sure you want to clear all cached data? This will help free up storage space." = "Ești sigur că vrei să ștergi toate datele din cache? Acest lucru va elibera spațiu de stocare";
|
||||
"Are you sure you want to delete '%@'?" = "Ești sigur că vrei să ștergi '%@'?";
|
||||
"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Ești sigur că vrei să ștergi toate episoadele";
|
||||
"Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." = "Ești sigur că vrei să ștergeți toate materialele descărcate? Poți alege să ștergeți numai biblioteca, păstrând fișierele descărcate pentru utilizare ulterioară.";
|
||||
"Are you sure you want to erase all app data? This action cannot be undone." = "Ești sigur că vrei să ștergi toate datele? Această acțiune nu este reversibilă";
|
||||
/* Features */
|
||||
"Background Enabled" = "Background pornit";
|
||||
"Bookmark items for an easier access later." = "Marchează elemente pentru acces mai rapid in viitor";
|
||||
"Bookmarks" = "Marcaje";
|
||||
"Bottom Padding" = "Înălțime";
|
||||
"Cancel" = "Anulează";
|
||||
"Cellular Quality" = "Calitate Celulară";
|
||||
"Check out some community modules here!" = "Vezi module Aici";
|
||||
"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." = "Alegeți rezoluția video preferată pentru conexiunile WiFi și celulare. Rezoluțiile mai mari folosesc mai multe date, dar oferă o calitate mai bună. Dacă calitatea exactă nu este disponibilă, cea mai apropiată opțiune va fi selectată automat.\n\nNotă: nu toate sursele video și playerele acceptă selecția calității. Această caracteristică funcționează cel mai bine cu fluxurile HLS folosind playerul Sora";
|
||||
"Clear" = "Șterge";
|
||||
"Clear All Downloads" = "Șterge toate descărcările";
|
||||
"Clear Cache" = "Șterge Cache";
|
||||
"Clear Library Only" = "Șterge doar Biblioteca";
|
||||
"Clear Logs" = "Șterge Logs";
|
||||
"Click the plus button to add a module!" = "Apasă plus pentru a adăuga un modul";
|
||||
"Continue Watching" = "Continuă să vizionezi";
|
||||
"Continue Watching Episode %d" = "Continuă să vizionezi episodul %d";
|
||||
"Contributors" = "Contribuitori";
|
||||
"Copied to Clipboard" = "Copiat în Clipboard";
|
||||
"Copy to Clipboard" = "Copiază in Clipboard";
|
||||
"Copy URL" = "Copiază URL";
|
||||
/* Episodes */
|
||||
"%lld Episodes" = "%lld Episoade";
|
||||
"%lld of %lld" = "%lld din %lld";
|
||||
"%lld-%lld" = " %lld-%lld";
|
||||
"%lld%% seen" = " %lld%% văzut";
|
||||
"Episode %lld" = "Episod %lld";
|
||||
"Episodes" = "Episoade";
|
||||
"Episodes might not be available yet or there could be an issue with the source." = "Este posibil ca episoadele să nu fie încă disponibile sau ar putea exista o problemă cu sursa";
|
||||
"Episodes Range" = " Episoade";
|
||||
/* System */
|
||||
"cranci1" = "cranci1";
|
||||
"Dark" = "Întunecat";
|
||||
"DATA & LOGS" = "Date si LOGS";
|
||||
"Debug" = " Debug";
|
||||
"Debugging and troubleshooting." = " Debugging si Troubleshooting";
|
||||
/* Actions */
|
||||
"Delete" = " Șterge";
|
||||
"Delete All" = "Șterge tot";
|
||||
"Delete All Downloads" = "Șterge toate descărcările";
|
||||
"Delete All Episodes" = "Șterge toate episoadele";
|
||||
"Delete Download" = "Șterge Descărcarea";
|
||||
"Delete Episode" ="Șterge Episodul";
|
||||
/* Player */
|
||||
"Double Tap to Seek" = " Apasă de două ori pentru a căuta";
|
||||
"Double tapping the screen on it's sides will skip with the short tap setting." = "Apăsarea de două ori va da skip";
|
||||
/* Downloads */
|
||||
"Download" = "Descarcă";
|
||||
"Download Episode" = "Descarcă episodul";
|
||||
"Download Summary" = "Descarcă Rezumatul";
|
||||
"Download This Episode" = "Descarcă acest episod";
|
||||
"Downloaded" = " Descărcat";
|
||||
"Downloaded Shows" = "Seriale Descărcate";
|
||||
"Downloading" = " Se Descarcă";
|
||||
"Downloads" = "Descărcări";
|
||||
/* Settings */
|
||||
"Enable Analytics" = "Pornește Analiticele";
|
||||
"Enable Subtitles" = "Pornește subtitrările";
|
||||
/* Data Management */
|
||||
"Erase" = "Șterge";
|
||||
"Erase all App Data" = "Șterge toate dataele";
|
||||
"Erase App Data" = "Șterge datele din aplicație";
|
||||
/* Errors */
|
||||
"Error" = "Eroare";
|
||||
"Error Fetching Results" = "Eroare în Preluare";
|
||||
"Errors and critical issues." = "Erori Critice";
|
||||
"Failed to load contributors" = "Încărcare a contibuitorilor eșuată";
|
||||
/* Features */
|
||||
"Fetch Episode metadata" = "Fetch Episode metadata";
|
||||
"Files Downloaded" = "Fișiere descărcate";
|
||||
"Font Size" = "mărime Font";
|
||||
/* Interface */
|
||||
"Force Landscape" = "Blochează în Landscape";
|
||||
"General" = "General";
|
||||
"General events and activities." = "Evenimente si activități generale";
|
||||
"General Preferences" = "preferințe generale";
|
||||
"Hide Splash Screen" = "Ascunde Splash Screen";
|
||||
"HLS video downloading." = "Video HLS se descarcă";
|
||||
"Hold Speed" = "Viteză Hold";
|
||||
/* Info */
|
||||
"Info" = "Informații";
|
||||
"INFOS" = "INFOS";
|
||||
"Installed Modules" = "Module Instalate";
|
||||
"Interface" = " Interfață";
|
||||
/* Social */
|
||||
"Join the Discord" = " Alătură-te pe Discord";
|
||||
/* Layout */
|
||||
"Landscape Columns" = " Columne Landscape";
|
||||
"Language" = "Limbă";
|
||||
"LESS" = "Micșorează";
|
||||
/* Library */
|
||||
"Library" = "Bibliotecă";
|
||||
"License (GPLv3.0)" = " Licență (GPLv3.0)";
|
||||
"Light" = "Lumină";
|
||||
/* Loading States */
|
||||
"Loading Episode %lld..." = "Se încarcă episodul %lld...";
|
||||
"Loading logs..." = "Se încarcă logs";
|
||||
"Loading module information..." = "Se încarcă informațiile despre modul";
|
||||
"Loading Stream" = "Se încarcă Streamul";
|
||||
/* Logging */
|
||||
"Log Debug Info" = "Înregistrează Info Debug";
|
||||
"Log Filters" = "Înregistrează Filtre";
|
||||
"Log In with AniList" = "Intră cu AniList";
|
||||
"Log In with Trakt" = "Intră cu Trakt";
|
||||
"Log Out from AniList" = "Log Out din Anilist";
|
||||
"Log Out from Trakt" = " Log Out din Trakt";
|
||||
"Log Types" = "Tipuri de înregistrări";
|
||||
"Logged in as" = "Înregistrat Ca";
|
||||
"Logged in as " = " Înregistrat Ca";
|
||||
/* Logs and Settings */
|
||||
"Logs" = "Logs";
|
||||
"Long press Skip" = "Long press Skip";
|
||||
"MAIN" = "Principal";
|
||||
"Main Developer" = "Developer PRINCIPAL";
|
||||
"MAIN SETTINGS" = "SETĂRI PRINCIPALE";
|
||||
/* Media Actions */
|
||||
"Mark All Previous Watched" = "Marchează toate cele anterioare ca văzute";
|
||||
"Mark as Watched" = " Marchează ca văzut";
|
||||
"Mark Episode as Watched" = "Marchează episodul ca văzut";
|
||||
"Mark Previous Episodes as Watched" = "marchează episoadele anterioare ca văzute";
|
||||
"Mark watched" = "Marchează Văzute";
|
||||
"Match with AniList" = "Potrivește cu AniList";
|
||||
"Match with TMDB" = "Potrivește cu TMDB";
|
||||
"Matched ID: %lld" = "ID Potrivit:%lld";
|
||||
"Matched with: %@" = "Potrivit cu: %@";
|
||||
"Max Concurrent Downloads" = "Număr maxim de încărcări concomitente";
|
||||
/* Media Interface */
|
||||
"Media Grid Layout" = "Aspect Grilă Media";
|
||||
"Media Player" = "Player Media";
|
||||
"Media View" = "Aspect Media";
|
||||
"Metadata Provider" = "Furnizor Metadata";
|
||||
"Metadata Providers Order" = "Ordine Furnizori Metadata";
|
||||
"Module Removed" = " Module Eliminate";
|
||||
"Modules" = " Module";
|
||||
/* Headers */
|
||||
"MODULES" = "MODULE";
|
||||
"MORE" = "MAI MULT";
|
||||
/* Status Messages */
|
||||
"No Active Downloads" = "Fără Descărcări active";
|
||||
"No AniList matches found" = "Nicio potrivire AniList";
|
||||
"No Data Available" = "Fără date disponibile";
|
||||
"No Downloads" = "Nicio Descărcare";
|
||||
"No episodes available" = "Fără Episoade disponibile";
|
||||
"No Episodes Available" = "Fără Episoadedisponibile";
|
||||
"No items to continue watching." = "Niciun element pentru a continua vizionarea";
|
||||
"No matches found" = "Nu s-a găsit nicio Potrivire";
|
||||
"No Module Selected" = "Niciun Modul Selectat";
|
||||
"No Modules" = "Fără module";
|
||||
"No Results Found" = "Nu s-a găsit niciun rezultat";
|
||||
"No Search Results Found" = "Nu s-au găsit rezultate";
|
||||
"Nothing to Continue Watching" = "Nimic pentru a continua vizionarea";
|
||||
/*Notes and Messages */
|
||||
"Note that the modules will be replaced only if there is a different version string inside the JSON file." = " Rețineți că modulele vor fi înlocuite numai dacă există un șir de versiuni diferit în fișierul JSON";
|
||||
"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Cache-ul aplicației ajută aplicația să încarce imagini mai rapid.\n\nȘtergerea folderului Documente va șterge toate modulele descărcate\n\nȘtergerea datelor va inlătura toate setările si datele din aplicație";
|
||||
"Translators" = "Translatori";
|
||||
"Paste URL" = " Paste URL";
|
||||
/* Added missing localizations */
|
||||
"Series Title" = "Titlul Seriei";
|
||||
"Content Source" = "Sursa";
|
||||
"Watch Progress" = "Progres";
|
||||
"Recent searches" = "Căutări recente";
|
||||
"Nothing to Continue Reading" = "Nimic de citit în continuare";
|
||||
"Your recently read novels will appear here" = "Nuvelele citite recent vor apărea aici";
|
||||
"No Bookmarks" = "Niciun Marcaj";
|
||||
"Add bookmarks to this collection" ="Adaugă un marcaj colecției";
|
||||
"items" = "elemente";
|
||||
"All Watching" = "Toate seriile în progres";
|
||||
"No Reading History" = "Fără Istoric";
|
||||
"Books you're reading will appear here" = "Cărțile pe care le citeștivor apărea aici";
|
||||
"Create Collection" = "Crează o Colecție";
|
||||
"Collection Name" = "Numele Colecției";
|
||||
"Rename Collection" = "Renumește Colecția";
|
||||
"Rename" = "Renumește";
|
||||
"Novel Title" = "Titlul Nuvelei";
|
||||
"Read Progress" = "Progres";
|
||||
"Date Created" = "Data Creării";
|
||||
"Name" = "Nume";
|
||||
"Item Count" = "Număr Elemente";
|
||||
"Date Added" = "Data Adăugării";
|
||||
"Title" = " Titlu";
|
||||
"Source" = "Sursă";
|
||||
"Search reading..." = "Caută în Reading";
|
||||
"Search collections..." = "Caută în Colecții";
|
||||
"Search bookmarks..." = "Caută marcaje";
|
||||
"%d items" = "%d items";
|
||||
"Fetching Data" = "Se preiau datele";
|
||||
"Please wait while fetching." = " Se preiau datele";
|
||||
"Start Reading"= " Incepe citirea";
|
||||
"Chapters" = " Capitole";
|
||||
"Completed" = " Completat";
|
||||
"Drag to reorder" = " Trage pentru a rearanja";
|
||||
"Drag to reorder sections" = "Trage pentru a rearanja secțiunile";
|
||||
"Library View" = " Aspect Pagină";
|
||||
"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Personalizează secțiunile din bibliotecă. Le poți rearanja sau dezactiva complet";
|
||||
"Library Sections Order" = "Ordinea Secțiunilor din Bibliotecă";
|
||||
"Completion Percentage" = "Procent de Completare";
|
||||
"Translators" = "Translatori";
|
||||
"Paste URL" = "Paste URL";
|
||||
"Collections" = "Colecții";
|
||||
"Continue Reading" = "Continuă să citești";
|
||||
|
|
@ -20,4 +20,6 @@ struct ContinueWatchingItem: Codable, Identifiable {
|
|||
let module: ScrapingModule
|
||||
let headers: [String:String]?
|
||||
let totalEpisodes: Int
|
||||
let episodeTitle: String?
|
||||
let seasonNumber: Int?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,11 +254,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
private var isMenuOpen = false
|
||||
private var menuProtectionTimer: Timer?
|
||||
|
||||
let episodeTitle: String
|
||||
|
||||
init(module: ScrapingModule,
|
||||
urlString: String,
|
||||
fullUrl: String,
|
||||
title: String,
|
||||
episodeNumber: Int,
|
||||
episodeTitle: String,
|
||||
seasonNumber: Int,
|
||||
onWatchNext: @escaping () -> Void,
|
||||
subtitlesURL: String?,
|
||||
aniListID: Int,
|
||||
|
|
@ -271,6 +275,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
self.titleText = title
|
||||
self.episodeNumber = episodeNumber
|
||||
self.episodeImageUrl = episodeImageUrl
|
||||
self.episodeTitle = episodeTitle
|
||||
self.seasonNumber = seasonNumber
|
||||
self.onWatchNext = onWatchNext
|
||||
self.subtitlesURL = subtitlesURL
|
||||
self.aniListID = aniListID
|
||||
|
|
@ -1257,9 +1263,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
titleContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleContainer.backgroundColor = .clear
|
||||
controlsContainerView.addSubview(titleContainer)
|
||||
|
||||
episodeNumberLabel = UILabel()
|
||||
episodeNumberLabel.text = "Episode \(episodeNumber)"
|
||||
let hasTitle = !episodeTitle.isEmpty
|
||||
let isSingleSeason = (seasonNumber == 1 || seasonNumber == nil)
|
||||
let episodePart = "E\(episodeNumber)"
|
||||
let seasonPart = isSingleSeason ? "" : "S\(seasonNumber ?? 1)"
|
||||
let colon = hasTitle ? ":" : ""
|
||||
let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined()
|
||||
episodeNumberLabel.text = hasTitle ? "\(main)\(colon) \(episodeTitle)" : main
|
||||
episodeNumberLabel.textColor = UIColor(white: 1.0, alpha: 0.6)
|
||||
episodeNumberLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
episodeNumberLabel.textAlignment = .left
|
||||
|
|
@ -1946,7 +1957,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
aniListID: self.aniListID,
|
||||
module: self.module,
|
||||
headers: self.headers,
|
||||
totalEpisodes: self.totalEpisodes
|
||||
totalEpisodes: self.totalEpisodes,
|
||||
episodeTitle: self.episodeTitle,
|
||||
seasonNumber: self.seasonNumber
|
||||
)
|
||||
ContinueWatchingManager.shared.save(item: item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -314,7 +314,9 @@ class VideoPlayerViewController: UIViewController {
|
|||
aniListID: self.aniListID,
|
||||
module: self.module,
|
||||
headers: self.headers,
|
||||
totalEpisodes: self.totalEpisodes
|
||||
totalEpisodes: self.totalEpisodes,
|
||||
episodeTitle: "",
|
||||
seasonNumber: self.seasonNumber
|
||||
)
|
||||
ContinueWatchingManager.shared.save(item: item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,23 @@ struct SoraApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
_ = LocalizationManager.shared
|
||||
|
||||
if let languages = UserDefaults.standard.object(forKey: "AppleLanguages") as? [String],
|
||||
let primaryLanguage = languages.first,
|
||||
primaryLanguage == "mn" || primaryLanguage == "mn-Cyrl" {
|
||||
Logger.shared.log("App initialized with Mongolian language: \(primaryLanguage)", type: "Debug")
|
||||
|
||||
if let path = Bundle.main.path(forResource: "mn", ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) {
|
||||
let testKey = "About"
|
||||
let testString = bundle.localizedString(forKey: testKey, value: nil, table: nil)
|
||||
Logger.shared.log("Test Mongolian string for '\(testKey)': \(testString)", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("Failed to load Mongolian bundle", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
await Self.clearTmpFolder()
|
||||
await MainActor.run {
|
||||
|
|
|
|||
|
|
@ -411,6 +411,8 @@ struct AssetMetadata: Codable {
|
|||
let season: Int?
|
||||
let episode: Int?
|
||||
let showPosterURL: URL? // Main show poster URL (distinct from episode-specific images)
|
||||
let episodeTitle: String?
|
||||
let seasonNumber: Int?
|
||||
|
||||
init(
|
||||
title: String,
|
||||
|
|
@ -421,7 +423,9 @@ struct AssetMetadata: Codable {
|
|||
showTitle: String? = nil,
|
||||
season: Int? = nil,
|
||||
episode: Int? = nil,
|
||||
showPosterURL: URL? = nil
|
||||
showPosterURL: URL? = nil,
|
||||
episodeTitle: String? = nil,
|
||||
seasonNumber: Int? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.overview = overview
|
||||
|
|
@ -432,6 +436,8 @@ struct AssetMetadata: Codable {
|
|||
self.season = season
|
||||
self.episode = episode
|
||||
self.showPosterURL = showPosterURL
|
||||
self.episodeTitle = episodeTitle
|
||||
self.seasonNumber = seasonNumber
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
71
Sora/Utlis & Misc/Extensions/Bundle+Language.swift
Normal file
71
Sora/Utlis & Misc/Extensions/Bundle+Language.swift
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//
|
||||
// Bundle+Language.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by paul on 2025-07-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class LanguageBundleManager {
|
||||
static let shared = LanguageBundleManager()
|
||||
|
||||
private var bundles: [String: Bundle] = [:]
|
||||
|
||||
func localizedBundle(for language: String) -> Bundle {
|
||||
if let cachedBundle = bundles[language] {
|
||||
return cachedBundle
|
||||
}
|
||||
|
||||
let mainBundle = Bundle.main
|
||||
if let path = mainBundle.path(forResource: language, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) {
|
||||
bundles[language] = bundle
|
||||
return bundle
|
||||
}
|
||||
|
||||
if language == "mn-Cyrl" {
|
||||
if let path = mainBundle.path(forResource: "mn", ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) {
|
||||
bundles[language] = bundle
|
||||
Logger.shared.log("Found Mongolian bundle using mn.lproj", type: "Debug")
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
||||
if language == "ro" {
|
||||
if let path = mainBundle.path(forResource: "ro", ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) {
|
||||
bundles[language] = bundle
|
||||
Logger.shared.log("Found Romanian bundle using ro.lproj", type: "Debug")
|
||||
return bundle
|
||||
} else {
|
||||
Logger.shared.log("Could not find bundle for Romanian (ro)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
Logger.shared.log("Could not find bundle for language: \(language)", type: "Error")
|
||||
return mainBundle
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func localized(language: String) -> String {
|
||||
let bundle = LanguageBundleManager.shared.localizedBundle(for: language)
|
||||
return bundle.localizedString(forKey: self, value: nil, table: nil)
|
||||
}
|
||||
|
||||
static var currentLanguageCode: String {
|
||||
if let languages = UserDefaults.standard.object(forKey: "AppleLanguages") as? [String],
|
||||
let primaryLanguage = languages.first {
|
||||
return primaryLanguage
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
|
||||
extension Bundle {
|
||||
static var currentLanguageBundle: Bundle {
|
||||
return LanguageBundleManager.shared.localizedBundle(for: String.currentLanguageCode)
|
||||
}
|
||||
}
|
||||
124
Sora/Utlis & Misc/Extensions/LocalizationManager.swift
Normal file
124
Sora/Utlis & Misc/Extensions/LocalizationManager.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//
|
||||
// LocalizationManager.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by paul on 2025-07-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class LocalizationManager {
|
||||
static let shared = LocalizationManager()
|
||||
|
||||
private var currentLanguage: String = "en"
|
||||
private var translationCache: [String: [String: String]] = [:]
|
||||
|
||||
private init() {
|
||||
if let languages = UserDefaults.standard.object(forKey: "AppleLanguages") as? [String],
|
||||
let primaryLanguage = languages.first {
|
||||
currentLanguage = primaryLanguage
|
||||
Logger.shared.log("LocalizationManager initialized with language: \(primaryLanguage)", type: "Debug")
|
||||
}
|
||||
|
||||
loadTranslationsIfNeeded(for: "mn")
|
||||
loadTranslationsIfNeeded(for: "ro")
|
||||
}
|
||||
|
||||
func setLanguage(_ languageCode: String) {
|
||||
currentLanguage = languageCode
|
||||
loadTranslationsIfNeeded(for: languageCode)
|
||||
Logger.shared.log("LocalizationManager language set to: \(languageCode)", type: "Debug")
|
||||
}
|
||||
|
||||
func localizedString(for key: String, comment: String = "") -> String {
|
||||
if currentLanguage == "mn" || currentLanguage == "mn-Cyrl" {
|
||||
if let translations = translationCache["mn"],
|
||||
let localizedString = translations[key] {
|
||||
return localizedString
|
||||
}
|
||||
|
||||
loadTranslationsIfNeeded(for: "mn")
|
||||
|
||||
if let translations = translationCache["mn"],
|
||||
let localizedString = translations[key] {
|
||||
return localizedString
|
||||
}
|
||||
|
||||
Logger.shared.log("Missing Mongolian translation for key: \(key)", type: "Debug")
|
||||
}
|
||||
|
||||
if currentLanguage == "ro" {
|
||||
if let translations = translationCache["ro"],
|
||||
let localizedString = translations[key] {
|
||||
return localizedString
|
||||
}
|
||||
|
||||
loadTranslationsIfNeeded(for: "ro")
|
||||
|
||||
if let translations = translationCache["ro"],
|
||||
let localizedString = translations[key] {
|
||||
return localizedString
|
||||
}
|
||||
|
||||
Logger.shared.log("Missing Romanian translation for key: \(key)", type: "Debug")
|
||||
}
|
||||
|
||||
return NSLocalizedString(key, comment: comment)
|
||||
}
|
||||
|
||||
private func loadTranslationsIfNeeded(for languageCode: String) {
|
||||
if translationCache[languageCode] != nil {
|
||||
return
|
||||
}
|
||||
|
||||
guard let path = Bundle.main.path(forResource: "Localizable", ofType: "strings", inDirectory: "\(languageCode).lproj") else {
|
||||
Logger.shared.log("Could not find Localizable.strings for \(languageCode)", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let fileContents = try String(contentsOfFile: path, encoding: .utf8)
|
||||
var translations: [String: String] = [:]
|
||||
|
||||
let lines = fileContents.components(separatedBy: .newlines)
|
||||
for line in lines {
|
||||
let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if trimmedLine.isEmpty || trimmedLine.hasPrefix("/*") || trimmedLine.hasPrefix("//") {
|
||||
continue
|
||||
}
|
||||
|
||||
if let range = trimmedLine.range(of: "\" = \"") {
|
||||
let keyStartIndex = trimmedLine.index(after: trimmedLine.startIndex)
|
||||
let keyEndIndex = range.lowerBound
|
||||
let valueStartIndex = range.upperBound
|
||||
let valueEndIndex = trimmedLine.index(trimmedLine.endIndex, offsetBy: -2, limitedBy: trimmedLine.startIndex) ?? trimmedLine.startIndex
|
||||
|
||||
if keyStartIndex < keyEndIndex && valueStartIndex < valueEndIndex {
|
||||
let key = String(trimmedLine[keyStartIndex..<keyEndIndex])
|
||||
let value = String(trimmedLine[valueStartIndex..<valueEndIndex])
|
||||
translations[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
translationCache[languageCode] = translations
|
||||
Logger.shared.log("Loaded \(translations.count) translations for \(languageCode)", type: "Debug")
|
||||
} catch {
|
||||
Logger.shared.log("Error loading translations for \(languageCode): \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var localized: String {
|
||||
return LocalizationManager.shared.localizedString(for: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension Text {
|
||||
init(localized key: String) {
|
||||
self.init(verbatim: LocalizationManager.shared.localizedString(for: key))
|
||||
}
|
||||
}
|
||||
|
|
@ -51,7 +51,8 @@ extension JSController {
|
|||
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
|
||||
for episodeData in episodesResult {
|
||||
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
|
||||
episodeLinks.append(EpisodeLink(number: number, title: "", href: link, duration: nil))
|
||||
let title = episodeData["title"] ?? ""
|
||||
episodeLinks.append(EpisodeLink(number: number, title: title, href: link, duration: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,8 @@ struct DownloadView: View {
|
|||
fullUrl: asset.originalURL.absoluteString,
|
||||
title: asset.metadata?.showTitle ?? asset.name,
|
||||
episodeNumber: asset.metadata?.episode ?? 0,
|
||||
episodeTitle: asset.metadata?.episodeTitle ?? "",
|
||||
seasonNumber: asset.metadata?.seasonNumber ?? 1,
|
||||
onWatchNext: {},
|
||||
subtitlesURL: asset.localSubtitleURL?.absoluteString,
|
||||
aniListID: 0,
|
||||
|
|
|
|||
|
|
@ -339,6 +339,8 @@ struct FullWidthContinueWatchingCell: View {
|
|||
fullUrl: item.fullUrl,
|
||||
title: item.mediaTitle,
|
||||
episodeNumber: item.episodeNumber,
|
||||
episodeTitle: item.episodeTitle ?? "",
|
||||
seasonNumber: item.seasonNumber ?? 1,
|
||||
onWatchNext: { },
|
||||
subtitlesURL: item.subtitles,
|
||||
aniListID: item.aniListID ?? 0,
|
||||
|
|
@ -405,12 +407,12 @@ struct FullWidthContinueWatchingCell: View {
|
|||
.lineLimit(1)
|
||||
|
||||
HStack {
|
||||
Text("Episode \(item.episodeNumber)")
|
||||
Text(episodeLabel(for: item))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(item.progress * 100))% seen")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
|
@ -473,4 +475,15 @@ struct FullWidthContinueWatchingCell: View {
|
|||
currentProgress = max(0, min(item.progress, 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func episodeLabel(for item: ContinueWatchingItem) -> String {
|
||||
let hasTitle = !(item.episodeTitle?.isEmpty ?? true)
|
||||
let isSingleSeason = (item.seasonNumber ?? 1) <= 1
|
||||
let episodePart = "E\(item.episodeNumber)"
|
||||
let seasonPart = isSingleSeason ? "" : "S\(item.seasonNumber ?? 1)"
|
||||
let colon = hasTitle ? ":" : ""
|
||||
let title = item.episodeTitle ?? ""
|
||||
let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined()
|
||||
return hasTitle ? "\(main)\(colon) \(title)" : main
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ struct BookmarkGridItemView: View {
|
|||
.overlay(
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.5))
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay(
|
||||
LazyImage(url: URL(string: module.metadata.iconUrl)) { state in
|
||||
|
|
|
|||
|
|
@ -411,6 +411,8 @@ struct ContinueWatchingCell: View {
|
|||
fullUrl: item.fullUrl,
|
||||
title: item.mediaTitle,
|
||||
episodeNumber: item.episodeNumber,
|
||||
episodeTitle: item.episodeTitle ?? "",
|
||||
seasonNumber: item.seasonNumber ?? 1,
|
||||
onWatchNext: { },
|
||||
subtitlesURL: item.subtitles,
|
||||
aniListID: item.aniListID ?? 0,
|
||||
|
|
@ -453,12 +455,12 @@ struct ContinueWatchingCell: View {
|
|||
.lineLimit(1)
|
||||
|
||||
HStack {
|
||||
Text("Episode \(item.episodeNumber)")
|
||||
Text(episodeLabel(for: item))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
|
||||
Text("\(Int(item.progress * 100))% seen")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
|
@ -562,6 +564,16 @@ struct ContinueWatchingCell: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func episodeLabel(for item: ContinueWatchingItem) -> String {
|
||||
let hasTitle = !(item.episodeTitle?.isEmpty ?? true)
|
||||
let isSingleSeason = (item.seasonNumber ?? 1) <= 1
|
||||
let episodePart = "E\(item.episodeNumber)"
|
||||
let seasonPart = isSingleSeason ? "" : "S\(item.seasonNumber ?? 1)"
|
||||
let colon = hasTitle ? ":" : ""
|
||||
let title = item.episodeTitle ?? ""
|
||||
let main = [seasonPart, episodePart].filter { !$0.isEmpty }.joined()
|
||||
return hasTitle ? "\(main)\(colon) \(title)" : main
|
||||
}
|
||||
|
||||
struct RoundedCorner: Shape {
|
||||
var radius: CGFloat = .infinity
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ struct MediaInfoView: View {
|
|||
@State private var refreshTrigger: Bool = false
|
||||
@State private var buttonRefreshTrigger: Bool = false
|
||||
|
||||
@State private var episodeTitleCache: [Int: String] = [:]
|
||||
|
||||
private var selectedRangeKey: String { "selectedRangeStart_\(href)" }
|
||||
private var selectedSeasonKey: String { "selectedSeason_\(href)" }
|
||||
|
||||
|
|
@ -192,6 +194,12 @@ struct MediaInfoView: View {
|
|||
UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey)
|
||||
}
|
||||
.onChange(of: selectedSeason) { newValue in
|
||||
let ranges = generateRanges(for: currentEpisodeList.count)
|
||||
if let validRange = ranges.first(where: { $0 == selectedRange }) {
|
||||
selectedRange = validRange
|
||||
} else {
|
||||
selectedRange = ranges.first ?? 0..<episodeChunkSize
|
||||
}
|
||||
UserDefaults.standard.set(newValue, forKey: selectedSeasonKey)
|
||||
}
|
||||
.onChange(of: selectedChapterRange) { newValue in
|
||||
|
|
@ -565,7 +573,7 @@ struct MediaInfoView: View {
|
|||
@ViewBuilder
|
||||
private var rangeSelectorStyled: some View {
|
||||
Menu {
|
||||
ForEach(generateRanges(), id: \..self) { range in
|
||||
ForEach(episodeRanges, id: \.self) { range in
|
||||
Button(action: { selectedRange = range }) {
|
||||
Text("\(range.lowerBound + 1)-\(range.upperBound)")
|
||||
}
|
||||
|
|
@ -590,27 +598,24 @@ struct MediaInfoView: View {
|
|||
Text(NSLocalizedString("Episodes", comment: ""))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if isGroupedBySeasons || episodeLinks.count > episodeChunkSize {
|
||||
HStack(spacing: 8) {
|
||||
if isGroupedBySeasons {
|
||||
seasonSelectorStyled
|
||||
}
|
||||
Spacer()
|
||||
if episodeLinks.count > episodeChunkSize {
|
||||
rangeSelectorStyled
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.padding(.top, -8)
|
||||
sourceButton
|
||||
menuButton
|
||||
}
|
||||
if isGroupedBySeasons || episodeLinks.count > episodeChunkSize {
|
||||
HStack {
|
||||
if isGroupedBySeasons {
|
||||
seasonSelectorStyled
|
||||
} else {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer()
|
||||
if episodeLinks.count > episodeChunkSize {
|
||||
rangeSelectorStyled
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
|
||||
sourceButton
|
||||
menuButton
|
||||
}
|
||||
.padding(.top, -8)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -628,9 +633,9 @@ struct MediaInfoView: View {
|
|||
@ViewBuilder
|
||||
private var flatEpisodeList: some View {
|
||||
VStack(spacing: 15) {
|
||||
ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
||||
let ep = episodeLinks[i]
|
||||
createEpisodeCell(episode: ep, index: i, season: 1)
|
||||
ForEach(currentEpisodeList.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
||||
let ep = currentEpisodeList[i]
|
||||
createEpisodeCell(episode: ep, index: i, season: isGroupedBySeasons ? selectedSeason + 1 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -640,8 +645,9 @@ struct MediaInfoView: View {
|
|||
let seasons = groupedEpisodes()
|
||||
if !seasons.isEmpty, selectedSeason < seasons.count {
|
||||
VStack(spacing: 15) {
|
||||
ForEach(seasons[selectedSeason]) { ep in
|
||||
createEpisodeCell(episode: ep, index: selectedSeason, season: selectedSeason + 1)
|
||||
ForEach(seasons[selectedSeason].indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
||||
let ep = seasons[selectedSeason][i]
|
||||
createEpisodeCell(episode: ep, index: i, season: selectedSeason + 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1942,38 +1948,63 @@ struct MediaInfoView: View {
|
|||
DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
|
||||
return
|
||||
}
|
||||
|
||||
guard self.activeFetchID == fetchID else { return }
|
||||
let isMovie = tmdbType == .movie
|
||||
|
||||
let customMediaPlayer = CustomMediaPlayerViewController(
|
||||
module: module,
|
||||
urlString: url.absoluteString,
|
||||
fullUrl: fullURL,
|
||||
title: title,
|
||||
episodeNumber: selectedEpisodeNumber,
|
||||
onWatchNext: { selectNextEpisode() },
|
||||
subtitlesURL: subtitles,
|
||||
aniListID: itemID ?? 0,
|
||||
totalEpisodes: episodeLinks.count,
|
||||
episodeImageUrl: selectedEpisodeImage,
|
||||
headers: headers ?? nil
|
||||
)
|
||||
customMediaPlayer.seasonNumber = selectedSeason + 1
|
||||
customMediaPlayer.tmdbID = tmdbID
|
||||
customMediaPlayer.isMovie = isMovie
|
||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||
Logger.shared.log("Opening custom media player with url: \(url)")
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootVC = windowScene.windows.first?.rootViewController {
|
||||
findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
|
||||
} else {
|
||||
Logger.shared.log("Failed to find root view controller", type: "Error")
|
||||
DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
|
||||
let episode: EpisodeLink? = {
|
||||
if isGroupedBySeasons {
|
||||
let seasons = groupedEpisodes()
|
||||
if selectedSeason < seasons.count {
|
||||
return seasons[selectedSeason].first(where: { $0.number == selectedEpisodeNumber })
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return episodeLinks.first(where: { $0.number == selectedEpisodeNumber })
|
||||
}
|
||||
}()
|
||||
fetchTMDBEpisodeTitle(episodeNumber: selectedEpisodeNumber, season: selectedSeason + 1) { episodeTitle in
|
||||
let customMediaPlayer = CustomMediaPlayerViewController(
|
||||
module: module,
|
||||
urlString: url.absoluteString,
|
||||
fullUrl: fullURL,
|
||||
title: title,
|
||||
episodeNumber: selectedEpisodeNumber,
|
||||
episodeTitle: episodeTitle,
|
||||
seasonNumber: selectedSeason + 1,
|
||||
onWatchNext: { selectNextEpisode() },
|
||||
subtitlesURL: subtitles,
|
||||
aniListID: itemID ?? 0,
|
||||
totalEpisodes: episodeLinks.count,
|
||||
episodeImageUrl: selectedEpisodeImage,
|
||||
headers: headers ?? nil
|
||||
)
|
||||
customMediaPlayer.tmdbID = tmdbID
|
||||
customMediaPlayer.isMovie = isMovie
|
||||
customMediaPlayer.modalPresentationStyle = .fullScreen
|
||||
Logger.shared.log("Opening custom media player with url: \(url)")
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootVC = windowScene.windows.first?.rootViewController {
|
||||
findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
|
||||
} else {
|
||||
Logger.shared.log("Failed to find root view controller", type: "Error")
|
||||
DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchTMDBEpisodeTitle(episodeNumber: Int, season: Int, completion: @escaping (String) -> Void) {
|
||||
guard let tmdbID = tmdbID else { completion(""); return }
|
||||
let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNumber)?api_key=738b4edd0a156cc126dc4a4b8aea4aca"
|
||||
guard let url = URL(string: urlString) else { completion(""); return }
|
||||
URLSession.shared.dataTask(with: url) { data, _, _ in
|
||||
var title = ""
|
||||
if let data = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
title = json["name"] as? String ?? ""
|
||||
}
|
||||
DispatchQueue.main.async { completion(title) }
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func downloadSingleEpisodeDirectly(episode: EpisodeLink) {
|
||||
if isSingleEpisodeDownloading { return }
|
||||
|
||||
|
|
@ -2196,8 +2227,14 @@ struct MediaInfoView: View {
|
|||
completion(nil as EpisodeMetadataInfo?)
|
||||
return
|
||||
}
|
||||
|
||||
fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion)
|
||||
fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number) { info in
|
||||
if let info = info, let enTitle = info.title["en"] {
|
||||
DispatchQueue.main.async {
|
||||
episodeTitleCache[episode.number] = enTitle
|
||||
}
|
||||
}
|
||||
completion(info)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchEpisodeMetadataFromNetwork(anilistId: Int, episodeNumber: Int, completion: @escaping (EpisodeMetadataInfo?) -> Void) {
|
||||
|
|
@ -2359,4 +2396,38 @@ struct MediaInfoView: View {
|
|||
UserDefaults.standard.set(true, forKey: "navigatingToReaderView")
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Episode Range Fix for Seasons
|
||||
private func generateRanges(for count: Int) -> [Range<Int>] {
|
||||
let chunkSize = episodeChunkSize
|
||||
var ranges: [Range<Int>] = []
|
||||
for i in stride(from: 0, to: count, by: chunkSize) {
|
||||
let end = min(i + chunkSize, count)
|
||||
ranges.append(i..<end)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private var currentEpisodeList: [EpisodeLink] {
|
||||
if isGroupedBySeasons {
|
||||
let seasons = groupedEpisodes()
|
||||
if selectedSeason < seasons.count {
|
||||
return seasons[selectedSeason]
|
||||
}
|
||||
return []
|
||||
} else {
|
||||
return episodeLinks
|
||||
}
|
||||
}
|
||||
|
||||
private var episodeRanges: [Range<Int>] {
|
||||
generateRanges(for: currentEpisodeList.count)
|
||||
}
|
||||
|
||||
private func getEpisodeTitleForPlayer(episodeNumber: Int) -> String {
|
||||
if let cached = episodeTitleCache[episodeNumber], !cached.isEmpty {
|
||||
return cached
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,6 +328,12 @@ struct TranslatorsView: View {
|
|||
login: "yoshi1780",
|
||||
avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/262d7c1a61ff49355ddb74c76c7c5c7f_webp.png?raw=true",
|
||||
language: "Mongolian"
|
||||
),
|
||||
Translator(
|
||||
id: 11,
|
||||
login: "Perju",
|
||||
avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/82e3e7054935345b494e12ac33fd8e4f_webp.png?raw=true",
|
||||
language: "Romanian"
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
fileprivate func backupsFolderURL() -> URL {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let backups = docs.appendingPathComponent("Backups")
|
||||
if !FileManager.default.fileExists(atPath: backups.path) {
|
||||
try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
return backups
|
||||
}
|
||||
|
||||
fileprivate func listBackupFiles() -> [URL] {
|
||||
let folder = backupsFolderURL()
|
||||
let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []
|
||||
return files.filter { $0.pathExtension == "json" }
|
||||
.sorted { $0.lastPathComponent > $1.lastPathComponent }
|
||||
}
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
|
|
@ -92,106 +117,237 @@ fileprivate struct SettingsActionRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
fileprivate struct BackupCoverageItem: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let isIncluded: Bool
|
||||
var showDivider: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(isIncluded ? Color.green : Color.red)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(Color.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isIncluded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(isIncluded ? Color.green : Color.red)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct BackupCoverageView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundColor(.gray)
|
||||
Text(NSLocalizedString("Included", comment: "Title for items included in backup"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(height: 1)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
BackupCoverageItem(icon: "film", title: NSLocalizedString("Continue Watching", comment: "Continue watching backup item"), isIncluded: true, showDivider: false)
|
||||
BackupCoverageItem(icon: "book", title: NSLocalizedString("Continue Reading", comment: "Continue reading backup item"), isIncluded: true, showDivider: false)
|
||||
BackupCoverageItem(icon: "bookmark", title: NSLocalizedString("Collections & Bookmarks", comment: "Collections backup item"), isIncluded: true, showDivider: false)
|
||||
BackupCoverageItem(icon: "magnifyingglass", title: NSLocalizedString("Search History", comment: "Search history backup item"), isIncluded: true, showDivider: false)
|
||||
BackupCoverageItem(icon: "puzzlepiece", title: NSLocalizedString("Modules", comment: "Modules backup item"), isIncluded: true, showDivider: false)
|
||||
BackupCoverageItem(icon: "gearshape", title: NSLocalizedString("User Settings", comment: "User settings backup item"), isIncluded: true, showDivider: false)
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "xmark.circle")
|
||||
.foregroundColor(.gray)
|
||||
Text(NSLocalizedString("Not Included", comment: "Title for items not included in backup"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(height: 1)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
BackupCoverageItem(icon: "arrow.down.circle", title: NSLocalizedString("Downloaded Files", comment: "Downloads backup item"), isIncluded: false, showDivider: false)
|
||||
BackupCoverageItem(icon: "person.crop.circle", title: NSLocalizedString("Account Logins", comment: "Account logins backup item"), isIncluded: false, showDivider: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewBackup: View {
|
||||
@State private var showExporter = false
|
||||
@State private var showImporter = false
|
||||
@State private var exportURL: URL?
|
||||
@State private var showAlert = false
|
||||
@State private var alertMessage = ""
|
||||
@State private var exportData: Data? = nil
|
||||
@State private var selectedBackup: URL? = nil
|
||||
@State private var showImportNotice = false
|
||||
@State private var showBackupList = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: NSLocalizedString("Backup & Restore", comment: "Settings section title for backup and restore"),
|
||||
footer: NSLocalizedString("Notice: This feature is still experimental. Please double-check your data after import/export. \nAlso note that when importing a backup your current data will be overwritten, it is not possible to merge yet.", comment: "Footer notice for experimental backup/restore feature")
|
||||
) {
|
||||
SettingsActionRow(
|
||||
icon: "arrow.up.doc",
|
||||
title: NSLocalizedString("Export Backup", comment: "Export backup button title"),
|
||||
action: {
|
||||
exportData = generateBackupData()
|
||||
showExporter = true
|
||||
},
|
||||
showDivider: true
|
||||
)
|
||||
SettingsActionRow(
|
||||
icon: "arrow.down.doc",
|
||||
title: NSLocalizedString("Import Backup", comment: "Import backup button title"),
|
||||
action: {
|
||||
showImporter = true
|
||||
},
|
||||
showDivider: false
|
||||
)
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
SettingsNavigationRow(icon: "arrow.up.doc", title: NSLocalizedString("Save Backup", comment: "Save backup button title"), showChevron: false, textColor: .accentColor) {
|
||||
if let data = generateBackupData() {
|
||||
let url = backupsFolderURL().appendingPathComponent(exportFilename())
|
||||
do {
|
||||
try data.write(to: url)
|
||||
alertMessage = "Backup saved to Backups folder."
|
||||
} catch {
|
||||
alertMessage = "Failed to save backup: \(error.localizedDescription)"
|
||||
}
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
VStack(spacing: 0) {
|
||||
SettingsNavigationRow(icon: "arrow.down.doc", title: NSLocalizedString("Import Backup", comment: "Import backup button title"), showChevron: false, textColor: .accentColor) {
|
||||
showImportNotice = true
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
VStack(spacing: 0) {
|
||||
SettingsNavigationRow(icon: "folder", title: "Show Backups", showChevron: true, textColor: .accentColor) {
|
||||
showBackupList = true
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
NavigationLink(destination: BackupListView(), isActive: $showBackupList) { EmptyView() }
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
BackupCoverageView()
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
Text(NSLocalizedString("Notice: This feature is still experimental. Please double-check your data after import/export. \nAlso note that when importing a backup your current data will be overwritten, it is not possible to merge yet.", comment: "Footer notice for experimental backup/restore feature"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.gray)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
.scrollViewBottomPadding()
|
||||
.padding(.bottom, 20)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
.navigationTitle(NSLocalizedString("Backup & Restore", comment: "Navigation title for backup and restore view"))
|
||||
.fileExporter(
|
||||
isPresented: $showExporter,
|
||||
document: BackupDocument(data: exportData ?? Data()),
|
||||
contentType: .json,
|
||||
defaultFilename: exportFilename()
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
alertMessage = "Exported to \(url.lastPathComponent)"
|
||||
showAlert = true
|
||||
case .failure(let error):
|
||||
alertMessage = "Export failed: \(error.localizedDescription)"
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImporter,
|
||||
allowedContentTypes: [.json]
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
var success = false
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
try restoreBackupData(data)
|
||||
alertMessage = "Import successful!"
|
||||
success = true
|
||||
} catch {
|
||||
alertMessage = "Import failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
alertMessage = "Import failed: Could not access file."
|
||||
}
|
||||
showAlert = true
|
||||
case .failure(let error):
|
||||
alertMessage = "Import failed: \(error.localizedDescription)"
|
||||
showAlert = true
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showAlert) {
|
||||
Alert(title: Text(NSLocalizedString("Backup", comment: "Alert title for backup actions")), message: Text(alertMessage), dismissButton: .default(Text("OK")))
|
||||
}
|
||||
.sheet(isPresented: $showImportNotice) {
|
||||
ImportNoticeView()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func generateBackupData() -> Data? {
|
||||
private func exportFilename() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||
let dateString = formatter.string(from: Date())
|
||||
return "SoraBackup_\(dateString).json"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private func generateBackupData() -> Data? {
|
||||
let continueWatching = ContinueWatchingManager.shared.fetchItems()
|
||||
let continueReading = ContinueReadingManager.shared.fetchItems()
|
||||
let collections = (try? JSONDecoder().decode([BookmarkCollection].self, from: UserDefaults.standard.data(forKey: "bookmarkCollections") ?? Data())) ?? []
|
||||
let searchHistory = UserDefaults.standard.stringArray(forKey: "searchHistory") ?? []
|
||||
let modules = ModuleManager().modules
|
||||
|
||||
let userSettingsKeys: [String] = [
|
||||
"episodeChunkSize",
|
||||
"fetchEpisodeMetadata",
|
||||
"analyticsEnabled",
|
||||
"hideSplashScreen",
|
||||
"useNativeTabBar",
|
||||
"metadataProvidersOrderData",
|
||||
"tmdbImageWidth",
|
||||
"metadataProviders",
|
||||
"externalPlayer",
|
||||
"alwaysLandscape",
|
||||
"rememberPlaySpeed",
|
||||
"holdSpeedPlayer",
|
||||
"skipIncrement",
|
||||
"skipIncrementHold",
|
||||
"remainingTimePercentage",
|
||||
"holdForPauseEnabled",
|
||||
"skip85Visible",
|
||||
"doubleTapSeekEnabled",
|
||||
"skipIntroOutroVisible",
|
||||
"pipButtonVisible",
|
||||
"autoplayNext",
|
||||
"videoQualityWiFi",
|
||||
"videoQualityCellular",
|
||||
"subtitlesEnabled",
|
||||
"allowCellularDownloads",
|
||||
"maxConcurrentDownloads",
|
||||
"downloadQuality",
|
||||
"mediaColumnsPortrait",
|
||||
"mediaColumnsLandscape",
|
||||
"librarySectionsOrderData",
|
||||
"disabledLibrarySectionsData",
|
||||
"selectedModuleId",
|
||||
"didReceiveDefaultPageLink",
|
||||
"refreshModulesOnLaunch",
|
||||
"sendPushUpdates",
|
||||
"sendTraktUpdates",
|
||||
"selectedAppearance",
|
||||
"selectedLanguage",
|
||||
"metadataProvidersOrder",
|
||||
"chapterChunkSize",
|
||||
"lastCommunityURL"
|
||||
]
|
||||
var userSettings: [String: Any] = [:]
|
||||
for key in userSettingsKeys {
|
||||
if let data = UserDefaults.standard.object(forKey: key) as? Data {
|
||||
userSettings[key] = data.base64EncodedString()
|
||||
} else {
|
||||
userSettings[key] = UserDefaults.standard.object(forKey: key)
|
||||
}
|
||||
}
|
||||
if let subtitleSettings = UserDefaults.standard.data(forKey: "SubtitleSettings") {
|
||||
userSettings["SubtitleSettings"] = subtitleSettings.base64EncodedString()
|
||||
}
|
||||
if let logFilterStates = UserDefaults.standard.dictionary(forKey: "LogFilterStates") {
|
||||
userSettings["LogFilterStates"] = logFilterStates
|
||||
}
|
||||
if let segmentsColorData = UserDefaults.standard.data(forKey: "segmentsColorData") {
|
||||
userSettings["segmentsColorData"] = segmentsColorData.base64EncodedString()
|
||||
}
|
||||
let backup: [String: Any] = [
|
||||
"continueWatching": continueWatching.map { try? $0.toDictionary() },
|
||||
"continueReading": continueReading.map { try? $0.toDictionary() },
|
||||
"collections": collections.map { try? $0.toDictionary() },
|
||||
"searchHistory": searchHistory,
|
||||
"modules": modules.map { try? $0.toDictionary() }
|
||||
"modules": modules.map { try? $0.toDictionary() },
|
||||
"userSettings": userSettings
|
||||
]
|
||||
|
||||
return try? JSONSerialization.data(withJSONObject: backup, options: .prettyPrinted)
|
||||
}
|
||||
|
||||
|
|
@ -221,15 +377,255 @@ struct SettingsViewBackup: View {
|
|||
let modulesURL = docs.appendingPathComponent("modules.json")
|
||||
try modData.write(to: modulesURL)
|
||||
}
|
||||
// Restore user settings if present
|
||||
if let userSettings = json["userSettings"] as? [String: Any] {
|
||||
for (key, value) in userSettings {
|
||||
if let str = value as? String, let data = Data(base64Encoded: str), ["SubtitleSettings", "segmentsColorData", "metadataProvidersOrderData", "librarySectionsOrderData", "disabledLibrarySectionsData"].contains(key) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
UserDefaults.standard.synchronize()
|
||||
}
|
||||
|
||||
|
||||
private func exportFilename() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||
let dateString = formatter.string(from: Date())
|
||||
return "SoraBackup_\(dateString).json"
|
||||
fileprivate struct SettingsNavigationRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let showChevron: Bool
|
||||
let textColor: Color
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(textColor)
|
||||
Text(title)
|
||||
.foregroundStyle(textColor)
|
||||
Spacer()
|
||||
if showChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.background(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
struct BackupListView: View {
|
||||
@State private var backups: [URL] = []
|
||||
@State private var showShareSheet = false
|
||||
@State private var shareURL: URL? = nil
|
||||
@State private var selectedBackup: URL? = nil
|
||||
@State private var deleteURL: URL? = nil
|
||||
@State private var alertMessage = ""
|
||||
@State private var activeAlert: ActiveAlert? = nil
|
||||
|
||||
private enum ActiveAlert: Identifiable {
|
||||
case delete, importBackup, info
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .delete: return 0
|
||||
case .importBackup: return 1
|
||||
case .info: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshBackups() {
|
||||
backups = listBackupFiles()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(backups.indices, id: \ .self) { idx in
|
||||
let url = backups[idx]
|
||||
VStack(spacing: 0) {
|
||||
SettingsNavigationRow(
|
||||
icon: "doc",
|
||||
title: url.lastPathComponent,
|
||||
showChevron: false,
|
||||
textColor: .accentColor
|
||||
) {
|
||||
selectedBackup = url
|
||||
activeAlert = .importBackup
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Export", systemImage: "square.and.arrow.up") {
|
||||
shareURL = url
|
||||
showShareSheet = true
|
||||
}
|
||||
Button("Delete", role: .destructive, action: {
|
||||
deleteURL = url
|
||||
activeAlert = .delete
|
||||
})
|
||||
}
|
||||
if idx != backups.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
if !backups.isEmpty {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
if backups.isEmpty {
|
||||
Text("No backups found in the Backups folder.")
|
||||
.foregroundColor(.gray)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
if !backups.isEmpty {
|
||||
Text("Tap on a backup to import it.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { refreshBackups() }) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: refreshBackups)
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let url = shareURL {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
}
|
||||
.alert(item: $activeAlert) { alertType in
|
||||
switch alertType {
|
||||
case .delete:
|
||||
return Alert(
|
||||
title: Text("Delete Backup"),
|
||||
message: Text("Are you sure you want to delete this backup?"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
if let url = deleteURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
refreshBackups()
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .importBackup:
|
||||
return Alert(
|
||||
title: Text("Import Backup"),
|
||||
message: Text("Are you sure you want to import this backup? This will overwrite your current data."),
|
||||
primaryButton: .destructive(Text("Import")) {
|
||||
if let url = selectedBackup {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
try restoreBackupData(data)
|
||||
alertMessage = "Import successful! The app will now restart to apply the changes."
|
||||
} catch {
|
||||
alertMessage = "Import failed: \(error.localizedDescription)"
|
||||
}
|
||||
activeAlert = .info
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .info:
|
||||
return Alert(title: Text("Backup"), message: Text(alertMessage), dismissButton: .default(Text("OK")) {
|
||||
if alertMessage.contains("restart") {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationTitle("Backups")
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportNoticeView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "arrow.down.doc")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(.accentColor)
|
||||
Text("How to Import a Backup")
|
||||
.font(.title2).bold()
|
||||
.padding(.bottom, 8)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "1.circle.fill").foregroundColor(.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Open the **Files** app on your device.")
|
||||
Text("")
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "2.circle.fill").foregroundColor(.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Navigate to:")
|
||||
Text("**On My iPhone/iPad** > **Sora** > **Backups**")
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "3.circle.fill").foregroundColor(.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Copy your backup file (ending in **.json**) into the **Backups** folder.")
|
||||
}
|
||||
}
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "4.circle.fill").foregroundColor(.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Return to Sora and tap **Show Backups** to see your file.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.body)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 16)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -291,9 +291,10 @@ struct SettingsViewData: View {
|
|||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
||||
for fileURL in fileURLs {
|
||||
if fileURL.lastPathComponent == "Backups" { continue } // Skip Backups folder
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
Logger.shared.log("All files in documents folder removed", type: "General")
|
||||
Logger.shared.log("All files in documents folder removed (except Backups)", type: "General")
|
||||
exit(0)
|
||||
} catch {
|
||||
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
|
||||
|
|
|
|||
|
|
@ -158,31 +158,13 @@ struct SettingsViewGeneral: View {
|
|||
try! JSONEncoder().encode(["TMDB","AniList"])
|
||||
}()
|
||||
@AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original"
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
@AppStorage("metadataProviders") private var metadataProviders: String = "TMDB"
|
||||
@AppStorage("librarySectionsOrderData") private var librarySectionsOrderData: Data = {
|
||||
try! JSONEncoder().encode(["continueWatching", "continueReading", "collections"])
|
||||
}()
|
||||
@AppStorage("disabledLibrarySectionsData") private var disabledLibrarySectionsData: Data = {
|
||||
try! JSONEncoder().encode([String]())
|
||||
}()
|
||||
|
||||
private var metadataProvidersOrder: [String] {
|
||||
get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] }
|
||||
set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) }
|
||||
}
|
||||
|
||||
private var librarySectionsOrder: [String] {
|
||||
get { (try? JSONDecoder().decode([String].self, from: librarySectionsOrderData)) ?? ["continueWatching", "continueReading", "collections"] }
|
||||
set { librarySectionsOrderData = try! JSONEncoder().encode(newValue) }
|
||||
}
|
||||
|
||||
private var disabledLibrarySections: [String] {
|
||||
get { (try? JSONDecoder().decode([String].self, from: disabledLibrarySectionsData)) ?? [] }
|
||||
set { disabledLibrarySectionsData = try! JSONEncoder().encode(newValue) }
|
||||
}
|
||||
|
||||
private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"]
|
||||
private let sortOrderOptions = ["Ascending", "Descending"]
|
||||
private let metadataProvidersList = ["TMDB", "AniList"]
|
||||
|
|
@ -244,6 +226,7 @@ struct SettingsViewGeneral: View {
|
|||
"Kazakh",
|
||||
"Mongolian",
|
||||
"Norsk",
|
||||
"Romanian",
|
||||
"Russian",
|
||||
"Slovak",
|
||||
"Spanish",
|
||||
|
|
@ -266,6 +249,7 @@ struct SettingsViewGeneral: View {
|
|||
case "Mongolian": return "Монгол"
|
||||
case "Swedish": return "Svenska"
|
||||
case "Italian": return "Italiano"
|
||||
case "Romanian": return "Română"
|
||||
default: return lang
|
||||
}
|
||||
},
|
||||
|
|
@ -348,30 +332,6 @@ struct SettingsViewGeneral: View {
|
|||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: NSLocalizedString("Media Grid Layout", comment: ""),
|
||||
footer: NSLocalizedString("Adjust the number of media items per row in portrait and landscape modes.", comment: "")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "rectangle.portrait",
|
||||
title: NSLocalizedString("Portrait Columns", comment: ""),
|
||||
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4),
|
||||
optionToString: { "\($0)" },
|
||||
selection: $mediaColumnsPortrait
|
||||
)
|
||||
|
||||
SettingsPickerRow(
|
||||
icon: "rectangle",
|
||||
title: NSLocalizedString("Landscape Columns", comment: ""),
|
||||
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5),
|
||||
optionToString: { "\($0)" },
|
||||
selection: $mediaColumnsLandscape,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
SettingsSection(
|
||||
title: NSLocalizedString("Advanced", comment: ""),
|
||||
footer: NSLocalizedString("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.", comment: "")
|
||||
|
|
@ -383,70 +343,6 @@ struct SettingsViewGeneral: View {
|
|||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: NSLocalizedString("Library View", comment: ""),
|
||||
footer: NSLocalizedString("Customize the sections shown in your library. You can reorder sections or disable them completely.", comment: "")
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(NSLocalizedString("Library Sections Order", comment: ""))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
List {
|
||||
ForEach(Array(librarySectionsOrder.enumerated()), id: \.element) { index, section in
|
||||
HStack {
|
||||
Text("\(index + 1)")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Image(systemName: sectionIcon(for: section))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text(sectionName(for: section))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: toggleBinding(for: section))
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparatorTint(.gray.opacity(0.3))
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.onMove { from, to in
|
||||
var arr = librarySectionsOrder
|
||||
arr.move(fromOffsets: from, toOffset: to)
|
||||
librarySectionsOrderData = try! JSONEncoder().encode(arr)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.frame(height: CGFloat(librarySectionsOrder.count * 70))
|
||||
.background(Color.clear)
|
||||
|
||||
Text(NSLocalizedString("Drag to reorder sections", comment: ""))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, -6)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
|
|
@ -460,47 +356,4 @@ struct SettingsViewGeneral: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionName(for section: String) -> String {
|
||||
switch section {
|
||||
case "continueWatching":
|
||||
return NSLocalizedString("Continue Watching", comment: "")
|
||||
case "continueReading":
|
||||
return NSLocalizedString("Continue Reading", comment: "")
|
||||
case "collections":
|
||||
return NSLocalizedString("Collections", comment: "")
|
||||
default:
|
||||
return section
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionIcon(for section: String) -> String {
|
||||
switch section {
|
||||
case "continueWatching":
|
||||
return "play.fill"
|
||||
case "continueReading":
|
||||
return "book.fill"
|
||||
case "collections":
|
||||
return "folder.fill"
|
||||
default:
|
||||
return "questionmark"
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleBinding(for section: String) -> Binding<Bool> {
|
||||
return Binding(
|
||||
get: { !self.disabledLibrarySections.contains(section) },
|
||||
set: { isEnabled in
|
||||
var sections = self.disabledLibrarySections
|
||||
if isEnabled {
|
||||
sections.removeAll { $0 == section }
|
||||
} else {
|
||||
if !sections.contains(section) {
|
||||
sections.append(section)
|
||||
}
|
||||
}
|
||||
self.disabledLibrarySectionsData = try! JSONEncoder().encode(sections)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
//
|
||||
// SettingsViewLibrary.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by paul on 05/02/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
fileprivate struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
let footer: String?
|
||||
let content: Content
|
||||
|
||||
init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.footer = footer
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
if let footer = footer {
|
||||
Text(footer)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsToggleRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
@Binding var isOn: Bool
|
||||
var showDivider: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider().padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct SettingsPickerRow<T: Hashable>: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let options: [T]
|
||||
let optionToString: (T) -> String
|
||||
@Binding var selection: T
|
||||
var showDivider: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(optionToString(option)).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if showDivider {
|
||||
Divider().padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsViewLibrary: View {
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
@AppStorage("librarySectionsOrderData") private var librarySectionsOrderData: Data = {
|
||||
try! JSONEncoder().encode(["continueWatching", "continueReading", "collections"])
|
||||
}()
|
||||
@AppStorage("disabledLibrarySectionsData") private var disabledLibrarySectionsData: Data = {
|
||||
try! JSONEncoder().encode([String]())
|
||||
}()
|
||||
|
||||
private var librarySectionsOrder: [String] {
|
||||
get { (try? JSONDecoder().decode([String].self, from: librarySectionsOrderData)) ?? ["continueWatching", "continueReading", "collections"] }
|
||||
set { librarySectionsOrderData = try! JSONEncoder().encode(newValue) }
|
||||
}
|
||||
|
||||
private var disabledLibrarySections: [String] {
|
||||
get { (try? JSONDecoder().decode([String].self, from: disabledLibrarySectionsData)) ?? [] }
|
||||
set { disabledLibrarySectionsData = try! JSONEncoder().encode(newValue) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: NSLocalizedString("Media Grid Layout", comment: ""),
|
||||
footer: NSLocalizedString("Adjust the number of media items per row in portrait and landscape modes.", comment: "")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "rectangle.portrait",
|
||||
title: NSLocalizedString("Portrait Columns", comment: ""),
|
||||
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4),
|
||||
optionToString: { "\($0)" },
|
||||
selection: $mediaColumnsPortrait
|
||||
)
|
||||
|
||||
SettingsPickerRow(
|
||||
icon: "rectangle",
|
||||
title: NSLocalizedString("Landscape Columns", comment: ""),
|
||||
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5),
|
||||
optionToString: { "\($0)" },
|
||||
selection: $mediaColumnsLandscape,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: NSLocalizedString("Library View", comment: ""),
|
||||
footer: NSLocalizedString("Customize the sections shown in your library. You can reorder sections or disable them completely.", comment: "")
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(NSLocalizedString("Library Sections Order", comment: ""))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
List {
|
||||
ForEach(Array(librarySectionsOrder.enumerated()), id: \.element) { index, section in
|
||||
HStack {
|
||||
Text("\(index + 1)")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Image(systemName: sectionIcon(for: section))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Text(sectionName(for: section))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: toggleBinding(for: section))
|
||||
.labelsHidden()
|
||||
.tint(.accentColor.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparatorTint(.gray.opacity(0.3))
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.onMove { from, to in
|
||||
var arr = librarySectionsOrder
|
||||
arr.move(fromOffsets: from, toOffset: to)
|
||||
librarySectionsOrderData = try! JSONEncoder().encode(arr)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.frame(height: CGFloat(librarySectionsOrder.count * 70))
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle(NSLocalizedString("Library", comment: ""))
|
||||
.scrollViewBottomPadding()
|
||||
}
|
||||
|
||||
private func sectionName(for section: String) -> String {
|
||||
switch section {
|
||||
case "continueWatching":
|
||||
return NSLocalizedString("Continue Watching", comment: "")
|
||||
case "continueReading":
|
||||
return NSLocalizedString("Continue Reading", comment: "")
|
||||
case "collections":
|
||||
return NSLocalizedString("Collections", comment: "")
|
||||
default:
|
||||
return section.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionIcon(for section: String) -> String {
|
||||
switch section {
|
||||
case "continueWatching":
|
||||
return "play.circle"
|
||||
case "continueReading":
|
||||
return "book"
|
||||
case "collections":
|
||||
return "folder"
|
||||
default:
|
||||
return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleBinding(for section: String) -> Binding<Bool> {
|
||||
return Binding(
|
||||
get: { !self.disabledLibrarySections.contains(section) },
|
||||
set: { isEnabled in
|
||||
var sections = self.disabledLibrarySections
|
||||
if isEnabled {
|
||||
sections.removeAll { $0 == section }
|
||||
} else {
|
||||
if !sections.contains(section) {
|
||||
sections.append(section)
|
||||
}
|
||||
}
|
||||
self.disabledLibrarySectionsData = try! JSONEncoder().encode(sections)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -171,6 +171,11 @@ struct SettingsView: View {
|
|||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewLibrary().navigationBarBackButtonHidden(false)) {
|
||||
SettingsNavigationRow(icon: "books.vertical", titleKey: "Library")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewPlayer().navigationBarBackButtonHidden(false)) {
|
||||
SettingsNavigationRow(icon: "play.circle", titleKey: "Video Player")
|
||||
}
|
||||
|
|
@ -458,6 +463,40 @@ class Settings: ObservableObject {
|
|||
languageCode = "kk"
|
||||
case "Mongolian":
|
||||
languageCode = "mn"
|
||||
|
||||
let mainBundle = Bundle.main
|
||||
if let lprojPaths = mainBundle.paths(forResourcesOfType: "lproj", inDirectory: nil) as? [String] {
|
||||
let availableLangs = lprojPaths.map { path -> String in
|
||||
let components = path.components(separatedBy: "/")
|
||||
let filename = components.last ?? ""
|
||||
return filename.replacingOccurrences(of: ".lproj", with: "")
|
||||
}
|
||||
Logger.shared.log("Available language bundles: \(availableLangs.joined(separator: ", "))", type: "Debug")
|
||||
}
|
||||
|
||||
if let _ = mainBundle.path(forResource: "mn", ofType: "lproj") {
|
||||
Logger.shared.log("Found mn.lproj bundle", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("mn.lproj bundle not found", type: "Error")
|
||||
}
|
||||
case "Romanian":
|
||||
languageCode = "ro"
|
||||
|
||||
let mainBundle = Bundle.main
|
||||
if let lprojPaths = mainBundle.paths(forResourcesOfType: "lproj", inDirectory: nil) as? [String] {
|
||||
let availableLangs = lprojPaths.map { path -> String in
|
||||
let components = path.components(separatedBy: "/")
|
||||
let filename = components.last ?? ""
|
||||
return filename.replacingOccurrences(of: ".lproj", with: "")
|
||||
}
|
||||
Logger.shared.log("Available language bundles: \(availableLangs.joined(separator: ", "))", type: "Debug")
|
||||
}
|
||||
|
||||
if let _ = mainBundle.path(forResource: "ro", ofType: "lproj") {
|
||||
Logger.shared.log("Found ro.lproj bundle", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("ro.lproj bundle not found", type: "Error")
|
||||
}
|
||||
case "Swedish":
|
||||
languageCode = "sv"
|
||||
case "Italian":
|
||||
|
|
@ -465,7 +504,32 @@ class Settings: ObservableObject {
|
|||
default:
|
||||
languageCode = "en"
|
||||
}
|
||||
|
||||
UserDefaults.standard.set([languageCode], forKey: "AppleLanguages")
|
||||
Logger.shared.log("Setting language to: \(languageCode) for \(selectedLanguage)", type: "Debug")
|
||||
|
||||
UserDefaults.standard.synchronize()
|
||||
|
||||
LocalizationManager.shared.setLanguage(languageCode)
|
||||
|
||||
if selectedLanguage == "Mongolian" {
|
||||
if let mongolianBundle = Bundle(path: Bundle.main.path(forResource: "mn", ofType: "lproj") ?? "") {
|
||||
Logger.shared.log("Mongolian bundle: \(mongolianBundle)", type: "Debug")
|
||||
|
||||
let testKey = "About"
|
||||
let testString = mongolianBundle.localizedString(forKey: testKey, value: nil, table: nil)
|
||||
Logger.shared.log("Test Mongolian string for '\(testKey)': \(testString)", type: "Debug")
|
||||
}
|
||||
}
|
||||
|
||||
if selectedLanguage == "Romanian" {
|
||||
if let romanianBundle = Bundle(path: Bundle.main.path(forResource: "ro", ofType: "lproj") ?? "") {
|
||||
Logger.shared.log("Romanian bundle: \(romanianBundle)", type: "Debug")
|
||||
|
||||
let testKey = "About"
|
||||
let testString = romanianBundle.localizedString(forKey: testKey, value: nil, table: nil)
|
||||
Logger.shared.log("Test Romanian string for '\(testKey)': \(testString)", type: "Debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@
|
|||
0410697F2E00ABE900A157BB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0410697C2E00ABE900A157BB /* Localizable.strings */; };
|
||||
041069832E00C71000A157BB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041069812E00C71000A157BB /* Localizable.strings */; };
|
||||
041261042E00D14F00D05B47 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 041261022E00D14F00D05B47 /* Localizable.strings */; };
|
||||
0414ECFE2E32D6EF00A7E76A /* Bundle+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0414ECFC2E32D6EF00A7E76A /* Bundle+Language.swift */; };
|
||||
0414ECFF2E32D6EF00A7E76A /* LocalizationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0414ECFD2E32D6EF00A7E76A /* LocalizationManager.swift */; };
|
||||
0414ED032E32D72400A7E76A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0414ED012E32D72400A7E76A /* Localizable.strings */; };
|
||||
0414ED052E32D90000A7E76A /* SettingsViewLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0414ED042E32D90000A7E76A /* SettingsViewLibrary.swift */; };
|
||||
041E9D722E11D71F0025F150 /* SettingsViewBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */; };
|
||||
04536F712E04BA3B00A11248 /* JSController-Novel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F702E04BA3B00A11248 /* JSController-Novel.swift */; };
|
||||
04536F742E04BA5600A11248 /* ReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04536F722E04BA5600A11248 /* ReaderView.swift */; };
|
||||
|
|
@ -42,6 +46,7 @@
|
|||
04AD07162E03704700EB74C1 /* BookmarkCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AD07152E03704700EB74C1 /* BookmarkCell.swift */; };
|
||||
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; };
|
||||
04E00C9F2E09F5920056124A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 04E00C9D2E09F5920056124A /* Localizable.strings */; };
|
||||
04E8EB062E34149800F4930D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 04E8EB042E34082100F4930D /* Localizable.strings */; };
|
||||
04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */; };
|
||||
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; };
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; };
|
||||
|
|
@ -138,6 +143,10 @@
|
|||
0410697B2E00ABE900A157BB /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
041069802E00C71000A157BB /* kk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kk; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
041261012E00D14F00D05B47 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
0414ECFC2E32D6EF00A7E76A /* Bundle+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Language.swift"; sourceTree = "<group>"; };
|
||||
0414ECFD2E32D6EF00A7E76A /* LocalizationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationManager.swift; sourceTree = "<group>"; };
|
||||
0414ED002E32D72400A7E76A /* mn-Cyrl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "mn-Cyrl"; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
0414ED042E32D90000A7E76A /* SettingsViewLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLibrary.swift; sourceTree = "<group>"; };
|
||||
041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewBackup.swift; sourceTree = "<group>"; };
|
||||
0452339E2E02149C002EA23C /* bos */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bos; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
04536F702E04BA3B00A11248 /* JSController-Novel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Novel.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -163,6 +172,7 @@
|
|||
04AD07152E03704700EB74C1 /* BookmarkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkCell.swift; sourceTree = "<group>"; };
|
||||
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
|
||||
04E00C9E2E09F5920056124A /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
04E8EB052E34082100F4930D /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = Localizable.strings; sourceTree = "<group>"; };
|
||||
04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
|
||||
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
|
||||
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -330,6 +340,14 @@
|
|||
path = sv.lproj;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0414ED022E32D72400A7E76A /* mn-Cyrl.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0414ED012E32D72400A7E76A /* Localizable.strings */,
|
||||
);
|
||||
path = "mn-Cyrl.lproj";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0452339C2E021491002EA23C /* bos.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -425,6 +443,14 @@
|
|||
path = it.lproj;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04E8EB032E34081100F4930D /* ro.lproj */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04E8EB042E34082100F4930D /* Localizable.strings */,
|
||||
);
|
||||
path = ro.lproj;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -582,6 +608,7 @@
|
|||
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0414ED042E32D90000A7E76A /* SettingsViewLibrary.swift */,
|
||||
7260B66C2E32A8CB00365CDA /* OrphanedDownloadsView.swift */,
|
||||
041E9D712E11D71F0025F150 /* SettingsViewBackup.swift */,
|
||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */,
|
||||
|
|
@ -620,6 +647,8 @@
|
|||
133D7C862D2BE2640075467E /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0414ECFC2E32D6EF00A7E76A /* Bundle+Language.swift */,
|
||||
0414ECFD2E32D6EF00A7E76A /* LocalizationManager.swift */,
|
||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
|
||||
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
|
||||
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
|
||||
|
|
@ -684,6 +713,8 @@
|
|||
13530BE02E00028E0048B7DE /* Localization */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04E8EB032E34081100F4930D /* ro.lproj */,
|
||||
0414ED022E32D72400A7E76A /* mn-Cyrl.lproj */,
|
||||
04F8DF9A2E1B2814006248D8 /* mn.lproj */,
|
||||
04E00C9A2E09E96B0056124A /* it.lproj */,
|
||||
0452339C2E021491002EA23C /* bos.lproj */,
|
||||
|
|
@ -929,6 +960,8 @@
|
|||
cs,
|
||||
it,
|
||||
mn,
|
||||
"mn-Cyrl",
|
||||
ro,
|
||||
);
|
||||
mainGroup = 133D7C612D2BE2500075467E;
|
||||
packageReferences = (
|
||||
|
|
@ -951,6 +984,7 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
04E8EB062E34149800F4930D /* Localizable.strings in Resources */,
|
||||
0488FA9A2DFDF380007575E1 /* Localizable.strings in Resources */,
|
||||
0488FA9E2DFDF3BB007575E1 /* Localizable.strings in Resources */,
|
||||
0409FE872DFF0870000DB00C /* Localizable.strings in Resources */,
|
||||
|
|
@ -964,6 +998,7 @@
|
|||
04E00C9F2E09F5920056124A /* Localizable.strings in Resources */,
|
||||
041069832E00C71000A157BB /* Localizable.strings in Resources */,
|
||||
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */,
|
||||
0414ED032E32D72400A7E76A /* Localizable.strings in Resources */,
|
||||
0488FA952DFDE724007575E1 /* Localizable.strings in Resources */,
|
||||
0488FA962DFDE724007575E1 /* Localizable.strings in Resources */,
|
||||
);
|
||||
|
|
@ -999,6 +1034,7 @@
|
|||
047F170E2E0C93F30081B5FB /* ContinueReadingItem.swift in Sources */,
|
||||
047F170F2E0C93F30081B5FB /* ContinueReadingManager.swift in Sources */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
0414ED052E32D90000A7E76A /* SettingsViewLibrary.swift in Sources */,
|
||||
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
|
||||
04AD07162E03704700EB74C1 /* BookmarkCell.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
|
|
@ -1057,6 +1093,8 @@
|
|||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
|
||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
||||
0414ECFE2E32D6EF00A7E76A /* Bundle+Language.swift in Sources */,
|
||||
0414ECFF2E32D6EF00A7E76A /* LocalizationManager.swift in Sources */,
|
||||
7273F0402E26B19700DF083D /* DownloadPersistence.swift in Sources */,
|
||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
||||
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */,
|
||||
|
|
@ -1135,6 +1173,14 @@
|
|||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0414ED012E32D72400A7E76A /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
0414ED002E32D72400A7E76A /* mn-Cyrl */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
0452339D2E02149C002EA23C /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
|
@ -1191,6 +1237,14 @@
|
|||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04E8EB042E34082100F4930D /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
04E8EB052E34082100F4930D /* ro */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
04F8DF9B2E1B2822006248D8 /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
|
|
|
|||
Loading…
Reference in a new issue