Compare commits

...

32 commits
1.0.0 ... main

Author SHA1 Message Date
cranci1
e609916df6 Merge branch 'dev' 2025-07-11 10:12:48 +02:00
cranci1
ac9d4f9e39 Revert "maybe test"
Some checks failed
Build and Release / Build IPA (push) Has been cancelled
Build and Release / Build Mac Catalyst (push) Has been cancelled
This reverts commit 19fe680a86.
2025-07-11 10:11:01 +02:00
cranci1
19fe680a86 maybe test 2025-07-11 10:03:33 +02:00
cranci1
b67b44a069 yeah looks a bit better tbh no?
Some checks failed
Build and Release / Build IPA (push) Has been cancelled
Build and Release / Build Mac Catalyst (push) Has been cancelled
2025-07-08 10:56:47 +02:00
cranci1
27b4212567 kinda fixed the title maybe?
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-07-08 10:51:51 +02:00
cranci1
94735cd23d buttons fixes 2025-07-08 10:25:41 +02:00
cranci1
982fe468c8 fixed build yaml file too 2025-07-08 10:11:06 +02:00
cranci1
681c43ec69 ok starting player refactor 2025-07-08 10:10:08 +02:00
50/50
e9da8de67e
title (#218) 2025-07-08 09:51:34 +02:00
cranci1
112770024c made default chunk size to 50
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-07-07 10:30:14 +02:00
cranci1
f6f9bcaa8f Fixed episode chunk alignment 2025-07-07 10:27:25 +02:00
cranci1
5607ec70ff what was this monster 😭
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-07-07 10:23:25 +02:00
cranci1
bca07c9c46 fixed logger issue 2025-07-07 10:17:10 +02:00
cranci1
501a3da48f fixed recent search issues 2025-07-07 10:15:36 +02:00
cranci
b9523c259f
Update README.md
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-07-06 16:06:49 +02:00
ibro
5d0c3fd977
Fixed chapters not loading (#216)
* hey

* mediainfoview fixed chapters not loading

* added back that

* ts annoying 🥀
2025-07-06 15:53:07 +02:00
cranci1
508dcd4a42 fixed TMDB not being primary choice when nil
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
(Seiike fault ofc)
2025-07-05 16:29:55 +02:00
cranci1
4aaf5ab518 removed paul tabs 2025-07-05 16:04:17 +02:00
cranci
0a411e8421
better download icon
Some checks failed
Build and Release / Build IPA (push) Has been cancelled
Build and Release / Build Mac Catalyst (push) Has been cancelled
2025-07-02 17:01:26 +02:00
realdoomsboygaming
3974fc7003
Add downloaded indicator to EpisodeCell (#215) 2025-07-02 17:00:30 +02:00
cranci1
52e7101472 nice
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-07-01 18:21:49 +02:00
cranci1
09b1d9b0b1 test crash fix + layout fix 2025-07-01 18:11:16 +02:00
realdoomsboygaming
d707858ad7
Add autoplay feature and playback end handling (#214)
- Introduced a new setting for "Autoplay Next" in the player settings.
- Implemented autoplay functionality in the CustomMediaPlayerViewController to automatically start the next episode when playback ends.
- Added notification observers for handling playback completion and managing the autoplay behavior.

Co-authored-by: cranci <100066266+cranci1@users.noreply.github.com>
2025-07-01 18:08:14 +02:00
cranci1
95d6d1ac14 removed non used files 2025-07-01 18:05:13 +02:00
cranci1
f5e0dc7cc7 added back trackers view 2025-07-01 17:58:38 +02:00
cranci1
1b6c62a204 better safe handling? 2025-07-01 17:56:00 +02:00
cranci1
ad323efe34 oh hell nah i aint changing the Package.resolved 2025-07-01 17:46:34 +02:00
cranci1
81393601e1 images reduction (-22kb)
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-07-01 14:43:49 +02:00
realdoomsboygaming
f03f4c0b8a
Fix One Pace (#212)
Some checks are pending
Build and Release / Build IPA (push) Waiting to run
Build and Release / Build Mac Catalyst (push) Waiting to run
2025-06-30 22:21:51 +02:00
cranci1
868e2251fe Update project.pbxproj 2025-06-19 15:22:58 +02:00
cranci1
cf5451599b Merge branch 'dev' 2025-06-18 17:59:18 +02:00
cranci
1bce1e1c5d
Update AllWatching.swift (#204) 2025-06-17 23:58:28 +02:00
32 changed files with 2287 additions and 1658 deletions

View file

@ -3,6 +3,10 @@ on:
push:
branches:
- dev
pull_request:
branches:
- dev
jobs:
build-ios:
name: Build IPA

View file

@ -35,17 +35,7 @@
## Installation
You can download Sora on the App Store for stable updates or on Testflight for more updates but maybe some instability. (Testflight is recommended):
<a href="https://apps.apple.com/us/app/sulfur/id6742741043">
<img src="https://askyourself.app/assets/appstore.png" width="170" alt="Build and Release IPA">
</a>
<a href="https://testflight.apple.com/join/qMUCpNaS">
<img src="https://askyourself.app/assets/testflight.png" width="170" alt="Build and Release IPA">
</a>
Additionally, you can install the app using Xcode or using the .ipa file, which you can find in the [Releases](https://github.com/cranci1/Sora/releases) tab or the [nightly](https://nightly.link/cranci1/Sora/workflows/build/dev/Sulfur-IPA.zip) build page.
You can download Sora using Xcode or using the .ipa file, which you can find in the [Releases](https://github.com/cranci1/Sora/releases) tab or the [Nightly](https://nightly.link/cranci1/Sora/workflows/build/dev/Sulfur-IPA.zip) build page.
## Frequently Asked Questions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -55,8 +55,11 @@ struct ContentView: View {
.searchable(text: $searchQuery)
} else {
ZStack(alignment: .bottom) {
Group {
ZStack {
tabView(for: selectedTab)
.id(selectedTab)
.transition(.opacity)
.animation(.easeInOut(duration: 0.3), value: selectedTab)
}
.onPreferenceChange(TabBarVisibilityKey.self) { shouldShowTabBar = $0 }
@ -120,5 +123,3 @@ struct TabBarVisibilityKey: PreferenceKey {
value = nextValue()
}
}

View file

@ -19,6 +19,7 @@
<string>de</string>
<string>it</string>
<string>kk</string>
<string>mn</string>
<string>nn</string>
<string>ru</string>
<string>sk</string>

View 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 125, 2650, 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" = "Унших үргэлжлүүлэх";

View file

@ -33,32 +33,8 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
VStack(spacing: 8) {
ZStack(alignment: .center) {
ZStack(alignment: .center) {
// Intro Segments
ForEach(introSegments, id: \.self) { segment in
HStack(spacing: 0) {
Spacer()
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
Rectangle()
.fill(introColor.opacity(0.5))
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
Spacer()
}
}
// Outro Segments
ForEach(outroSegments, id: \.self) { segment in
HStack(spacing: 0) {
Spacer()
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
Rectangle()
.fill(outroColor.opacity(0.5))
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
Spacer()
}
}
Capsule()
.fill(emptyColor)
.fill(.ultraThinMaterial)
}
.clipShape(Capsule())
@ -77,6 +53,28 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
Spacer(minLength: 0)
}
})
ForEach(introSegments, id: \.self) { segment in
HStack(spacing: 0) {
Spacer()
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
Rectangle()
.fill(introColor.opacity(0.5))
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
Spacer()
}
}
ForEach(outroSegments, id: \.self) { segment in
HStack(spacing: 0) {
Spacer()
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
Rectangle()
.fill(outroColor.opacity(0.5))
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
Spacer()
}
}
}
HStack {

View file

@ -21,6 +21,7 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
@State private var localTempProgress: T = 0
@State private var lastVolumeValue: T = 0
@GestureState private var isActive: Bool = false
@State private var isAtEnd: Bool = false
var body: some View {
GeometryReader { bounds in
@ -51,8 +52,9 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
handleIconTap()
}
}
.frame(width: isActive ? bounds.size.width * 1.02 : bounds.size.width, alignment: .center)
.frame(width: getStretchWidth(bounds: bounds), alignment: .center)
.animation(animation, value: isActive)
.animation(animation, value: isAtEnd)
}
.frame(width: bounds.size.width, height: bounds.size.height)
.gesture(
@ -61,16 +63,26 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
.onChanged { gesture in
let delta = gesture.translation.width / bounds.size.width
localTempProgress = T(delta)
let totalProgress = localRealProgress + localTempProgress
if totalProgress <= 0.0 || totalProgress >= 1.0 {
isAtEnd = true
} else {
isAtEnd = false
}
value = sliderValueInRange()
}
.onEnded { _ in
localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0)
localTempProgress = 0
isAtEnd = false
}
)
.onChange(of: isActive) { newValue in
if !newValue {
value = sliderValueInRange()
isAtEnd = false
}
onEditingChanged(newValue)
}
@ -91,7 +103,7 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
}
}
}
.frame(height: isActive ? height * 1.25 : height)
.frame(height: getStretchHeight())
}
private var getIconName: String {
@ -133,9 +145,12 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
}
private var animation: Animation {
isActive
? .spring()
: .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
.interpolatingSpring(
mass: 1.0,
stiffness: 100,
damping: 15,
initialVelocity: 0.0
)
}
private func progress(for val: T) -> T {
@ -150,4 +165,25 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
+ inRange.lowerBound
return max(min(rawVal, inRange.upperBound), inRange.lowerBound)
}
private func getStretchWidth(bounds: GeometryProxy) -> CGFloat {
let baseWidth = bounds.size.width
if isAtEnd {
return baseWidth * 1.08
} else if isActive {
return baseWidth * 1.04
} else {
return baseWidth
}
}
private func getStretchHeight() -> CGFloat {
if isAtEnd {
return height * 1.35
} else if isActive {
return height * 1.25
} else {
return height
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +0,0 @@
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let title: String
let href: String
let duration: Int?
}

View file

@ -23,9 +23,9 @@ struct SoraApp: App {
TraktToken.checkAuthenticationStatus { isAuthenticated in
if isAuthenticated {
Logger.shared.log("Trakt authentication is valid")
Logger.shared.log("Trakt authentication is valid", type: "Debug")
} else {
Logger.shared.log("Trakt authentication required", type: "Error")
Logger.shared.log("Trakt authentication required", type: "Debug")
}
}
}

View file

@ -159,8 +159,8 @@ class TraktToken {
guard status == errSecSuccess,
let tokenData = result as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return nil
}
return token
}
@ -179,8 +179,8 @@ class TraktToken {
guard status == errSecSuccess,
let tokenData = result as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return nil
}
return token
}

View file

@ -1,223 +0,0 @@
//
// DownloadManager.swift
// Sulfur
//
// Created by Francesco on 29/04/25.
//
import SwiftUI
import AVKit
import Foundation
import AVFoundation
struct DownloadedAsset: Identifiable, Codable {
let id: UUID
var name: String
let downloadDate: Date
let originalURL: URL
let localURL: URL
var fileSize: Int64?
let module: ScrapingModule
init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL, module: ScrapingModule) {
self.id = id
self.name = name
self.downloadDate = downloadDate
self.originalURL = originalURL
self.localURL = localURL
self.module = module
self.fileSize = getFileSize()
}
func getFileSize() -> Int64? {
do {
let values = try localURL.resourceValues(forKeys: [.fileSizeKey])
return Int64(values.fileSize ?? 0)
} catch {
return nil
}
}
}
class DownloadManager: NSObject, ObservableObject {
@Published var activeDownloads: [ActiveDownload] = []
@Published var savedAssets: [DownloadedAsset] = []
private var assetDownloadURLSession: AVAssetDownloadURLSession!
private var activeDownloadTasks: [URLSessionTask: (URL, ScrapingModule)] = [:]
override init() {
super.init()
initializeDownloadSession()
loadSavedAssets()
reconcileFileSystemAssets()
}
private func initializeDownloadSession() {
let configuration = URLSessionConfiguration.background(withIdentifier: "downloader-\(UUID().uuidString)")
assetDownloadURLSession = AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: self,
delegateQueue: .main
)
}
func downloadAsset(from url: URL, module: ScrapingModule, headers: [String: String]? = nil) {
var urlRequest = URLRequest(url: url)
if let headers = headers {
for (key, value) in headers {
urlRequest.addValue(value, forHTTPHeaderField: key)
}
} else {
urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin")
urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer")
}
urlRequest.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]])
let task = assetDownloadURLSession.makeAssetDownloadTask(
asset: asset,
assetTitle: url.lastPathComponent,
assetArtworkData: nil,
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
)
let download = ActiveDownload(
id: UUID(),
originalURL: url,
progress: 0,
task: task!
)
activeDownloads.append(download)
activeDownloadTasks[task!] = (url, module)
task?.resume()
}
func deleteAsset(_ asset: DownloadedAsset) {
do {
try FileManager.default.removeItem(at: asset.localURL)
savedAssets.removeAll { $0.id == asset.id }
saveAssets()
} catch {
Logger.shared.log("Error deleting asset: \(error)")
}
}
func renameAsset(_ asset: DownloadedAsset, newName: String) {
guard let index = savedAssets.firstIndex(where: { $0.id == asset.id }) else { return }
savedAssets[index].name = newName
saveAssets()
}
private func saveAssets() {
do {
let data = try JSONEncoder().encode(savedAssets)
UserDefaults.standard.set(data, forKey: "savedAssets")
} catch {
Logger.shared.log("Error saving assets: \(error)")
}
}
private func loadSavedAssets() {
guard let data = UserDefaults.standard.data(forKey: "savedAssets") else { return }
do {
savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data)
} catch {
Logger.shared.log("Error loading saved assets: \(error)")
}
}
private func reconcileFileSystemAssets() {
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
do {
let fileURLs = try FileManager.default.contentsOfDirectory(
at: documents,
includingPropertiesForKeys: [.creationDateKey, .fileSizeKey],
options: .skipsHiddenFiles
)
} catch {
Logger.shared.log("Error reconciling files: \(error)")
}
}
}
extension DownloadManager: AVAssetDownloadDelegate {
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
guard let (originalURL, module) = activeDownloadTasks[assetDownloadTask] else { return }
let newAsset = DownloadedAsset(
name: originalURL.lastPathComponent,
downloadDate: Date(),
originalURL: originalURL,
localURL: location,
module: module
)
savedAssets.append(newAsset)
saveAssets()
cleanupDownloadTask(assetDownloadTask)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error else { return }
Logger.shared.log("Download error: \(error.localizedDescription)")
cleanupDownloadTask(task)
}
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {
guard let (originalURL, _) = activeDownloadTasks[assetDownloadTask], let downloadIndex = activeDownloads.firstIndex(where: { $0.originalURL == originalURL }) else { return }
let progress = loadedTimeRanges
.map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds }
.reduce(0, +)
activeDownloads[downloadIndex].progress = progress
}
private func cleanupDownloadTask(_ task: URLSessionTask) {
activeDownloadTasks.removeValue(forKey: task)
activeDownloads.removeAll { $0.task == task }
}
}
struct DownloadProgressView: View {
let download: ActiveDownload
var body: some View {
VStack(alignment: .leading) {
Text(download.originalURL.lastPathComponent)
.font(.subheadline)
ProgressView(value: download.progress)
.progressViewStyle(LinearProgressViewStyle())
Text("\(Int(download.progress * 100))%")
.font(.caption)
}
}
}
struct AssetRowView: View {
let asset: DownloadedAsset
var body: some View {
VStack(alignment: .leading) {
Text(asset.name)
.font(.headline)
Text("\(asset.fileSize ?? 0) bytes • \(asset.downloadDate.formatted())")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
struct ActiveDownload: Identifiable {
let id: UUID
let originalURL: URL
var progress: Double
let task: URLSessionTask
}

View file

@ -86,18 +86,48 @@ extension JSContext {
}
var headers: [String: String]? = nil
if let headersDict = headersAny as? [String: Any] {
var safeHeaders: [String: String] = [:]
for (key, value) in headersDict {
if let valueStr = value as? String {
safeHeaders[key] = valueStr
} else {
Logger.shared.log("Header value is not a String: \(key): \(value)", type: "Error")
if let headersAny = headersAny {
if headersAny is NSNull {
headers = nil
} else if let headersDict = headersAny as? [String: Any] {
var safeHeaders: [String: String] = [:]
for (key, value) in headersDict {
let stringValue: String
if let str = value as? String {
stringValue = str
} else if let num = value as? NSNumber {
stringValue = num.stringValue
} else if value is NSNull {
continue
} else {
stringValue = String(describing: value)
}
safeHeaders[key] = stringValue
}
headers = safeHeaders.isEmpty ? nil : safeHeaders
} else if let headersDict = headersAny as? [AnyHashable: Any] {
var safeHeaders: [String: String] = [:]
for (key, value) in headersDict {
let stringKey = String(describing: key)
let stringValue: String
if let str = value as? String {
stringValue = str
} else if let num = value as? NSNumber {
stringValue = num.stringValue
} else if value is NSNull {
continue
} else {
stringValue = String(describing: value)
}
safeHeaders[stringKey] = stringValue
}
headers = safeHeaders.isEmpty ? nil : safeHeaders
} else {
Logger.shared.log("Headers argument is not a dictionary, type: \(type(of: headersAny))", type: "Warning")
headers = nil
}
headers = safeHeaders
} else if headersAny != nil {
Logger.shared.log("Headers argument is not a dictionary", type: "Error")
}
let httpMethod = method ?? "GET"
@ -132,7 +162,9 @@ extension JSContext {
let textEncoding = getEncoding(from: encoding)
if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
let bodyIsEmpty = body == nil || (body)?.isEmpty == true || body == "null" || body == "undefined"
if httpMethod == "GET" && !bodyIsEmpty {
Logger.shared.log("GET request must not have a body", type: "Error")
DispatchQueue.main.async {
reject.call(withArguments: ["GET request must not have a body"])
@ -140,8 +172,13 @@ extension JSContext {
return
}
if httpMethod != "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
request.httpBody = body.data(using: .utf8)
if httpMethod != "GET" && !bodyIsEmpty {
if let bodyString = body {
request.httpBody = bodyString.data(using: .utf8)
} else {
let bodyString = String(describing: body!)
request.httpBody = bodyString.data(using: .utf8)
}
}
if let headers = headers {
@ -149,7 +186,8 @@ extension JSContext {
request.setValue(value, forHTTPHeaderField: key)
}
}
Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error")
Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Debug")
let session = URLSession.fetchData(allowRedirects: redirect.boolValue)
let task = session.downloadTask(with: request) { tempFileURL, response, error in
@ -181,8 +219,13 @@ extension JSContext {
var safeHeaders: [String: String] = [:]
if let httpResponse = response as? HTTPURLResponse {
for (key, value) in httpResponse.allHeaderFields {
if let keyString = key as? String,
let valueString = value as? String {
if let keyString = key as? String {
let valueString: String
if let str = value as? String {
valueString = str
} else {
valueString = String(describing: value)
}
safeHeaders[keyString] = valueString
}
}
@ -225,43 +268,45 @@ extension JSContext {
task.resume()
}
self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString)
let fetchv2Definition = """
function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true, encoding ) {
var processedBody = null;
if(method != "GET")
{
processedBody = (body && (typeof body === 'object')) ? JSON.stringify(body) : (body || null)
}
var finalEncoding = encoding || "utf-8";
return new Promise(function(resolve, reject) {
fetchV2Native(url, headers, method, processedBody, redirect, finalEncoding, function(rawText) {
const responseObj = {
headers: rawText.headers,
status: rawText.status,
_data: rawText.body,
text: function() {
return Promise.resolve(this._data);
},
json: function() {
try {
return Promise.resolve(JSON.parse(this._data));
} catch (e) {
return Promise.reject("JSON parse error: " + e.message);
}
}
};
resolve(responseObj);
}, reject);
});
}
function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true, encoding) {
var processedBody = null;
if(method != "GET") {
processedBody = (body && (typeof body === 'object')) ? JSON.stringify(body) : (body || null)
}
var finalEncoding = encoding || "utf-8";
// Ensure headers is an object and not null/undefined
var processedHeaders = {};
if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
processedHeaders = headers;
}
return new Promise(function(resolve, reject) {
fetchV2Native(url, processedHeaders, method, processedBody, redirect, finalEncoding, function(rawText) {
const responseObj = {
headers: rawText.headers,
status: rawText.status,
_data: rawText.body,
text: function() {
return Promise.resolve(this._data);
},
json: function() {
try {
return Promise.resolve(JSON.parse(this._data));
} catch (e) {
return Promise.reject("JSON parse error: " + e.message);
}
}
};
resolve(responseObj);
}, reject);
});
}
"""
self.evaluateScript(fetchv2Definition)
}

View file

@ -22,4 +22,5 @@ extension Notification.Name {
static let hideTabBar = Notification.Name("hideTabBar")
static let showTabBar = Notification.Name("showTabBar")
static let searchQueryChanged = Notification.Name("searchQueryChanged")
static let tabBarSearchQueryUpdated = Notification.Name("tabBarSearchQueryUpdated")
}

View file

@ -1197,7 +1197,7 @@ extension JSController: AVAssetDownloadDelegate {
let download = activeDownloads[downloadIndex]
// Move the downloaded file to Application Support directory for persistence
guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent) else {
guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent, originalURL: download.originalURL) else {
print("Failed to move downloaded file to persistent storage")
return
}
@ -1245,8 +1245,9 @@ extension JSController: AVAssetDownloadDelegate {
/// - Parameters:
/// - location: The original location from the download task
/// - filename: Name to use for the file
/// - originalURL: The original download URL to determine proper file extension
/// - Returns: URL to the new persistent location or nil if move failed
private func moveToApplicationSupportDirectory(from location: URL, filename: String) -> URL? {
private func moveToApplicationSupportDirectory(from location: URL, filename: String, originalURL: URL) -> URL? {
let fileManager = FileManager.default
// Get Application Support directory
@ -1269,23 +1270,31 @@ extension JSController: AVAssetDownloadDelegate {
let safeFilename = filename.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "-")
// Determine file extension based on the source location
// Determine file extension based on the original download URL, not the downloaded file
let fileExtension: String
if location.pathExtension.isEmpty {
// If no extension from the source, check if it's likely an HLS download (which becomes .movpkg)
// or preserve original URL extension
if safeFilename.contains(".m3u8") || safeFilename.contains("hls") {
fileExtension = "movpkg"
print("Using .movpkg extension for HLS download: \(safeFilename)")
} else {
fileExtension = "mp4" // Default for direct video downloads
print("Using .mp4 extension for direct video download: \(safeFilename)")
}
// Check the original URL to determine if this was an HLS stream or direct MP4
let originalURLString = originalURL.absoluteString.lowercased()
let originalPathExtension = originalURL.pathExtension.lowercased()
if originalURLString.contains(".m3u8") || originalURLString.contains("/hls/") || originalURLString.contains("m3u8") {
// This was an HLS stream, keep as .movpkg
fileExtension = "movpkg"
print("Using .movpkg extension for HLS download: \(safeFilename)")
} else if originalPathExtension == "mp4" || originalURLString.contains(".mp4") || originalURLString.contains("download") {
// This was a direct MP4 download, use .mp4 extension regardless of what AVAssetDownloadTask created
fileExtension = "mp4"
print("Using .mp4 extension for direct MP4 download: \(safeFilename)")
} else {
// Use the extension from the downloaded file
// Fallback: check the downloaded file extension
let sourceExtension = location.pathExtension.lowercased()
fileExtension = (sourceExtension == "movpkg") ? "movpkg" : "mp4"
print("Using extension from source file: \(sourceExtension) -> \(fileExtension)")
if sourceExtension == "movpkg" && originalURLString.contains("m3u8") {
fileExtension = "movpkg"
print("Using .movpkg extension for HLS stream: \(safeFilename)")
} else {
fileExtension = "mp4"
print("Using .mp4 extension as fallback: \(safeFilename)")
}
}
print("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)")

View file

@ -9,7 +9,6 @@ import Foundation
import JavaScriptCore
extension JSController {
func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
@ -94,41 +93,64 @@ extension JSController {
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
var hasLeftDetailsGroup = false
let detailsGroupQueue = DispatchQueue(label: "details.group")
let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString])
guard let promiseDetails = promiseValueDetails else {
Logger.shared.log("extractDetails did not return a Promise", type: "Error")
dispatchGroup.leave()
detailsGroupQueue.sync {
guard !hasLeftDetailsGroup else { return }
hasLeftDetailsGroup = true
dispatchGroup.leave()
}
completion([], [])
return
}
let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in
if let jsonOfDetails = result.toString(),
let dataDetails = jsonOfDetails.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] {
resultItems = array.map { item -> MediaItem in
MediaItem(
description: item["description"] as? String ?? "",
aliases: item["aliases"] as? String ?? "",
airdate: item["airdate"] as? String ?? ""
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error")
detailsGroupQueue.sync {
guard !hasLeftDetailsGroup else {
Logger.shared.log("extractDetails: thenBlock called but group already left", type: "Debug")
return
}
} else {
Logger.shared.log("Result is not a string of extractDetails", type: "Error")
hasLeftDetailsGroup = true
if let jsonOfDetails = result.toString(),
let dataDetails = jsonOfDetails.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] {
resultItems = array.map { item -> MediaItem in
MediaItem(
description: item["description"] as? String ?? "",
aliases: item["aliases"] as? String ?? "",
airdate: item["airdate"] as? String ?? ""
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractDetails", type: "Error")
}
dispatchGroup.leave()
}
dispatchGroup.leave()
}
let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error")
dispatchGroup.leave()
detailsGroupQueue.sync {
guard !hasLeftDetailsGroup else {
Logger.shared.log("extractDetails: catchBlock called but group already left", type: "Debug")
return
}
hasLeftDetailsGroup = true
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error")
dispatchGroup.leave()
}
}
let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context)
@ -140,50 +162,80 @@ extension JSController {
dispatchGroup.enter()
let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString])
var hasLeftEpisodesGroup = false
let episodesGroupQueue = DispatchQueue(label: "episodes.group")
let timeoutWorkItem = DispatchWorkItem {
Logger.shared.log("Timeout for extractEpisodes", type: "Warning")
dispatchGroup.leave()
episodesGroupQueue.sync {
guard !hasLeftEpisodesGroup else {
Logger.shared.log("extractEpisodes: timeout called but group already left", type: "Debug")
return
}
hasLeftEpisodesGroup = true
dispatchGroup.leave()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem)
guard let promiseEpisodes = promiseValueEpisodes else {
Logger.shared.log("extractEpisodes did not return a Promise", type: "Error")
timeoutWorkItem.cancel()
dispatchGroup.leave()
episodesGroupQueue.sync {
guard !hasLeftEpisodesGroup else { return }
hasLeftEpisodesGroup = true
dispatchGroup.leave()
}
completion([], [])
return
}
let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in
timeoutWorkItem.cancel()
if let jsonOfEpisodes = result.toString(),
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] {
episodeLinks = array.map { item -> EpisodeLink in
EpisodeLink(
number: item["number"] as? Int ?? 0,
title: "",
href: item["href"] as? String ?? "",
duration: nil
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error")
episodesGroupQueue.sync {
guard !hasLeftEpisodesGroup else {
Logger.shared.log("extractEpisodes: thenBlock called but group already left", type: "Debug")
return
}
} else {
Logger.shared.log("Result is not a string of extractEpisodes", type: "Error")
hasLeftEpisodesGroup = true
if let jsonOfEpisodes = result.toString(),
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] {
episodeLinks = array.map { item -> EpisodeLink in
EpisodeLink(
number: item["number"] as? Int ?? 0,
title: "",
href: item["href"] as? String ?? "",
duration: nil
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractEpisodes", type: "Error")
}
dispatchGroup.leave()
}
dispatchGroup.leave()
}
let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in
timeoutWorkItem.cancel()
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error")
dispatchGroup.leave()
episodesGroupQueue.sync {
guard !hasLeftEpisodesGroup else {
Logger.shared.log("extractEpisodes: catchBlock called but group already left", type: "Debug")
return
}
hasLeftEpisodesGroup = true
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error")
dispatchGroup.leave()
}
}
let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context)

View file

@ -59,30 +59,48 @@ extension JSController {
let group = DispatchGroup()
group.enter()
var chaptersArr: [[String: Any]] = []
var hasLeftGroup = false
let groupQueue = DispatchQueue(label: "extractChapters.group")
let thenBlock: @convention(block) (JSValue) -> Void = { jsValue in
Logger.shared.log("extractChapters thenBlock: \(jsValue)", type: "Debug")
if let arr = jsValue.toArray() as? [[String: Any]] {
Logger.shared.log("extractChapters: parsed as array, count = \(arr.count)", type: "Debug")
chaptersArr = arr
} else if let jsonString = jsValue.toString(), let data = jsonString.data(using: .utf8) {
do {
if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
Logger.shared.log("extractChapters: parsed as JSON string, count = \(arr.count)", type: "Debug")
chaptersArr = arr
} else {
Logger.shared.log("extractChapters: JSON string did not parse to array", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractChapters: \(error)", type: "Error")
groupQueue.sync {
guard !hasLeftGroup else {
Logger.shared.log("extractChapters: thenBlock called but group already left", type: "Debug")
return
}
} else {
Logger.shared.log("extractChapters: could not parse result", type: "Error")
hasLeftGroup = true
if let arr = jsValue.toArray() as? [[String: Any]] {
Logger.shared.log("extractChapters: parsed as array, count = \(arr.count)", type: "Debug")
chaptersArr = arr
} else if let jsonString = jsValue.toString(), let data = jsonString.data(using: .utf8) {
do {
if let arr = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
Logger.shared.log("extractChapters: parsed as JSON string, count = \(arr.count)", type: "Debug")
chaptersArr = arr
} else {
Logger.shared.log("extractChapters: JSON string did not parse to array", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractChapters: \(error)", type: "Error")
}
} else {
Logger.shared.log("extractChapters: could not parse result", type: "Error")
}
group.leave()
}
group.leave()
}
let catchBlock: @convention(block) (JSValue) -> Void = { jsValue in
Logger.shared.log("extractChapters catchBlock: \(jsValue)", type: "Error")
group.leave()
groupQueue.sync {
guard !hasLeftGroup else {
Logger.shared.log("extractChapters: catchBlock called but group already left", type: "Debug")
return
}
hasLeftGroup = true
group.leave()
}
}
result.invokeMethod("then", withArguments: [thenBlock])
result.invokeMethod("catch", withArguments: [catchBlock])
@ -182,24 +200,42 @@ extension JSController {
group.enter()
var extractedText = ""
var extractError: Error? = nil
var hasLeftGroup = false
let groupQueue = DispatchQueue(label: "extractText.group")
let thenBlock: @convention(block) (JSValue) -> Void = { jsValue in
Logger.shared.log("extractText thenBlock: received value", type: "Debug")
if let text = jsValue.toString(), !text.isEmpty {
Logger.shared.log("extractText: successfully extracted text", type: "Debug")
extractedText = text
} else {
extractError = JSError.emptyContent
groupQueue.sync {
guard !hasLeftGroup else {
Logger.shared.log("extractText: thenBlock called but group already left", type: "Debug")
return
}
hasLeftGroup = true
if let text = jsValue.toString(), !text.isEmpty {
Logger.shared.log("extractText: successfully extracted text", type: "Debug")
extractedText = text
} else {
extractError = JSError.emptyContent
}
group.leave()
}
group.leave()
}
let catchBlock: @convention(block) (JSValue) -> Void = { jsValue in
Logger.shared.log("extractText catchBlock: \(jsValue)", type: "Error")
if extractedText.isEmpty {
extractError = JSError.jsException(jsValue.toString() ?? "Unknown error")
groupQueue.sync {
guard !hasLeftGroup else {
Logger.shared.log("extractText: catchBlock called but group already left", type: "Debug")
return
}
hasLeftGroup = true
if extractedText.isEmpty {
extractError = JSError.jsException(jsValue.toString() ?? "Unknown error")
}
group.leave()
}
group.leave()
}
result.invokeMethod("then", withArguments: [thenBlock])
@ -277,8 +313,8 @@ extension JSController {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
Logger.shared.log("Direct fetch failed with status code: \((response as? HTTPURLResponse)?.statusCode ?? -1)", type: "Error")
throw JSError.invalidResponse
}
@ -317,4 +353,4 @@ extension JSController {
Logger.shared.log("Direct fetch successful, content length: \(content.count)", type: "Debug")
return content
}
}
}

View file

@ -24,7 +24,7 @@ extension Color {
default:
(r, g, b, a) = (1, 1, 1, 1)
}
self.init(
.sRGB,
red: Double(r) / 255,
@ -45,9 +45,18 @@ struct TabBar: View {
@FocusState private var keyboardFocus: Bool
@State private var keyboardHidden: Bool = true
@State private var searchLocked: Bool = false
@State private var keyboardHeight: CGFloat = 0
@GestureState private var isHolding: Bool = false
@State private var dragOffset: CGFloat = 0
@State private var isDragging: Bool = false
@State private var dragTargetIndex: Int? = nil
@State private var jellyScale: CGFloat = 1.0
@State private var lastDragTranslation: CGFloat = 0
@State private var previousDragOffset: CGFloat = 0
@State private var lastUpdateTime: Date = Date()
@State private var capsuleOffset: CGFloat = 0
private var gradientOpacity: CGFloat {
let accentColor = UIColor(Color.accentColor)
var white: CGFloat = 0
@ -57,6 +66,8 @@ struct TabBar: View {
@Namespace private var animation
private let tabWidth: CGFloat = 70
var body: some View {
HStack {
if showSearch && keyboardHidden {
@ -95,7 +106,6 @@ struct TabBar: View {
}
.disabled(!keyboardHidden || searchLocked)
}
HStack {
if showSearch {
HStack {
@ -116,7 +126,6 @@ struct TabBar: View {
}
}
.onChange(of: searchQuery) { newValue in
//
NotificationCenter.default.post(
name: .searchQueryChanged,
object: nil,
@ -142,10 +151,101 @@ struct TabBar: View {
.frame(height: 24)
.padding(8)
} else {
ForEach(0..<tabs.count, id: \.self) { index in
let tab = tabs[index]
tabButton(for: tab, index: index)
ZStack(alignment: .leading) {
let isActuallyMoving = abs(jellyScale - 1.0) > 0.01
Capsule()
.fill(.white)
.shadow(color: .black.opacity(0.2), radius: 6)
.frame(width: tabWidth, height: 44)
.scaleEffect(x: isActuallyMoving ? jellyScale : 1.0, y: isActuallyMoving ? (2.0 - jellyScale) : 1.0, anchor: .center)
.scaleEffect(isDragging || isHolding ? 1.15 : 1.0)
.offset(x: capsuleOffset)
.zIndex(1)
let capsuleIndex: Int = isDragging ? Int(round(dragOffset / tabWidth)) : selectedTab
HStack(spacing: 0) {
ForEach(0..<tabs.count, id: \ .self) { index in
let tab = tabs[index]
let shouldEnlarge = isDragging && index == capsuleIndex
let isSelected = (index == selectedTab)
let isActive = (isDragging && index == capsuleIndex) || (!isDragging && index == selectedTab)
if selectedTab == index {
tabButton(for: tab, index: index, scale: shouldEnlarge ? 1.35 : 1.0, isActive: isActive, isSelected: isSelected)
.frame(width: tabWidth, height: 44)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isDragging {
dragOffset = CGFloat(selectedTab) * tabWidth
capsuleOffset = dragOffset
isDragging = true
dragTargetIndex = selectedTab
}
if isDragging && selectedTab == index {
let now = Date()
let dt = now.timeIntervalSince(lastUpdateTime)
lastDragTranslation = value.translation.width
let totalWidth = tabWidth * CGFloat(tabs.count)
let startX = CGFloat(selectedTab) * tabWidth
let newOffset = startX + value.translation.width
dragOffset = min(max(newOffset, 0), totalWidth - tabWidth)
capsuleOffset = dragOffset
dragTargetIndex = dragTargetIndex(selectedTab: selectedTab, dragOffset: dragOffset, tabCount: tabs.count, tabWidth: tabWidth)
var velocity: CGFloat = 0
if dt > 0 {
velocity = (dragOffset - previousDragOffset) / CGFloat(dt)
}
let absVelocity = abs(velocity)
let scaleX = min(1.0 + min(absVelocity / 1200, 0.18), 1.18)
withAnimation(.interpolatingSpring(stiffness: 200, damping: 18)) {
jellyScale = scaleX
}
previousDragOffset = dragOffset
lastUpdateTime = now
}
}
.onEnded { value in
if isDragging && selectedTab == index {
previousDragOffset = 0
lastUpdateTime = Date()
lastDragTranslation = 0
let totalWidth = tabWidth * CGFloat(tabs.count)
let startX = CGFloat(selectedTab) * tabWidth
let newOffset = startX + value.translation.width
let target = dragTargetIndex(selectedTab: selectedTab, dragOffset: newOffset, tabCount: tabs.count, tabWidth: tabWidth)
withAnimation(.interpolatingSpring(stiffness: 110, damping: 19)) {
selectedTab = target
jellyScale = 1.0
capsuleOffset = CGFloat(target) * tabWidth
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) {
dragOffset = 0
isDragging = false
dragTargetIndex = nil
capsuleOffset = CGFloat(selectedTab) * tabWidth
}
if target == tabs.count - 1 {
searchLocked = true
withAnimation(.bouncy(duration: 0.3)) {
lastTab = index
showSearch = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
searchLocked = false
}
}
}
}
)
} else {
tabButton(for: tab, index: index, scale: shouldEnlarge ? 1.35 : 1.0, isActive: isActive, isSelected: isSelected)
.frame(width: tabWidth, height: 44)
.contentShape(Rectangle())
}
}
}
.zIndex(2)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: isDragging)
}
}
}
@ -180,7 +280,7 @@ struct TabBar: View {
.padding(.bottom, -100)
.padding(.top, -10)
}
.offset(y: keyboardFocus ? -keyboardHeight + 40 : 0)
.offset(y: keyboardFocus ? -keyboardHeight + 40 : 0)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardHeight)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: keyboardFocus)
.onChange(of: keyboardHeight) { newValue in
@ -188,6 +288,7 @@ struct TabBar: View {
}
}
.onAppear {
capsuleOffset = CGFloat(selectedTab) * tabWidth
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardHeight = keyboardFrame.height
@ -197,53 +298,73 @@ struct TabBar: View {
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
keyboardHeight = 0
}
NotificationCenter.default.addObserver(forName: .tabBarSearchQueryUpdated, object: nil, queue: .main) { notification in
if let query = notification.userInfo?["searchQuery"] as? String {
searchQuery = query
}
}
}
.onDisappear {
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: .tabBarSearchQueryUpdated, object: nil)
}
.onChange(of: selectedTab) { newValue in
if !isDragging {
withAnimation(.interpolatingSpring(stiffness: 320, damping: 22)) {
capsuleOffset = CGFloat(newValue) * tabWidth
}
}
}
}
@ViewBuilder
private func tabButton(for tab: TabItem, index: Int) -> some View {
Button(action: {
private func tabButton(for tab: TabItem, index: Int, scale: CGFloat = 1.0, isActive: Bool, isSelected: Bool) -> some View {
let icon = Image(systemName: tab.icon + (isActive ? ".fill" : ""))
.frame(width: 28, height: 28)
.matchedGeometryEffect(id: tab.icon, in: animation)
.foregroundStyle(isActive ? .black : .gray)
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(width: tabWidth)
.opacity(isActive ? 1 : 0.5)
.scaleEffect(scale)
return icon
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture()
.onEnded {
if isDragging || isHolding { return }
if index == tabs.count - 1 {
searchLocked = true
withAnimation(.bouncy(duration: 0.3)) {
lastTab = selectedTab
selectedTab = index
showSearch = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
searchLocked = false
}
} else {
if !searchLocked {
if (!searchLocked) {
withAnimation(.bouncy(duration: 0.3)) {
lastTab = selectedTab
selectedTab = index
}
}
}
}
}
}
}) {
Image(systemName: tab.icon + (selectedTab == index ? ".fill" : ""))
.frame(width: 28, height: 28)
.matchedGeometryEffect(id: tab.icon, in: animation)
.foregroundStyle(selectedTab == index ? .black : .gray)
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(width: 70)
.opacity(selectedTab == index ? 1 : 0.5)
}
.background(
selectedTab == index ?
Capsule()
.fill(.white)
.shadow(color: .black.opacity(0.2), radius: 6)
.matchedGeometryEffect(id: "background_capsule", in: animation)
: nil
)
)
}
private func enlargedTabIndex(selectedTab: Int, dragOffset: CGFloat, tabCount: Int, tabWidth: CGFloat) -> Int {
let index = Int(round(dragOffset / tabWidth))
return min(max(index, 0), tabCount - 1)
}
private func dragTargetIndex(selectedTab: Int, dragOffset: CGFloat, tabCount: Int, tabWidth: CGFloat) -> Int {
let index = Int(round(dragOffset / tabWidth))
return min(max(index, 0), tabCount - 1)
}
}

View file

@ -149,6 +149,10 @@ private extension EpisodeCell {
episodeThumbnail
episodeInfo
Spacer()
if case .downloaded = downloadStatus {
downloadedIndicator
.padding(.trailing, 8)
}
CircularProgressBar(progress: currentProgress)
.frame(width: 40, height: 40)
.padding(.trailing, 4)
@ -228,6 +232,12 @@ private extension EpisodeCell {
}
}
var downloadedIndicator: some View {
Image(systemName: "externaldrive.fill.badge.checkmark")
.foregroundColor(.accentColor)
.font(.system(size: 18))
}
var contextMenuContent: some View {
Group {
if progress <= 0.9 {

View file

@ -47,7 +47,7 @@ struct MediaInfoView: View {
@State private var selectedSeason: Int = 0
@State private var selectedRange: Range<Int> = {
let size = UserDefaults.standard.integer(forKey: "episodeChunkSize")
let chunk = size == 0 ? 100 : size
let chunk = size == 0 ? 50 : size
return 0..<chunk
}()
@ -76,7 +76,7 @@ struct MediaInfoView: View {
private var selectedSeasonKey: String { "selectedSeason_\(href)" }
@AppStorage("externalPlayer") private var externalPlayer: String = "Default"
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 50
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
@ObservedObject private var jsController = JSController.shared
@ -89,11 +89,11 @@ struct MediaInfoView: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
@AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = {
try! JSONEncoder().encode(["AniList","TMDB"])
try! JSONEncoder().encode(["TMDB","AniList"])
}()
private var metadataProvidersOrder: [String] {
get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] }
get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["TMDB","AniList"] }
set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) }
}
@ -164,11 +164,11 @@ struct MediaInfoView: View {
}
@State private var selectedChapterRange: Range<Int> = {
let size = UserDefaults.standard.integer(forKey: "chapterChunkSize")
let chunk = size == 0 ? 100 : size
let size = UserDefaults.standard.integer(forKey: "episodeChunkSize")
let chunk = size == 0 ? 50 : size
return 0..<chunk
}()
@AppStorage("chapterChunkSize") private var chapterChunkSize: Int = 100
@AppStorage("chapterChunkSize") private var chapterChunkSize: Int = 50
private var selectedChapterRangeKey: String { "selectedChapterRangeStart_\(href)" }
var body: some View {
@ -189,12 +189,12 @@ struct MediaInfoView: View {
UserDefaults.standard.set(true, forKey: "isMediaInfoActive")
// swipe back
/*
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = false
}
*/
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let navigationController = window.rootViewController?.children.first as? UINavigationController {
navigationController.interactivePopGestureRecognizer?.isEnabled = false
}
*/
}
.onChange(of: selectedRange) { newValue in
UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey)
@ -209,7 +209,7 @@ struct MediaInfoView: View {
currentFetchTask?.cancel()
activeFetchID = nil
UserDefaults.standard.set(false, forKey: "isMediaInfoActive")
UIScrollView.appearance().bounces = true
UIScrollView.appearance().bounces = true
}
.task {
await setupInitialData()
@ -239,7 +239,7 @@ struct MediaInfoView: View {
private var navigationOverlay: some View {
VStack {
HStack {
Button(action: {
Button(action: {
dismiss()
}) {
Image(systemName: "chevron.left")
@ -404,7 +404,7 @@ struct MediaInfoView: View {
.foregroundColor(.secondary)
.lineLimit(showFullSynopsis ? nil : 3)
.animation(nil, value: showFullSynopsis)
HStack {
Spacer()
Text(showFullSynopsis ? NSLocalizedString("LESS", comment: "") : NSLocalizedString("MORE", comment: ""))
@ -487,7 +487,7 @@ struct MediaInfoView: View {
.cornerRadius(15)
.gradientOutline()
}
Button(action: { openSafariViewController(with: href) }) {
Image(systemName: "safari")
.resizable()
@ -540,24 +540,11 @@ struct MediaInfoView: View {
if episodeLinks.count != 1 {
VStack(alignment: .leading, spacing: 16) {
episodesSectionHeader
if isGroupedBySeasons || episodeLinks.count > episodeChunkSize {
HStack(spacing: 8) {
if isGroupedBySeasons {
seasonSelectorStyled
}
Spacer()
if episodeLinks.count > episodeChunkSize {
rangeSelectorStyled
.padding(.trailing, 4)
}
}
.padding(.top, -8)
}
episodeListSection
}
}
}
@ViewBuilder
private var seasonSelectorStyled: some View {
let seasons = groupedEpisodes()
@ -581,7 +568,7 @@ struct MediaInfoView: View {
}
}
}
@ViewBuilder
private var rangeSelectorStyled: some View {
Menu {
@ -614,6 +601,20 @@ struct MediaInfoView: View {
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
}
@ -715,21 +716,23 @@ struct MediaInfoView: View {
.foregroundColor(.primary)
Spacer()
HStack(spacing: 4) {
if chapters.count > chapterChunkSize {
HStack {
Spacer()
chapterRangeSelectorStyled
}
.padding(.bottom, 0)
}
sourceButton
menuButton
}
}
if chapters.count > chapterChunkSize {
HStack {
Spacer()
chapterRangeSelectorStyled
}
.padding(.bottom, 0)
}
LazyVStack(spacing: 15) {
ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in
let chapter = chapters[i]
let _ = refreshTrigger
LazyVStack(spacing: 15) {
ForEach(chapters.indices.filter { selectedChapterRange.contains($0) }, id: \..self) { i in
let chapter = chapters[i]
let _ = refreshTrigger
if let href = chapter["href"] as? String,
let number = chapter["number"] as? Int,
let title = chapter["title"] as? String {
@ -751,7 +754,7 @@ struct MediaInfoView: View {
ChapterCell(
chapterNumber: String(number),
chapterTitle: title,
isCurrentChapter: false,
isCurrentChapter: false,
progress: UserDefaults.standard.double(forKey: "readingProgress_\(href)"),
href: href
)
@ -792,7 +795,7 @@ struct MediaInfoView: View {
}
}
}
@ViewBuilder
private var chapterRangeSelectorStyled: some View {
Menu {
@ -868,7 +871,7 @@ struct MediaInfoView: View {
.sheet(isPresented: $isMatchingPresented) {
AnilistMatchPopupView(seriesTitle: title) { id, matched in
handleAniListMatch(selectedID: id)
matchedTitle = matched
matchedTitle = matched
fetchMetadataIDIfNeeded()
}
}
@ -876,7 +879,7 @@ struct MediaInfoView: View {
TMDBMatchPopupView(seriesTitle: title) { id, type, matched in
tmdbID = id
tmdbType = type
matchedTitle = matched
matchedTitle = matched
fetchMetadataIDIfNeeded()
}
}
@ -952,7 +955,7 @@ struct MediaInfoView: View {
UserDefaults.standard.set(imageUrl, forKey: "mediaInfoImageUrl_\(module.id.uuidString)")
Logger.shared.log("Saved MediaInfoView image URL: \(imageUrl) for module \(module.id.uuidString)", type: "Debug")
if module.metadata.novel == true {
if !hasFetched {
@ -969,13 +972,11 @@ struct MediaInfoView: View {
if let jsContent = jsContent {
jsController.loadScript(jsContent)
}
await withTaskGroup(of: Void.self) { group in
var chaptersLoaded = false
var detailsLoaded = false
let timeout: TimeInterval = 8.0
let start = Date()
group.addTask {
let fetchedChapters = try? await JSController.shared.extractChapters(moduleId: module.id.uuidString, href: href)
await MainActor.run {
@ -997,9 +998,6 @@ struct MediaInfoView: View {
if !(self.synopsis.isEmpty && self.aliases.isEmpty && self.airdate.isEmpty) {
detailsLoaded = true
continuation.resume()
} else if Date().timeIntervalSince(start) > timeout {
detailsLoaded = true
continuation.resume()
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
checkDetails()
@ -1010,13 +1008,6 @@ struct MediaInfoView: View {
checkDetails()
}
}
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
await MainActor.run {
chaptersLoaded = true
detailsLoaded = true
}
}
while true {
let loaded = await MainActor.run { chaptersLoaded && detailsLoaded }
if loaded { break }
@ -1441,7 +1432,6 @@ struct MediaInfoView: View {
}
}
func fetchDetails() {
Logger.shared.log("fetchDetails: called", type: "Debug")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@ -1449,88 +1439,15 @@ struct MediaInfoView: View {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
let completion: (Any?, [EpisodeLink]) -> Void = { items, episodes in
self.handleFetchDetailsResponse(items: items, episodes: episodes)
}
if module.metadata.asyncJS == true {
jsController.fetchDetailsJS(url: href) { items, episodes in
Logger.shared.log("fetchDetails: items = \(items)", type: "Debug")
Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug")
if let mediaItems = items as? [MediaItem], let item = mediaItems.first {
self.synopsis = item.description
self.aliases = item.aliases
self.airdate = item.airdate
} else if let str = items as? String {
if let data = str.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let dict = arr.first {
self.synopsis = dict["description"] as? String ?? ""
self.aliases = dict["aliases"] as? String ?? ""
self.airdate = dict["airdate"] as? String ?? ""
}
} else if let dict = items as? [String: Any] {
self.synopsis = dict["description"] as? String ?? ""
self.aliases = dict["aliases"] as? String ?? ""
self.airdate = dict["airdate"] as? String ?? ""
} else if let arr = items as? [[String: Any]], let dict = arr.first {
self.synopsis = dict["description"] as? String ?? ""
self.aliases = dict["aliases"] as? String ?? ""
self.airdate = dict["airdate"] as? String ?? ""
} else {
Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error")
}
if self.module.metadata.novel ?? false {
Logger.shared.log("fetchDetails: (novel) chapters count = \(self.chapters.count)", type: "Debug")
self.isLoading = false
self.isRefetching = false
} else {
Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug")
self.episodeLinks = episodes
self.restoreSelectionState()
self.isLoading = false
self.isRefetching = false
}
}
jsController.fetchDetailsJS(url: href, completion: completion)
} else {
jsController.fetchDetails(url: href) { items, episodes in
Logger.shared.log("fetchDetails: items = \(items)", type: "Debug")
Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug")
if let mediaItems = items as? [MediaItem], let item = mediaItems.first {
self.synopsis = item.description
self.aliases = item.aliases
self.airdate = item.airdate
} else if let str = items as? String {
if let data = str.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let dict = arr.first {
self.synopsis = dict["description"] as? String ?? ""
self.aliases = dict["aliases"] as? String ?? ""
self.airdate = dict["airdate"] as? String ?? ""
}
} else if let dict = items as? [String: Any] {
self.synopsis = dict["description"] as? String ?? ""
self.aliases = dict["aliases"] as? String ?? ""
self.airdate = dict["airdate"] as? String ?? ""
} else if let arr = items as? [[String: Any]], let dict = arr.first {
self.synopsis = dict["description"] as? String ?? ""
self.aliases = dict["aliases"] as? String ?? ""
self.airdate = dict["airdate"] as? String ?? ""
} else {
Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error")
}
if self.module.metadata.novel ?? false {
Logger.shared.log("fetchDetails: (novel) chapters count = \(self.chapters.count)", type: "Debug")
self.isLoading = false
self.isRefetching = false
} else {
Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug")
self.episodeLinks = episodes
self.restoreSelectionState()
self.isLoading = false
self.isRefetching = false
}
}
jsController.fetchDetails(url: href, completion: completion)
}
} catch {
Logger.shared.log("Error loading module: \(error)", type: "Error")
@ -1541,6 +1458,53 @@ struct MediaInfoView: View {
}
}
private func handleFetchDetailsResponse(items: Any?, episodes: [EpisodeLink]) {
Logger.shared.log("fetchDetails: items = \(items)", type: "Debug")
Logger.shared.log("fetchDetails: episodes = \(episodes)", type: "Debug")
processItemsResponse(items)
if module.metadata.novel ?? false {
Logger.shared.log("fetchDetails: (novel) chapters count = \(chapters.count)", type: "Debug")
} else {
Logger.shared.log("fetchDetails: (episodes) episodes count = \(episodes.count)", type: "Debug")
episodeLinks = episodes
restoreSelectionState()
}
isLoading = false
isRefetching = false
}
private func processItemsResponse(_ items: Any?) {
if let mediaItems = items as? [MediaItem], let item = mediaItems.first {
synopsis = item.description
aliases = item.aliases
airdate = item.airdate
} else if let str = items as? String {
parseStringResponse(str)
} else if let dict = items as? [String: Any] {
extractMetadataFromDict(dict)
} else if let arr = items as? [[String: Any]], let dict = arr.first {
extractMetadataFromDict(dict)
} else {
Logger.shared.log("Failed to process items of type: \(type(of: items))", type: "Error")
}
}
private func parseStringResponse(_ str: String) {
guard let data = str.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let dict = arr.first else { return }
extractMetadataFromDict(dict)
}
private func extractMetadataFromDict(_ dict: [String: Any]) {
synopsis = dict["description"] as? String ?? ""
aliases = dict["aliases"] as? String ?? ""
airdate = dict["airdate"] as? String ?? ""
}
private func fetchAniListPosterImageAndSet() {
guard let listID = itemID, listID > 0 else { return }
AniListMutation().fetchCoverImage(animeId: listID) { result in
@ -1590,7 +1554,7 @@ struct MediaInfoView: View {
func checkCompletion() {
guard aniListCompleted && tmdbCompleted else { return }
let primaryProvider = order.first ?? "AniList"
let primaryProvider = order.first ?? "TMDB"
if primaryProvider == "AniList" && aniListSuccess {
activeProvider = "AniList"
@ -1721,7 +1685,6 @@ struct MediaInfoView: View {
}.resume()
}
func fetchStream(href: String) {
let fetchID = UUID()
activeFetchID = fetchID
@ -1966,7 +1929,6 @@ struct MediaInfoView: View {
}
}
private func downloadSingleEpisodeDirectly(episode: EpisodeLink) {
if isSingleEpisodeDownloading { return }
@ -2252,7 +2214,6 @@ struct MediaInfoView: View {
}.resume()
}
private func presentAlert(_ alert: UIAlertController) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
@ -2354,5 +2315,3 @@ struct MediaInfoView: View {
})
}
}

View file

@ -108,6 +108,12 @@ struct SearchView: View {
cellWidth: cellWidth,
onHistoryItemSelected: { query in
searchQuery = query
searchDebounceTimer?.invalidate()
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
NotificationCenter.default.post(name: .tabBarSearchQueryUpdated, object: nil, userInfo: ["searchQuery": query])
performSearch()
},
onHistoryItemDeleted: { index in
removeFromHistory(at: index)

View file

@ -1,251 +0,0 @@
//
// SettingsSharedComponents.swift
// Sora
//
import SwiftUI
// MARK: - Settings Section
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)
}
}
}
}
// MARK: - Settings Row
struct SettingsRow: View {
let icon: String
let title: String
var value: String? = nil
var isExternal: Bool = false
var textColor: Color = .primary
var showDivider: Bool = true
init(icon: String, title: String, value: String? = nil, isExternal: Bool = false, textColor: Color = .primary, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.value = value
self.isExternal = isExternal
self.textColor = textColor
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(textColor)
Text(title)
.foregroundStyle(textColor)
Spacer()
if let value = value {
Text(value)
.foregroundStyle(.gray)
}
if isExternal {
Image(systemName: "arrow.up.forward")
.foregroundStyle(.gray)
.font(.footnote)
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
.font(.footnote)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Settings Toggle Row
struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
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)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Settings Picker Row
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
init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
// MARK: - Settings Stepper Row
struct SettingsStepperRow: View {
let icon: String
let title: String
@Binding var value: Double
let range: ClosedRange<Double>
let step: Double
var formatter: (Double) -> String = { "\(Int($0))" }
var showDivider: Bool = true
init(icon: String, title: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._value = value
self.range = range
self.step = step
self.formatter = formatter
self.showDivider = showDivider
}
var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Stepper(formatter(value), value: $value, in: range, step: step)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}

View file

@ -1,246 +0,0 @@
//
// SettingsComponents.swift
// Sora
//
import SwiftUI
internal struct SettingsSection<Content: View>: View {
internal let title: String
internal let footer: String?
internal let content: Content
internal init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) {
self.title = title
self.footer = footer
self.content = content()
}
internal 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)
}
}
}
}
internal struct SettingsRow: View {
internal let icon: String
internal let title: String
internal var value: String? = nil
internal var isExternal: Bool = false
internal var textColor: Color = .primary
internal var showDivider: Bool = true
internal init(icon: String, title: String, value: String? = nil, isExternal: Bool = false, textColor: Color = .primary, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.value = value
self.isExternal = isExternal
self.textColor = textColor
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(textColor)
Text(title)
.foregroundStyle(textColor)
Spacer()
if let value = value {
Text(value)
.foregroundStyle(.gray)
}
if isExternal {
Image(systemName: "arrow.up.forward")
.foregroundStyle(.gray)
.font(.footnote)
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.gray)
.font(.footnote)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
internal struct SettingsToggleRow: View {
internal let icon: String
internal let title: String
@Binding internal var isOn: Bool
internal var showDivider: Bool = true
internal init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
internal 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)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
internal struct SettingsPickerRow<T: Hashable>: View {
internal let icon: String
internal let title: String
internal let options: [T]
internal let optionToString: (T) -> String
@Binding internal var selection: T
internal var showDivider: Bool = true
internal init(icon: String, title: String, options: [T], optionToString: @escaping (T) -> String, selection: Binding<T>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self.options = options
self.optionToString = optionToString
self._selection = selection
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Menu {
ForEach(options, id: \.self) { option in
Button(action: { selection = option }) {
Text(optionToString(option))
}
}
} label: {
Text(optionToString(selection))
.foregroundStyle(.gray)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}
internal struct SettingsStepperRow: View {
internal let icon: String
internal let title: String
@Binding internal var value: Double
internal let range: ClosedRange<Double>
internal let step: Double
internal var formatter: (Double) -> String = { "\(Int($0))" }
internal var showDivider: Bool = true
internal init(icon: String, title: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, formatter: @escaping (Double) -> String = { "\(Int($0))" }, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._value = value
self.range = range
self.step = step
self.formatter = formatter
self.showDivider = showDivider
}
internal var body: some View {
VStack(spacing: 0) {
HStack {
Image(systemName: icon)
.frame(width: 24, height: 24)
.foregroundStyle(.primary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Stepper(formatter(value), value: $value, in: range, step: step)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
if showDivider {
Divider()
.padding(.horizontal, 16)
}
}
}
}

View file

@ -191,11 +191,6 @@ struct ContributorsView: View {
id: 71751652,
login: "qooode",
avatarUrl: "https://avatars.githubusercontent.com/u/71751652?v=4"
),
Contributor(
id: 8116188,
login: "undeaDD",
avatarUrl: "https://avatars.githubusercontent.com/u/8116188?v=4"
)
]
}
@ -327,6 +322,12 @@ struct TranslatorsView: View {
login: "Cufiy",
avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/y1wwm0ed_png.png?raw=true",
language: "German"
),
Translator(
id: 10,
login: "yoshi1780",
avatarUrl: "https://github.com/50n50/assets/blob/main/pfps/262d7c1a61ff49355ddb74c76c7c5c7f_webp.png?raw=true",
language: "Mongolian"
)
]

View file

@ -105,7 +105,7 @@ struct SettingsViewBackup: View {
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.", comment: "Footer notice for experimental backup/restore feature")
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",

View file

@ -150,7 +150,6 @@ fileprivate struct SettingsPickerRow<T: Hashable>: View {
struct SettingsViewGeneral: View {
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
@AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false
@ -243,6 +242,7 @@ struct SettingsViewGeneral: View {
"German",
"Italian",
"Kazakh",
"Mongolian",
"Norsk",
"Russian",
"Slovak",
@ -263,6 +263,7 @@ struct SettingsViewGeneral: View {
case "Russian": return "Русский"
case "Norsk": return "Norsk"
case "Kazakh": return "Қазақша"
case "Mongolian": return "Монгол"
case "Swedish": return "Svenska"
case "Italian": return "Italiano"
default: return lang
@ -336,12 +337,12 @@ struct SettingsViewGeneral: View {
.listStyle(.plain)
.frame(height: CGFloat(metadataProvidersOrder.count * 65))
.background(Color.clear)
.padding(.bottom, 8)
Text(NSLocalizedString("Drag to reorder", comment: ""))
.font(.caption)
.foregroundStyle(.gray)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, -6)
.padding(.bottom, 8)
}
.environment(\.editMode, .constant(.active))
@ -369,17 +370,7 @@ struct SettingsViewGeneral: View {
)
}
SettingsSection(
title: NSLocalizedString("Modules", comment: ""),
footer: NSLocalizedString("Note that the modules will be replaced only if there is a different version string inside the JSON file.", comment: "")
) {
SettingsToggleRow(
icon: "arrow.clockwise",
title: NSLocalizedString("Refresh Modules on Launch", comment: ""),
isOn: $refreshModulesOnLaunch,
showDivider: false
)
}
SettingsSection(
title: NSLocalizedString("Advanced", comment: ""),
@ -446,12 +437,12 @@ struct SettingsViewGeneral: View {
.listStyle(.plain)
.frame(height: CGFloat(librarySectionsOrder.count * 70))
.background(Color.clear)
.padding(.bottom, 8)
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))

View file

@ -58,6 +58,46 @@ fileprivate struct SettingsSection<Content: View>: View {
}
}
fileprivate struct SettingsToggleRow: View {
let icon: String
let title: String
@Binding var isOn: Bool
var showDivider: Bool = true
init(icon: String, title: String, isOn: Binding<Bool>, showDivider: Bool = true) {
self.icon = icon
self.title = title
self._isOn = isOn
self.showDivider = showDivider
}
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 ModuleListItemView: View {
let module: Module
let selectedModuleId: String?
@ -151,6 +191,7 @@ struct SettingsViewModule: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@EnvironmentObject var moduleManager: ModuleManager
@AppStorage("didReceiveDefaultPageLink") private var didReceiveDefaultPageLink: Bool = false
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true
@State private var errorMessage: String?
@State private var isLoading = false
@ -212,6 +253,18 @@ struct SettingsViewModule: View {
}
}
}
SettingsSection(
title: NSLocalizedString("Module Settings", comment: ""),
footer: NSLocalizedString("Note that the modules will be replaced only if there is a different version string inside the JSON file.", comment: "")
) {
SettingsToggleRow(
icon: "arrow.clockwise",
title: NSLocalizedString("Refresh Modules on Launch", comment: ""),
isOn: $refreshModulesOnLaunch,
showDivider: false
)
}
}
.padding(.vertical, 20)
}

View file

@ -205,6 +205,7 @@ struct SettingsViewPlayer: View {
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
@AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true
@AppStorage("autoplayNext") private var autoplayNext: Bool = true
@AppStorage("videoQualityWiFi") private var wifiQuality: String = VideoQualityPreference.defaultWiFiPreference.rawValue
@AppStorage("videoQualityCellular") private var cellularQuality: String = VideoQualityPreference.defaultCellularPreference.rawValue
@ -247,6 +248,13 @@ struct SettingsViewPlayer: View {
showDivider: true
)
SettingsToggleRow(
icon: "play.circle.fill",
title: NSLocalizedString("Autoplay Next", comment: ""),
isOn: $autoplayNext,
showDivider: true
)
SettingsPickerRow(
icon: "timer",
title: NSLocalizedString("Completion Percentage", comment: ""),
@ -296,29 +304,6 @@ struct SettingsViewPlayer: View {
)
}
SettingsSection(title: NSLocalizedString("Progress bar Marker Color", comment: "")) {
ColorPicker(NSLocalizedString("Segments Color", comment: ""), selection: Binding(
get: {
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
return Color(uiColor)
}
return .yellow
},
set: { newColor in
let uiColor = UIColor(newColor)
if let data = try? NSKeyedArchiver.archivedData(
withRootObject: uiColor,
requiringSecureCoding: false
) {
UserDefaults.standard.set(data, forKey: "segmentsColorData")
}
}
))
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
SettingsSection(
title: NSLocalizedString("Skip Settings", comment: ""),
footer: NSLocalizedString("Double tapping the screen on it's sides will skip with the short tap setting.", comment: "")

View file

@ -127,11 +127,10 @@ fileprivate struct ModulePreviewRow: View {
}
struct SettingsView: View {
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
@Environment(\.colorScheme) var colorScheme
@StateObject var settings = Settings()
@EnvironmentObject var moduleManager: ModuleManager
@State private var isNavigationActive = false
var body: some View {
@ -177,6 +176,11 @@ struct SettingsView: View {
NavigationLink(destination: SettingsViewDownloads().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Downloads")
}
Divider().padding(.horizontal, 16)
NavigationLink(destination: SettingsViewTrackers().navigationBarBackButtonHidden(false)) {
SettingsNavigationRow(icon: "square.3.stack.3d", titleKey: "Trackers")
}
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
@ -248,7 +252,7 @@ struct SettingsView: View {
SettingsNavigationRow(icon: "info.circle", titleKey: "About Sora")
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora")!) {
HStack {
Image("Github Icon")
@ -270,7 +274,7 @@ struct SettingsView: View {
.padding(.vertical, 12)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) {
HStack {
Image("Discord Icon")
@ -292,7 +296,7 @@ struct SettingsView: View {
.padding(.vertical, 12)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) {
SettingsNavigationRow(
icon: "exclamationmark.circle.fill",
@ -302,7 +306,7 @@ struct SettingsView: View {
)
}
Divider().padding(.horizontal, 16)
Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) {
SettingsNavigationRow(
icon: "doc.text.fill",
@ -330,8 +334,8 @@ struct SettingsView: View {
)
.padding(.horizontal, 20)
}
Text("Sora \(version) by cranci1")
Text("Sora 1.0.1 by cranci1")
.font(.footnote)
.foregroundStyle(.gray)
.frame(maxWidth: .infinity, alignment: .center)
@ -449,6 +453,8 @@ class Settings: ObservableObject {
languageCode = "nn"
case "Kazakh":
languageCode = "kk"
case "Mongolian":
languageCode = "mn"
case "Swedish":
languageCode = "sv"
case "Italian":

View file

@ -165,6 +165,7 @@
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>"; };
04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = "<group>"; };
04F8DF9C2E1B2822006248D8 /* mn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mn; path = Localizable.strings; sourceTree = "<group>"; };
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
@ -444,6 +445,14 @@
path = Models;
sourceTree = "<group>";
};
04F8DF9A2E1B2814006248D8 /* mn.lproj */ = {
isa = PBXGroup;
children = (
04F8DF9B2E1B2822006248D8 /* Localizable.strings */,
);
path = mn.lproj;
sourceTree = "<group>";
};
13103E802D589D6C000F0673 /* Tracking & Metadata */ = {
isa = PBXGroup;
children = (
@ -633,9 +642,9 @@
133D7C8A2D2BE2640075467E /* JSLoader */ = {
isa = PBXGroup;
children = (
04536F702E04BA3B00A11248 /* JSController-Novel.swift */,
134A387B2DE4B5B90041B687 /* Downloads */,
133D7C8B2D2BE2640075467E /* JSController.swift */,
04536F702E04BA3B00A11248 /* JSController-Novel.swift */,
132AF1202D99951700A0140B /* JSController-Streams.swift */,
132AF1222D9995C300A0140B /* JSController-Details.swift */,
132AF1242D9995F900A0140B /* JSController-Search.swift */,
@ -670,6 +679,7 @@
13530BE02E00028E0048B7DE /* Localization */ = {
isa = PBXGroup;
children = (
04F8DF9A2E1B2814006248D8 /* mn.lproj */,
04E00C9A2E09E96B0056124A /* it.lproj */,
0452339C2E021491002EA23C /* bos.lproj */,
041261032E00D14F00D05B47 /* sv.lproj */,
@ -912,6 +922,7 @@
bs,
cs,
it,
mn,
);
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
@ -1172,6 +1183,14 @@
name = Localizable.strings;
sourceTree = "<group>";
};
04F8DF9B2E1B2822006248D8 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
04F8DF9C2E1B2822006248D8 /* mn */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */