diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index ac124f0..563f32d 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -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 } diff --git a/Sora/Info.plist b/Sora/Info.plist index dc64431..ebd099a 100644 --- a/Sora/Info.plist +++ b/Sora/Info.plist @@ -19,6 +19,7 @@ de it kk + mn nn ru sk diff --git a/Sora/Localization/mn.lproj/Localizable.strings b/Sora/Localization/mn.lproj/Localizable.strings new file mode 100644 index 0000000..c5e820a --- /dev/null +++ b/Sora/Localization/mn.lproj/Localizable.strings @@ -0,0 +1,467 @@ +/* General */ +"About" = "Бидний тухайд"; +"About Sora" = "Sora аппын тухай"; +"Active" = "Идэвхтэй"; +"Active Downloads" = "Татаж байна"; +"Actively downloading media can be tracked from here." = "Татаж байгаа үзвэрүүдийг эндээс харж болно"; +"Add Module" = "Модул нэмэх"; +"Adjust the number of media items per row in portrait and landscape modes." = "Хэвтээ болон босоо загварын нэг мөрөн харуулах үзвэрийн тоо"; +"Advanced" = "Нарийн тохиргоо"; +"AKA Sulfur" = "өөрөөр Sulfur"; +"All Bookmarks" = "Бүх хадгалсан үзвэрүүд"; +"All Watching" = "Үзэж буй үзвэрүүд"; +"Also known as Sulfur" = "өөрөөр Sulfur гэж нэрлэдэг"; +"AniList" = "AniList"; +"AniList ID" = "AniList ХД"; +"AniList Match" = "AniList тохирол"; +"AniList.co" = "AniList.co"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Аппыг сайжруулах зорилгоор мэдээллийг нууцалж цуглуулдаг. Таны хувийн мэдээллийг цуглуулдаггүй болно. Мөн хүссэн үедээ мэдээлэл цуглуулахыг цуцалж болно."; +"App Info" = "Аппын мэдээлэл "; +"App Language" = "Хэл"; +"App Storage" = "Багтаамж"; +"Appearance" = "Харагдах байдал"; + +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "Та хадгалагдсан өгөгдлийн устгахдаа итгэлтэй байна уу? Устгасан тохиолдолд багтаамж чөлөөлөгдөнө."; +"Are you sure you want to delete '%@'?" = "Та '%@' үзвэрийг устгахдаа итгэлтэй байна уу?"; +"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Та '%2$@' үзвэрийн %1$d ангиудыг устгахдаа итгэлтэй байна уу?"; +"Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." = "Та бүх татаж авсан үзвэрийг устгахдаа итгэлтэй байна уу? Зөвхөн сангаа цэврэлсэнээр, татаж авсан үзвэрүүдээ устгахгүй байж болно."; +"Are you sure you want to erase all app data? This action cannot be undone." = "Та аппын бүх өгөгдлийг утгахдаа итгэлтэй байна уу? Энэ үйлдлийг буцаах боломжгүй."; + +/* Features */ +"Background Enabled" = "Аппыг идэвхгүй үед татах"; +"Bookmark items for an easier access later." = "Үзвэрийг хадгалсанаар дараа нь олоход хялбар болно"; +"Bookmarks" = "Хадгалсан үзвэр"; +"Bottom Padding" = "Доод зай"; +"Cancel" = "Цуцлах"; +"Cellular Quality" = "Утасны дата бичлэгийн чанар"; +"Check out some community modules here!" = "Илүү олон модулиудыг эндээс олоорой!"; +"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." = "Өөрийн WiFi болон утасны датанд тааруулж бичлэгийн чанарыг сонгоорой. Өндөр чанартай бичлэг нь илүү их дата ашиглана. Хэрэв таны сонгосон бичлэгийн чанар байхгүй бол хамгийн ойролцоо чанарыг сонгож тоглуулна.\n\nТэмдэглэл: Бүх үзвэрүүд болон бичлэг тоглуулагч нь чанар сонгох үйлдэлгүй байдаг. Бичлэгийн чанар сонгох үйлдлийг HLS төрлийн үзвэрийг Sora тоглуулагч ашиглан үзэж байгаа тохиолдолд ашиглахад хамгийн тохиромжтой байдаг."; +"Clear" = "Устгах"; +"Clear All Downloads" = "Бүх таталтыг устгах"; +"Clear Cache" = "Кэш цэвэрлэх"; +"Clear Library Only" = "Зөвхөн санг цэвэрлэх"; +"Clear Logs" = "Лог цэвэрлэх"; +"Click the plus button to add a module!" = "Нэмэх тэмдэг дээр дарж шинэ модуль нэмнэ үү!"; +"Continue Watching" = "Үргэлжлүүлж үзэх"; +"Continue Watching Episode %d" = "%d ангийг үргэлжлүүлж үзэх"; +"Contributors" = "Хувь нэмэр оруулсан"; +"Copied to Clipboard" = "Хуулсан"; +"Copy to Clipboard" = "Хуулсан"; +"Copy URL" = "Холбоосыг хуулах"; + +/* Episodes */ +"%lld Episodes" = "%lld анги"; +"%lld of %lld" = "%lld-ийн %lld"; +"%lld-%lld" = "%lld-%lld"; +"%lld%% seen" = "%lld%% үзсэн"; +"Episode %lld" = "%lld-р анги"; +"Episodes" = "Ангиуд"; +"Episodes might not be available yet or there could be an issue with the source." = " "; +"Episodes Range" = " "; + +/* System */ +"cranci1" = "cranci1"; +"Dark" = "Хар"; +"DATA & LOGS" = "Өгөгдөл ба лог"; +"Debug" = "Алдаа илрүүлэх"; +"Debugging and troubleshooting." = "Алдааг илрүүлэх ба асуудал олох"; + +/* Actions */ +"Delete" = "Устгах"; +"Delete All" = "Бүгдийг устгах"; +"Delete All Downloads" = "Бүх таталтыг устгах"; +"Delete All Episodes" = "Бүх ангийг устгах"; +"Delete Download" = "Таталт устгах"; +"Delete Episode" = "Анги устгах"; + +/* Player */ +"Double Tap to Seek" = "Хоёр дарж гүйлгэх"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Дэлгэцийн хоёр талд хоёр удаа хурдан дарвал богино хугацаагаар бичлэгийг гүйлгэнэ."; + +/* Downloads */ +"Download" = "Татах"; +"Download Episode" = "Анги татах"; +"Download Summary" = "Таталтын түүх"; +"Download This Episode" = "Энэ ангийг татах"; +"Downloaded" = "Татсан"; +"Downloaded Shows" = "Татсан үзвэрүүд"; +"Downloading" = "Татаж байна"; +"Downloads" = "Таталтууд"; + +/* Settings */ +"Enable Analytics" = "Аналитик ажилуулах"; +"Enable Subtitles" = "Хадмал харуулах"; + +/* Data Management */ +"Erase" = "Арилгах"; +"Erase all App Data" = "Аппын бүх өгөгдлийг арилгах"; +"Erase App Data" = "Аппын өгөгдлийг арилгах"; + +/* Errors */ +"Error" = "Алдаа"; +"Error Fetching Results" = "Илэрцийг олоход гарсан алдаа"; +"Errors and critical issues." = "Алдаанууд болон ноцтой асуудлууд"; +"Failed to load contributors" = "Контрибуторуудыг ачааллаж чадсангүй"; + +/* Features */ +"Fetch Episode metadata" = "Ангийн мета мэдээллийг татах"; +"Files Downloaded" = "Татаж авсан файлууд"; +"Font Size" = "Үсгийн хэмжээ"; + +/* Interface */ +"Force Landscape" = "Байнга хэвтээ байлгах"; +"General" = "Ерөнхий"; +"General events and activities." = "Ерөнхий эвэнт ба үйл ажиллагаанууд"; +"General Preferences" = "Ерөнхий тохиргоо"; +"Hide Splash Screen" = "Эхлэлийн дэлгэцийг нуух"; +"HLS video downloading." = "HLS бичлэг таталт"; +"Hold Speed" = "Дарах хурд"; + +/* Info */ +"Info" = "Мэдээлэл"; +"INFOS" = "МЭДЭЭЛЛҮҮД"; +"Installed Modules" = "Суулгасан модулиуд"; +"Interface" = "Харилцах хэсэг"; + +/* Social */ +"Join the Discord" = "Дискорд сувагт нэгдэх"; + +/* Layout */ +"Landscape Columns" = "Хэвтээ багана"; +"Language" = "Хэл"; +"LESS" = "БАГАСГАХ"; + +/* Library */ +"Library" = "Сан"; +"License (GPLv3.0)" = "Лиценз (GPLх3.0)"; +"Light" = "Цагаан"; + +/* Loading States */ +"Loading Episode %lld..." = "%lld-р ангийг ачаалж байна..."; +"Loading logs..." = "Логийг ачаалж байна..."; +"Loading module information..." = "Модулийн мэдээллийг ачаалж байна..."; +"Loading Stream" = "Үзвэрийг ачаалж байна"; + +/* Logging */ +"Log Debug Info" = "Дибаг мэдээллийг бичих"; +"Log Filters" = "Лог шүүлтүүрүүд"; +"Log In with AniList" = "AniList-ээр нэвтрэх"; +"Log In with Trakt" = "Trakt-аар нэвтрэх"; +"Log Out from AniList" = "AniList-ээс гарах"; +"Log Out from Trakt" = "Trakt-аас гарах"; +"Log Types" = "Логийн төрлүүд"; +"Logged in as" = "Нэвтэрсэн байна"; +"Logged in as " = " нэвтэрсэн байна"; + +/* Logs and Settings */ +"Logs" = "Логууд"; +"Long press Skip" = "Удаан дарж алгасах"; +"MAIN" = "ҮНДСЭН"; +"Main Developer" = "Үндсэн Хөгжүүлэгч"; +"MAIN SETTINGS" = "ҮНДСЭН ТОХИРГООНУУД"; + +/* Media Actions */ +"Mark All Previous Watched" = "Өмнөх бүгдийг үзсэнээр тэмдэглэх"; +"Mark as Watched" = "Үзсэнээр тэмдэглэх"; +"Mark Episode as Watched" = "Ангийг үзсэнээр тэмдэглэх"; +"Mark Previous Episodes as Watched" = "Өмнөх бүх ангийг үзсэнээр тэмдэглэх"; +"Mark watched" = "Үзсэнийг тэмдэглэх"; +"Match with AniList" = "Anilist-тэй тааруулах"; +"Match with TMDB" = "TMDB-тэй тааруулах"; +"Matched ID: %lld" = "Тааруулсан ХД: %lld"; +"Matched with: %@" = "Тааруулсан: %@"; +"Max Concurrent Downloads" = "Зэрэг татах дээд хэмжээ"; + +/* Media Interface */ +"Media Grid Layout" = "Медиа грид байршил"; +"Media Player" = "Медиа тоглуулагч"; +"Media View" = "Медиа харагдац"; +"Metadata Provider" = "Нэмэлт мэдээлэл нийлүүлэгч"; +"Metadata Providers Order" = "Нэмэлт мэдээлэл нийлүүлэгчид"; +"Module Removed" = "Модуль устсан"; +"Modules" = "Модулиуд"; + +/* Headers */ +"MODULES" = "МОДУЛИУД"; +"MORE" = "ИЛҮҮ"; + +/* Status Messages */ +"No Active Downloads" = "Идэвхтэй таталт байхгүй байна"; +"No AniList matches found" = "Anilist дээр олдсонгүй"; +"No Data Available" = "Мэдээлэл байхгүй байна"; +"No Downloads" = "Таталт байхгүй байна"; +"No episodes available" = "Анги олдсонгүй"; +"No Episodes Available" = "Анги Олдсонгүй"; +"No items to continue watching." = "Үргэлүүлж үзэх зүйл байхгүй"; +"No matches found" = "Илэрц олдсонгүй"; +"No Module Selected" = "Модуль сонгоогүй байна"; +"No Modules" = "Модуль байгүй"; +"No Results Found" = "Хайлт олдсонгүй"; +"No Search Results Found" = "Хайлтын Үр Дүн Олдсонгүй"; +"Nothing to Continue Watching" = "Үргэлжлүүлж Үзэх Зүйл Байхгүй"; + +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Модулийн JSON файл доторх хувилбарийн нэр өөрчлөгдсөн тохиолдолд л модуль шинэчлэгдэнэ."; + +/* Actions */ +"OK" = "ЗА"; +"Open Community Library" = "Нийтлэг Санг Нээх"; + +/* External Services */ +"Open in AniList" = "Anilist дотор нээх"; +"Original Poster" = "Жинхэнэ Постлогч"; + +/* Playback */ +"Paused" = "Зогсоосон"; +"Play" = "Тоглуулах"; +"Player" = "Тоглуулагч"; + +/* System Messages */ +"Please restart the app to apply the language change." = "Аппаас гарч дахин орсноор хэл солигдоно"; +"Please select a module from settings" = "Тохиргооны хэсгээс модуль сонгоно уу"; + +/* Interface */ +"Portrait Columns" = "Босоо Баганууд"; +"Progress bar Marker Color" = "Явцын зурвасын тэмдэглэгээний өнгө"; +"Provider: %@" = "Нийлүүлэгч: %@"; + +/* Queue */ +"Queue" = "Дараалал"; +"Queued" = "Хүлээлтэд орсон"; + +/* Content */ +"Recently watched content will appear here." = "Сүүлд үзсэн үзвэрүүд энд харагдана"; + +/* Settings */ +"Refresh Modules on Launch" = "Апп нээгдэх болгонд модуль шинэчлэх"; +"Refresh Storage Info" = "Багтаамжийн мэдээллийг шинэчлэх"; +"Remember Playback speed" = "Тоглуулах хурдыг сануулах"; + +/* Actions */ +"Remove" = "Устгах"; +"Remove All Cache" = "Бүх Кэшийг Устгах"; + +/* File Management */ +"Remove All Documents" = "Бүх мэдээлийг устгах"; +"Remove Documents" = "Мэдээллийг Устгах"; +"Remove Downloaded Media" = "Татаж авсан үзвэрийг устгах"; +"Remove Downloads" = "Таталтуудыг Устгах"; +"Remove from Bookmarks" = "Хадгалахаа болих"; +"Remove Item" = "Анги Устгах"; + +/* Support */ +"Report an Issue" = "Алдаа мэдээлэх"; + +/* Reset Options */ +"Reset" = "Анхны төлөвт оруулах"; +"Reset AniList ID" = "AniList ХД анхны төлөвт оруулах"; +"Reset Episode Progress" = "Эхнээс нь үзэх"; +"Reset progress" = "Анхны төлөвт оруулах явц"; +"Reset Progress" = "Явцыг ахний төлөвт оруулах"; + +/* System */ +"Restart Required" = "Дахин ачааллах шаардлагатай"; +"Running Sora %@ - cranci1" = "Sora %@ ачаалж байна - cranci1"; + +/* Actions */ +"Save" = "Хадгалах"; +"Search" = "Хайа"; + +/* Search */ +"Search downloads" = "Татсан үзвэр хайх"; +"Search for something..." = "Үзвэр хайх..."; +"Search..." = "Хайх..."; + +/* Content */ +"Season %d" = "%d-р Улирал"; +"Season %lld" = "%lld-р Улирал"; +"Segments Color" = "Ерөнхий өнгө"; + +/* Modules */ +"Select Module" = "Модуль сонгох"; +"Set Custom AniList ID" = "AniList ХД харуулах"; + +/* Interface */ +"Settings" = "Тохиргоо"; +"Shadow" = "Сүүдэр"; +"Show More (%lld more characters)" = "Илүү харуулах (%lld тэмдэгт харагдана)"; +"Show PiP Button" = "PiP товч харуулах"; +"Show Skip 85s Button" = "85с алгасах товч харуулах"; +"Show Skip Intro / Outro Buttons" = "Эхлэл/Төгсгөлийн дууг алгасах точ хөруулах"; +"Shows" = "Харуулах"; +"Size (%@)" = "Хэмжээ (%d)"; +"Skip Settings" = "Алгасах тохиргоо"; + +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Зарим үйлдлүүд нь зөвхөн Sora болон Үндсэн тоглуулагч дээр ажилладаг, тухайлбал Үргэлж хэвтээ байдлаар үзэх, удаан дарж хурд удирдах болон алгасах хугацаа нь тохиргоо"; + +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "Sora %@ by cranci1"; +"Sora and cranci1 are not affiliated with AniList or Trakt in any way. + +Also note that progress updates may not be 100% accurate." = " +Sora ба cranci1 нь AniList эсвэл Trakt-тэй ямар ч хамааралгүй болно. + +Мөн явцын шинэчлэлтүүд 100% үнэн зөв байж чадахгүй гэдгийг анхаарна уу."; +"Sora GitHub Repository" = "Sora GitHub хуудас"; +"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur нь үргэлж үнэ төлбөргүй, зар сурталчилгаагүй байх болно!"; + +/* Interface */ +"Sort" = "Эрэмблэх"; +"Speed Settings" = "Тоглуулах хурд"; + +/* Playback */ +"Start Watching" = "Үзэх"; +"Start Watching Episode %d" = "%d ангийг үзэх"; +"Storage Used" = "Ашигласан багтаамж"; +"Stream" = "Үзвэр"; +"Streaming and video playback." = "Үзвэр ба бичлэг"; + +/* Subtitles */ +"Subtitle Color" = "Хадмалын өнгө"; +"Subtitle Settings" = "Хадмалын тохиргоо"; + +/* Sync */ +"Sync anime progress" = "Аниме үзсэн ангиудыг тэмдэглэх"; +"Sync TV shows progress" = "Цувралын үзсэн ангиудыг тэмдэглэх"; + +/* System */ +"System" = "Систем"; + +/* Instructions */ +"Tap a title to override the current match." = "Нэр дээр дарж одоогийн хайлтыг солино уу"; +"Tap Skip" = "Энд дарж гүйлгэнэ үү"; +"Tap to manage your modules" = "Энд дарж модуль солино уу"; +"Tap to select a module" = "Энд дарж модуль сонгоно уу"; + +/* App Information */ +"The app cache helps the app load images faster. Clearing the Documents folder will delete all downloaded modules. Do not erase App Data unless you understand the consequences — it may cause the app to malfunction." = " Аппын кэш нь зургуудийг илүү хурдан ачаалахад тусалдаг. Documents хавтасыг устгавал бүх татсан үзвэрүүдийг утгана. Гарах үр дагаврыг нь ойлголгүйгээр Апп датаг бүү устга - Энэ нь дараа нь аппыг буруу ажиллахад нөлөөлөх боломжтой"; +"The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily. For episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." = " Ангийн хязгаар нь нэг хуудсанд хэдэн харагдахыг тохируулдаг. Ингэснээр ангиуд нь багцлагдаж (Жишээ нь 1-25, 26-50 гэх мэт), үзэх ангиа сонгоход илүү хялбар болгоно. Ангийн мета өгөгдөл нь тухайн ангийн харагдац зураг болон нэрийг хадгалдаг тул зарим тохиолдолд спойлер агуулдаг."; + + + +"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "Модуль зөвхөн нэг анги агуулсан байсан тул, кино байх боломжтой гэж үзээд тусгай дэлгэц хийхээр шийдсэн."; + +/* Interface */ +"Thumbnails Width" = "Үзвэрийн зургийн урт"; +"TMDB Match" = "ТМДБ тохирол"; +"Trackers" = "Тракерууд"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; + +/* Search */ +"Try different keywords" = "Өөр үгээр хайж үзнэ үү"; +"Try different search terms" = "Өөр төрлөөр хайж үзнэ үү"; + +/* Player Controls */ +"Two Finger Hold for Pause" = "Хоёр хуруугаар дарж бичлэгийг зогсоох"; +"Unable to fetch matches. Please try again later." = "Илэрц олж чадсангүй. Дараа дахин оролдоно уу?"; +"Use TMDB Poster Image" = "ТМДБ нүүр зураг ашиглах"; + +/* Version */ +"v%@" = "х%@"; +"Video Player" = "Бичлэг тоглуулагч"; + +/* Video Settings */ +"Video Quality Preferences" = "Бичлэгийн чанарын тохиргоо"; +"View All" = "Бүгдийг харах"; +"Watched" = "Үзсэн"; +"Why am I not seeing any episodes?" = "Яагаад нэг ч анги байхгүй байна?"; +"WiFi Quality" = "WiFi чанар"; + +/* User Status */ +"You are not logged in" = "Та нэвтрээгүй байна"; +"You have no items saved." = "Танд хадгалсан үзвэр байхгүй байна"; +"Your downloaded episodes will appear here" = "Таны татсан үзвэрийн ангиуд энд харагдана"; +"Your recently watched content will appear here" = "Таны сүүлд үзсэн үзвэрүүд энд харагдана"; + +/* Download Settings */ +"Download Settings" = "Таталтын тохиргоо"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Зэрэг таталт хийх хязгаар нь зэрэг татах ангийн тоо юм. Өндөр байх тусам илүү их дата болон утасны нөөцийг ашиглана."; +"Quality" = "Чанар"; +"Max Concurrent Downloads" = "Зэрэг таталт хийх хязгаар"; +"Allow Cellular Downloads" = "Утасны датагаар татах"; +"Quality Information" = "Чанарын мэдээлэл"; + +/* Storage */ +"Storage Management" = "Багтаамж удирдах"; +"Storage Used" = "Ашигласан багтаамж"; +"Library cleared successfully" = "Санг амжилттай цэвэрлэлээ"; +"All downloads deleted successfully" = "Бүх татсан үзвэрүүдийг амжилттай устлаа"; + +/* New additions */ +"Recent searches" = "Сүүлд хайсан"; +"me frfr" = "me frfr"; +"Data" = "Мэдээлэл"; +"Maximum Quality Available" = "Хамгийн өндөр чанартай"; +"All Reading" = "Бүх унших зүйл"; +"No Reading History" = "Унших түүх байхгүй"; +"Books you're reading will appear here" = "Таны уншиж байгаа номууд энд харагдана"; +"All Watching" = "Бүх үзэх зүйл"; +"Continue Reading" = "Унших үргэлжлүүлэх"; +"Nothing to Continue Reading" = "Үргэлжлүүлж унших зүйл байхгүй"; +"Your recently read novels will appear here" = "Таны саяхан уншсан зохиолууд энд харагдана"; +"No Bookmarks" = "Хадгалсан зүйл байхгүй"; +"Add bookmarks to this collection" = "Энэ цуглуулгад хадгалсан зүйл нэмэх"; +"items" = "зүйл"; +"Chapter %d" = "Бүлэг %d"; +"Episode %d" = "Анги %d"; +"%d%%" = "%d%%"; +"%d%% seen" = "%d%% үзсэн"; +"DownloadCountFormat" = "Татаж авсан: %d"; +"Error loading chapter" = "Бүлэг ачаалахад алдаа гарлаа"; +"Font Size: %dpt" = "Фонтын хэмжээ: %dpt"; +"Line Spacing: %.1f" = "Мөр хоорондын зай: %.1f"; +"Line Spacing" = "Мөр хоорондын зай"; +"Margin: %dpx" = "Захын зай: %dpx"; +"Margin" = "Захын зай"; +"Auto Scroll Speed" = "Автомат гүйлгэх хурд"; +"Speed" = "Хурд"; +"Speed: %.1fx" = "Хурд: %.1fx"; +"Matched %@: %@" = "Таарсан %@: %@"; +"Enter the AniList ID for this series" = "Энэ цувралын AniList ID-г оруулна уу"; + +/* New additions */ +"Create Collection" = "Цуглуулга үүсгэх"; +"Collection Name" = "Цуглуулгын нэр"; +"Rename Collection" = "Цуглуулгын нэр солих"; +"Rename" = "Нэр солих"; +"All Reading" = "Бүх унших зүйл"; +"Recently Added" = "Саяхан нэмэгдсэн"; +"Novel Title" = "Зохиолын гарчиг"; +"Read Progress" = "Уншсан явц"; +"Date Created" = "Үүсгэсэн огноо"; +"Name" = "Нэр"; +"Item Count" = "Зүйлийн тоо"; +"Date Added" = "Нэмсэн огноо"; +"Title" = "Гарчиг"; +"Source" = "Эх сурвалж"; +"Search reading..." = "Унж байгаа зүйл хайх..."; +"Search collections..." = "Цуглуулга хайх..."; +"Search bookmarks..." = "Хадгалсан зүйл хайх..."; +"%d items" = "%d зүйл"; +"Fetching Data" = "Өгөгдөл татаж байна"; +"Please wait while fetching." = "Татаж байна, хүлээнэ үү."; +"Start Reading" = "Унж эхлэх"; +"Chapters" = "Бүлгүүд"; +"Completed" = "Дууссан"; +"Drag to reorder" = "Дарааллаар байрлуулахын тулд чирнэ үү"; +"Drag to reorder sections" = "Хэсгүүдийг дарааллаар байрлуулахын тулд чирнэ үү"; +"Library View" = "Сангийн харагдац"; +"Customize the sections shown in your library. You can reorder sections or disable them completely." = "Сангийн харагдах хэсгүүдийг тохируулна уу. Хэсгүүдийг дахин эрэмбэлж эсвэл бүрэн идэвхгүй болгож болно."; +"Library Sections Order" = "Сангийн хэсгүүдийн дараалал"; +"Completion Percentage" = "Дуусгах хувь"; +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.\n\nThe completion percentage setting determines at what point before the end of a video the app will mark it as completed on AniList and Trakt." = "Зарим үйлдлүүд нь зөвхөн Sora болон Үндсэн тоглуулагч дээр ажилладаг, тухайлбал Үргэлж хэвтээ байдлаар үзэх, удаан дарж хурд удирдах болон алгасах хугацаа нь тохиргоо\n\nДуусгах хувь нь бичлэгийн төгсгөлөөс хэдэн хувийн өмнө AniList болон Trakt дээр үзсэнээр тэмдэглэхээ тодорхойлно."; +"The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app." = "Аппын кэш нь зургуудийг илүү хурдан ачаалахад тусалдаг.\n\nDocuments хавтасыг устгавал бүх татсан үзвэрүүдийг утгана.\n\nАпп датаг устгавал аппын бүх тохиргоо болон өгөгдөл устана."; +"Translators" = "Орчуулагчид"; +"Paste URL" = "Холбоосыг буулгах"; + +/* Added missing localizations */ +"Series Title" = "Цувралын гарчиг"; +"Content Source" = "Агуулгын эх сурвалж"; +"Watch Progress" = "Үзсэн явц"; +"Recent searches" = "Саяхны хайлт"; +"Collections" = "Цуглуулгууд"; +"Continue Reading" = "Унших үргэлжлүүлэх"; \ No newline at end of file diff --git a/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift index 5c97e1d..5a25b02 100644 --- a/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift @@ -33,32 +33,8 @@ struct MusicProgressSlider: 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: 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 { diff --git a/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift b/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift index 15437c6..443a0db 100644 --- a/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift +++ b/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift @@ -21,6 +21,7 @@ struct VolumeSlider: 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: 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: 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: View { } } } - .frame(height: isActive ? height * 1.25 : height) + .frame(height: getStretchHeight()) } private var getIconName: String { @@ -133,9 +145,12 @@ struct VolumeSlider: 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: 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 + } + } } diff --git a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift index 623cb50..a91d66d 100644 --- a/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift +++ b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift @@ -92,7 +92,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = [] var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = [] var currentMarqueeConstraints: [NSLayoutConstraint] = [] - private var currentMenuButtonTrailing: NSLayoutConstraint! var subtitleForegroundColor: String = "white" var subtitleBackgroundEnabled: Bool = true @@ -107,7 +106,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - var marqueeLabel: MarqueeLabel! var playerViewController: AVPlayerViewController! var controlsContainerView: UIView! var playPauseButton: UIImageView! @@ -124,6 +122,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var qualityButton: UIButton! var holdSpeedIndicator: UIButton! private var lockButton: UIButton! + private var controlButtonsContainer: GradientBlurButton! + + private var unlockButton: UIButton! var isHLSStream: Bool = false var qualities: [(String, String)] = [] @@ -176,26 +177,41 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var dimButtonToRight: NSLayoutConstraint! private var dimButtonTimer: Timer? - private lazy var controlsToHide: [UIView] = [ - dismissButton, - playPauseButton, - backwardButton, - forwardButton, - sliderHostingController?.view, - skip85Button, - marqueeLabel, - menuButton, - qualityButton, - speedButton, - watchNextButton, - volumeSliderHostingView, - pipButton, - airplayButton, - timeBatteryContainer, - endTimeIcon, - endTimeLabel, - endTimeSeparator - ].compactMap { $0 } + private var controlsToHide: [UIView] { + var views = [ + dismissButton, + playPauseButton, + backwardButton, + forwardButton, + sliderHostingController?.view, + skip85Button, + controlButtonsContainer, + volumeSliderHostingView, + pipButton, + airplayButton, + timeBatteryContainer, + endTimeIcon, + endTimeLabel, + endTimeSeparator, + lockButton, + dimButton, + titleStackView, + titleLabel, + episodeNumberLabel, + controlsContainerView + ].compactMap { $0 }.filter { $0.superview != nil } + + if let airplayParent = airplayButton?.superview { + views.append(airplayParent) + } + + views.append(contentsOf: view.subviews.filter { + $0 is UIVisualEffectView || + ($0.layer.cornerRadius > 0 && $0 != dismissButton && $0 != lockButton && $0 != dimButton && $0 != pipButton && $0 != holdSpeedIndicator && $0 != volumeSliderHostingView) + }) + + return views + } private var originalHiddenStates: [UIView: Bool] = [:] @@ -222,6 +238,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var endTimeSeparator: UIView? private var isEndTimeVisible: Bool = false + private var titleStackAboveSkipButtonConstraints: [NSLayoutConstraint] = [] + private var titleStackAboveSliderConstraints: [NSLayoutConstraint] = [] + + var episodeNumberLabel: UILabel! + var titleLabel: MarqueeLabel! + var titleStackView: UIStackView! + + private var controlButtonsContainerBottomConstraint: NSLayoutConstraint? + + private var isMenuOpen = false + private var menuProtectionTimer: Timer? + init(module: ScrapingModule, urlString: String, fullUrl: String, @@ -300,6 +328,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele super.viewDidLoad() view.backgroundColor = .black + skipOutroDismissedInSession = false + skipIntroDismissedInSession = false + setupHoldGesture() loadSubtitleSettings() setupPlayerViewController() @@ -320,11 +351,27 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele addTimeObserver() startUpdateTimer() setupLockButton() + setupUnlockButton() setupAudioSession() updateSkipButtonsVisibility() setupHoldSpeedIndicator() setupPipIfSupported() setupTimeBatteryIndicator() + setupTopRowLayout() + updateSkipButtonsVisibility() + + isControlsVisible = true + for control in controlsToHide { + control.alpha = 1.0 + } + + if let volumeSlider = volumeSliderHostingView { + volumeSlider.alpha = 1.0 + volumeSlider.isHidden = false + view.bringSubviewToFront(volumeSlider) + } + + setupControlButtonsContainer() view.bringSubviewToFront(subtitleStackView) subtitleStackView.isHidden = !SubtitleSettingsManager.shared.settings.enabled @@ -340,7 +387,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - controlsToHide.forEach { originalHiddenStates[$0] = $0.isHidden } + for control in controlsToHide { + originalHiddenStates[control] = control.isHidden + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.checkForHLSStream() @@ -416,6 +465,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem ) + + + } + + private func setupTopRowLayout() { + if let old = view.subviews.first(where: { $0 is GradientBlurButton && $0 != controlButtonsContainer && $0 != skip85Button }) { + old.removeFromSuperview() + } + + let capsuleContainer = GradientBlurButton(type: .custom) + capsuleContainer.translatesAutoresizingMaskIntoConstraints = false + capsuleContainer.backgroundColor = .clear + capsuleContainer.layer.cornerRadius = 21 + capsuleContainer.clipsToBounds = true + view.addSubview(capsuleContainer) + capsuleContainer.alpha = isControlsVisible ? 1.0 : 0.0 + + let buttons: [UIView] = [airplayButton, pipButton, lockButton, dimButton] + for btn in buttons { + btn.removeFromSuperview() + capsuleContainer.addSubview(btn) + } + + NSLayoutConstraint.activate([ + capsuleContainer.leadingAnchor.constraint(equalTo: dismissButton.superview!.trailingAnchor, constant: 12), + capsuleContainer.centerYAnchor.constraint(equalTo: dismissButton.superview!.centerYAnchor), + capsuleContainer.heightAnchor.constraint(equalToConstant: 42) + ]) + + for (index, btn) in buttons.enumerated() { + NSLayoutConstraint.activate([ + btn.centerYAnchor.constraint(equalTo: capsuleContainer.centerYAnchor), + btn.widthAnchor.constraint(equalToConstant: 40), + btn.heightAnchor.constraint(equalToConstant: 40) + ]) + if index == 0 { + btn.leadingAnchor.constraint(equalTo: capsuleContainer.leadingAnchor, constant: 20).isActive = true + } else { + btn.leadingAnchor.constraint(equalTo: buttons[index - 1].trailingAnchor, constant: 18).isActive = true + } + if index == buttons.count - 1 { + btn.trailingAnchor.constraint(equalTo: capsuleContainer.trailingAnchor, constant: -10).isActive = true + } + } + + + view.bringSubviewToFront(skip85Button) + + if let volumeSlider = volumeSliderHostingView { + view.bringSubviewToFront(volumeSlider) + } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -427,20 +527,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + if let pipController = pipController { + pipController.playerLayer.frame = view.bounds + } - guard let marqueeLabel = marqueeLabel else { + guard let episodeNumberLabel = episodeNumberLabel else { return } - let availableWidth = marqueeLabel.frame.width - let textWidth = marqueeLabel.intrinsicContentSize.width + let availableWidth = episodeNumberLabel.frame.width + let textWidth = episodeNumberLabel.intrinsicContentSize.width if textWidth > availableWidth { - marqueeLabel.lineBreakMode = .byTruncatingTail + episodeNumberLabel.lineBreakMode = .byTruncatingTail } else { - marqueeLabel.lineBreakMode = .byClipping + episodeNumberLabel.lineBreakMode = .byClipping } - updateMenuButtonConstraints() + updateMarqueeConstraintsForBottom() } override func viewDidAppear(_ animated: Bool) { @@ -480,11 +583,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele timeUpdateTimer.invalidate() } + menuProtectionTimer?.invalidate() + menuProtectionTimer = nil + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) NotificationCenter.default.removeObserver(self) UIDevice.current.isBatteryMonitoringEnabled = false - // Clean up end time related resources endTimeIcon?.removeFromSuperview() endTimeLabel?.removeFromSuperview() endTimeSeparator?.removeFromSuperview() @@ -528,7 +633,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele volumeSliderHostingView?.removeFromSuperview() hiddenVolumeView.removeFromSuperview() subtitleStackView?.removeFromSuperview() - marqueeLabel?.removeFromSuperview() + episodeNumberLabel?.removeFromSuperview() + titleLabel?.removeFromSuperview() + titleStackView?.removeFromSuperview() controlsContainerView?.removeFromSuperview() blackCoverView?.removeFromSuperview() skipIntroButton?.removeFromSuperview() @@ -585,8 +692,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() - self.updateMenuButtonConstraints() - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { self.view.layoutIfNeeded() } @@ -607,7 +712,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playerViewController.player = player playerViewController.showsPlaybackControls = false addChild(playerViewController) - view.addSubview(playerViewController.view) + if playerViewController.view.superview == nil { + view.addSubview(playerViewController.view) + } playerViewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ playerViewController.view.topAnchor.constraint(equalTo: view.topAnchor), @@ -638,7 +745,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele blackCoverView.backgroundColor = UIColor.black.withAlphaComponent(0.4) blackCoverView.translatesAutoresizingMaskIntoConstraints = false blackCoverView.isUserInteractionEnabled = false - controlsContainerView.insertSubview(blackCoverView, at: 0) + blackCoverView.alpha = 0.0 + view.insertSubview(blackCoverView, belowSubview: controlsContainerView) NSLayoutConstraint.activate([ blackCoverView.topAnchor.constraint(equalTo: view.topAnchor), blackCoverView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -646,17 +754,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele blackCoverView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) + let backwardCircle = createCircularBlurBackground(size: 60) + controlsContainerView.addSubview(backwardCircle) + backwardCircle.translatesAutoresizingMaskIntoConstraints = false + + let playPauseCircle = createCircularBlurBackground(size: 80) + controlsContainerView.addSubview(playPauseCircle) + playPauseCircle.translatesAutoresizingMaskIntoConstraints = false + + let forwardCircle = createCircularBlurBackground(size: 60) + controlsContainerView.addSubview(forwardCircle) + forwardCircle.translatesAutoresizingMaskIntoConstraints = false + backwardButton = UIImageView(image: UIImage(systemName: "gobackward")) backwardButton.tintColor = .white backwardButton.contentMode = .scaleAspectFit backwardButton.isUserInteractionEnabled = true - backwardButton.layer.shadowColor = UIColor.black.cgColor - backwardButton.layer.shadowOffset = CGSize(width: 0, height: 2) - backwardButton.layer.shadowOpacity = 0.6 - backwardButton.layer.shadowRadius = 4 - backwardButton.layer.masksToBounds = false - let backwardTap = UITapGestureRecognizer(target: self, action: #selector(seekBackward)) backwardTap.numberOfTapsRequired = 1 backwardButton.addGestureRecognizer(backwardTap) @@ -674,12 +788,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playPauseButton.contentMode = .scaleAspectFit playPauseButton.isUserInteractionEnabled = true - playPauseButton.layer.shadowColor = UIColor.black.cgColor - playPauseButton.layer.shadowOffset = CGSize(width: 0, height: 2) - playPauseButton.layer.shadowOpacity = 0.6 - playPauseButton.layer.shadowRadius = 4 - playPauseButton.layer.masksToBounds = false - let playPauseTap = UITapGestureRecognizer(target: self, action: #selector(togglePlayPause)) playPauseTap.delaysTouchesBegan = false playPauseTap.delegate = self @@ -695,12 +803,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele forwardButton.contentMode = .scaleAspectFit forwardButton.isUserInteractionEnabled = true - forwardButton.layer.shadowColor = UIColor.black.cgColor - forwardButton.layer.shadowOffset = CGSize(width: 0, height: 2) - forwardButton.layer.shadowOpacity = 0.6 - forwardButton.layer.shadowRadius = 4 - forwardButton.layer.masksToBounds = false - let forwardTap = UITapGestureRecognizer(target: self, action: #selector(seekForward)) forwardTap.numberOfTapsRequired = 1 forwardButton.addGestureRecognizer(forwardTap) @@ -723,7 +825,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...(duration > 0 ? duration : 1.0), activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), height: 33, @@ -776,23 +878,55 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ]) NSLayoutConstraint.activate([ - playPauseButton.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), - playPauseButton.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), + playPauseCircle.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), + playPauseCircle.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), + playPauseCircle.widthAnchor.constraint(equalToConstant: 80), + playPauseCircle.heightAnchor.constraint(equalToConstant: 80), + + backwardCircle.centerYAnchor.constraint(equalTo: playPauseCircle.centerYAnchor), + backwardCircle.trailingAnchor.constraint(equalTo: playPauseCircle.leadingAnchor, constant: -50), + backwardCircle.widthAnchor.constraint(equalToConstant: 60), + backwardCircle.heightAnchor.constraint(equalToConstant: 60), + + forwardCircle.centerYAnchor.constraint(equalTo: playPauseCircle.centerYAnchor), + forwardCircle.leadingAnchor.constraint(equalTo: playPauseCircle.trailingAnchor, constant: 50), + forwardCircle.widthAnchor.constraint(equalToConstant: 60), + forwardCircle.heightAnchor.constraint(equalToConstant: 60) + ]) + + NSLayoutConstraint.activate([ + playPauseButton.centerXAnchor.constraint(equalTo: playPauseCircle.centerXAnchor), + playPauseButton.centerYAnchor.constraint(equalTo: playPauseCircle.centerYAnchor), playPauseButton.widthAnchor.constraint(equalToConstant: 50), playPauseButton.heightAnchor.constraint(equalToConstant: 50), - backwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), - backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -50), - backwardButton.widthAnchor.constraint(equalToConstant: 40), - backwardButton.heightAnchor.constraint(equalToConstant: 40), + backwardButton.centerXAnchor.constraint(equalTo: backwardCircle.centerXAnchor), + backwardButton.centerYAnchor.constraint(equalTo: backwardCircle.centerYAnchor), + backwardButton.widthAnchor.constraint(equalToConstant: 35), + backwardButton.heightAnchor.constraint(equalToConstant: 35), - forwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), - forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 50), - forwardButton.widthAnchor.constraint(equalToConstant: 40), - forwardButton.heightAnchor.constraint(equalToConstant: 40) + forwardButton.centerXAnchor.constraint(equalTo: forwardCircle.centerXAnchor), + forwardButton.centerYAnchor.constraint(equalTo: forwardCircle.centerYAnchor), + forwardButton.widthAnchor.constraint(equalToConstant: 35), + forwardButton.heightAnchor.constraint(equalToConstant: 35) ]) } + private func createCircularBlurBackground(size: CGFloat) -> UIView { + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = size / 2 + blurView.clipsToBounds = true + + NSLayoutConstraint.activate([ + blurView.widthAnchor.constraint(equalToConstant: size), + blurView.heightAnchor.constraint(equalToConstant: size) + ]) + + return blurView + } + @objc private func handleTwoFingerTapPause(_ gesture: UITapGestureRecognizer) { if gesture.state == .ended { togglePlayPause() @@ -974,53 +1108,71 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "xmark", withConfiguration: config) + let dismissCircle = createCircularBlurBackground(size: 42) + view.addSubview(dismissCircle) + dismissCircle.translatesAutoresizingMaskIntoConstraints = false + dismissButton = UIButton(type: .system) dismissButton.setImage(image, for: .normal) dismissButton.tintColor = .white dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) - controlsContainerView.addSubview(dismissButton) + + if let blurView = dismissCircle as? UIVisualEffectView { + blurView.contentView.addSubview(dismissButton) + } else { + dismissCircle.addSubview(dismissButton) + } + dismissButton.translatesAutoresizingMaskIntoConstraints = false - dismissButton.layer.shadowColor = UIColor.black.cgColor - dismissButton.layer.shadowOffset = CGSize(width: 0, height: 2) - dismissButton.layer.shadowOpacity = 0.6 - dismissButton.layer.shadowRadius = 4 - dismissButton.layer.masksToBounds = false - NSLayoutConstraint.activate([ - dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16), - dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + dismissCircle.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), + dismissCircle.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + dismissCircle.widthAnchor.constraint(equalToConstant: 42), + dismissCircle.heightAnchor.constraint(equalToConstant: 42), + + dismissButton.centerXAnchor.constraint(equalTo: dismissCircle.centerXAnchor), + dismissButton.centerYAnchor.constraint(equalTo: dismissCircle.centerYAnchor), dismissButton.widthAnchor.constraint(equalToConstant: 40), dismissButton.heightAnchor.constraint(equalToConstant: 40) ]) } func setupMarqueeLabel() { - marqueeLabel = MarqueeLabel() - marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)" - marqueeLabel.type = .continuous - marqueeLabel.textColor = .white - marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy) + episodeNumberLabel = UILabel() + episodeNumberLabel.text = "Episode \(episodeNumber)" + episodeNumberLabel.textColor = UIColor(white: 1.0, alpha: 0.6) + episodeNumberLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold) + episodeNumberLabel.textAlignment = .left + episodeNumberLabel.setContentHuggingPriority(.required, for: .vertical) - marqueeLabel.speed = .rate(35) - marqueeLabel.fadeLength = 10.0 - marqueeLabel.leadingBuffer = 1.0 - marqueeLabel.trailingBuffer = 16.0 - marqueeLabel.animationDelay = 2.5 + titleLabel = MarqueeLabel() + titleLabel.text = titleText + titleLabel.type = .continuous + titleLabel.textColor = .white + titleLabel.font = UIFont.systemFont(ofSize: 20, weight: .heavy) + titleLabel.speed = .rate(35) + titleLabel.fadeLength = 10.0 + titleLabel.leadingBuffer = 1.0 + titleLabel.trailingBuffer = 16.0 + titleLabel.animationDelay = 2.5 + titleLabel.layer.shadowColor = UIColor.black.cgColor + titleLabel.layer.shadowOffset = CGSize(width: 0, height: 2) + titleLabel.layer.shadowOpacity = 0.6 + titleLabel.layer.shadowRadius = 4 + titleLabel.layer.masksToBounds = false + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.textAlignment = .left + titleLabel.setContentHuggingPriority(.defaultLow, for: .vertical) - marqueeLabel.layer.shadowColor = UIColor.black.cgColor - marqueeLabel.layer.shadowOffset = CGSize(width: 0, height: 2) - marqueeLabel.layer.shadowOpacity = 0.6 - marqueeLabel.layer.shadowRadius = 4 - marqueeLabel.layer.masksToBounds = false - - marqueeLabel.lineBreakMode = .byTruncatingTail - marqueeLabel.textAlignment = .left - - controlsContainerView.addSubview(marqueeLabel) - marqueeLabel.translatesAutoresizingMaskIntoConstraints = false - - updateMarqueeConstraints() + titleStackView = UIStackView(arrangedSubviews: [episodeNumberLabel, titleLabel]) + titleStackView.axis = .vertical + titleStackView.alignment = .leading + titleStackView.spacing = 0 + titleStackView.clipsToBounds = false + titleStackView.isLayoutMarginsRelativeArrangement = true + controlsContainerView.addSubview(titleStackView) + titleStackView.translatesAutoresizingMaskIntoConstraints = false } func volumeSlider() { @@ -1034,18 +1186,41 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele hostingController.view.backgroundColor = UIColor.clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false - controlsContainerView.addSubview(hostingController.view) + let volumeCapsule = GradientBlurButton(type: .custom) + volumeCapsule.translatesAutoresizingMaskIntoConstraints = false + volumeCapsule.backgroundColor = .white + volumeCapsule.layer.cornerRadius = 21 + volumeCapsule.clipsToBounds = true + controlsContainerView.addSubview(volumeCapsule) + + if let blurView = volumeCapsule as? UIVisualEffectView { + blurView.contentView.addSubview(hostingController.view) + } else { + volumeCapsule.addSubview(hostingController.view) + } addChild(hostingController) hostingController.didMove(toParent: self) self.volumeSliderHostingView = hostingController.view NSLayoutConstraint.activate([ - hostingController.view.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16), - hostingController.view.widthAnchor.constraint(equalToConstant: 160), + volumeCapsule.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + volumeCapsule.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16), + volumeCapsule.heightAnchor.constraint(equalToConstant: 42), + volumeCapsule.widthAnchor.constraint(equalToConstant: 200), + + hostingController.view.centerYAnchor.constraint(equalTo: volumeCapsule.centerYAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: volumeCapsule.leadingAnchor, constant: 20), + hostingController.view.trailingAnchor.constraint(equalTo: volumeCapsule.trailingAnchor, constant: -20), hostingController.view.heightAnchor.constraint(equalToConstant: 30) ]) + + self.volumeSliderHostingView = volumeCapsule + + volumeCapsule.alpha = 1.0 + volumeCapsule.isHidden = false + hostingController.view.alpha = 1.0 + hostingController.view.isHidden = false } private func setupHoldSpeedIndicator() { @@ -1061,75 +1236,147 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele holdSpeedIndicator.setTitle(" \(speed)", for: .normal) holdSpeedIndicator.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) holdSpeedIndicator.setImage(image, for: .normal) - - holdSpeedIndicator.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) + holdSpeedIndicator.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) holdSpeedIndicator.tintColor = .white holdSpeedIndicator.setTitleColor(.white, for: .normal) - holdSpeedIndicator.layer.cornerRadius = 21 - holdSpeedIndicator.alpha = 0 + holdSpeedIndicator.alpha = 0.0 - holdSpeedIndicator.layer.shadowColor = UIColor.black.cgColor - holdSpeedIndicator.layer.shadowOffset = CGSize(width: 0, height: 2) - holdSpeedIndicator.layer.shadowOpacity = 0.6 - holdSpeedIndicator.layer.shadowRadius = 4 - holdSpeedIndicator.layer.masksToBounds = false + let blurEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = 21 + blurView.clipsToBounds = true view.addSubview(holdSpeedIndicator) holdSpeedIndicator.translatesAutoresizingMaskIntoConstraints = false + holdSpeedIndicator.insertSubview(blurView, at: 0) + NSLayoutConstraint.activate([ holdSpeedIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), holdSpeedIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), holdSpeedIndicator.heightAnchor.constraint(equalToConstant: 40), - holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85) + holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85), + + blurView.leadingAnchor.constraint(equalTo: holdSpeedIndicator.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: holdSpeedIndicator.trailingAnchor), + blurView.topAnchor.constraint(equalTo: holdSpeedIndicator.topAnchor), + blurView.bottomAnchor.constraint(equalTo: holdSpeedIndicator.bottomAnchor) ]) holdSpeedIndicator.isUserInteractionEnabled = false + holdSpeedIndicator.layer.cornerRadius = 21 + holdSpeedIndicator.clipsToBounds = true + + holdSpeedIndicator.bringSubviewToFront(holdSpeedIndicator.imageView!) + holdSpeedIndicator.bringSubviewToFront(holdSpeedIndicator.titleLabel!) + + holdSpeedIndicator.imageView?.contentMode = .scaleAspectFit + holdSpeedIndicator.titleLabel?.textAlignment = .center } - private func updateSkipButtonsVisibility() { + func updateSkipButtonsVisibility() { + if !isControlsVisible { return } let t = currentTimeVal - let controlsShowing = isControlsVisible - func handle(_ button: UIButton, range: CMTimeRange?) { - guard let r = range else { button.isHidden = true; return } - - let inInterval = t >= r.start.seconds && t <= r.end.seconds - let target = controlsShowing ? 0.0 : skipButtonBaseAlpha - - if inInterval { - if button.isHidden { - button.alpha = 0 - } - button.isHidden = false + let skipIntroAvailable = skipIntervals.op != nil && + t >= skipIntervals.op!.start.seconds && + t <= skipIntervals.op!.end.seconds && + !skipIntroDismissedInSession + + let skipOutroAvailable = skipIntervals.ed != nil && + t >= skipIntervals.ed!.start.seconds && + t <= skipIntervals.ed!.end.seconds && + !skipOutroDismissedInSession + + let shouldShowSkip85 = isSkip85Visible && !skipIntroAvailable + + if skipIntroAvailable { + skipIntroButton.setTitle(" Skip Intro", for: .normal) + skipIntroButton.setImage(UIImage(systemName: "forward.frame"), for: .normal) + UIView.animate(withDuration: 0.2) { + self.skipIntroButton.alpha = 1.0 + } + } else { + UIView.animate(withDuration: 0.2) { + self.skipIntroButton.alpha = 0.0 + } + } + + if shouldShowSkip85 { + skip85Button.setTitle(" Skip 85s", for: .normal) + skip85Button.setImage(UIImage(systemName: "goforward"), for: .normal) + skip85Button.isHidden = false + UIView.animate(withDuration: 0.2) { + self.skip85Button.alpha = 1.0 + } + } else { + UIView.animate(withDuration: 0.2) { + self.skip85Button.alpha = 0.0 + } completion: { _ in + self.skip85Button.isHidden = true + } + } + + view.bringSubviewToFront(skip85Button) + + if skipOutroAvailable { + if skipOutroButton.superview == nil { + controlsContainerView.addSubview(skipOutroButton) - UIView.animate(withDuration: 0.25) { - button.alpha = target - } - return + NSLayoutConstraint.activate([ + skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), + skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), + skipOutroButton.heightAnchor.constraint(equalToConstant: 40), + skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) + ]) + + view.setNeedsLayout() + view.layoutIfNeeded() } + + UIView.animate(withDuration: 0.2) { + self.skipOutroButton.alpha = 1.0 + } + } else { + removeSkipOutroButton() + } + + if !isMenuOpen { + updateControlButtonsContainerPosition() + updateMarqueeConstraintsForBottom() - guard !button.isHidden else { return } - UIView.animate(withDuration: 0.15, animations: { - button.alpha = 0 - }) { _ in - button.isHidden = true + UIView.animate(withDuration: 0.25) { + self.view.layoutIfNeeded() } } - handle(skipIntroButton, range: skipIntervals.op) - handle(skipOutroButton, range: skipIntervals.ed) + let hasVisibleButtons = [watchNextButton, speedButton, qualityButton, menuButton].contains { button in + guard let button = button else { return false } + return !button.isHidden + } - if skipIntroDismissedInSession { - skipIntroButton.isHidden = true - } else { - handle(skipIntroButton, range: skipIntervals.op) + if !isMenuOpen && (hasVisibleButtons || controlButtonsContainer.superview != nil) { + self.setupControlButtonsContainer() } - if skipOutroDismissedInSession { - skipOutroButton.isHidden = true + } + + private func updateControlButtonsContainerPosition() { + guard controlButtonsContainer.superview != nil else { return } + + controlButtonsContainerBottomConstraint?.isActive = false + + let skipOutroActuallyVisible = skipOutroButton.superview != nil && skipOutroButton.alpha > 0.1 + + if !skipOutroActuallyVisible { + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: sliderHostingController!.view.topAnchor, constant: -27) } else { - handle(skipOutroButton, range: skipIntervals.ed) + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: skipOutroButton.topAnchor, constant: -5) } + + controlButtonsContainerBottomConstraint?.isActive = true } private func updateSegments() { @@ -1168,7 +1415,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...validDuration, activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), height: 33, @@ -1230,333 +1477,72 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele func setupSkipButtons() { let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig) - skipIntroButton = GradientOverlayButton(type: .system) + skipIntroButton = GradientBlurButton(type: .system) skipIntroButton.setTitle(" Skip Intro", for: .normal) skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipIntroButton.setImage(introImage, for: .normal) - - skipIntroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) skipIntroButton.tintColor = .white skipIntroButton.setTitleColor(.white, for: .normal) skipIntroButton.layer.cornerRadius = 21 - skipIntroButton.alpha = skipButtonBaseAlpha - - skipIntroButton.layer.shadowColor = UIColor.black.cgColor - skipIntroButton.layer.shadowOffset = CGSize(width: 0, height: 2) - skipIntroButton.layer.shadowOpacity = 0.6 - skipIntroButton.layer.shadowRadius = 4 - skipIntroButton.layer.masksToBounds = false - + skipIntroButton.clipsToBounds = true + skipIntroButton.alpha = 0.0 skipIntroButton.addTarget(self, action: #selector(skipIntro), for: .touchUpInside) - - view.addSubview(skipIntroButton) + controlsContainerView.addSubview(skipIntroButton) skipIntroButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - skipIntroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), - skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), + skipIntroButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -12), skipIntroButton.heightAnchor.constraint(equalToConstant: 40), skipIntroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) ]) let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig) - skipOutroButton = GradientOverlayButton(type: .system) + skipOutroButton = GradientBlurButton(type: .system) skipOutroButton.setTitle(" Skip Outro", for: .normal) skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipOutroButton.setImage(outroImage, for: .normal) - - skipOutroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) skipOutroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) skipOutroButton.tintColor = .white skipOutroButton.setTitleColor(.white, for: .normal) skipOutroButton.layer.cornerRadius = 21 - skipOutroButton.alpha = skipButtonBaseAlpha - - skipOutroButton.layer.shadowColor = UIColor.black.cgColor - skipOutroButton.layer.shadowOffset = CGSize(width: 0, height: 2) - skipOutroButton.layer.shadowOpacity = 0.6 - skipOutroButton.layer.shadowRadius = 4 - skipOutroButton.layer.masksToBounds = false - + skipOutroButton.clipsToBounds = true + skipOutroButton.alpha = 0.0 skipOutroButton.addTarget(self, action: #selector(skipOutro), for: .touchUpInside) - - view.addSubview(skipOutroButton) skipOutroButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), - skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - skipOutroButton.heightAnchor.constraint(equalToConstant: 40), - skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) - ]) - } - - private func setupDimButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) - dimButton = UIButton(type: .system) - dimButton.setImage(UIImage(systemName: "moon.fill", withConfiguration: cfg), for: .normal) - dimButton.tintColor = .white - dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside) - controlsContainerView.addSubview(dimButton) - dimButton.translatesAutoresizingMaskIntoConstraints = false - - dimButton.layer.shadowColor = UIColor.black.cgColor - dimButton.layer.shadowOffset = CGSize(width: 0, height: 2) - dimButton.layer.shadowOpacity = 0.6 - dimButton.layer.shadowRadius = 4 - dimButton.layer.masksToBounds = false - - NSLayoutConstraint.activate([ - dimButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 15), - dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor), - dimButton.widthAnchor.constraint(equalToConstant: 24), - dimButton.heightAnchor.constraint(equalToConstant: 24) - ]) - - dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor) - dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16) - dimButtonToSlider.isActive = true - } - private func setupLockButton() { - let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) - lockButton = UIButton(type: .system) - lockButton.setImage( - UIImage(systemName: "lock.open.fill", withConfiguration: cfg), - for: .normal - ) - lockButton.tintColor = .white - lockButton.layer.shadowColor = UIColor.black.cgColor - lockButton.layer.shadowOffset = CGSize(width: 0, height: 2) - lockButton.layer.shadowOpacity = 0.6 - lockButton.layer.shadowRadius = 4 - lockButton.layer.masksToBounds = false - - lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside) - - view.addSubview(lockButton) - lockButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - lockButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 60), - lockButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor), - lockButton.widthAnchor.constraint(equalToConstant: 24), - lockButton.heightAnchor.constraint(equalToConstant: 24), - ]) - } - - func updateMarqueeConstraints() { - UIView.performWithoutAnimation { - NSLayoutConstraint.deactivate(currentMarqueeConstraints) - - let leftSpacing: CGFloat = 2 - let rightSpacing: CGFloat = 6 - let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false) - ? volumeSliderHostingView!.leadingAnchor - : view.safeAreaLayoutGuide.trailingAnchor - - currentMarqueeConstraints = [ - marqueeLabel.leadingAnchor.constraint( - equalTo: dismissButton.trailingAnchor, constant: leftSpacing), - marqueeLabel.trailingAnchor.constraint( - equalTo: trailingAnchor, constant: -rightSpacing - 10), - marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) - ] - NSLayoutConstraint.activate(currentMarqueeConstraints) - view.layoutIfNeeded() - } - } - - private func setupPipIfSupported() { - airplayButton = AVRoutePickerView(frame: .zero) - airplayButton.translatesAutoresizingMaskIntoConstraints = false - airplayButton.activeTintColor = .white - airplayButton.tintColor = .white - airplayButton.backgroundColor = .clear - airplayButton.prioritizesVideoDevices = true - airplayButton.setContentHuggingPriority(.required, for: .horizontal) - airplayButton.setContentCompressionResistancePriority(.required, for: .horizontal) - controlsContainerView.addSubview(airplayButton) - - airplayButton.layer.shadowColor = UIColor.black.cgColor - airplayButton.layer.shadowOffset = CGSize(width: 0, height: 2) - airplayButton.layer.shadowOpacity = 0.6 - airplayButton.layer.shadowRadius = 4 - airplayButton.layer.masksToBounds = false - - guard AVPictureInPictureController.isPictureInPictureSupported() else { - return - } - let pipPlayerLayer = AVPlayerLayer(player: playerViewController.player) - pipPlayerLayer.frame = playerViewController.view.layer.bounds - pipPlayerLayer.videoGravity = .resizeAspect - - playerViewController.view.layer.insertSublayer(pipPlayerLayer, at: 0) - pipController = AVPictureInPictureController(playerLayer: pipPlayerLayer) - pipController?.delegate = self - - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) - let Image = UIImage(systemName: "pip", withConfiguration: config) - pipButton = UIButton(type: .system) - pipButton.setImage(Image, for: .normal) - pipButton.tintColor = .white - pipButton.addTarget(self, action: #selector(pipButtonTapped(_:)), for: .touchUpInside) - - pipButton.layer.shadowColor = UIColor.black.cgColor - pipButton.layer.shadowOffset = CGSize(width: 0, height: 2) - pipButton.layer.shadowOpacity = 0.6 - pipButton.layer.shadowRadius = 4 - pipButton.layer.masksToBounds = false - - controlsContainerView.addSubview(pipButton) - pipButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - pipButton.centerYAnchor.constraint(equalTo: dimButton.centerYAnchor), - pipButton.trailingAnchor.constraint(equalTo: dimButton.leadingAnchor, constant: -8), - pipButton.widthAnchor.constraint(equalToConstant: 44), - pipButton.heightAnchor.constraint(equalToConstant: 44), - airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor), - airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -4), - airplayButton.widthAnchor.constraint(equalToConstant: 44), - airplayButton.heightAnchor.constraint(equalToConstant: 44) - ]) - - pipButton.isHidden = !isPipButtonVisible - - NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil) - } - - func setupMenuButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "text.bubble", withConfiguration: config) - - menuButton = UIButton(type: .system) - menuButton.setImage(image, for: .normal) - menuButton.tintColor = .white - - if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { - menuButton.showsMenuAsPrimaryAction = true - menuButton.menu = buildOptionsMenu() - } else { - menuButton.isHidden = true - } - - menuButton.layer.shadowColor = UIColor.black.cgColor - menuButton.layer.shadowOffset = CGSize(width: 0, height: 2) - menuButton.layer.shadowOpacity = 0.6 - menuButton.layer.shadowRadius = 4 - menuButton.layer.masksToBounds = false - - controlsContainerView.addSubview(menuButton) - menuButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - menuButton.topAnchor.constraint(equalTo: qualityButton.topAnchor), - menuButton.widthAnchor.constraint(equalToConstant: 40), - menuButton.heightAnchor.constraint(equalToConstant: 40), - ]) - - currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -6) - } - - func setupSpeedButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "speedometer", withConfiguration: config) - - speedButton = UIButton(type: .system) - speedButton.setImage(image, for: .normal) - speedButton.tintColor = .white - speedButton.showsMenuAsPrimaryAction = true - speedButton.menu = speedChangerMenu() - - speedButton.layer.shadowColor = UIColor.black.cgColor - speedButton.layer.shadowOffset = CGSize(width: 0, height: 2) - speedButton.layer.shadowOpacity = 0.6 - speedButton.layer.shadowRadius = 4 - speedButton.layer.masksToBounds = false - - controlsContainerView.addSubview(speedButton) - speedButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - speedButton.topAnchor.constraint(equalTo: watchNextButton.topAnchor), - speedButton.trailingAnchor.constraint(equalTo: watchNextButton.leadingAnchor, constant: 18), - speedButton.widthAnchor.constraint(equalToConstant: 40), - speedButton.heightAnchor.constraint(equalToConstant: 40) - ]) - } - - func setupWatchNextButton() { - let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "forward.end", withConfiguration: config) - - watchNextButton = UIButton(type: .system) - watchNextButton.setImage(image, for: .normal) - watchNextButton.backgroundColor = .clear - watchNextButton.tintColor = .white - watchNextButton.setTitleColor(.white, for: .normal) - - watchNextButton.layer.shadowColor = UIColor.black.cgColor - watchNextButton.layer.shadowOffset = CGSize(width: 0, height: 2) - watchNextButton.layer.shadowOpacity = 0.6 - watchNextButton.layer.shadowRadius = 4 - watchNextButton.layer.masksToBounds = false - - watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) - - controlsContainerView.addSubview(watchNextButton) - watchNextButton.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor, constant: 20), - watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - watchNextButton.heightAnchor.constraint(equalToConstant: 40), - watchNextButton.widthAnchor.constraint(equalToConstant: 80) - ]) } func setupSkip85Button() { let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let image = UIImage(systemName: "goforward", withConfiguration: config) - - skip85Button = GradientOverlayButton(type: .system) + skip85Button = GradientBlurButton(type: .system) skip85Button.setTitle(" Skip 85s", for: .normal) skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skip85Button.setImage(image, for: .normal) - - skip85Button.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) skip85Button.tintColor = .white skip85Button.setTitleColor(.white, for: .normal) skip85Button.layer.cornerRadius = 21 - skip85Button.alpha = 0.7 - - skip85Button.layer.shadowColor = UIColor.black.cgColor - skip85Button.layer.shadowOffset = CGSize(width: 0, height: 2) - skip85Button.layer.shadowOpacity = 0.6 - skip85Button.layer.shadowRadius = 4 - skip85Button.layer.masksToBounds = false - + skip85Button.clipsToBounds = true + skip85Button.alpha = 0.0 skip85Button.addTarget(self, action: #selector(skip85Tapped), for: .touchUpInside) - - view.addSubview(skip85Button) + controlsContainerView.addSubview(skip85Button) skip85Button.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), - skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), + + let skip85Constraints = [ + skip85Button.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -12), skip85Button.heightAnchor.constraint(equalToConstant: 40), skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) - ]) - - skip85Button.isHidden = !isSkip85Visible + ] + NSLayoutConstraint.activate(skip85Constraints) } private func setupQualityButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) - let image = UIImage(systemName: "4k.tv", withConfiguration: config) + let image = UIImage(systemName: "tv", withConfiguration: config) qualityButton = UIButton(type: .system) qualityButton.setImage(image, for: .normal) @@ -1565,21 +1551,107 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.menu = qualitySelectionMenu() qualityButton.isHidden = true - qualityButton.layer.shadowColor = UIColor.black.cgColor - qualityButton.layer.shadowOffset = CGSize(width: 0, height: 2) - qualityButton.layer.shadowOpacity = 0.6 - qualityButton.layer.shadowRadius = 4 - qualityButton.layer.masksToBounds = false + qualityButton.addTarget(self, action: #selector(protectMenuFromRecreation), for: .touchDown) - controlsContainerView.addSubview(qualityButton) qualityButton.translatesAutoresizingMaskIntoConstraints = false + } + + private func setupControlButtonsContainer() { + controlButtonsContainer?.removeFromSuperview() + + controlButtonsContainer = GradientBlurButton(type: .custom) + controlButtonsContainer.isUserInteractionEnabled = true + controlButtonsContainer.translatesAutoresizingMaskIntoConstraints = false + controlButtonsContainer.backgroundColor = .clear + controlButtonsContainer.layer.cornerRadius = 21 + controlButtonsContainer.clipsToBounds = true + controlsContainerView.addSubview(controlButtonsContainer) + + Logger.shared.log("Setting up control buttons container - isHLSStream: \(isHLSStream)", type: "Debug") + + if isHLSStream { + Logger.shared.log("HLS stream detected, showing quality button", type: "Debug") + qualityButton.isHidden = false + qualityButton.menu = qualitySelectionMenu() + } else { + Logger.shared.log("Not an HLS stream, quality button will be hidden", type: "Debug") + qualityButton.isHidden = true + } + + let visibleButtons = [watchNextButton, speedButton, qualityButton, menuButton].compactMap { button -> UIButton? in + guard let button = button else { + Logger.shared.log("Button is nil", type: "Debug") + return nil + } + + if button == qualityButton { + Logger.shared.log("Quality button state - isHidden: \(button.isHidden), isHLSStream: \(isHLSStream)", type: "Debug") + } + + if button.isHidden { + if button == qualityButton { + Logger.shared.log("Quality button is hidden, skipping", type: "Debug") + } + return nil + } + + controlButtonsContainer.addSubview(button) + button.layer.shadowOpacity = 0 + + if button == qualityButton { + Logger.shared.log("Quality button added to container", type: "Debug") + } + + return button + } + + Logger.shared.log("Visible buttons count: \(visibleButtons.count)", type: "Debug") + Logger.shared.log("Visible buttons: \(visibleButtons.map { $0 == watchNextButton ? "watchNext" : $0 == speedButton ? "speed" : $0 == qualityButton ? "quality" : "menu" })", type: "Debug") + + if visibleButtons.isEmpty { + Logger.shared.log("No visible buttons, removing container from view hierarchy", type: "Debug") + controlButtonsContainer.removeFromSuperview() + return + } + + controlButtonsContainer.alpha = 1.0 + + for (index, button) in visibleButtons.enumerated() { + NSLayoutConstraint.activate([ + button.centerYAnchor.constraint(equalTo: controlButtonsContainer.centerYAnchor), + button.heightAnchor.constraint(equalToConstant: 40), + button.widthAnchor.constraint(equalToConstant: 40) + ]) + + if index == 0 { + button.trailingAnchor.constraint(equalTo: controlButtonsContainer.trailingAnchor, constant: -10).isActive = true + } else { + button.trailingAnchor.constraint(equalTo: visibleButtons[index - 1].leadingAnchor, constant: -6).isActive = true + } + + if index == visibleButtons.count - 1 { + controlButtonsContainer.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: -10).isActive = true + } + } NSLayoutConstraint.activate([ - qualityButton.topAnchor.constraint(equalTo: speedButton.topAnchor), - qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -6), - qualityButton.widthAnchor.constraint(equalToConstant: 40), - qualityButton.heightAnchor.constraint(equalToConstant: 40) + controlButtonsContainer.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), + controlButtonsContainer.heightAnchor.constraint(equalToConstant: 42) ]) + + let skipOutroActuallyVisible = skipOutroButton.superview != nil && skipOutroButton.alpha > 0.1 + + if skipOutroActuallyVisible { + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: skipOutroButton.topAnchor, constant: -5) + } else { + controlButtonsContainerBottomConstraint = controlButtonsContainer.bottomAnchor.constraint( + equalTo: sliderHostingController!.view.topAnchor, constant: -12) + } + + controlButtonsContainerBottomConstraint?.isActive = true + + Logger.shared.log("Control buttons container setup complete", type: "Debug") } func updateSubtitleLabelAppearance() { @@ -1616,7 +1688,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } - // Update end time when current time changes self.updateEndTime() self.updateSkipButtonsVisibility() @@ -1695,7 +1766,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...(self.duration > 0 ? self.duration : 1.0), activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), height: 33, @@ -1744,35 +1815,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - func updateMenuButtonConstraints() { - currentMenuButtonTrailing.isActive = false - - let anchor: NSLayoutXAxisAnchor - if (!qualityButton.isHidden) { - anchor = qualityButton.leadingAnchor - } else if (!speedButton.isHidden) { - anchor = speedButton.leadingAnchor - } else { - anchor = controlsContainerView.trailingAnchor - } - - currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6) - currentMenuButtonTrailing.isActive = true - } + @objc func toggleControls() { if controlsLocked { - lockButton.alpha = 1.0 + unlockButton.alpha = 1.0 lockButtonTimer?.invalidate() lockButtonTimer = Timer.scheduledTimer( withTimeInterval: 3.0, repeats: false ) { [weak self] _ in UIView.animate(withDuration: 0.3) { - self?.lockButton.alpha = 0 + self?.unlockButton.alpha = 0 } } - updateSkipButtonsVisibility() return } @@ -1789,16 +1845,40 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } + let holdSpeedAlpha = holdSpeedIndicator?.alpha ?? 0 + UIView.animate(withDuration: 0.2) { let alpha: CGFloat = self.isControlsVisible ? 1.0 : 0.0 - self.controlsContainerView.alpha = alpha - self.skip85Button.alpha = alpha - self.lockButton.alpha = alpha + + for control in self.controlsToHide { + control.alpha = alpha + } + + self.dismissButton.alpha = self.isControlsVisible ? 1.0 : 0.0 + + if let holdSpeed = self.holdSpeedIndicator { + holdSpeed.alpha = holdSpeedAlpha + } + + if self.isControlsVisible { + self.skip85Button.isHidden = false + self.skipIntroButton.alpha = 0.0 + self.skipOutroButton.alpha = 0.0 + } + self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible self.view.layoutIfNeeded() } - updateSkipButtonsVisibility() + + if isControlsVisible { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.setupControlButtonsContainer() + self.updateSkipButtonsVisibility() + } + } else { + updateSkipButtonsVisibility() + } } @objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) { @@ -1842,7 +1922,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard self != nil else { return } } + guard self != nil else { return } + } animateButtonRotation(forwardButton) } @@ -1855,6 +1936,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele seekForward() showSkipFeedback(direction: "forward") } + + skipOutroDismissedInSession = false } @objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) { @@ -1867,17 +1950,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele player.pause() isPlaying = false playPauseButton.image = UIImage(systemName: "play.fill") - - DispatchQueue.main.async { - if !self.isControlsVisible { - self.isControlsVisible = true - UIView.animate(withDuration: 0.1, animations: { - self.controlsContainerView.alpha = 1.0 - self.skip85Button.alpha = 0.8 - }) - self.updateSkipButtonsVisibility() - } - } } else { player.play() player.rate = currentPlaybackSpeed @@ -1887,11 +1959,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } @objc private func pipButtonTapped(_ sender: UIButton) { - guard let pip = pipController else { return } + Logger.shared.log("PiP button tapped", type: "Debug") + guard let pip = pipController else { + Logger.shared.log("PiP controller is nil", type: "Error") + return + } + Logger.shared.log("PiP controller found, isActive: \(pip.isPictureInPictureActive)", type: "Debug") if pip.isPictureInPictureActive { pip.stopPictureInPicture() + Logger.shared.log("Stopping PiP", type: "Debug") } else { pip.startPictureInPicture() + Logger.shared.log("Starting PiP", type: "Debug") } } @@ -1910,37 +1989,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isControlsVisible = !controlsLocked lockButtonTimer?.invalidate() + let holdSpeedAlpha = holdSpeedIndicator?.alpha ?? 0 + if controlsLocked { UIView.animate(withDuration: 0.25) { self.controlsContainerView.alpha = 0 - self.dimButton.alpha = 0 + self.dimButton.alpha = 0 for v in self.controlsToHide { v.alpha = 0 } self.skipIntroButton.alpha = 0 self.skipOutroButton.alpha = 0 - self.skip85Button.alpha = 0 + self.skip85Button.alpha = 0 self.lockButton.alpha = 0 + self.unlockButton.alpha = 1 + + if let holdSpeed = self.holdSpeedIndicator { + holdSpeed.alpha = holdSpeedAlpha + } self.subtitleBottomToSafeAreaConstraint?.isActive = true - self.subtitleBottomToSliderConstraint?.isActive = false + self.subtitleBottomToSliderConstraint?.isActive = false self.view.layoutIfNeeded() } - lockButton.setImage(UIImage(systemName: "lock.fill"), for: .normal) + lockButton.setImage(UIImage(systemName: "lock"), for: .normal) + lockButtonTimer = Timer.scheduledTimer( + withTimeInterval: 3.0, + repeats: false + ) { [weak self] _ in + UIView.animate(withDuration: 0.3) { + self?.unlockButton.alpha = 0 + } + } } else { UIView.animate(withDuration: 0.25) { self.controlsContainerView.alpha = 1 - self.dimButton.alpha = 1 + self.dimButton.alpha = 1 for v in self.controlsToHide { v.alpha = 1 } + self.unlockButton.alpha = 0 + + if let holdSpeed = self.holdSpeedIndicator { + holdSpeed.alpha = holdSpeedAlpha + } self.subtitleBottomToSafeAreaConstraint?.isActive = false - self.subtitleBottomToSliderConstraint?.isActive = true + self.subtitleBottomToSliderConstraint?.isActive = true self.view.layoutIfNeeded() } - lockButton.setImage(UIImage(systemName: "lock.open.fill"), for: .normal) + lockButton.setImage(UIImage(systemName: "lock.open"), for: .normal) updateSkipButtonsVisibility() } } @@ -1948,14 +2047,47 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc private func skipIntro() { if let range = skipIntervals.op { player.seek(to: range.end) - skipIntroButton.isHidden = true + skipIntroDismissedInSession = true + hideSkipIntroButton() + } + } + + private func hideSkipIntroButton() { + UIView.animate(withDuration: 0.2) { + self.skipIntroButton.alpha = 0.0 + } completion: { _ in + self.updateSkipButtonsVisibility() + self.view.setNeedsLayout() + self.view.layoutIfNeeded() } } @objc private func skipOutro() { if let range = skipIntervals.ed { player.seek(to: range.end) - skipOutroButton.isHidden = true + skipOutroDismissedInSession = true + removeSkipOutroButton() + } + } + + private func removeSkipOutroButton() { + if skipOutroButton.superview != nil { + UIView.animate(withDuration: 0.2, animations: { + self.skipOutroButton.alpha = 0.0 + }, completion: { _ in + self.view.setNeedsLayout() + self.view.layoutIfNeeded() + + self.updateControlButtonsContainerPosition() + + let hasVisibleButtons = [self.watchNextButton, self.speedButton, self.qualityButton, self.menuButton].contains { button in + guard let button = button else { return false } + return !button.isHidden + } + if hasVisibleButtons || self.controlButtonsContainer.superview != nil { + self.setupControlButtonsContainer() + } + }) } } @@ -1979,10 +2111,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isDimmed.toggle() dimButtonTimer?.invalidate() UIView.animate(withDuration: 0.25) { - self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4 + self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.0 } - dimButtonToSlider.isActive = !isDimmed - dimButtonToRight.isActive = isDimmed } func speedChangerMenu() -> UIMenu { @@ -2343,9 +2473,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func checkForHLSStream() { guard let url = URL(string: streamURL) else { return } - let streamType = module.metadata.streamType.lowercased() + Logger.shared.log("Checking for HLS stream: \(url.absoluteString)", type: "Debug") if url.absoluteString.contains(".m3u8") || url.absoluteString.contains(".m3u") { + Logger.shared.log("HLS stream detected", type: "Debug") isHLSStream = true baseM3U8URL = url currentQualityURL = url @@ -2380,17 +2511,22 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } - self.qualityButton.isHidden = false - self.qualityButton.menu = self.qualitySelectionMenu() - self.updateMenuButtonConstraints() - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { - self.view.layoutIfNeeded() + DispatchQueue.main.async { + Logger.shared.log("Showing quality button on main thread", type: "Debug") + self.qualityButton.isHidden = false + self.qualityButton.menu = self.qualitySelectionMenu() + + self.setupControlButtonsContainer() + + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { + self.view.layoutIfNeeded() + } } } } else { + Logger.shared.log("Not an HLS stream", type: "Debug") isHLSStream = false qualityButton.isHidden = true - updateMenuButtonConstraints() Logger.shared.log("Quality Selection: Non-HLS stream detected, quality selection unavailable", type: "General") } } @@ -2714,7 +2850,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele player.rate = speed UIView.animate(withDuration: 0.1) { - self.holdSpeedIndicator.alpha = 0.8 + self.holdSpeedIndicator.alpha = 1.0 } } @@ -2761,12 +2897,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ), inRange: 0...1, activeFillColor: .white, - fillColor: .white.opacity(0.6), + fillColor: .white, emptyColor: .white.opacity(0.3), height: 10, onEditingChanged: { _ in } ) - .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) } } @@ -2841,12 +2976,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele controlsContainerView.addSubview(container) self.timeBatteryContainer = container - // Add tap gesture to toggle end time visibility let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleEndTimeVisibility)) container.addGestureRecognizer(tapGesture) container.isUserInteractionEnabled = true - // Add end time components (initially hidden) let endTimeIcon = UIImageView(image: UIImage(systemName: "timer")) endTimeIcon.translatesAutoresizingMaskIntoConstraints = false endTimeIcon.tintColor = .white @@ -2968,7 +3101,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.endTimeSeparator?.alpha = alpha self.endTimeLabel?.alpha = alpha - // 调整容器位置以保持居中 if let container = self.timeBatteryContainer { container.transform = CGAffineTransform(translationX: offset, y: 0) } @@ -2986,14 +3118,23 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let currentSeconds = CMTimeGetSeconds(player.currentTime()) let remainingSeconds = duration - currentSeconds + let playbackSpeed = player.rate if remainingSeconds <= 0 { endTimeLabel?.text = "--:--" return } - // Calculate end time by adding remaining seconds to current time - let endTime = Date().addingTimeInterval(remainingSeconds) + if playbackSpeed == 0 { + let endTime = Date().addingTimeInterval(remainingSeconds) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + endTimeLabel?.text = formatter.string(from: endTime) + return + } + + let realTimeRemaining = remainingSeconds / Double(playbackSpeed) + let endTime = Date().addingTimeInterval(realTimeRemaining) let formatter = DateFormatter() formatter.dateFormat = "HH:mm" endTimeLabel?.text = formatter.string(from: endTime) @@ -3025,6 +3166,266 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } + + + + @objc private func protectMenuFromRecreation() { + isMenuOpen = true + } + + func updateMarqueeConstraintsForBottom() { + NSLayoutConstraint.deactivate(titleStackAboveSkipButtonConstraints) + NSLayoutConstraint.deactivate(titleStackAboveSliderConstraints) + + let skipIntroVisible = !(skipIntroButton?.isHidden ?? true) && (skipIntroButton?.alpha ?? 0) > 0.1 + let skip85Visible = !(skip85Button?.isHidden ?? true) && (skip85Button?.alpha ?? 0) > 0.1 + let skipOutroVisible = skipOutroButton.superview != nil && !skipOutroButton.isHidden && skipOutroButton.alpha > 0.1 + + if skipIntroVisible && skipIntroButton?.superview != nil && titleStackView.superview != nil { + titleStackAboveSkipButtonConstraints = [ + titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + titleStackView.bottomAnchor.constraint(equalTo: skipIntroButton.topAnchor, constant: -4), + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + ] + NSLayoutConstraint.activate(titleStackAboveSkipButtonConstraints) + } else if skip85Visible && skip85Button?.superview != nil && titleStackView.superview != nil { + titleStackAboveSkipButtonConstraints = [ + titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + titleStackView.bottomAnchor.constraint(equalTo: skip85Button.topAnchor, constant: -4), + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + ] + NSLayoutConstraint.activate(titleStackAboveSkipButtonConstraints) + } else if let sliderView = sliderHostingController?.view, titleStackView.superview != nil { + titleStackAboveSliderConstraints = [ + titleStackView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), + titleStackView.bottomAnchor.constraint(equalTo: sliderView.topAnchor, constant: -4), + titleStackView.widthAnchor.constraint(lessThanOrEqualTo: controlsContainerView.widthAnchor, multiplier: 0.7) + ] + NSLayoutConstraint.activate(titleStackAboveSliderConstraints) + } + + view.layoutIfNeeded() + } + + func setupWatchNextButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "forward.end", withConfiguration: config) + + watchNextButton = UIButton(type: .system) + watchNextButton.setImage(image, for: .normal) + watchNextButton.backgroundColor = .clear + watchNextButton.tintColor = .white + watchNextButton.setTitleColor(.white, for: .normal) + watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) + watchNextButton.translatesAutoresizingMaskIntoConstraints = false + } + + private func setupDimButton() { + let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + dimButton = UIButton(type: .system) + dimButton.setImage(UIImage(systemName: "moon", withConfiguration: cfg), for: .normal) + dimButton.tintColor = .white + dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside) + view.addSubview(dimButton) + dimButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + dimButton.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + dimButton.widthAnchor.constraint(equalToConstant: 24), + dimButton.heightAnchor.constraint(equalToConstant: 24) + ]) + + dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor) + dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16) + } + + func setupSpeedButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "speedometer", withConfiguration: config) + + speedButton = UIButton(type: .system) + speedButton.setImage(image, for: .normal) + speedButton.tintColor = .white + speedButton.showsMenuAsPrimaryAction = true + speedButton.menu = speedChangerMenu() + + speedButton.addTarget(self, action: #selector(protectMenuFromRecreation), for: .touchDown) + + speedButton.translatesAutoresizingMaskIntoConstraints = false + } + + func setupMenuButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "captions.bubble", withConfiguration: config) + + menuButton = UIButton(type: .system) + menuButton.setImage(image, for: .normal) + menuButton.tintColor = .white + menuButton.showsMenuAsPrimaryAction = true + + if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { + menuButton.menu = buildOptionsMenu() + } else { + menuButton.isHidden = true + } + + menuButton.addTarget(self, action: #selector(protectMenuFromRecreation), for: .touchDown) + + menuButton.translatesAutoresizingMaskIntoConstraints = false + } + + private func setupLockButton() { + let cfg = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + lockButton = UIButton(type: .system) + lockButton.setImage( + UIImage(systemName: "lock.open", withConfiguration: cfg), + for: .normal + ) + lockButton.tintColor = .white + + lockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside) + + view.addSubview(lockButton) + lockButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + lockButton.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), + lockButton.widthAnchor.constraint(equalToConstant: 24), + lockButton.heightAnchor.constraint(equalToConstant: 24), + ]) + } + + private func setupUnlockButton() { + let cfg = UIImage.SymbolConfiguration(pointSize: 40, weight: .medium) + unlockButton = UIButton(type: .system) + unlockButton.setImage( + UIImage(systemName: "lock", withConfiguration: cfg), + for: .normal + ) + unlockButton.tintColor = .white + unlockButton.alpha = 0 + unlockButton.layer.shadowColor = UIColor.black.cgColor + unlockButton.layer.shadowOffset = CGSize(width: 0, height: 2) + unlockButton.layer.shadowOpacity = 0.6 + unlockButton.layer.shadowRadius = 4 + unlockButton.layer.masksToBounds = false + + unlockButton.addTarget(self, action: #selector(lockTapped), for: .touchUpInside) + + view.addSubview(unlockButton) + unlockButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + unlockButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + unlockButton.centerYAnchor.constraint(equalTo: view.centerYAnchor), + unlockButton.widthAnchor.constraint(equalToConstant: 80), + unlockButton.heightAnchor.constraint(equalToConstant: 80) + ]) + } + + private func setupPipIfSupported() { + airplayButton = AVRoutePickerView(frame: .zero) + airplayButton.translatesAutoresizingMaskIntoConstraints = false + airplayButton.activeTintColor = .white + airplayButton.tintColor = .white + airplayButton.backgroundColor = .clear + airplayButton.prioritizesVideoDevices = true + airplayButton.setContentHuggingPriority(.required, for: .horizontal) + airplayButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + let airplayContainer = UIView() + airplayContainer.translatesAutoresizingMaskIntoConstraints = false + airplayContainer.backgroundColor = .clear + view.addSubview(airplayContainer) + airplayContainer.addSubview(airplayButton) + + NSLayoutConstraint.activate([ + airplayContainer.widthAnchor.constraint(equalToConstant: 24), + airplayContainer.heightAnchor.constraint(equalToConstant: 24), + airplayButton.centerXAnchor.constraint(equalTo: airplayContainer.centerXAnchor), + airplayButton.centerYAnchor.constraint(equalTo: airplayContainer.centerYAnchor), + airplayButton.widthAnchor.constraint(equalToConstant: 24), + airplayButton.heightAnchor.constraint(equalToConstant: 24) + ]) + + for subview in airplayButton.subviews { + subview.contentMode = .scaleAspectFit + if let button = subview as? UIButton { + button.imageEdgeInsets = .zero + button.contentEdgeInsets = .zero + } + } + + guard AVPictureInPictureController.isPictureInPictureSupported() else { + return + } + + playerViewController.allowsPictureInPicturePlayback = true + + let playerLayerContainer = UIView() + playerLayerContainer.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(playerLayerContainer, at: 0) + + NSLayoutConstraint.activate([ + playerLayerContainer.topAnchor.constraint(equalTo: view.topAnchor), + playerLayerContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor), + playerLayerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + playerLayerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + let playerLayer = AVPlayerLayer(player: player) + playerLayer.frame = playerLayerContainer.bounds + playerLayer.videoGravity = .resizeAspect + playerLayerContainer.layer.addSublayer(playerLayer) + + pipController = AVPictureInPictureController(playerLayer: playerLayer) + pipController?.delegate = self + + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + let Image = UIImage(systemName: "pip", withConfiguration: config) + pipButton = UIButton(type: .system) + pipButton.setImage(Image, for: .normal) + pipButton.tintColor = .white + pipButton.addTarget(self, action: #selector(pipButtonTapped(_:)), for: .touchUpInside) + + controlsContainerView.addSubview(pipButton) + pipButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + pipButton.centerYAnchor.constraint(equalTo: dimButton.centerYAnchor), + pipButton.trailingAnchor.constraint(equalTo: dimButton.leadingAnchor, constant: -8), + pipButton.widthAnchor.constraint(equalToConstant: 40), + pipButton.heightAnchor.constraint(equalToConstant: 40), + airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor), + airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -4), + airplayButton.widthAnchor.constraint(equalToConstant: 24), + airplayButton.heightAnchor.constraint(equalToConstant: 24) + ]) + + pipButton.isHidden = !isPipButtonVisible + + NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil) + } + + func updateMarqueeConstraints() { + UIView.performWithoutAnimation { + NSLayoutConstraint.deactivate(currentMarqueeConstraints) + + let leftSpacing: CGFloat = 2 + let rightSpacing: CGFloat = 6 + let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false) + ? volumeSliderHostingView!.leadingAnchor + : view.safeAreaLayoutGuide.trailingAnchor + + currentMarqueeConstraints = [ + episodeNumberLabel.leadingAnchor.constraint( + equalTo: dismissButton.trailingAnchor, constant: leftSpacing), + episodeNumberLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, constant: -rightSpacing - 10), + episodeNumberLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + NSLayoutConstraint.activate(currentMarqueeConstraints) + view.layoutIfNeeded() + } + } } class GradientOverlayButton: UIButton { @@ -3103,3 +3504,116 @@ class PassthroughView: UIView { return false } } + +class GradientBlurButton: UIButton { + private var gradientLayer: CAGradientLayer? + private var borderMask: CAShapeLayer? + private var blurView: UIVisualEffectView? + private var storedTitle: String? + private var storedImage: UIImage? + + override var isHidden: Bool { + didSet { + updateVisualState() + } + } + + override var alpha: CGFloat { + didSet { + updateVisualState() + } + } + + private func updateVisualState() { + let shouldBeVisible = !isHidden && alpha > 0.1 + + storedTitle = self.title(for: .normal) + storedImage = self.image(for: .normal) + + cleanupVisualEffects() + + if shouldBeVisible { + setupBlurAndGradient() + } else { + backgroundColor = .clear + } + + if let title = storedTitle, !title.isEmpty { + setTitle(title, for: .normal) + } + if let image = storedImage { + setImage(image, for: .normal) + } + } + + private func cleanupVisualEffects() { + blurView?.removeFromSuperview() + blurView = nil + + backgroundColor = .clear + layer.borderWidth = 0 + layer.shadowOpacity = 0 + layer.cornerRadius = 0 + clipsToBounds = false + } + + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + if newSuperview == nil { + cleanupVisualEffects() + } else if newSuperview != nil && !isHidden && alpha > 0.1 { + setupBlurAndGradient() + } else if newSuperview != nil { + cleanupVisualEffects() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + if !isHidden && alpha > 0.1 { + setupBlurAndGradient() + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + if !isHidden && alpha > 0.1 { + setupBlurAndGradient() + } + } + + private func setupBlurAndGradient() { + let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + blur.isUserInteractionEnabled = false + blur.layer.cornerRadius = 21 + blur.clipsToBounds = true + blur.translatesAutoresizingMaskIntoConstraints = false + insertSubview(blur, at: 0) + NSLayoutConstraint.activate([ + blur.leadingAnchor.constraint(equalTo: leadingAnchor), + blur.trailingAnchor.constraint(equalTo: trailingAnchor), + blur.topAnchor.constraint(equalTo: topAnchor), + blur.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + self.blurView = blur + + clipsToBounds = true + layer.cornerRadius = 21 + } + + override func layoutSubviews() { + super.layoutSubviews() + + if let imageView = self.imageView { + bringSubviewToFront(imageView) + } + if let titleLabel = self.titleLabel { + bringSubviewToFront(titleLabel) + } + } + + override func removeFromSuperview() { + cleanupVisualEffects() + super.removeFromSuperview() + } +} diff --git a/Sora/Utlis & Misc/TabBar/TabBar.swift b/Sora/Utlis & Misc/TabBar/TabBar.swift index 0b383c0..01b6ae0 100644 --- a/Sora/Utlis & Misc/TabBar/TabBar.swift +++ b/Sora/Utlis & Misc/TabBar/TabBar.swift @@ -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.. 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.. 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) } } } @@ -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 @@ -209,48 +310,61 @@ struct TabBar: View { 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) } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index a5c234f..b2a91f6 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -322,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" ) ] diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 234f706..7b43cf8 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -149,8 +149,7 @@ fileprivate struct SettingsPickerRow: View { } struct SettingsViewGeneral: View { - @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 50 - @AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true + @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 @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 @@ -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: ""), diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index b8ac26b..66026b0 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -58,6 +58,46 @@ fileprivate struct SettingsSection: 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, 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) } diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 2c72061..0e2f290 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -454,6 +454,8 @@ class Settings: ObservableObject { languageCode = "nn" case "Kazakh": languageCode = "kk" + case "Mongolian": + languageCode = "mn" case "Swedish": languageCode = "sv" case "Italian": diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index c9bf7ef..4f956e7 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; }; 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; }; + 04F8DF9C2E1B2822006248D8 /* mn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mn; path = Localizable.strings; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; @@ -444,6 +445,14 @@ path = Models; sourceTree = ""; }; + 04F8DF9A2E1B2814006248D8 /* mn.lproj */ = { + isa = PBXGroup; + children = ( + 04F8DF9B2E1B2822006248D8 /* Localizable.strings */, + ); + path = mn.lproj; + sourceTree = ""; + }; 13103E802D589D6C000F0673 /* Tracking & Metadata */ = { isa = PBXGroup; children = ( @@ -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 = ""; }; + 04F8DF9B2E1B2822006248D8 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 04F8DF9C2E1B2822006248D8 /* mn */, + ); + name = Localizable.strings; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */