diff --git a/README.md b/README.md index 3dd66e5..3d358db 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,12 @@ - [x] macOS 12.0+ support - [x] iOS/iPadOS 15.0+ support -- [x] JavaScript as main Loader +- [x] JavaScript as main loader - [x] Download support (HLS & MP4) -- [x] Tracking Services (AniList, Trakt) -- [x] Apple KeyChain support for auth Tokens -- [x] Streams support (Jellyfin/Plex like servers) -- [x] External Metadata providers (TMDB, AniList) +- [x] Tracking services (AniList, Trakt) +- [x] Apple Keychain support for auth tokens +- [x] Streams support (Jellyfin/Plex-like servers) +- [x] External metadata providers (TMDB, AniList) - [x] Background playback and Picture-in-Picture (PiP) support - [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA, TracyPlayer) @@ -49,23 +49,24 @@ Additionally, you can install the app using Xcode or using the .ipa file, which ## Frequently Asked Questions -1. **What is Sora?** -Sora is a modular web scraping application designed to work exclusively with custom modules. +1. **What is Sora?** + Sora is a modular web scraping application designed to work exclusively with custom modules. -2. **Is Sora safe?** -Yes, Sora is open-source and prioritizes user privacy. It does not store user data on external servers and does not collect crash logs. +2. **Is Sora safe?** + Yes, Sora is open-source and prioritizes user privacy. It does not store user data on external servers and does not collect crash logs. -3. **Will Sora ever be paid?** -No, Sora will always remain free without subscriptions, paid content, or any type of login. +3. **Will Sora ever be paid?** + No, Sora will always remain free without subscriptions, paid content, or any type of login. -4. **How can I get modules?** -Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or create your own. +4. **How can I get modules?** + Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or create your own. ## Acknowledgements Frameworks: - [Drops](https://github.com/omaralbeik/Drops) - MIT License - [NukeUI](https://github.com/kean/NukeUI) - MIT License +- [SoraCore](https://github.com/cranci1/SoraCore) - Custom License - [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) - MIT License Misc: @@ -95,16 +96,16 @@ along with Sora. If not, see . ## Legal -**_Sora is not made for Piracy! The Sora project does not condone any form of piracy._** +**_Sora is not intended for piracy. The Sora project does not endorse or support any form of piracy._** ### No Liability -The developer(s) of this software assumes no liability for damages, legal claims, or other issues arising from the use or misuse of this software or any third-party modules. Users bear full responsibility for their actions. Use this software and modules at your own risk. +The developer(s) of this software assume no liability for damages, legal claims, or other issues arising from the use or misuse of this software or any third-party modules. Users bear full responsibility for their actions. Use of this software and its modules is at your own risk. ### Third-Party Websites and Intellectual Property -This software is not affiliated with or endorsed by any third-party entity. Any references to third-party sites in user-generated modules do not imply endorsement. Users are responsible for verifying that their scraping activities comply with the terms of service and intellectual property rights of the sites they interact with. +This software is not affiliated with or endorsed by any third-party entities. Any references to third-party sites in user-generated modules do not imply endorsement. Users are responsible for ensuring their scraping activities comply with the terms of service and intellectual property rights of the sites they interact with. ### DMCA -The developer(s) are not responsible for the misuse of any content inside or outside the app and shall not be responsible for the dissemination of any content within the app. Any violations should be sent to the source website or module creator. The developer is not legally responsible for any module used inside the app. +The developer(s) are not responsible for the misuse of any content inside or outside the app and shall not be held liable for the dissemination of any content within the app. Any violations should be reported to the source website or module creator. The developer bears no legal responsibility for any module used within the app. diff --git a/Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json b/Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json new file mode 100644 index 0000000..7d4793c --- /dev/null +++ b/Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SplashScreenIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png b/Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png new file mode 100644 index 0000000..c8f6f6f Binary files /dev/null and b/Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png differ diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index d66b847..e15ac25 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -8,7 +8,14 @@ }, "%lld Episodes" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Afleveringen" + } + } + } }, "%lld of %lld" : { "localizations" : { @@ -34,31 +41,212 @@ }, "%lld%% seen" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%% gezien" + } + } + } }, "•" : { }, "About" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Over" + } + } + } + }, + "About Sora" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About Sora" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Over Sora" + } + } + } + }, + "Active" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Active" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Actief" + } + } + } + }, + "Active Downloads" : { }, "Actively downloading media can be tracked from here." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Actively downloading media can be tracked from here." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Actief downloaden van media kan hier worden gevolgd." + } + } + } }, "Add Module" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Module" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Module Toevoegen" + } + } + } + }, + "Adjust the number of media items per row in portrait and landscape modes." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Adjust the number of media items per row in portrait and landscape modes." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Pas het aantal media-items per rij aan in staande en liggende modus." + } + } + } + }, + "Advanced" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Advanced" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geavanceerd" + } + } + } }, "AKA Sulfur" : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "AKA Sulfur" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "AKA Sulfur" + } + } + } }, "All Bookmarks" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All Bookmarks" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle Bladwijzers" + } + } + } }, "All Prev" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle vorige" + } + } + } }, "All Watching" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All Watching" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alles Wat Ik Kijk" + } + } + } + }, + "Also known as Sulfur" : { + }, + "AniList" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AniList" + } + } + } }, "AniList ID" : { @@ -68,367 +256,3651 @@ }, "AniList.co" : { + }, + "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Anonieme gegevens worden verzameld om de app te verbeteren. Er worden geen persoonlijke gegevens verzameld. Dit kan op elk moment worden uitgeschakeld." + } + } + } }, "App Data" : { + }, + "App Info" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "App Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "App Info" + } + } + } + }, + "App Language" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "App Language" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "App Taal" + } + } + } + }, + "App Storage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Storage" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Opslag" + } + } + } + }, + "Appearance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Appearance" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Uiterlijk" + } + } + } + }, + "Are you sure you want to clear all cached data? This will help free up storage space." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to clear all cached data? This will help free up storage space." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle gecachte gegevens wilt wissen? Dit helpt opslagruimte vrij te maken." + } + } + } }, "Are you sure you want to delete '%@'?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to delete '%@'?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je '%@' wilt verwijderen?" + } + } + } + }, + "Are you sure you want to delete all %d episodes in '%@'?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to delete all %1$d episodes in '%2$@'?" + } + } + } }, "Are you sure you want to delete all %lld episodes in '%@'?" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Are you sure you want to delete all %1$lld episodes in '%2$@'?" } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle %1$lld afleveringen in '%2$@' wilt verwijderen?" + } } } }, "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." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "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." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle gedownloade bestanden wilt verwijderen? Je kunt ervoor kiezen om alleen de bibliotheek te wissen terwijl je de gedownloade bestanden voor later gebruik bewaart." + } + } + } }, "Are you sure you want to erase all app data? This action cannot be undone." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to erase all app data? This action cannot be undone." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle app-gegevens wilt wissen? Deze actie kan niet ongedaan worden gemaakt." + } + } + } + }, + "Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone." : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je alle gedownloade mediabestanden (.mov, .mp4, .pkg) wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.\n" + } + } + } }, "Are you sure you want to remove all files in the Documents folder? This will remove all modules." : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je alle bestanden in de map Documenten wilt verwijderen? Dit zal alle modules verwijderen.\n" + } + } + } }, "Author" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auteur\n" + } + } + } + }, + "Background Enabled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Background Enabled" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Achtergrond Ingeschakeld" + } + } + } }, "Bookmark items for an easier access later." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark items for an easier access later." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bladwijzer items voor eenvoudigere toegang later." + } + } + } }, "Bookmarks" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bladwijzers" + } + } + } + }, + "Bottom Padding" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bottom Padding" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Onderste Padding" + } + } + } }, "Cancel" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Annuleren" + } + } + } + }, + "Cellular Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cellular Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Mobiele Kwaliteit" + } + } + } }, "Check out some community modules here!" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check out some community modules here!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bekijk hier enkele community modules!" + } + } + } + }, + "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." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "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." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kies de gewenste videoresolutie voor WiFi en mobiele verbindingen. Hogere resoluties gebruiken meer data maar bieden betere kwaliteit. Als de exacte kwaliteit niet beschikbaar is, wordt automatisch de dichtstbijzijnde optie geselecteerd.\n\nLet op: Niet alle videobronnen en spelers ondersteunen kwaliteitsselectie. Deze functie werkt het beste met HLS-streams met de Sora-speler." + } + } + } }, "Clear" : { - - }, - "Clear All Caches" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Wissen" + } + } + } }, "Clear All Downloads" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Alle Downloads" + } + } + } + }, + "Clear Cache" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Cache" + } + } + } }, "Clear Library Only" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen bibliotheek wissen\n" + } + } + } }, "Clear Logs" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Logs" + } + } + } }, "Click the plus button to add a module!" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Click the plus button to add a module!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Klik op de plus-knop om een module toe te voegen!" + } + } + } }, "Continue Watching" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Watching" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verder Kijken" + } + } + } + }, + "Continue Watching Episode %d" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Watching Episode %d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verder Kijken Aflevering %d" + } + } + } + }, + "Contributors" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Contributors" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bijdragers" + } + } + } + }, + "Copied to Clipboard" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied to Clipboard" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gekopieerd naar Klembord" + } + } + } }, "Copy to Clipboard" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy to Clipboard" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kopiëren naar Klembord" + } + } + } }, "Copy URL" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy URL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "URL Kopiëren" + } + } + } }, "cranci1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "cranci1" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "cranci1" + } + } + } + }, + "Dark" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dark" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Donker" + } + } + } + }, + "DATA & LOGS" : { }, - "Current Cache Size" : { - + "Debug" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + } + } }, - "DATA/LOGS" : { - + "Debugging and troubleshooting." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debugging and troubleshooting." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debuggen en probleemoplossing." + } + } + } }, "Delete" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Verwijderen" + } + } + } }, "Delete All" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alles Wissen" + } + } + } }, "Delete All Downloads" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Downloads Wissen" + } + } + } }, "Delete All Episodes" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Afleveringen Wissen" + } + } + } }, "Delete Download" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads Wissen" + } + } + } }, "Delete Episode" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveringen Wissen" + } + } + } + }, + "Double Tap to Seek" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Double Tap to Seek" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Dubbel Tikken om te Zoeken" + } + } + } + }, + "Double tapping the screen on it's sides will skip with the short tap setting." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Double tapping the screen on it's sides will skip with the short tap setting." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Dubbel tikken op de zijkanten van het scherm zal overslaan met de korte tik instelling." + } + } + } }, "Download" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloaden" + } + } + } }, "Download Episode" : { + "extractionState" : "stale", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aflevering Downloaden" + } + } + } + }, + "Download Summary" : { + + }, + "Download This Episode" : { + + }, + "Downloaded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloaded" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownload" + } + } + } + }, + "Downloaded Shows" : { + + }, + "Downloading" : { }, "Downloads" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + } + } + }, + "Enable Analytics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Analytics" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Analytics Inschakelen" + } + } + } + }, + "Enable Subtitles" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Subtitles" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ondertiteling Inschakelen" + } + } + } }, "Enter the AniList ID for this media" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter the AniList ID for this media" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Voer de AniList ID in voor deze media" + } + } + } + }, + "Enter the AniList ID for this series" : { }, "Episode %lld" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Episode %lld" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Aflevering %lld" + } + } + } }, "Episodes" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveringen" + } + } + } }, "Episodes might not be available yet or there could be an issue with the source." : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes might not be available yet or there could be an issue with the source." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveringen zijn mogelijk nog niet beschikbaar of er is een probleem met de bron." + } + } + } + }, + "Episodes Range" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Episodes Range" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Afleveringen Bereik" + } + } + } }, "Erase" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijden" + } + } + } + }, + "Erase all App Data" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erase all App Data" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Alle App Data" + } + } + } }, "Erase App Data" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder App Data" + } + } + } }, "Error" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Error" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Fout" + } + } + } + }, + "Error Fetching Results" : { + }, + "Errors and critical issues." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errors and critical issues." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fouten en kritieke problemen." + } + } + } }, "Failed to load contributors" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to load contributors" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Laden van bijdragers mislukt" + } + } + } + }, + "Fetch Episode metadata" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fetch Episode metadata" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Haal Aflevering Metadata op" + } + } + } }, "Files Downloaded" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Files Downloaded" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownloade Bestanden" + } + } + } + }, + "Font Size" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Font Size" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Lettergrootte" + } + } + } + }, + "Force Landscape" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Force Landscape" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Forceer Landschap" + } + } + } }, "General" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Algemeen" + } + } + } + }, + "General events and activities." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General events and activities." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algemene gebeurtenissen en activiteiten." + } + } + } + }, + "General Preferences" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General Preferences" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Algemene Voorkeuren" + } + } + } + }, + "Hide Splash Screen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Splash Screen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Splash Screen Verbergen" + } + } + } + }, + "HLS video downloading." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HLS video downloading." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "HLS video downloaden." + } + } + } + }, + "Hold Speed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hold Speed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Vasthouden Snelheid" + } + } + } + }, + "Info" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info" + } + } + } }, "INFOS" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "INFOS" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "INFO" + } + } + } + }, + "Installed Modules" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installed Modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geïnstalleerde Modules" + } + } + } + }, + "Interface" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Interface" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Interface" + } + } + } + }, + "Join the Discord" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Join the Discord" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Word lid van de Discord" + } + } + } + }, + "Landscape Columns" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Landscape Columns" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Liggende Kolommen" + } + } + } + }, + "Language" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Language" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Taal" + } + } + } }, "LESS" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LESS" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "MINDER" + } + } + } }, "Library" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Library" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bibliotheek" + } + } + } + }, + "License (GPLv3.0)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "License (GPLv3.0)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licentie (GPLv3.0)" + } + } + } + }, + "Light" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Light" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Licht" + } + } + } }, "Loading Episode %lld..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading Episode %lld..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Aflevering %lld laden..." + } + } + } }, "Loading logs..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading logs..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Logboeken laden..." + } + } + } }, "Loading module information..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading module information..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Module-informatie laden..." + } + } + } }, "Loading Stream" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading Stream" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Stream Laden" + } + } + } }, "Log Debug Info" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Debug Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Debug Info Loggen" + } + } + } }, "Log Filters" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Filters" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Filters" + } + } + } }, "Log In with AniList" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log In with AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Inloggen met AniList" + } + } + } }, "Log In with Trakt" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log In with Trakt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Inloggen met Trakt" + } + } + } }, "Log Out from AniList" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Out from AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Uitloggen van AniList" + } + } + } }, "Log Out from Trakt" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Out from Trakt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Uitloggen van Trakt" + } + } + } + }, + "Log Types" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log Types" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logboek Types" + } + } + } + }, + "Logged in as" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logged in as" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingelogd als" + } + } + } }, "Logged in as " : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged in as " + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ingelogd als " + } + } + } }, "Logs" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logs" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Logboeken" + } + } + } + }, + "Long press Skip" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Long press Skip" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Lang Drukken Overslaan" + } + } + } }, "MAIN" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main Settings" + } + } + } + }, + "Main Developer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Main Developer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Hoofdontwikkelaar" + } + } + } + }, + "MAIN SETTINGS" : { }, "Mark All Previous Watched" : { - + "extractionState" : "stale", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markeer alles als gezien\n" + } + } + } }, "Mark as Watched" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markeer als gezien" + } + } + } + }, + "Mark Episode as Watched" : { + }, + "Mark Previous Episodes as Watched" : { + + }, + "Mark watched" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markeer als gezien" + } + } + } }, "Match with AniList" : { }, - "Matched with: %@" : { + "Match with TMDB" : { + }, + "Matched ID: %lld" : { + + }, + "Matched with: %@" : { + "extractionState" : "stale", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match met: %@" + } + } + } }, "Max Concurrent Downloads" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximaal gelijktijdige downloads\n" + } + } + } }, "me frfr" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "me frfr" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "me frfr" + } + } + } + }, + "Media Grid Layout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Grid Layout" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Raster Layout" + } + } + } + }, + "Media Player" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Player" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Speler" + } + } + } + }, + "Media View" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Media View" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Mediaweergave" + } + } + } + }, + "Metadata Provider" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Metadata Provider" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Metadata Provider" + } + } + } + }, + "Metadata Providers Order" : { + }, + "Module Removed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module Removed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module Verwijderd" + } + } + } }, "Modules" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Modules" + } + } + } + }, + "MODULES" : { }, "MORE" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "MORE" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "MEER" + } + } + } }, "No Active Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Active Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Actieve Downloads" + } + } + } + }, + "No AniList matches found" : { }, "No Data Available" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Data Available" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Gegevens Beschikbaar" + } + } + } }, "No Downloads" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Downloads" + } + } + } }, "No episodes available" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No episodes available" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen afleveringen beschikbaar" + } + } + } }, "No Episodes Available" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Episodes Available" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Afleveringen Beschikbaar" + } + } + } }, "No items to continue watching." : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No items to continue watching." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen items om verder te kijken." + } + } + } }, "No matches found" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No matches found" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen overeenkomsten gevonden" + } + } + } }, "No Module Selected" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Module Selected" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Module Geselecteerd" + } + } + } }, "No Modules" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Modules" + } + } + } }, "No Results Found" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Results Found" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Resultaten Gevonden" + } + } + } + }, + "No Search Results Found" : { + + }, + "Note that the modules will be replaced only if there is a different version string inside the JSON file." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note that the modules will be replaced only if there is a different version string inside the JSON file." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Let op: de modules worden alleen vervangen als er een andere versiestring in het JSON-bestand staat." + } + } + } + }, + "Nothing to Continue Watching" : { }, "OK" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + } + } }, "Open Community Library" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Community Library" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Community Bibliotheek" + } + } + } }, "Open in AniList" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Openen in AniList" + } + } + } + }, + "Original Poster" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Original Poster" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Originele Poster" + } + } + } + }, + "Paused" : { }, "Play" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Play" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Afspelen" + } + } + } }, "Player" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Player" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Speler" + } + } + } + }, + "Please restart the app to apply the language change." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please restart the app to apply the language change." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Herstart de app om de taalwijziging toe te passen." + } + } + } }, "Please select a module from settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please select a module from settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Selecteer een module uit de instellingen" + } + } + } + }, + "Portrait Columns" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Portrait Columns" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Staande Kolommen" + } + } + } + }, + "Progress bar Marker Color" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Progress bar Marker Color" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Voortgangsbalk Markeerkleur" + } + } + } + }, + "Provider: %@" : { + + }, + "Queue" : { }, "Queued" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Queued" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "In Wachtrij" + } + } + } }, "Recently watched content will appear here." : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recently watched content will appear here." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Recent bekeken inhoud verschijnt hier." + } + } + } + }, + "Refresh Modules on Launch" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Refresh Modules on Launch" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ververs Modules bij Opstarten" + } + } + } }, "Refresh Storage Info" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Refresh Storage Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslaginformatie Vernieuwen" + } + } + } + }, + "Remember Playback speed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remember Playback speed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Onthoud Afspeelsnelheid" + } + } + } }, "Remove" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Verwijderen" + } + } + } + }, + "Remove All Cache" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove All Cache" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Alle Cache" + } + } + } + }, + "Remove All Documents" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove All Documents" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Alle Documenten" + } + } + } }, "Remove Documents" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Documents" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Documenten Verwijderen" + } + } + } + }, + "Remove Downloaded Media" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Downloaded Media" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownloade Media Verwijderen" + } + } + } + }, + "Remove Downloads" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Downloads" + } + } + } }, "Remove from Bookmarks" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove from Bookmarks" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Verwijderen uit Bladwijzers" + } + } + } }, "Remove Item" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Item" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Item Verwijderen" + } + } + } + }, + "Report an Issue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report an Issue" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporteer een Probleem" + } + } + } }, "Reset" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reset" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Resetten" + } + } + } }, "Reset AniList ID" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reset AniList ID" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "AniList ID Resetten" + } + } + } + }, + "Reset Episode Progress" : { + }, + "Reset progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voortgang resetten" + } + } + } }, "Reset Progress" : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reset Progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Voortgang Resetten" + } + } + } + }, + "Restart Required" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart Required" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Herstart Vereist" + } + } + } }, "Running Sora %@ - cranci1" : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Running Sora %@ - cranci1" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sora %@ draait - cranci1" + } + } + } }, "Save" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslaan" + } + } + } }, "Search" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoeken" + } + } + } }, "Search downloads" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads zoeken" + } + } + } }, "Search for something..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search for something..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoek naar iets..." + } + } + } }, "Search..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoeken..." + } + } + } + }, + "Season %d" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Season %d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seizoen %d" + } + } + } }, "Season %lld" : { + }, + "Segments Color" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Segments Color" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Segmenten Kleur" + } + } + } }, "Select Module" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Module" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Module Selecteren" + } + } + } }, "Set Custom AniList ID" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Custom AniList ID" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Aangepaste AniList ID Instellen" + } + } + } }, "Settings" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Instellingen" + } + } + } + }, + "Shadow" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Shadow" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Schaduw" + } + } + } }, "Show More (%lld more characters)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show More (%lld more characters)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Meer Tonen (%lld meer tekens)" + } + } + } + }, + "Show PiP Button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show PiP Button" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Toon PiP Knop" + } + } + } + }, + "Show Skip 85s Button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Skip 85s Button" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Toon Overslaan 85s Knop" + } + } + } + }, + "Show Skip Intro / Outro Buttons" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Skip Intro / Outro Buttons" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Toon Overslaan Intro / Outro Knoppen" + } + } + } + }, + "Shows" : { + }, + "Size (%@)" : { + + }, + "Skip Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Overslaan Instellingen" + } + } + } + }, + "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sommige functies zijn beperkt tot de Sora en Standaard speler, zoals ForceLandscape, holdSpeed en aangepaste tijd overslaan stappen." + } + } + } }, "Sora" : { }, - "Sort" : { + "Sora %@ by cranci1" : { + }, + "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora en cranci1 zijn op geen enkele manier verbonden met AniList of Trakt.\n\nHoud er ook rekening mee dat voortgangsupdates mogelijk niet 100% nauwkeurig zijn." + } + } + } + }, + "Sora GitHub Repository" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora GitHub Repository" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora GitHub Repository" + } + } + } + }, + "Sora/Sulfur will always remain free with no ADs!" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sora/Sulfur will always remain free with no ADs!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sora/Sulfur blijft altijd gratis zonder advertenties!" + } + } + } + }, + "Sort" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sort" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sorteren" + } + } + } + }, + "Speed Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Speed Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Snelheidsinstellingen" + } + } + } + }, + "Start Watching" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Watching" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start met Kijken" + } + } + } + }, + "Start Watching Episode %d" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Watching Episode %d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start met Kijken Aflevering %d" + } + } + } }, "Storage Used" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage Used" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gebruikte Opslag" + } + } + } + }, + "Stream" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stream" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stream" + } + } + } + }, + "Streaming and video playback." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming and video playback." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming en video afspelen." + } + } + } + }, + "Subtitle Color" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subtitle Color" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ondertitelingskleur" + } + } + } + }, + "Subtitle Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subtitle Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ondertitelingsinstellingen" + } + } + } + }, + "Sync anime progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync anime progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniseer anime voortgang" + } + } + } + }, + "Sync TV shows progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync TV shows progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniseer TV series voortgang" + } + } + } + }, + "System" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Systeem" + } + } + } }, "Tap a title to override the current match." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap a title to override the current match." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik op een titel om de huidige match te overschrijven." + } + } + } + }, + "Tap Skip" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap Skip" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik Overslaan" + } + } + } + }, + "Tap to manage your modules" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap to manage your modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik om je modules te beheren" + } + } + } + }, + "Tap to select a module" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap to select a module" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik om een module te selecteren" + } + } + } + }, + "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De app cache helpt de app om afbeeldingen sneller te laden.\n\nHet wissen van de Documents map zal alle gedownloade modules verwijderen.\n\nWis de App Data niet tenzij je de gevolgen begrijpt — het kan ervoor zorgen dat de app niet meer goed werkt." + } + } + } + }, + "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.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "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.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Het afleveringen bereik bepaalt hoeveel afleveringen er op elke pagina verschijnen. Afleveringen worden gegroepeerd in sets (zoals 1-25, 26-50, enzovoort), waardoor je er gemakkelijker doorheen kunt navigeren.\n\nVoor aflevering metadata verwijst dit naar de aflevering miniatuur en titel, aangezien deze soms spoilers kunnen bevatten." + } + } + } }, "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De module heeft slechts één aflevering geleverd, dit is waarschijnlijk een film, daarom hebben we aparte schermen gemaakt voor deze gevallen." + } + } + } + }, + "Thumbnails Width" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Thumbnails Width" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Miniatuur Breedte" + } + } + } + }, + "TMDB Match" : { }, "Trackers" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Trackers" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Trackers" + } + } + } + }, + "Trakt" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trakt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trakt" + } + } + } }, "Trakt.tv" : { }, "Try different keywords" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try different keywords" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Probeer andere zoekwoorden" + } + } + } + }, + "Try different search terms" : { + + }, + "Two Finger Hold for Pause" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Two Finger Hold for Pause" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Twee Vingers Vasthouden voor Pauze" + } + } + } + }, + "Unable to fetch matches. Please try again later." : { }, "Use TMDB Poster Image" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Use TMDB Poster Image" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "TMDB Poster Afbeelding Gebruiken" + } + } + } }, "v%@" : { + }, + "Video Player" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Player" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Videospeler" + } + } + } + }, + "Video Quality Preferences" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Quality Preferences" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Kwaliteit Voorkeuren" + } + } + } }, "View All" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View All" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alles Bekijken" + } + } + } }, "Watched" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Watched" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bekeken" + } + } + } }, "Why am I not seeing any episodes?" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Why am I not seeing any episodes?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waarom zie ik geen afleveringen?" + } + } + } + }, + "WiFi Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "WiFi Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "WiFi Kwaliteit" + } + } + } + }, + "You are not logged in" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are not logged in" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je bent niet ingelogd" + } + } + } }, "You have no items saved." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You have no items saved." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Je hebt geen items opgeslagen." + } + } + } }, "Your downloaded episodes will appear here" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your downloaded episodes will appear here" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Je gedownloade afleveringen verschijnen hier" + } + } + } + }, + "Your recently watched content will appear here" : { + }, + "Download Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Download Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Download Instellingen" + } + } + } + }, + "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Maximum gelijktijdige downloads bepaalt hoeveel afleveringen tegelijk kunnen worden gedownload. Hogere waarden kunnen meer bandbreedte en apparaatbronnen gebruiken." + } + } + } + }, + "Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kwaliteit" + } + } + } + }, + "Max Concurrent Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Max Concurrent Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Maximum Gelijktijdige Downloads" + } + } + } + }, + "Allow Cellular Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow Cellular Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads via Mobiel Netwerk Toestaan" + } + } + } + }, + "Quality Information" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quality Information" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kwaliteitsinformatie" + } + } + } + }, + "Storage Management" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage Management" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslagbeheer" + } + } + } + }, + "Storage Used" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage Used" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gebruikte Opslag" + } + } + } + }, + "Files Downloaded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Files Downloaded" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownloade Bestanden" + } + } + } + }, + "Refresh Storage Info" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Refresh Storage Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslaginformatie Vernieuwen" + } + } + } + }, + "Clear All Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear All Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle Downloads Wissen" + } + } + } + }, + "Delete All Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete All Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle Downloads Verwijderen" + } + } + } + }, + "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." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "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." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle gedownloade bestanden wilt verwijderen? Je kunt ervoor kiezen om alleen de bibliotheek te wissen terwijl je de gedownloade bestanden voor later gebruik bewaart." + } + } + } + }, + "Clear Library Only" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear Library Only" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alleen Bibliotheek Wissen" + } + } + } + }, + "Library cleared successfully" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Library cleared successfully" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bibliotheek succesvol gewist" + } + } + } + }, + "All downloads deleted successfully" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All downloads deleted successfully" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle downloads succesvol verwijderd" + } + } + } + }, + "Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + } + } } }, "version" : "1.0" diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 79f8db0..560ba24 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -6,54 +6,12 @@ // import SwiftUI -import UIKit - -class OrientationManager: ObservableObject { - static let shared = OrientationManager() - - @Published var isLocked = false - private var lockedOrientation: UIInterfaceOrientationMask = .all - - private init() {} - - func lockOrientation() { - let currentOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait - - switch currentOrientation { - case .portrait, .portraitUpsideDown: - lockedOrientation = .portrait - case .landscapeLeft, .landscapeRight: - lockedOrientation = .landscape - default: - lockedOrientation = .portrait - } - - isLocked = true - - UIDevice.current.setValue(currentOrientation.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - - func unlockOrientation(after delay: TimeInterval = 0.0) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - self.isLocked = false - self.lockedOrientation = .all - - UIViewController.attemptRotationToDeviceOrientation() - } - } - - func supportedOrientations() -> UIInterfaceOrientationMask { - return isLocked ? lockedOrientation : .all - } -} @main struct SoraApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var settings = Settings() @StateObject private var moduleManager = ModuleManager() - @StateObject private var librarykManager = LibraryManager() + @StateObject private var libraryManager = LibraryManager() @StateObject private var downloadManager = DownloadManager() @StateObject private var jsController = JSController.shared @@ -73,28 +31,30 @@ struct SoraApp: App { var body: some Scene { WindowGroup { - ContentView() - .environmentObject(moduleManager) - .environmentObject(settings) - .environmentObject(librarykManager) - .environmentObject(downloadManager) - .environmentObject(jsController) - .accentColor(settings.accentColor) - .onAppear { - settings.updateAppearance() - Task { - if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") { - await moduleManager.refreshModules() - } - } - } - .onOpenURL { url in - if let params = url.queryParameters, params["code"] != nil { - Self.handleRedirect(url: url) - } else { - handleURL(url) + Group { + if !UserDefaults.standard.bool(forKey: "hideSplashScreen") { + SplashScreenView() + } else { + ContentView() + } + } + .environmentObject(moduleManager) + .environmentObject(settings) + .environmentObject(libraryManager) + .environmentObject(downloadManager) + .environmentObject(jsController) + .accentColor(settings.accentColor) + .onAppear { + settings.updateAppearance() + Task { + if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") { + await moduleManager.refreshModules() } } + } + .onOpenURL { url in + handleURL(url) + } } } @@ -142,50 +102,4 @@ struct SoraApp: App { break } } - - static func handleRedirect(url: URL) { - guard let params = url.queryParameters, - let code = params["code"] else { - Logger.shared.log("Failed to extract authorization code") - return - } - - switch url.host { - case "anilist": - AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in - if success { - Logger.shared.log("AniList token exchange successful") - } else { - Logger.shared.log("AniList token exchange failed", type: "Error") - } - } - - case "trakt": - TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in - if success { - Logger.shared.log("Trakt token exchange successful") - } else { - Logger.shared.log("Trakt token exchange failed", type: "Error") - } - } - - default: - Logger.shared.log("Unknown authentication service", type: "Error") - } - } -} -class AppInfo: NSObject { - @objc func getBundleIdentifier() -> String { - return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur" - } - - @objc func getDisplayName() -> String { - return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String - } -} - -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - return OrientationManager.shared.supportedOrientations() - } -} +} \ No newline at end of file diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift index de958b1..aa4aadf 100644 --- a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift @@ -16,19 +16,28 @@ class AniListLogin { static func authenticate() { let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code" guard let url = URL(string: urlString) else { + Logger.shared.log("Invalid authorization URL", type: "Error") return } - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:]) { success in - if success { - Logger.shared.log("Safari opened successfully", type: "Debug") + WebAuthenticationManager.shared.authenticate(url: url, callbackScheme: "sora") { result in + switch result { + case .success(let callbackURL): + if let params = callbackURL.queryParameters, + let code = params["code"] { + AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("AniList token exchange successful", type: "Debug") + } else { + Logger.shared.log("AniList token exchange failed", type: "Error") + } + } } else { - Logger.shared.log("Failed to open Safari", type: "Error") + Logger.shared.log("No authorization code in callback URL", type: "Error") } + case .failure(let error): + Logger.shared.log("Authentication failed: \(error.localizedDescription)", type: "Error") } - } else { - Logger.shared.log("Cannot open URL", type: "Error") } } } diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 27aa960..7569809 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -195,6 +195,50 @@ class AniListMutation { }.resume() } + func fetchCoverImage( + animeId: Int, + completion: @escaping (Result) -> Void + ) { + let query = """ + query ($id: Int) { + Media(id: $id, type: ANIME) { + coverImage { large } + } + } + """ + let variables = ["id": animeId] + let body: [String: Any] = ["query": query, "variables": variables] + + guard let url = URL(string: "https://graphql.anilist.co"), + let httpBody = try? JSONSerialization.data(withJSONObject: body) + else { + completion(.failure(NSError(domain: "AniList", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL or payload"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + + URLSession.shared.dataTask(with: request) { data, _, error in + if let error = error { + return completion(.failure(error)) + } + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let media = dataDict["Media"] as? [String: Any], + let cover = media["coverImage"] as? [String: Any], + let imageUrl = cover["large"] as? String + else { + return completion(.failure(NSError(domain: "AniList", code: 1, userInfo: [NSLocalizedDescriptionKey: "Malformed response"]))) + } + completion(.success(imageUrl)) + } + .resume() + } + private struct AniListMediaResponse: Decodable { struct DataField: Decodable { struct Media: Decodable { let idMal: Int? } diff --git a/Sora/Tracking Services/TMDB/TMDB-FetchID.swift b/Sora/Tracking Services/TMDB/TMDB-FetchID.swift index 818bc96..0112a6d 100644 --- a/Sora/Tracking Services/TMDB/TMDB-FetchID.swift +++ b/Sora/Tracking Services/TMDB/TMDB-FetchID.swift @@ -23,7 +23,7 @@ class TMDBFetcher { let results: [TMDBResult] } - private let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca" + let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca" private let session = URLSession.custom func fetchBestMatchID(for title: String, completion: @escaping (Int?, MediaType?) -> Void) { diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift index fc0c9c3..3dd98d1 100644 --- a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift @@ -16,19 +16,28 @@ class TraktLogin { static func authenticate() { let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code" guard let url = URL(string: urlString) else { + Logger.shared.log("Invalid authorization URL", type: "Error") return } - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:]) { success in - if success { - Logger.shared.log("Safari opened successfully", type: "Debug") + WebAuthenticationManager.shared.authenticate(url: url, callbackScheme: "sora") { result in + switch result { + case .success(let callbackURL): + if let params = callbackURL.queryParameters, + let code = params["code"] { + TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("Trakt token exchange successful", type: "Debug") + } else { + Logger.shared.log("Trakt token exchange failed", type: "Error") + } + } } else { - Logger.shared.log("Failed to open Safari", type: "Error") + Logger.shared.log("No authorization code in callback URL", type: "Error") } + case .failure(let error): + Logger.shared.log("Authentication failed: \(error.localizedDescription)", type: "Error") } - } else { - Logger.shared.log("Cannot open URL", type: "Error") } } } diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift index 2108e9c..5c2eb20 100644 --- a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -25,37 +25,26 @@ class TraktMutation { guard status == errSecSuccess, let tokenData = item as? Data, let token = String(data: tokenData, encoding: .utf8) else { - return nil - } + return nil + } return token } - enum ExternalIDType { - case imdb(String) - case tmdb(Int) - - var dictionary: [String: Any] { - switch self { - case .imdb(let id): - return ["imdb": id] - case .tmdb(let id): - return ["tmdb": id] - } - } - } - - func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { - if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool, - sendTraktUpdates == false { + func markAsWatched(type: String, tmdbID: Int, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { + let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool ?? true + if !sendTraktUpdates { + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Trakt updates disabled by user"]))) return } guard let userToken = getTokenFromKeychain() else { + Logger.shared.log("Trakt access token not found in keychain", type: "Error") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) return } let endpoint = "/sync/history" + let watchedAt = ISO8601DateFormatter().string(from: Date()) let body: [String: Any] switch type { @@ -63,26 +52,33 @@ class TraktMutation { body = [ "movies": [ [ - "ids": externalID.dictionary + "ids": ["tmdb": tmdbID], + "watched_at": watchedAt ] ] ] case "episode": guard let episode = episodeNumber, let season = seasonNumber else { - completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing episode or season number"]))) + let errorMsg = "Missing episode (\(episodeNumber ?? -1)) or season (\(seasonNumber ?? -1)) number" + Logger.shared.log(errorMsg, type: "Error") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMsg]))) return } + Logger.shared.log("Preparing episode watch request - TMDB ID: \(tmdbID), Season: \(season), Episode: \(episode)", type: "Debug") body = [ "shows": [ [ - "ids": externalID.dictionary, + "ids": ["tmdb": tmdbID], "seasons": [ [ "number": season, "episodes": [ - ["number": episode] + [ + "number": episode, + "watched_at": watchedAt + ] ] ] ] @@ -91,39 +87,65 @@ class TraktMutation { ] default: + Logger.shared.log("Invalid content type: \(type)", type: "Error") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid content type"]))) return } var request = URLRequest(url: apiURL.appendingPathComponent(endpoint)) request.httpMethod = "POST" - request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") request.setValue("2", forHTTPHeaderField: "trakt-api-version") request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key") do { - request.httpBody = try JSONSerialization.data(withJSONObject: body) + let jsonData = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted]) + request.httpBody = jsonData + + if let jsonString = String(data: jsonData, encoding: .utf8) { + Logger.shared.log("Trakt API Request Body: \(jsonString)", type: "Debug") + } } catch { + Logger.shared.log("Failed to serialize request body: \(error.localizedDescription)", type: "Error") completion(.failure(error)) return } let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { + Logger.shared.log("Trakt API network error: \(error.localizedDescription)", type: "Error") completion(.failure(error)) return } - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 - completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"]))) - return - } + guard let httpResponse = response as? HTTPURLResponse else { + Logger.shared.log("Trakt API: No HTTP response received", type: "Error") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No HTTP response"]))) + return + } - Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug") - completion(.success(())) + if let data = data, let responseString = String(data: data, encoding: .utf8) { + Logger.shared.log("Trakt API Response Body: \(responseString)", type: "Debug") + } + + if (200...299).contains(httpResponse.statusCode) { + Logger.shared.log("Successfully updated watch status on Trakt for \(type)", type: "General") + completion(.success(())) + } else { + var errorMessage = "HTTP \(httpResponse.statusCode)" + if let data = data, + let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let error = errorJson["error"] as? String { + errorMessage = "\(errorMessage): \(error)" + } + if let errorDescription = errorJson["error_description"] as? String { + errorMessage = "\(errorMessage) - \(errorDescription)" + } + } + Logger.shared.log("Trakt API Error: \(errorMessage)", type: "Error") + completion(.failure(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) + } } task.resume() diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift index 454f6a5..3c09ea9 100644 --- a/Sora/Utils/DownloadUtils/DownloadManager.swift +++ b/Sora/Utils/DownloadUtils/DownloadManager.swift @@ -58,7 +58,7 @@ class DownloadManager: NSObject, ObservableObject { localPlaybackURL = localURL } } catch { - print("Error loading local content: \(error)") + Logger.shared.log("Could not load local content: \(error)", type: "Error") } } } @@ -71,7 +71,7 @@ extension DownloadManager: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let error = error else { return } - print("Download error: \(error.localizedDescription)") + Logger.shared.log("Download failed: \(error.localizedDescription)", type: "Error") activeDownloadTasks.removeValue(forKey: task) } diff --git a/Sora/Utils/DownloadUtils/DownloadModels.swift b/Sora/Utils/DownloadUtils/DownloadModels.swift index 1278542..fd20229 100644 --- a/Sora/Utils/DownloadUtils/DownloadModels.swift +++ b/Sora/Utils/DownloadUtils/DownloadModels.swift @@ -32,13 +32,13 @@ enum DownloadQualityPreference: String, CaseIterable { var description: String { switch self { case .best: - return "Highest available quality (largest file size)" + return "Maximum quality available (largest file size)" case .high: - return "High quality (720p or higher)" + return "High quality (720p or better)" case .medium: - return "Medium quality (480p-720p)" + return "Medium quality (480p to 720p)" case .low: - return "Lowest available quality (smallest file size)" + return "Minimum quality available (smallest file size)" } } } diff --git a/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift b/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift index c0886b9..939a8e2 100644 --- a/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift +++ b/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift @@ -16,13 +16,13 @@ enum M3U8StreamExtractorError: Error { var localizedDescription: String { switch self { case .networkError(let error): - return "Network error: \(error.localizedDescription)" + return "Connection error: \(error.localizedDescription)" case .parsingError(let message): - return "Parsing error: \(message)" + return "Stream parsing error: \(message)" case .noStreamFound: - return "No suitable stream found in playlist" + return "No compatible stream found in playlist" case .invalidURL: - return "Invalid stream URL" + return "Stream URL is invalid" } } } diff --git a/Sora/Utils/Drops/DropManager.swift b/Sora/Utils/Drops/DropManager.swift index 8cdce3a..9170158 100644 --- a/Sora/Utils/Drops/DropManager.swift +++ b/Sora/Utils/Drops/DropManager.swift @@ -67,8 +67,8 @@ class DropManager { let willStartImmediately = JSController.shared.willDownloadStartImmediately() let message = willStartImmediately - ? "Episode \(episodeNumber) download started" - : "Episode \(episodeNumber) queued" + ? "Episode \(episodeNumber) is now downloading" + : "Episode \(episodeNumber) added to download queue" showDrop( title: willStartImmediately ? "Download Started" : "Download Queued", diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 791af6f..beb18ec 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -5,15 +5,13 @@ // Created by Hamzo on 19/03/25. // +import SoraCore import JavaScriptCore extension JSContext { func setupConsoleLogging() { let consoleObject = JSValue(newObjectIn: self) - let appInfoBridge = AppInfo() - consoleObject?.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString) - let consoleLogFunction: @convention(block) (String) -> Void = { message in Logger.shared.log(message, type: "Debug") } @@ -139,9 +137,9 @@ extension JSContext { Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error") let session = URLSession.fetchData(allowRedirects: redirect.boolValue) - let task = session.downloadTask(with: request) { tempFileURL, response, error in - defer { session.finishTasksAndInvalidate() } - + let task = session.downloadTask(with: request) { tempFileURL, response, error in + defer { session.finishTasksAndInvalidate() } + let callReject: (String) -> Void = { message in DispatchQueue.main.async { reject.call(withArguments: [message]) @@ -276,6 +274,7 @@ extension JSContext { } func setupJavaScriptEnvironment() { + setupWeirdCode() setupConsoleLogging() setupNativeFetch() setupFetchV2() diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index 629117a..d1cacb7 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -6,6 +6,7 @@ // import Foundation +import Network class FetchDelegate: NSObject, URLSessionTaskDelegate { private let allowRedirects: Bool @@ -27,29 +28,29 @@ class FetchDelegate: NSObject, URLSessionTaskDelegate { extension URLSession { static let userAgents = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2569.45", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2478.89", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.86", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.2849.80", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Safari/605.1.15", "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_1_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:127.0) Gecko/20100101 Firefox/127.0", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", - "Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36", - "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPad; CPU OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Android 15; Mobile; rv:128.0) Gecko/128.0 Firefox/128.0", - "Mozilla/5.0 (Android 15; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0", + "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0" ] static var randomUserAgent: String = { @@ -70,3 +71,51 @@ extension URLSession { return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) } } + +enum NetworkType { + case wifi + case cellular + case unknown +} + +class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + @Published var currentNetworkType: NetworkType = .unknown + @Published var isConnected: Bool = false + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + self?.currentNetworkType = self?.getNetworkType(from: path) ?? .unknown + } + } + monitor.start(queue: queue) + } + + private func getNetworkType(from path: NWPath) -> NetworkType { + if path.usesInterfaceType(.wifi) { + return .wifi + } else if path.usesInterfaceType(.cellular) { + return .cellular + } else { + return .unknown + } + } + + static func getCurrentNetworkType() -> NetworkType { + return shared.currentNetworkType + } + + deinit { + monitor.cancel() + } +} diff --git a/Sora/Utils/Extensions/UserDefaults.swift b/Sora/Utils/Extensions/UserDefaults.swift index 4d52347..86cb388 100644 --- a/Sora/Utils/Extensions/UserDefaults.swift +++ b/Sora/Utils/Extensions/UserDefaults.swift @@ -7,6 +7,63 @@ import UIKit +enum VideoQualityPreference: String, CaseIterable { + case best = "Best" + case p1080 = "1080p" + case p720 = "720p" + case p420 = "420p" + case p360 = "360p" + case worst = "Worst" + + static let wifiDefaultKey = "videoQualityWiFi" + static let cellularDefaultKey = "videoQualityCellular" + + static let defaultWiFiPreference: VideoQualityPreference = .best + static let defaultCellularPreference: VideoQualityPreference = .p720 + + static let qualityPriority: [VideoQualityPreference] = [.best, .p1080, .p720, .p420, .p360, .worst] + + static func findClosestQuality(preferred: VideoQualityPreference, availableQualities: [(String, String)]) -> (String, String)? { + for (name, url) in availableQualities { + if isQualityMatch(preferred: preferred, qualityName: name) { + return (name, url) + } + } + + let preferredIndex = qualityPriority.firstIndex(of: preferred) ?? qualityPriority.count + + for i in 0.. Bool { + let lowercaseName = qualityName.lowercased() + + switch preferred { + case .best: + return lowercaseName.contains("best") || lowercaseName.contains("highest") || lowercaseName.contains("max") + case .p1080: + return lowercaseName.contains("1080") || lowercaseName.contains("1920") + case .p720: + return lowercaseName.contains("720") || lowercaseName.contains("1280") + case .p420: + return lowercaseName.contains("420") || lowercaseName.contains("480") + case .p360: + return lowercaseName.contains("360") || lowercaseName.contains("640") + case .worst: + return lowercaseName.contains("worst") || lowercaseName.contains("lowest") || lowercaseName.contains("min") + } + } +} + extension UserDefaults { func color(forKey key: String) -> UIColor? { guard let colorData = data(forKey: key) else { return nil } @@ -30,4 +87,19 @@ extension UserDefaults { Logger.shared.log("Error archiving color: \(error)", type: "Error") } } + + static func getVideoQualityPreference() -> VideoQualityPreference { + let networkType = NetworkMonitor.getCurrentNetworkType() + + switch networkType { + case .wifi: + let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.wifiDefaultKey) + return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultWiFiPreference + case .cellular: + let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.cellularDefaultKey) + return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultCellularPreference + case .unknown: + return .p720 + } + } } diff --git a/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift new file mode 100644 index 0000000..621152c --- /dev/null +++ b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift @@ -0,0 +1,527 @@ +// +// JSController+Downloader.swift +// Sora +// +// Created by doomsboygaming on 6/13/25 +// + +import Foundation +import SwiftUI +import AVFoundation + + +struct DownloadRequest { + let url: URL + let headers: [String: String] + let title: String? + let imageURL: URL? + let isEpisode: Bool + let showTitle: String? + let season: Int? + let episode: Int? + let subtitleURL: URL? + let showPosterURL: URL? + + init(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil, + isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, + episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil) { + self.url = url + self.headers = headers + self.title = title + self.imageURL = imageURL + self.isEpisode = isEpisode + self.showTitle = showTitle + self.season = season + self.episode = episode + self.subtitleURL = subtitleURL + self.showPosterURL = showPosterURL + } +} + +struct QualityOption { + let name: String + let url: String + let height: Int? + + init(name: String, url: String, height: Int? = nil) { + self.name = name + self.url = url + self.height = height + } +} + +extension JSController { + + func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, + imageURL: URL? = nil, isEpisode: Bool = false, + showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + + let request = DownloadRequest( + url: url, headers: headers, title: title, imageURL: imageURL, + isEpisode: isEpisode, showTitle: showTitle, season: season, + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + ) + + logDownloadStart(request: request) + + if url.absoluteString.contains(".m3u8") { + handleM3U8Download(request: request, completionHandler: completionHandler) + } else { + handleDirectDownload(request: request, completionHandler: completionHandler) + } + } + + + private func handleM3U8Download(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + let preferredQuality = DownloadQualityPreference.current.rawValue + logM3U8Detection(preferredQuality: preferredQuality) + + parseM3U8(url: request.url, headers: request.headers) { [weak self] qualities in + DispatchQueue.main.async { + guard let self = self else { return } + + if qualities.isEmpty { + self.logM3U8NoQualities() + self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + return + } + + self.logM3U8QualitiesFound(qualities: qualities) + let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality) + self.logM3U8QualitySelected(quality: selectedQuality) + + if let qualityURL = URL(string: selectedQuality.url) { + let qualityRequest = DownloadRequest( + url: qualityURL, headers: request.headers, title: request.title, + imageURL: request.imageURL, isEpisode: request.isEpisode, + showTitle: request.showTitle, season: request.season, + episode: request.episode, subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL + ) + self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler) + } else { + self.logM3U8InvalidURL() + self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + } + } + } + } + + private func handleDirectDownload(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + logDirectDownload() + + let urlString = request.url.absoluteString.lowercased() + if urlString.contains(".mp4") || urlString.contains("mp4") { + logMP4Detection() + downloadMP4(request: request, completionHandler: completionHandler) + } else { + downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + } + } + + + func downloadMP4(url: URL, headers: [String: String], title: String? = nil, + imageURL: URL? = nil, isEpisode: Bool = false, + showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + + let request = DownloadRequest( + url: url, headers: headers, title: title, imageURL: imageURL, + isEpisode: isEpisode, showTitle: showTitle, season: season, + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + ) + + downloadMP4(request: request, completionHandler: completionHandler) + } + + private func downloadMP4(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + guard validateURL(request.url) else { + completionHandler?(false, "Invalid URL scheme") + return + } + + guard let downloadSession = downloadURLSession else { + completionHandler?(false, "Download session not available") + return + } + + let metadata = createAssetMetadata(from: request) + let downloadType: DownloadType = request.isEpisode ? .episode : .movie + let downloadID = UUID() + + let asset = AVURLAsset(url: request.url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": request.headers + ]) + + guard let downloadTask = downloadSession.makeAssetDownloadTask( + asset: asset, + assetTitle: request.title ?? request.url.lastPathComponent, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] + ) else { + completionHandler?(false, "Failed to create download task") + return + } + + let activeDownload = createActiveDownload( + id: downloadID, request: request, asset: asset, + downloadTask: downloadTask, downloadType: downloadType, metadata: metadata + ) + + addActiveDownload(activeDownload, task: downloadTask) + setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) + downloadTask.resume() + + postDownloadNotification() + completionHandler?(true, "Download started") + } + + + private func parseM3U8(url: URL, headers: [String: String], completion: @escaping ([QualityOption]) -> Void) { + var request = URLRequest(url: url) + for (key, value) in headers { + request.addValue(value, forHTTPHeaderField: key) + } + + logM3U8FetchStart(url: url) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let httpResponse = response as? HTTPURLResponse { + self.logHTTPStatus(httpResponse.statusCode, for: url) + if httpResponse.statusCode >= 400 { + completion([]) + return + } + } + + if let error = error { + self.logM3U8FetchError(error) + completion([]) + return + } + + guard let data = data, let content = String(data: data, encoding: .utf8) else { + self.logM3U8DecodeError() + completion([]) + return + } + + self.logM3U8FetchSuccess(dataSize: data.count) + let qualities = self.parseM3U8Content(content: content, baseURL: url) + completion(qualities) + }.resume() + } + + private func parseM3U8Content(content: String, baseURL: URL) -> [QualityOption] { + let lines = content.components(separatedBy: .newlines) + logM3U8ParseStart(lineCount: lines.count) + + var qualities: [QualityOption] = [] + qualities.append(QualityOption(name: "Auto (Recommended)", url: baseURL.absoluteString)) + + for (index, line) in lines.enumerated() { + if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { + if let qualityOption = parseStreamInfoLine(line: line, nextLine: lines[index + 1], baseURL: baseURL) { + if !qualities.contains(where: { $0.name == qualityOption.name }) { + qualities.append(qualityOption) + logM3U8QualityAdded(quality: qualityOption) + } + } + } + } + + logM3U8ParseComplete(qualityCount: qualities.count - 1) // -1 for Auto + return qualities + } + + private func parseStreamInfoLine(line: String, nextLine: String, baseURL: URL) -> QualityOption? { + guard let resolutionRange = line.range(of: "RESOLUTION="), + let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") + ?? line[resolutionRange.upperBound...].range(of: "\n") else { + return nil + } + + let resolutionPart = String(line[resolutionRange.upperBound.. String { + switch height { + case 1080...: return "\(height)p (FHD)" + case 720..<1080: return "\(height)p (HD)" + case 480..<720: return "\(height)p (SD)" + default: return "\(height)p" + } + } + + private func resolveQualityURL(_ urlString: String, baseURL: URL) -> String { + if urlString.hasPrefix("http") { + return urlString + } + + if urlString.contains(".m3u8") { + return URL(string: urlString, relativeTo: baseURL)?.absoluteString + ?? baseURL.deletingLastPathComponent().absoluteString + "/" + urlString + } + + return urlString + } + + + private func selectQualityBasedOnPreference(qualities: [QualityOption], preferredQuality: String) -> QualityOption { + guard qualities.count > 1 else { + logQualitySelectionSingle() + return qualities[0] + } + + let (autoQuality, sortedQualities) = categorizeQualities(qualities: qualities) + logQualitySelectionStart(preference: preferredQuality, sortedCount: sortedQualities.count) + + let selected = selectQualityByPreference( + preference: preferredQuality, + sortedQualities: sortedQualities, + autoQuality: autoQuality, + fallback: qualities[0] + ) + + logQualitySelectionResult(quality: selected, preference: preferredQuality) + return selected + } + + private func categorizeQualities(qualities: [QualityOption]) -> (auto: QualityOption?, sorted: [QualityOption]) { + let autoQuality = qualities.first { $0.name.contains("Auto") } + let nonAutoQualities = qualities.filter { !$0.name.contains("Auto") } + + let sortedQualities = nonAutoQualities.sorted { first, second in + let firstHeight = first.height ?? extractHeight(from: first.name) + let secondHeight = second.height ?? extractHeight(from: second.name) + return firstHeight > secondHeight + } + + return (autoQuality, sortedQualities) + } + + private func selectQualityByPreference(preference: String, sortedQualities: [QualityOption], + autoQuality: QualityOption?, fallback: QualityOption) -> QualityOption { + switch preference { + case "Best": + return sortedQualities.first ?? fallback + case "High": + return findQualityByType(["720p", "HD"], in: sortedQualities) ?? sortedQualities.first ?? fallback + case "Medium": + return findQualityByType(["480p", "SD"], in: sortedQualities) + ?? (sortedQualities.isEmpty ? fallback : sortedQualities[sortedQualities.count / 2]) + case "Low": + return sortedQualities.last ?? fallback + default: + return autoQuality ?? fallback + } + } + + private func findQualityByType(_ types: [String], in qualities: [QualityOption]) -> QualityOption? { + return qualities.first { quality in + types.contains { quality.name.contains($0) } + } + } + + private func extractHeight(from qualityName: String) -> Int { + return Int(qualityName.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + } + + + private func validateURL(_ url: URL) -> Bool { + return url.scheme == "http" || url.scheme == "https" + } + + private func createAssetMetadata(from request: DownloadRequest) -> AssetMetadata? { + guard let title = request.title else { return nil } + + return AssetMetadata( + title: title, + posterURL: request.imageURL, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + showPosterURL: request.showPosterURL ?? request.imageURL + ) + } + + private func createActiveDownload(id: UUID, request: DownloadRequest, asset: AVURLAsset, + downloadTask: AVAssetDownloadTask? = nil, urlSessionTask: URLSessionDownloadTask? = nil, + downloadType: DownloadType, metadata: AssetMetadata?) -> JSActiveDownload { + return JSActiveDownload( + id: id, + originalURL: request.url, + progress: 0.0, + task: downloadTask, + urlSessionTask: urlSessionTask, + queueStatus: .downloading, + type: downloadType, + metadata: metadata, + title: request.title, + imageURL: request.imageURL, + subtitleURL: request.subtitleURL, + asset: asset, + headers: request.headers, + module: nil + ) + } + + private func addActiveDownload(_ download: JSActiveDownload, task: URLSessionTask) { + activeDownloads.append(download) + activeDownloadMap[task] = download.id + } + + private func postDownloadNotification() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) + } + } + + private func downloadWithOriginalMethod(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + self.startDownload( + url: request.url, + headers: request.headers, + title: request.title, + imageURL: request.imageURL, + isEpisode: request.isEpisode, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL, + completionHandler: completionHandler + ) + } + + + private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { + let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) + NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) + } + } + + if mp4ProgressObservations == nil { + mp4ProgressObservations = [:] + } + mp4ProgressObservations?[downloadID] = observation + } + + private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { + guard let downloadID = activeDownloadMap[task], + let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + return + } + activeDownloads[downloadIndex].progress = progress + } + + func cleanupMP4ProgressObservation(for downloadID: UUID) { + mp4ProgressObservations?[downloadID]?.invalidate() + mp4ProgressObservations?[downloadID] = nil + } +} + + +extension JSController { + private func logDownloadStart(request: DownloadRequest) { + Logger.shared.log("Download process started for URL: \(request.url.absoluteString)", type: "Download") + Logger.shared.log("Title: \(request.title ?? "None"), Episode: \(request.isEpisode ? "Yes" : "No")", type: "Debug") + if let showTitle = request.showTitle, let episode = request.episode { + Logger.shared.log("Show: \(showTitle), Season: \(request.season ?? 1), Episode: \(episode)", type: "Debug") + } + if let subtitle = request.subtitleURL { + Logger.shared.log("Subtitle URL provided: \(subtitle.absoluteString)", type: "Debug") + } + } + + private func logM3U8Detection(preferredQuality: String) { + Logger.shared.log("M3U8 playlist detected - quality preference: \(preferredQuality)", type: "Download") + } + + private func logM3U8NoQualities() { + Logger.shared.log("No quality options found in M3U8, using original URL", type: "Warning") + } + + private func logM3U8QualitiesFound(qualities: [QualityOption]) { + Logger.shared.log("Found \(qualities.count) quality options in M3U8 playlist", type: "Download") + for (index, quality) in qualities.enumerated() { + Logger.shared.log("Quality \(index + 1): \(quality.name)", type: "Debug") + } + } + + private func logM3U8QualitySelected(quality: QualityOption) { + Logger.shared.log("Selected quality: \(quality.name)", type: "Download") + Logger.shared.log("Final download URL: \(quality.url)", type: "Debug") + } + + private func logM3U8InvalidURL() { + Logger.shared.log("Invalid quality URL detected, falling back to original", type: "Warning") + } + + private func logDirectDownload() { + Logger.shared.log("Direct download initiated (non-M3U8)", type: "Download") + } + + private func logMP4Detection() { + Logger.shared.log("MP4 stream detected, using MP4 download method", type: "Download") + } + + private func logM3U8FetchStart(url: URL) { + Logger.shared.log("Fetching M3U8 content from: \(url.absoluteString)", type: "Debug") + } + + private func logHTTPStatus(_ statusCode: Int, for url: URL) { + let logType = statusCode >= 400 ? "Error" : "Debug" + Logger.shared.log("HTTP \(statusCode) for M3U8 request: \(url.absoluteString)", type: logType) + } + + private func logM3U8FetchError(_ error: Error) { + Logger.shared.log("Failed to fetch M3U8 content: \(error.localizedDescription)", type: "Error") + } + + private func logM3U8DecodeError() { + Logger.shared.log("Failed to decode M3U8 file content", type: "Error") + } + + private func logM3U8FetchSuccess(dataSize: Int) { + Logger.shared.log("Successfully fetched M3U8 content (\(dataSize) bytes)", type: "Debug") + } + + private func logM3U8ParseStart(lineCount: Int) { + Logger.shared.log("Parsing M3U8 file with \(lineCount) lines", type: "Debug") + } + + private func logM3U8QualityAdded(quality: QualityOption) { + Logger.shared.log("Added quality option: \(quality.name)", type: "Debug") + } + + private func logM3U8ParseComplete(qualityCount: Int) { + Logger.shared.log("M3U8 parsing complete: \(qualityCount) quality options found", type: "Debug") + } + + private func logQualitySelectionSingle() { + Logger.shared.log("Only one quality available, using default", type: "Debug") + } + + private func logQualitySelectionStart(preference: String, sortedCount: Int) { + Logger.shared.log("Quality selection: \(sortedCount) options, preference: \(preference)", type: "Debug") + } + + private func logQualitySelectionResult(quality: QualityOption, preference: String) { + Logger.shared.log("Quality selected: \(quality.name) (preference: \(preference))", type: "Download") + } +} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift deleted file mode 100644 index 765253c..0000000 --- a/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift +++ /dev/null @@ -1,384 +0,0 @@ -// -// JSController+M3U8Download.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI - -// No need to import DownloadQualityPreference as it's in the same module - -// Extension for integrating M3U8StreamExtractor with JSController for downloads -extension JSController { - - /// Initiates a download for a given URL, handling M3U8 playlists if necessary - /// - Parameters: - /// - url: The URL to download - /// - headers: HTTP headers to use for the request - /// - title: Title for the download (optional) - /// - imageURL: Image URL for the content (optional) - /// - isEpisode: Whether this is an episode (defaults to false) - /// - showTitle: Title of the show this episode belongs to (optional) - /// - season: Season number (optional) - /// - episode: Episode number (optional) - /// - subtitleURL: Optional subtitle URL to download after video (optional) - /// - completionHandler: Called when the download is initiated or fails - func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - // Use headers passed in from caller rather than generating our own baseUrl - // Receiving code should already be setting module.metadata.baseUrl - - print("---- DOWNLOAD PROCESS STARTED ----") - print("Original URL: \(url.absoluteString)") - print("Headers: \(headers)") - print("Title: \(title ?? "None")") - print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")") - if let subtitle = subtitleURL { - print("Subtitle URL: \(subtitle.absoluteString)") - } - - // Check if the URL is an M3U8 file - if url.absoluteString.contains(".m3u8") { - // Get the user's quality preference - let preferredQuality = DownloadQualityPreference.current.rawValue - - print("URL detected as M3U8 playlist - will select quality based on user preference: \(preferredQuality)") - - // Parse the M3U8 content to extract available qualities, matching CustomPlayer approach - parseM3U8(url: url, baseUrl: url.absoluteString, headers: headers) { [weak self] qualities in - DispatchQueue.main.async { - guard let self = self else { return } - - if qualities.isEmpty { - print("M3U8 Analysis: No quality options found in M3U8, downloading with original URL") - self.downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - return - } - - print("M3U8 Analysis: Found \(qualities.count) quality options") - for (index, quality) in qualities.enumerated() { - print(" \(index + 1). \(quality.0) - \(quality.1)") - } - - // Select appropriate quality based on user preference - let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality) - - print("M3U8 Analysis: Selected quality: \(selectedQuality.0)") - print("M3U8 Analysis: Selected URL: \(selectedQuality.1)") - - if let qualityURL = URL(string: selectedQuality.1) { - print("FINAL DOWNLOAD URL: \(qualityURL.absoluteString)") - print("QUALITY SELECTED: \(selectedQuality.0)") - - // Download with standard headers that match the player - self.downloadWithOriginalMethod( - url: qualityURL, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } else { - print("M3U8 Analysis: Invalid quality URL, falling back to original URL") - print("FINAL DOWNLOAD URL (fallback): \(url.absoluteString)") - - self.downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } - } - } - } else { - // Not an M3U8 file, use the original download method with standard headers - print("URL is not an M3U8 playlist - downloading directly") - print("FINAL DOWNLOAD URL (direct): \(url.absoluteString)") - - downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } - } - - /// Parses an M3U8 file to extract available quality options, matching CustomPlayer's approach exactly - /// - Parameters: - /// - url: The URL of the M3U8 file - /// - baseUrl: The base URL for setting headers - /// - headers: HTTP headers to use for the request - /// - completion: Called with the array of quality options (name, URL) - private func parseM3U8(url: URL, baseUrl: String, headers: [String: String], completion: @escaping ([(String, String)]) -> Void) { - var request = URLRequest(url: url) - - // Add headers from headers passed to downloadWithM3U8Support - // This ensures we use the same headers as the player (from module.metadata.baseUrl) - for (key, value) in headers { - request.addValue(value, forHTTPHeaderField: key) - } - - print("M3U8 Parser: Fetching M3U8 content from: \(url.absoluteString)") - - URLSession.shared.dataTask(with: request) { data, response, error in - // Log HTTP status for debugging - if let httpResponse = response as? HTTPURLResponse { - print("M3U8 Parser: HTTP Status: \(httpResponse.statusCode) for \(url.absoluteString)") - - if httpResponse.statusCode >= 400 { - print("M3U8 Parser: HTTP Error: \(httpResponse.statusCode)") - completion([]) - return - } - } - - if let error = error { - print("M3U8 Parser: Error fetching M3U8: \(error.localizedDescription)") - completion([]) - return - } - - guard let data = data, let content = String(data: data, encoding: .utf8) else { - print("M3U8 Parser: Failed to load or decode M3U8 file") - completion([]) - return - } - - print("M3U8 Parser: Successfully fetched M3U8 content (\(data.count) bytes)") - - let lines = content.components(separatedBy: .newlines) - print("M3U8 Parser: Found \(lines.count) lines in M3U8 file") - - var qualities: [(String, String)] = [] - - // Always include the original URL as "Auto" option - qualities.append(("Auto (Recommended)", url.absoluteString)) - print("M3U8 Parser: Added 'Auto' quality option with original URL") - - func getQualityName(for height: Int) -> String { - switch height { - case 1080...: return "\(height)p (FHD)" - case 720..<1080: return "\(height)p (HD)" - case 480..<720: return "\(height)p (SD)" - default: return "\(height)p" - } - } - - // Parse the M3U8 content to extract available streams - exactly like CustomPlayer - print("M3U8 Parser: Scanning for quality options...") - var qualitiesFound = 0 - - for (index, line) in lines.enumerated() { - if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { - print("M3U8 Parser: Found stream info at line \(index): \(line)") - - if let resolutionRange = line.range(of: "RESOLUTION="), - let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") - ?? line[resolutionRange.upperBound...].range(of: "\n") { - - let resolutionPart = String(line[resolutionRange.upperBound.. (String, String) { - // If only one quality is available, return it - if qualities.count <= 1 { - print("Quality Selection: Only one quality option available, returning it directly") - return qualities[0] - } - - // Extract "Auto" quality and the remaining qualities - let autoQuality = qualities.first { $0.0.contains("Auto") } - let nonAutoQualities = qualities.filter { !$0.0.contains("Auto") } - - print("Quality Selection: Found \(nonAutoQualities.count) non-Auto quality options") - print("Quality Selection: Auto quality option: \(autoQuality?.0 ?? "None")") - - // Sort non-auto qualities by resolution (highest first) - let sortedQualities = nonAutoQualities.sorted { first, second in - let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 - let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 - return firstHeight > secondHeight - } - - print("Quality Selection: Sorted qualities (highest to lowest):") - for (index, quality) in sortedQualities.enumerated() { - print(" \(index + 1). \(quality.0) - \(quality.1)") - } - - print("Quality Selection: User preference is '\(preferredQuality)'") - - // Select quality based on preference - switch preferredQuality { - case "Best": - // Return the highest quality (first in sorted list) - let selected = sortedQualities.first ?? qualities[0] - print("Quality Selection: Selected 'Best' quality: \(selected.0)") - return selected - - case "High": - // Look for 720p quality - let highQuality = sortedQualities.first { - $0.0.contains("720p") || $0.0.contains("HD") - } - - if let high = highQuality { - print("Quality Selection: Found specific 'High' (720p/HD) quality: \(high.0)") - return high - } else if let first = sortedQualities.first { - print("Quality Selection: No specific 'High' quality found, using highest available: \(first.0)") - return first - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(qualities[0].0)") - return qualities[0] - } - - case "Medium": - // Look for 480p quality - let mediumQuality = sortedQualities.first { - $0.0.contains("480p") || $0.0.contains("SD") - } - - if let medium = mediumQuality { - print("Quality Selection: Found specific 'Medium' (480p/SD) quality: \(medium.0)") - return medium - } else if !sortedQualities.isEmpty { - // Return middle quality from sorted list if no exact match - let middleIndex = sortedQualities.count / 2 - print("Quality Selection: No specific 'Medium' quality found, using middle quality: \(sortedQualities[middleIndex].0)") - return sortedQualities[middleIndex] - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)") - return autoQuality ?? qualities[0] - } - - case "Low": - // Return lowest quality (last in sorted list) - if let lowest = sortedQualities.last { - print("Quality Selection: Selected 'Low' quality: \(lowest.0)") - return lowest - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)") - return autoQuality ?? qualities[0] - } - - default: - // Default to Auto if available, otherwise first quality - if let auto = autoQuality { - print("Quality Selection: Default case, using Auto quality: \(auto.0)") - return auto - } else { - print("Quality Selection: No Auto quality found, using first available: \(qualities[0].0)") - return qualities[0] - } - } - } - - /// The original download method (adapted to be called internally) - /// This method should match the existing download implementation in JSController-Downloads.swift - private func downloadWithOriginalMethod(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - // Call the existing download method - self.startDownload( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } -} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift deleted file mode 100644 index 27e12e6..0000000 --- a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// JSController+MP4Download.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI -import AVFoundation - -// Extension for handling MP4 direct video downloads using AVAssetDownloadTask -extension JSController { - - /// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession - /// - Parameters: - /// - url: The MP4 URL to download - /// - headers: HTTP headers to use for the request - /// - title: Title for the download (optional) - /// - imageURL: Image URL for the content (optional) - /// - isEpisode: Whether this is an episode (defaults to false) - /// - showTitle: Title of the show this episode belongs to (optional) - /// - season: Season number (optional) - /// - episode: Episode number (optional) - /// - subtitleURL: Optional subtitle URL to download after video (optional) - /// - completionHandler: Called when the download is initiated or fails - func downloadMP4(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - - // Validate URL - guard url.scheme == "http" || url.scheme == "https" else { - completionHandler?(false, "Invalid URL scheme") - return - } - - // Ensure download session is available - guard let downloadSession = downloadURLSession else { - completionHandler?(false, "Download session not available") - return - } - - // Create metadata for the download - var metadata: AssetMetadata? = nil - if let title = title { - metadata = AssetMetadata( - title: title, - posterURL: imageURL, - showTitle: showTitle, - season: season, - episode: episode, - showPosterURL: showPosterURL ?? imageURL - ) - } - - // Determine download type based on isEpisode - let downloadType: DownloadType = isEpisode ? .episode : .movie - - // Generate a unique download ID - let downloadID = UUID() - - // Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey - let asset = AVURLAsset(url: url, options: [ - "AVURLAssetHTTPHeaderFieldsKey": headers - ]) - - // Create AVAssetDownloadTask using existing session - guard let downloadTask = downloadSession.makeAssetDownloadTask( - asset: asset, - assetTitle: title ?? url.lastPathComponent, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] - ) else { - completionHandler?(false, "Failed to create download task") - return - } - - // Create an active download object - let activeDownload = JSActiveDownload( - id: downloadID, - originalURL: url, - progress: 0.0, - task: downloadTask, - urlSessionTask: nil, - queueStatus: .downloading, - type: downloadType, - metadata: metadata, - title: title, - imageURL: imageURL, - subtitleURL: subtitleURL, - asset: asset, - headers: headers, - module: nil - ) - - // Add to active downloads and tracking - activeDownloads.append(activeDownload) - activeDownloadMap[downloadTask] = downloadID - - // Set up progress observation for MP4 downloads - setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) - - // Start the download - downloadTask.resume() - - // Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private - DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) - } - - // Initial success callback - completionHandler?(true, "Download started") - } - - // MARK: - MP4 Progress Observation - - /// Sets up progress observation for MP4 downloads using AVAssetDownloadTask - /// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods, - /// we observe the task's progress property directly - private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { - let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in - DispatchQueue.main.async { - guard let self = self else { return } - - // Update download progress using existing infrastructure - self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) - - // Post notification for UI updates - NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) - } - } - - // Store observation for cleanup using existing property from main JSController class - if mp4ProgressObservations == nil { - mp4ProgressObservations = [:] - } - mp4ProgressObservations?[downloadID] = observation - } - - /// Updates download progress for a specific MP4 task (avoiding name collision with existing method) - private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { - guard let downloadID = activeDownloadMap[task], - let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { - return - } - - // Update progress using existing mechanism - activeDownloads[downloadIndex].progress = progress - } - - /// Cleans up MP4 progress observation for a specific download - func cleanupMP4ProgressObservation(for downloadID: UUID) { - mp4ProgressObservations?[downloadID]?.invalidate() - mp4ProgressObservations?[downloadID] = nil - } -} diff --git a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift index 5f8f741..bb48d8f 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift @@ -38,15 +38,6 @@ extension JSController { showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil ) { - print("---- STREAM TYPE DOWNLOAD PROCESS STARTED ----") - print("Original URL: \(url.absoluteString)") - print("Stream Type: \(module.metadata.streamType)") - print("Headers: \(headers)") - print("Title: \(title ?? "None")") - print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")") - if let subtitle = subtitleURL { - print("Subtitle URL: \(subtitle.absoluteString)") - } let streamType = module.metadata.streamType.lowercased() if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") { diff --git a/Sora/Utils/JSLoader/JSController-Search.swift b/Sora/Utils/JSLoader/JSController-Search.swift index 007019e..7eb04a2 100644 --- a/Sora/Utils/JSLoader/JSController-Search.swift +++ b/Sora/Utils/JSLoader/JSController-Search.swift @@ -21,18 +21,18 @@ extension JSController { guard let self = self else { return } if let error = error { - Logger.shared.log("Network error: \(error)",type: "Error") + Logger.shared.log("Network error while searching: \(error)", type: "Error") DispatchQueue.main.async { completion([]) } return } guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML",type: "Error") + Logger.shared.log("Could not decode HTML response", type: "Error") DispatchQueue.main.async { completion([]) } return } - Logger.shared.log(html,type: "HTMLStrings") + Logger.shared.log(html, type: "HTMLStrings") if let parseFunction = self.context.objectForKeyedSubscript("searchResults"), let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { let resultItems = results.map { item in @@ -46,7 +46,7 @@ extension JSController { completion(resultItems) } } else { - Logger.shared.log("Failed to parse results",type: "Error") + Logger.shared.log("Could not parse search results", type: "Error") DispatchQueue.main.async { completion([]) } } }.resume() @@ -54,27 +54,27 @@ extension JSController { func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion([]) return } guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else { - Logger.shared.log("No JavaScript function searchResults found",type: "Error") + Logger.shared.log("Search function not found in module", type: "Error") completion([]) return } let promiseValue = searchResultsFunction.call(withArguments: [keyword]) guard let promise = promiseValue else { - Logger.shared.log("searchResults did not return a Promise",type: "Error") + Logger.shared.log("Search function returned invalid response", type: "Error") completion([]) return } let thenBlock: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "HTMLStrings") + Logger.shared.log(result.toString(), type: "HTMLStrings") if let jsonString = result.toString(), let data = jsonString.data(using: .utf8) { do { @@ -83,7 +83,7 @@ extension JSController { guard let title = item["title"] as? String, let imageUrl = item["image"] as? String, let href = item["href"] as? String else { - Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error") + Logger.shared.log("Invalid search result data format", type: "Error") return nil } return SearchItem(title: title, imageUrl: imageUrl, href: href) @@ -94,19 +94,19 @@ extension JSController { } } else { - Logger.shared.log("Failed to parse JSON",type: "Error") + Logger.shared.log("Could not parse JSON response", type: "Error") DispatchQueue.main.async { completion([]) } } } catch { - Logger.shared.log("JSON parsing error: \(error)",type: "Error") + Logger.shared.log("JSON parsing error: \(error)", type: "Error") DispatchQueue.main.async { completion([]) } } } else { - Logger.shared.log("Result is not a string",type: "Error") + Logger.shared.log("Invalid search result format", type: "Error") DispatchQueue.main.async { completion([]) } @@ -114,7 +114,7 @@ extension JSController { } let catchBlock: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error") + Logger.shared.log("Search operation failed: \(String(describing: error.toString()))", type: "Error") DispatchQueue.main.async { completion([]) } diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index d0d9d2e..163eae9 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -62,9 +62,7 @@ class JSController: NSObject, ObservableObject { } func updateMaxConcurrentDownloads(_ newLimit: Int) { - print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)") if !downloadQueue.isEmpty && !isProcessingQueue { - print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.") DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -75,7 +73,7 @@ class JSController: NSObject, ObservableObject { } } } else { - print("No queued downloads to process or queue is already being processed") + Logger.shared.log("No queued downloads to process or queue is already being processed") } } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index fcf4bf2..b8dadba 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -24,6 +24,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let onWatchNext: () -> Void let aniListID: Int var headers: [String:String]? = nil + var tmdbID: Int? = nil + var isMovie: Bool = false + var seasonNumber: Int = 1 private var aniListUpdatedSuccessfully = false private var aniListUpdateImpossible: Bool = false @@ -31,6 +34,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private let aniListMaxRetries = 6 private let totalEpisodes: Int + private var traktUpdateSent = false + private var traktUpdatedSuccessfully = false + var player: AVPlayer! var timeObserverToken: Any? var inactivityTimer: Timer? @@ -1291,7 +1297,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele 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) @@ -1385,19 +1390,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele pipButton.widthAnchor.constraint(equalToConstant: 44), pipButton.heightAnchor.constraint(equalToConstant: 44), airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor), - airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -8), + airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -6), 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 - ) + NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil) } func setupMenuButton() { @@ -1644,12 +1644,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration - if remainingPercentage < 0.1 && - self.aniListID != 0 && - !self.aniListUpdatedSuccessfully && - !self.aniListUpdateImpossible - { - self.tryAniListUpdate() + if remainingPercentage < 0.1 { + if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible { + self.tryAniListUpdate() + } + + if let tmdbId = self.tmdbID, tmdbId > 0, !self.traktUpdateSent { + self.sendTraktUpdate(tmdbId: tmdbId) + } } self.sliderHostingController?.rootView = MusicProgressSlider( @@ -1796,6 +1798,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc func seekBackward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") let finalSkip = skipValue > 0 ? skipValue : 10 + currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in guard self != nil else { return } @@ -1805,6 +1808,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc func seekForward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") + let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in @@ -1865,8 +1869,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele guard isPipAutoEnabled, let pip = pipController, !pip.isPictureInPictureActive else { - return - } + return + } pip.startPictureInPicture() } @@ -2061,6 +2065,45 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } + private func sendTraktUpdate(tmdbId: Int) { + guard !traktUpdateSent else { return } + traktUpdateSent = true + + let traktMutation = TraktMutation() + + if self.isMovie { + traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error") + } + } + } else { + guard self.episodeNumber > 0 && self.seasonNumber > 0 else { + Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") + return + } + + traktMutation.markAsWatched( + type: "episode", + tmdbID: tmdbId, + episodeNumber: self.episodeNumber, + seasonNumber: self.seasonNumber + ) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for Episode \(self?.episodeNumber ?? 0) (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error") + } + } + } + } + private func animateButtonRotation(_ button: UIView, clockwise: Bool = true) { if button.layer.animation(forKey: "rotate360") != nil { return @@ -2114,13 +2157,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func processM3U8Data(data: Data?, url: URL, completion: @escaping () -> Void) { guard let data = data, let content = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to load m3u8 file") - DispatchQueue.main.async { - self.qualities = [] - completion() - } - return - } + Logger.shared.log("Failed to load m3u8 file") + DispatchQueue.main.async { + self.qualities = [] + completion() + } + return + } let lines = content.components(separatedBy: .newlines) var qualities: [(String, String)] = [] @@ -2185,7 +2228,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func switchToQuality(urlString: String) { guard let url = URL(string: urlString), - currentQualityURL?.absoluteString != urlString else { return } + currentQualityURL?.absoluteString != urlString else { + Logger.shared.log("Quality Selection: Switch cancelled - same quality already selected", type: "General") + return + } + + let qualityName = qualities.first(where: { $0.1 == urlString })?.0 ?? "Unknown" + Logger.shared.log("Quality Selection: Switching to quality: \(qualityName) (\(urlString))", type: "General") let currentTime = player.currentTime() let wasPlaying = player.rate > 0 @@ -2244,7 +2293,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.menu = qualitySelectionMenu() if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 { + Logger.shared.log("Quality Selection: Successfully switched to: \(selectedQuality)", type: "General") DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye")) + } else { + Logger.shared.log("Quality Selection: Switch completed but quality name not found in list", type: "General") } } @@ -2294,11 +2346,34 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele baseM3U8URL = url currentQualityURL = url + let networkType = NetworkMonitor.getCurrentNetworkType() + let networkTypeString = networkType == .wifi ? "WiFi" : networkType == .cellular ? "Cellular" : "Unknown" + Logger.shared.log("Quality Selection: Detected network type: \(networkTypeString)", type: "General") + parseM3U8(url: url) { [weak self] in guard let self = self else { return } - if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), - self.qualities.contains(where: { $0.1 == last }) { - self.switchToQuality(urlString: last) + + Logger.shared.log("Quality Selection: Found \(self.qualities.count) available qualities", type: "General") + for (index, quality) in self.qualities.enumerated() { + Logger.shared.log("Quality Selection: Available [\(index + 1)]: \(quality.0) - \(quality.1)", type: "General") + } + + let preferredQuality = UserDefaults.getVideoQualityPreference() + Logger.shared.log("Quality Selection: User preference for \(networkTypeString): \(preferredQuality.rawValue)", type: "General") + + if let selectedQuality = VideoQualityPreference.findClosestQuality(preferred: preferredQuality, availableQualities: self.qualities) { + Logger.shared.log("Quality Selection: Selected quality: \(selectedQuality.0) (URL: \(selectedQuality.1))", type: "General") + self.switchToQuality(urlString: selectedQuality.1) + } else { + Logger.shared.log("Quality Selection: No matching quality found, using default", type: "General") + if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), + self.qualities.contains(where: { $0.1 == last }) { + Logger.shared.log("Quality Selection: Falling back to last selected quality", type: "General") + self.switchToQuality(urlString: last) + } else if let firstQuality = self.qualities.first { + Logger.shared.log("Quality Selection: Falling back to first available quality: \(firstQuality.0)", type: "General") + self.switchToQuality(urlString: firstQuality.1) + } } self.qualityButton.isHidden = false @@ -2312,6 +2387,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isHLSStream = false qualityButton.isHidden = true updateMenuButtonConstraints() + Logger.shared.log("Quality Selection: Non-HLS stream detected, quality selection unavailable", type: "General") } } @@ -2686,7 +2762,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele height: 10, onEditingChanged: { _ in } ) - .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) + .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) } } @@ -2701,6 +2777,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele default: return .white } } + + override var canBecomeFirstResponder: Bool { + return true + } + + override var keyCommands: [UIKeyCommand]? { + return [ + UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleSpaceKey), discoverabilityTitle: "Play/Pause"), + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeftArrow), discoverabilityTitle: "Seek Backward 10s"), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRightArrow), discoverabilityTitle: "Seek Forward 10s"), + UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleUpArrow), discoverabilityTitle: "Seek Forward 60s"), + UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleDownArrow), discoverabilityTitle: "Seek Backward 60s"), + UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(handleEscape), discoverabilityTitle: "Dismiss Player") + ] + } + + @objc private func handleSpaceKey() { + togglePlayPause() + } + + @objc private func handleLeftArrow() { + let skipValue = 10.0 + currentTimeVal = max(currentTimeVal - skipValue, 0) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(backwardButton, clockwise: false) + } + + @objc private func handleRightArrow() { + let skipValue = 10.0 + currentTimeVal = min(currentTimeVal + skipValue, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(forwardButton) + } + + @objc private func handleUpArrow() { + let skipValue = 60.0 + currentTimeVal = min(currentTimeVal + skipValue, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(forwardButton) + } + + @objc private func handleDownArrow() { + let skipValue = 60.0 + currentTimeVal = max(currentTimeVal - skipValue, 0) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(backwardButton, clockwise: false) + } + + @objc private func handleEscape() { + dismiss(animated: true, completion: nil) + } } class GradientOverlayButton: UIButton { diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 58a9a65..518c778 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -20,20 +20,79 @@ class VideoPlayerViewController: UIViewController { var aniListID: Int = 0 var headers: [String:String]? = nil var totalEpisodes: Int = 0 - + var tmdbID: Int? = nil + var isMovie: Bool = false + var seasonNumber: Int = 1 var episodeNumber: Int = 0 var episodeImageUrl: String = "" var mediaTitle: String = "" + var subtitlesLoader: VTTSubtitlesLoader? + var subtitleLabel: UILabel? + + private var aniListUpdateSent = false + private var aniListUpdatedSuccessfully = false + private var traktUpdateSent = false + private var traktUpdatedSuccessfully = false init(module: ScrapingModule) { self.module = module super.init(nibName: nil, bundle: nil) + if UserDefaults.standard.object(forKey: "subtitlesEnabled") == nil { + UserDefaults.standard.set(true, forKey: "subtitlesEnabled") + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func setupSubtitles() { + guard !subtitles.isEmpty, UserDefaults.standard.bool(forKey: "subtitlesEnabled"), let subtitleURL = URL(string: subtitles) else { + return + } + + subtitlesLoader = VTTSubtitlesLoader() + setupSubtitleLabel() + + subtitlesLoader?.load(from: subtitles) + + let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + self?.updateSubtitles(at: time.seconds) + } + } + + private func setupSubtitleLabel() { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .white + label.font = .systemFont(ofSize: 16, weight: .medium) + label.layer.shadowColor = UIColor.black.cgColor + label.layer.shadowOffset = CGSize(width: 1, height: 1) + label.layer.shadowOpacity = 0.8 + label.layer.shadowRadius = 2 + + guard let playerView = playerViewController?.view else { return } + playerView.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: playerView.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: playerView.trailingAnchor, constant: -16), + label.bottomAnchor.constraint(equalTo: playerView.bottomAnchor, constant: -32) + ]) + + self.subtitleLabel = label + } + + private func updateSubtitles(at time: Double) { + let currentSubtitle = subtitlesLoader?.cues.first { cue in + time >= cue.startTime && time <= cue.endTime + } + subtitleLabel?.text = currentSubtitle?.text ?? "" + } + override func viewDidLoad() { super.viewDidLoad() @@ -66,6 +125,10 @@ class VideoPlayerViewController: UIViewController { playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(playerViewController.view) playerViewController.didMove(toParent: self) + + if !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled") { + setupSubtitles() + } } addPeriodicTimeObserver(fullURL: fullUrl) @@ -113,8 +176,8 @@ class VideoPlayerViewController: UIViewController { guard let self = self, let currentItem = player.currentItem, currentItem.duration.seconds.isFinite else { - return - } + return + } let currentTime = time.seconds let duration = currentItem.duration.seconds @@ -144,15 +207,69 @@ class VideoPlayerViewController: UIViewController { let remainingPercentage = (duration - currentTime) / duration - if remainingPercentage < 0.1 && self.aniListID != 0 { - let aniListMutation = AniListMutation() - aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in - switch result { - case .success: - Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") - case .failure(let error): - Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") - } + if remainingPercentage < 0.1 { + if self.aniListID != 0 && !self.aniListUpdateSent { + self.sendAniListUpdate() + } + + if let tmdbId = self.tmdbID, tmdbId > 0, !self.traktUpdateSent { + self.sendTraktUpdate(tmdbId: tmdbId) + } + } + } + } + + private func sendAniListUpdate() { + guard !aniListUpdateSent else { return } + + aniListUpdateSent = true + let aniListMutation = AniListMutation() + + aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in + switch result { + case .success: + self?.aniListUpdatedSuccessfully = true + Logger.shared.log("Successfully updated AniList progress for Episode \(self?.episodeNumber ?? 0)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + } + } + } + + private func sendTraktUpdate(tmdbId: Int) { + guard !traktUpdateSent else { return } + traktUpdateSent = true + + let traktMutation = TraktMutation() + + if self.isMovie { + traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error") + } + } + } else { + guard self.episodeNumber > 0 && self.seasonNumber > 0 else { + Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") + return + } + + traktMutation.markAsWatched( + type: "episode", + tmdbID: tmdbId, + episodeNumber: self.episodeNumber, + seasonNumber: self.seasonNumber + ) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for Episode \(self?.episodeNumber ?? 0) (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error") } } } @@ -179,5 +296,8 @@ class VideoPlayerViewController: UIViewController { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) } + subtitleLabel?.removeFromSuperview() + subtitleLabel = nil + subtitlesLoader = nil } } diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index 11929c4..c05c734 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -22,20 +22,20 @@ struct ModuleAdditionSettingsView: View { ZStack { LinearGradient( gradient: Gradient(colors: [ - colorScheme == .light ? Color.black : Color.white, - Color.accentColor.opacity(0.08) + colorScheme == .dark ? Color.black : Color.white, + Color.accentColor.opacity(0.05) ]), startPoint: .top, endPoint: .bottom ) - .ignoresSafeArea() + .ignoresSafeArea() VStack(spacing: 0) { HStack { Spacer() Capsule() .frame(width: 40, height: 5) - .foregroundColor(Color(.systemGray4)) + .foregroundColor(Color(.systemGray3)) .padding(.top, 10) Spacer() } @@ -57,17 +57,22 @@ struct ModuleAdditionSettingsView: View { } .frame(width: 90, height: 90) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: Color.accentColor.opacity(0.18), radius: 10, x: 0, y: 6) + .shadow( + color: colorScheme == .dark + ? Color.black.opacity(0.3) + : Color.accentColor.opacity(0.15), + radius: 10, x: 0, y: 6 + ) .overlay( RoundedRectangle(cornerRadius: 22) - .stroke(Color.accentColor, lineWidth: 2) + .stroke(Color.accentColor.opacity(0.8), lineWidth: 2) ) .padding(.top, 10) VStack(spacing: 6) { Text(metadata.sourceName) .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundColor(.primary) + .foregroundColor(colorScheme == .dark ? .white : .black) .multilineTextAlignment(.center) .padding(.top, 6) @@ -84,14 +89,19 @@ struct ModuleAdditionSettingsView: View { } .frame(width: 32, height: 32) .clipShape(Circle()) - .shadow(radius: 2) + .shadow( + color: colorScheme == .dark + ? Color.black.opacity(0.4) + : Color.gray.opacity(0.3), + radius: 2 + ) VStack(alignment: .leading, spacing: 0) { Text(metadata.author.name) .font(.headline) - .foregroundColor(.primary) + .foregroundColor(colorScheme == .dark ? .white : .black) Text("Author") .font(.caption2) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) } Spacer() } @@ -99,7 +109,11 @@ struct ModuleAdditionSettingsView: View { .padding(.vertical, 8) .background( Capsule() - .fill(Color.accentColor.opacity(colorScheme == .dark ? 0.13 : 0.08)) + .fill( + colorScheme == .dark + ? Color.accentColor.opacity(0.15) + : Color.accentColor.opacity(0.08) + ) ) .padding(.top, 2) } @@ -125,7 +139,7 @@ struct ModuleAdditionSettingsView: View { } .background( RoundedRectangle(cornerRadius: 22) - .fill(Color(.systemGray6).opacity(colorScheme == .dark ? 0.18 : 0.8)) + .fill(colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05)) ) .padding(.top, 18) .padding(.horizontal, 2) @@ -142,7 +156,7 @@ struct ModuleAdditionSettingsView: View { .padding(16) .background( RoundedRectangle(cornerRadius: 18) - .fill(Color(.systemGray6).opacity(colorScheme == .dark ? 0.13 : 0.85)) + .fill(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) ) .padding(.top, 18) } @@ -152,8 +166,10 @@ struct ModuleAdditionSettingsView: View { VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) + .tint(.accentColor) Text("Loading module information...") - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) + .font(.body) } .frame(maxHeight: .infinity) .padding(.top, 100) @@ -165,6 +181,7 @@ struct ModuleAdditionSettingsView: View { Text(errorMessage) .foregroundColor(.red) .multilineTextAlignment(.center) + .font(.body) } .frame(maxHeight: .infinity) .padding(.top, 100) @@ -180,21 +197,26 @@ struct ModuleAdditionSettingsView: View { Text("Add Module") } .font(.headline) - .foregroundColor(colorScheme == .light ? .black : .white) + .foregroundColor(Color.accentColor) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( LinearGradient( gradient: Gradient(colors: [ - Color.accentColor.opacity(0.95), - Color.accentColor.opacity(0.7) + colorScheme == .dark ? Color.white : Color.black, + colorScheme == .dark ? Color.white.opacity(0.9) : Color.black.opacity(0.9) ]), startPoint: .leading, endPoint: .trailing ) .clipShape(RoundedRectangle(cornerRadius: 18)) ) - .shadow(color: Color.accentColor.opacity(0.18), radius: 8, x: 0, y: 4) + .shadow( + color: colorScheme == .dark + ? Color.black.opacity(0.3) + : Color.accentColor.opacity(0.25), + radius: 8, x: 0, y: 4 + ) .padding(.horizontal, 20) } .disabled(isLoading || moduleMetadata == nil) @@ -203,7 +225,7 @@ struct ModuleAdditionSettingsView: View { Button(action: { presentationMode.wrappedValue.dismiss() }) { Text("Cancel") .font(.body) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) .padding(.vertical, 8) } } @@ -271,18 +293,19 @@ struct FancyInfoTile: View { let icon: String let label: String let value: String + @Environment(\.colorScheme) var colorScheme var body: some View { VStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.accentColor) + .foregroundColor(colorScheme == .dark ? .white : .black) Text(label) .font(.caption2) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.5)) Text(value) .font(.system(size: 15, weight: .semibold, design: .rounded)) - .foregroundColor(.primary) + .foregroundColor(colorScheme == .dark ? .white : .black) .lineLimit(1) .minimumScaleFactor(0.7) } @@ -294,16 +317,17 @@ struct FancyInfoTile: View { struct FancyUrlRow: View { let title: String let value: String + @Environment(\.colorScheme) var colorScheme var body: some View { HStack(spacing: 8) { Text(title) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) Spacer() Text(value) .font(.footnote.monospaced()) - .foregroundColor(.accentColor) + .foregroundColor(colorScheme == .dark ? .white : .black) .lineLimit(1) .truncationMode(.middle) .onLongPressGesture { @@ -311,7 +335,7 @@ struct FancyUrlRow: View { DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) } Image(systemName: "doc.on.clipboard") - .foregroundColor(.accentColor) + .foregroundColor(colorScheme == .dark ? .white : .black) .font(.system(size: 14)) .onTapGesture { UIPasteboard.general.string = value diff --git a/Sora/Utils/SkeletonCells/Shimmer.swift b/Sora/Utils/SkeletonCells/Shimmer.swift index 3575963..e001aac 100644 --- a/Sora/Utils/SkeletonCells/Shimmer.swift +++ b/Sora/Utils/SkeletonCells/Shimmer.swift @@ -8,56 +8,48 @@ import SwiftUI struct Shimmer: ViewModifier { - @State private var phase: CGFloat = -1 + @State private var phase: CGFloat = 0 func body(content: Content) -> some View { content - .modifier(AnimatedMask(phase: phase) - .animation( - Animation.linear(duration: 1.2) - .repeatForever(autoreverses: false) - ) + .overlay( + shimmerOverlay + .allowsHitTesting(false) ) .onAppear { - phase = 1.5 + startAnimation() } } - struct AnimatedMask: AnimatableModifier { - var phase: CGFloat = 0 - - var animatableData: CGFloat { - get { phase } - set { phase = newValue } - } - - func body(content: Content) -> some View { - content - .overlay( - GeometryReader { geo in - let width = geo.size.width - let shimmerStart = phase - 0.25 - let shimmerEnd = phase + 0.25 - Rectangle() - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.white.opacity(0.05), location: shimmerStart - 0.15), - .init(color: Color.white.opacity(0.25), location: shimmerStart), - .init(color: Color.white.opacity(0.85), location: phase), - .init(color: Color.white.opacity(0.25), location: shimmerEnd), - .init(color: Color.white.opacity(0.05), location: shimmerEnd + 0.15) - ]), - startPoint: .leading, - endPoint: .trailing - ) - ) - .blur(radius: 8) - .rotationEffect(.degrees(20)) - .offset(x: -width * 0.7 + width * 2 * phase) - } - ) - .mask(content) - } + private var shimmerOverlay: some View { + Rectangle() + .fill(shimmerGradient) + .scaleEffect(x: 3, y: 1) + .rotationEffect(.degrees(20)) + .offset(x: -200 + (400 * phase)) + .animation( + .linear(duration: 1.2) + .repeatForever(autoreverses: false), + value: phase + ) + .clipped() + } + + private var shimmerGradient: LinearGradient { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .white.opacity(0.1), location: 0.3), + .init(color: .white.opacity(0.6), location: 0.5), + .init(color: .white.opacity(0.1), location: 0.7), + .init(color: .clear, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + private func startAnimation() { + phase = 1 } } diff --git a/Sora/Utils/TabBar/TabBar.swift b/Sora/Utils/TabBar/TabBar.swift index a646e58..ef3758c 100644 --- a/Sora/Utils/TabBar/TabBar.swift +++ b/Sora/Utils/TabBar/TabBar.swift @@ -90,7 +90,7 @@ struct TabBar: View { .stroke( LinearGradient( gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(gradientOpacity), location: 0), + .init(color: Color.accentColor.opacity(0.25), location: 0), .init(color: Color.accentColor.opacity(0), location: 1) ]), startPoint: .top, diff --git a/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift b/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift new file mode 100644 index 0000000..f0b06b2 --- /dev/null +++ b/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift @@ -0,0 +1,45 @@ +// +// WebAuthenticationManager.swift +// Sulfur +// +// Created by Francesco on 11/06/25. +// + +import AuthenticationServices + +class WebAuthenticationManager { + static let shared = WebAuthenticationManager() + private var webAuthSession: ASWebAuthenticationSession? + + func authenticate(url: URL, callbackScheme: String, completion: @escaping (Result) -> Void) { + webAuthSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in + if let error = error { + completion(.failure(error)) + return + } + + if let callbackURL = callbackURL { + completion(.success(callbackURL)) + } else { + completion(.failure(NSError(domain: "WebAuthenticationManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication callback URL not received"]))) + } + } + + webAuthSession?.presentationContextProvider = WebAuthenticationPresentationContext.shared + webAuthSession?.prefersEphemeralWebBrowserSession = true + webAuthSession?.start() + } +} + +class WebAuthenticationPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = WebAuthenticationPresentationContext() + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + fatalError("No window found") + } + + return window + } +} diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 6871a74..8093104 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -57,16 +57,16 @@ struct DownloadView: View { } .animation(.easeInOut(duration: 0.2), value: selectedTab) .navigationBarHidden(true) - .alert("Delete Download", isPresented: $showDeleteAlert) { - Button("Delete", role: .destructive) { + .alert(NSLocalizedString("Delete Download", comment: ""), isPresented: $showDeleteAlert) { + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { if let asset = assetToDelete { jsController.deleteAsset(asset) } } - Button("Cancel", role: .cancel) {} + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} } message: { if let asset = assetToDelete { - Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?") + Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName)) } } } @@ -83,7 +83,7 @@ struct DownloadView: View { VStack(spacing: 20) { if !jsController.downloadQueue.isEmpty { DownloadSectionView( - title: "Queue", + title: NSLocalizedString("Queue", comment: ""), icon: "clock.fill", downloads: jsController.downloadQueue ) @@ -91,7 +91,7 @@ struct DownloadView: View { if !jsController.activeDownloads.isEmpty { DownloadSectionView( - title: "Active Downloads", + title: NSLocalizedString("Active Downloads", comment: ""), icon: "arrow.down.circle.fill", downloads: jsController.activeDownloads ) @@ -140,12 +140,12 @@ struct DownloadView: View { .foregroundStyle(.tertiary) VStack(spacing: 8) { - Text("No Active Downloads") + Text(NSLocalizedString("No Active Downloads", comment: "")) .font(.title2) .fontWeight(.medium) .foregroundStyle(.primary) - Text("Actively downloading media can be tracked from here.") + Text(NSLocalizedString("Actively downloading media can be tracked from here.", comment: "")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -162,12 +162,12 @@ struct DownloadView: View { .foregroundStyle(.tertiary) VStack(spacing: 8) { - Text("No Downloads") + Text(NSLocalizedString("No Downloads", comment: "")) .font(.title2) .fontWeight(.medium) .foregroundStyle(.primary) - Text("Your downloaded episodes will appear here") + Text(NSLocalizedString("Your downloaded episodes will appear here", comment: "")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -274,7 +274,7 @@ struct CustomDownloadHeader: View { var body: some View { VStack(spacing: 0) { HStack { - Text("Downloads") + Text(NSLocalizedString("Downloads", comment: "")) .font(.largeTitle) .fontWeight(.bold) .foregroundStyle(.primary) @@ -293,29 +293,15 @@ struct CustomDownloadHeader: View { Image(systemName: isSearchActive ? "xmark.circle.fill" : "magnifyingglass") .resizable() .scaledToFit() - .frame(width: 24, height: 24) + .frame(width: 18, height: 18) .foregroundColor(.accentColor) - .padding(6) + .padding(10) .background( Circle() .fill(Color.gray.opacity(0.2)) .shadow(color: .accentColor.opacity(0.2), radius: 2) ) - .overlay( - Circle() - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - .frame(width: 32, height: 32) - ) + .circularGradientOutline() } if showSortMenu { @@ -336,28 +322,15 @@ struct CustomDownloadHeader: View { Image(systemName: "arrow.up.arrow.down") .resizable() .scaledToFit() - .frame(width: 24, height: 24) + .frame(width: 18, height: 18) .foregroundColor(.accentColor) - .padding(6) + .padding(10) .background( Circle() .fill(Color.gray.opacity(0.2)) .shadow(color: .accentColor.opacity(0.2), radius: 2) ) - .overlay( - Circle() - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) + .circularGradientOutline() } } } @@ -370,10 +343,12 @@ struct CustomDownloadHeader: View { HStack(spacing: 12) { HStack(spacing: 12) { Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) .foregroundColor(.secondary) - .font(.body) - TextField("Search downloads", text: $searchText) + TextField(NSLocalizedString("Search downloads", comment: ""), text: $searchText) .textFieldStyle(PlainTextFieldStyle()) .foregroundColor(.primary) @@ -382,8 +357,10 @@ struct CustomDownloadHeader: View { searchText = "" }) { Image(systemName: "xmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) .foregroundColor(.secondary) - .font(.body) } } } @@ -394,16 +371,16 @@ struct CustomDownloadHeader: View { .overlay( RoundedRectangle(cornerRadius: 12) .strokeBorder( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1.5 - ) + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.25), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1.5 + ) ) } .padding(.horizontal, 20) @@ -417,14 +394,14 @@ struct CustomDownloadHeader: View { VStack(spacing: 0) { HStack(spacing: 0) { TabButton( - title: "Active", + title: NSLocalizedString("Active", comment: ""), icon: "arrow.down.circle", isSelected: selectedTab == 0, action: { selectedTab = 0 } ) TabButton( - title: "Downloaded", + title: NSLocalizedString("Downloaded", comment: ""), icon: "checkmark.circle", isSelected: selectedTab == 1, action: { selectedTab = 1 } @@ -549,7 +526,7 @@ struct DownloadSummaryCard: View { HStack { Image(systemName: "chart.bar.fill") .foregroundColor(.accentColor) - Text("Download Summary".uppercased()) + Text(NSLocalizedString("Download Summary", comment: "").uppercased()) .font(.footnote) .fontWeight(.medium) .foregroundColor(.secondary) @@ -561,7 +538,7 @@ struct DownloadSummaryCard: View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 20) { SummaryItem( - title: "Shows", + title: NSLocalizedString("Shows", comment: ""), value: "\(totalShows)", icon: "tv.fill" ) @@ -569,7 +546,7 @@ struct DownloadSummaryCard: View { Divider().frame(height: 32) SummaryItem( - title: "Episodes", + title: NSLocalizedString("Episodes", comment: ""), value: "\(totalEpisodes)", icon: "play.rectangle.fill" ) @@ -582,7 +559,7 @@ struct DownloadSummaryCard: View { let sizeUnit = components.dropFirst().first.map(String.init) ?? "" SummaryItem( - title: "Size (\(sizeUnit))", + title: String(format: NSLocalizedString("Size (%@)", comment: ""), sizeUnit), value: sizeValue, icon: "internaldrive.fill" ) @@ -617,28 +594,6 @@ struct DownloadSummaryCard: View { } } - - private func formatFileSize(_ size: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useKB, .useMB, .useGB] - formatter.countStyle = .file - return formatter.string(fromByteCount: size) - } - - private func formatFileSizeWithUnit(_ size: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useKB, .useMB, .useGB] - formatter.countStyle = .file - - let formattedString = formatter.string(fromByteCount: size) - let components = formattedString.components(separatedBy: " ") - if components.count == 2 { - return "Size (\(components[1]))" - } - return "Size" - } - - struct SummaryItem: View { let title: String let value: String @@ -675,7 +630,7 @@ struct DownloadedSection: View { HStack { Image(systemName: "folder.fill") .foregroundColor(.accentColor) - Text("Downloaded Shows".uppercased()) + Text(NSLocalizedString("Downloaded Shows", comment: "").uppercased()) .font(.footnote) .fontWeight(.medium) .foregroundColor(.secondary) @@ -760,7 +715,7 @@ struct EnhancedActiveDownloadCard: View { VStack(spacing: 6) { HStack { if download.queueStatus == .queued { - Text("Queued") + Text(NSLocalizedString("Queued", comment: "")) .font(.caption) .fontWeight(.medium) .foregroundStyle(.orange) @@ -842,11 +797,11 @@ struct EnhancedActiveDownloadCard: View { private var statusText: String { if download.queueStatus == .queued { - return "Queued" + return NSLocalizedString("Queued", comment: "") } else if taskState == .running { - return "Downloading" + return NSLocalizedString("Downloading", comment: "") } else { - return "Paused" + return NSLocalizedString("Paused", comment: "") } } @@ -1071,7 +1026,7 @@ struct EnhancedShowEpisodesView: View { HStack { Image(systemName: "list.bullet.rectangle") .foregroundColor(.accentColor) - Text("Episodes".uppercased()) + Text(NSLocalizedString("Episodes", comment: "").uppercased()) .font(.footnote) .fontWeight(.medium) .foregroundColor(.secondary) @@ -1096,7 +1051,7 @@ struct EnhancedShowEpisodesView: View { } label: { HStack(spacing: 4) { Image(systemName: episodeSortOption.systemImage) - Text("Sort") + Text(NSLocalizedString("Sort", comment: "")) } .font(.subheadline) .foregroundColor(.accentColor) @@ -1107,7 +1062,7 @@ struct EnhancedShowEpisodesView: View { }) { HStack(spacing: 4) { Image(systemName: "trash") - Text("Delete All") + Text(NSLocalizedString("Delete All", comment: "")) } .font(.subheadline) .foregroundColor(.red) @@ -1118,7 +1073,7 @@ struct EnhancedShowEpisodesView: View { // Episodes List if group.assets.isEmpty { - Text("No episodes available") + Text(NSLocalizedString("No episodes available", comment: "")) .foregroundColor(.secondary) .italic() .padding(40) @@ -1131,7 +1086,7 @@ struct EnhancedShowEpisodesView: View { ) .contextMenu { Button(action: { onPlay(asset) }) { - Label("Play", systemImage: "play.fill") + Label(NSLocalizedString("Play", comment: ""), systemImage: "play.fill") } .disabled(!asset.fileExists) @@ -1139,7 +1094,7 @@ struct EnhancedShowEpisodesView: View { assetToDelete = asset showDeleteAlert = true }) { - Label("Delete", systemImage: "trash") + Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash") } } .onTapGesture { @@ -1152,27 +1107,27 @@ struct EnhancedShowEpisodesView: View { } .padding(.vertical) } - .navigationTitle("Episodes") + .navigationTitle(NSLocalizedString("Episodes", comment: "")) .navigationBarTitleDisplayMode(.inline) - .alert("Delete Episode", isPresented: $showDeleteAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { + .alert(NSLocalizedString("Delete Episode", comment: ""), isPresented: $showDeleteAlert) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { if let asset = assetToDelete { onDelete(asset) } } } message: { if let asset = assetToDelete { - Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?") + Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName)) } } - .alert("Delete All Episodes", isPresented: $showDeleteAllAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete All", role: .destructive) { + .alert(NSLocalizedString("Delete All Episodes", comment: ""), isPresented: $showDeleteAllAlert) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } + Button(NSLocalizedString("Delete All", comment: ""), role: .destructive) { deleteAllAssets() } } message: { - Text("Are you sure you want to delete all \(group.assetCount) episodes in '\(group.title)'?") + Text(String(format: NSLocalizedString("Are you sure you want to delete all %d episodes in '%@'?", comment: ""), group.assetCount, group.title)) } } diff --git a/Sora/Views/LibraryView/AllWatching.swift b/Sora/Views/LibraryView/AllWatching.swift index 878ac9f..0921bdf 100644 --- a/Sora/Views/LibraryView/AllWatching.swift +++ b/Sora/Views/LibraryView/AllWatching.swift @@ -36,10 +36,10 @@ struct AllWatchingView: View { @State private var sortOption: SortOption = .dateAdded enum SortOption: String, CaseIterable { - case dateAdded = "Date Added" - case title = "Title" - case source = "Source" - case progress = "Progress" + case dateAdded = "Recently Added" + case title = "Series Title" + case source = "Content Source" + case progress = "Watch Progress" } var sortedItems: [ContinueWatchingItem] { diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 1aa5829..66e282a 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -91,9 +91,9 @@ struct LibraryView: View { Image(systemName: "play.circle") .font(.largeTitle) .foregroundColor(.secondary) - Text("No items to continue watching.") + Text("Nothing to Continue Watching") .font(.headline) - Text("Recently watched content will appear here.") + Text("Your recently watched content will appear here") .font(.caption) .foregroundColor(.secondary) } @@ -303,8 +303,6 @@ struct ContinueWatchingCell: View { } .overlay( ZStack { - ProgressiveBlurView() - .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) VStack(alignment: .leading, spacing: 4) { Spacer() diff --git a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift b/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift similarity index 85% rename from Sora/Views/MediaInfoView/AnilistMatchPopupView.swift rename to Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift index 3799b67..8e05e38 100644 --- a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift +++ b/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift @@ -1,16 +1,15 @@ // -// AnilistMatchPopupView.swift -// Sulfur -// -// Created by seiike on 01/06/2025. +// AnilistMatchPopupView.swift +// Sulfur // +// Created by seiike on 01/06/2025. import NukeUI import SwiftUI struct AnilistMatchPopupView: View { let seriesTitle: String - let onSelect: (Int) -> Void + let onSelect: (Int, String) -> Void @State private var results: [[String: Any]] = [] @State private var isLoading = true @@ -43,7 +42,7 @@ struct AnilistMatchPopupView: View { .frame(maxWidth: .infinity) .padding() } else if results.isEmpty { - Text("No matches found") + Text("No AniList matches found") .font(.subheadline) .foregroundStyle(.gray) .frame(maxWidth: .infinity) @@ -52,10 +51,11 @@ struct AnilistMatchPopupView: View { LazyVStack(spacing: 15) { ForEach(results.indices, id: \.self) { index in let result = results[index] - Button(action: { if let id = result["id"] as? Int { - onSelect(id) + let title = result["title"] as? String ?? seriesTitle + onSelect(id, title) + dismiss() } }) { HStack(spacing: 12) { @@ -81,7 +81,6 @@ struct AnilistMatchPopupView: View { Text(result["title"] as? String ?? "Unknown") .font(.body) .foregroundStyle(.primary) - if let english = result["title_english"] as? String { Text(english) .font(.caption) @@ -135,34 +134,32 @@ struct AnilistMatchPopupView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .foregroundColor(isLightMode ? .black : .white) + Button("Cancel") { dismiss() } + .foregroundColor(isLightMode ? .black : .white) } ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { + Button { manualIDText = "" showingManualIDAlert = true - }) { + } label: { Image(systemName: "number") .foregroundColor(isLightMode ? .black : .white) } } } - .alert("Set Custom AniList ID", isPresented: $showingManualIDAlert, actions: { + .alert("Set Custom AniList ID", isPresented: $showingManualIDAlert) { TextField("AniList ID", text: $manualIDText) .keyboardType(.numberPad) Button("Cancel", role: .cancel) { } - Button("Save", action: { + Button("Save") { if let idInt = Int(manualIDText.trimmingCharacters(in: .whitespaces)) { - onSelect(idInt) + onSelect(idInt, seriesTitle) dismiss() } - }) - }, message: { - Text("Enter the AniList ID for this media") - }) + } + } message: { + Text("Enter the AniList ID for this series") + } } .onAppear(perform: fetchMatches) } @@ -186,7 +183,6 @@ struct AnilistMatchPopupView: View { """ guard let url = URL(string: "https://graphql.anilist.co") else { return } - var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -194,25 +190,23 @@ struct AnilistMatchPopupView: View { URLSession.shared.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { - self.isLoading = false + isLoading = false + guard + let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let page = dataDict["Page"] as? [String: Any], + let mediaList = page["media"] as? [[String: Any]] + else { return } - guard let data = data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let dataDict = json["data"] as? [String: Any], - let page = dataDict["Page"] as? [String: Any], - let mediaList = page["media"] as? [[String: Any]] else { - return - } - - self.results = mediaList.map { media in + results = mediaList.map { media in let titleInfo = media["title"] as? [String: Any] let cover = (media["coverImage"] as? [String: Any])?["large"] as? String - return [ "id": media["id"] ?? 0, "title": titleInfo?["romaji"] ?? "Unknown", - "title_english": titleInfo?["english"], - "cover": cover + "title_english": titleInfo?["english"] as Any, + "cover": cover as Any ] } } diff --git a/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift b/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift new file mode 100644 index 0000000..d34bc69 --- /dev/null +++ b/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift @@ -0,0 +1,170 @@ +// +// TMDBMatchPopupView.swift +// Sulfur +// +// Created by seiike on 12/06/2025. + +import SwiftUI +import NukeUI + +struct TMDBMatchPopupView: View { + let seriesTitle: String + let onSelect: (Int, TMDBFetcher.MediaType, String) -> Void + + @State private var results: [ResultItem] = [] + @State private var isLoading = true + @State private var showingError = false + + @Environment(\.dismiss) private var dismiss + + struct ResultItem: Identifiable { + let id: Int + let title: String + let mediaType: TMDBFetcher.MediaType + let posterURL: String? + } + + private struct TMDBSearchResult: Decodable { + let id: Int + let name: String? + let title: String? + let poster_path: String? + let popularity: Double + } + + private struct TMDBSearchResponse: Decodable { + let results: [TMDBSearchResult] + } + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 0) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else if results.isEmpty { + Text("No matches found") + .font(.subheadline) + .foregroundStyle(.gray) + .frame(maxWidth: .infinity) + .padding() + } else { + LazyVStack(spacing: 15) { + ForEach(results) { item in + Button { + onSelect(item.id, item.mediaType, item.title) + dismiss() + } label: { + HStack(spacing: 12) { + if let poster = item.posterURL, let url = URL(string: poster) { + LazyImage(url: url) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 50, height: 75) + .cornerRadius(6) + } else { + Rectangle() + .fill(.tertiary) + .frame(width: 50, height: 75) + .cornerRadius(6) + } + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.body) + .foregroundStyle(.primary) + Text(item.mediaType.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(11) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke( + Color.accentColor.opacity(0.2), + lineWidth: 0.5 + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + } + } + .navigationTitle("TMDB Match") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .alert("Error Fetching Results", isPresented: $showingError) { + Button("OK", role: .cancel) { } + } message: { + Text("Unable to fetch matches. Please try again later.") + } + } + .onAppear(perform: fetchMatches) + } + + private func fetchMatches() { + isLoading = true + results = [] + let fetcher = TMDBFetcher() + let apiKey = fetcher.apiKey + let dispatchGroup = DispatchGroup() + var temp: [ResultItem] = [] + var encounteredError = false + + for type in TMDBFetcher.MediaType.allCases { + dispatchGroup.enter() + let query = seriesTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=\(apiKey)&query=\(query)" + guard let url = URL(string: urlString) else { + encounteredError = true + dispatchGroup.leave() + continue + } + + URLSession.shared.dataTask(with: url) { data, _, error in + defer { dispatchGroup.leave() } + guard error == nil, + let data = data, + let response = try? JSONDecoder().decode(TMDBSearchResponse.self, from: data) + else { + encounteredError = true + return + } + + let items = response.results.prefix(6).map { res -> ResultItem in + let title = (type == .tv ? res.name : res.title) ?? "Unknown" + let poster = res.poster_path.map { "https://image.tmdb.org/t/p/w500\($0)" } + return ResultItem(id: res.id, title: title, mediaType: type, posterURL: poster) + } + temp.append(contentsOf: items) + }.resume() + } + + dispatchGroup.notify(queue: .main) { + if encounteredError { showingError = true } + results = Array(temp.prefix(6)) + isLoading = false + } + } +} diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 0707d64..f8024d1 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -15,34 +15,29 @@ struct EpisodeCell: View { let episodeID: Int let progress: Double let itemID: Int - var totalEpisodes: Int? - var defaultBannerImage: String - var module: ScrapingModule - var parentTitle: String - var showPosterURL: String? + let totalEpisodes: Int? + let defaultBannerImage: String + let module: ScrapingModule + let parentTitle: String + let showPosterURL: String? + let tmdbID: Int? + let seasonNumber: Int? - var isMultiSelectMode: Bool = false - var isSelected: Bool = false - var onSelectionChanged: ((Bool) -> Void)? + let isMultiSelectMode: Bool + let isSelected: Bool + let onSelectionChanged: ((Bool) -> Void)? - var onTap: (String) -> Void - var onMarkAllPrevious: () -> Void + let onTap: (String) -> Void + let onMarkAllPrevious: () -> Void - @State private var episodeTitle: String = "" - @State private var episodeImageUrl: String = "" - @State private var isLoading: Bool = true + @State private var episodeTitle = "" + @State private var episodeImageUrl = "" + @State private var isLoading = true @State private var currentProgress: Double = 0.0 - @State private var showDownloadConfirmation = false - @State private var isDownloading: Bool = false - @State private var isPlaying = false - @State private var loadedFromCache: Bool = false + @State private var isDownloading = false @State private var downloadStatus: EpisodeDownloadStatus = .notDownloaded - @State private var downloadRefreshTrigger: Bool = false - @State private var lastUpdateTime: Date = Date() - @State private var activeDownloadTask: AVAssetDownloadTask? = nil - @State private var lastStatusCheck: Date = Date() - @State private var lastLoggedStatus: EpisodeDownloadStatus? @State private var downloadAnimationScale: CGFloat = 1.0 + @State private var activeDownloadTask: AVAssetDownloadTask? @State private var swipeOffset: CGFloat = 0 @State private var isShowingActions: Bool = false @@ -55,10 +50,260 @@ struct EpisodeCell: View { @ObservedObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager - @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system + init( + episodeIndex: Int, + episode: String, + episodeID: Int, + progress: Double, + itemID: Int, + totalEpisodes: Int? = nil, + defaultBannerImage: String = "", + module: ScrapingModule, + parentTitle: String, + showPosterURL: String? = nil, + isMultiSelectMode: Bool = false, + isSelected: Bool = false, + onSelectionChanged: ((Bool) -> Void)? = nil, + onTap: @escaping (String) -> Void, + onMarkAllPrevious: @escaping () -> Void, + tmdbID: Int? = nil, + seasonNumber: Int? = nil + ) { + self.episodeIndex = episodeIndex + self.episode = episode + self.episodeID = episodeID + self.progress = progress + self.itemID = itemID + self.totalEpisodes = totalEpisodes + self.module = module + self.parentTitle = parentTitle + self.showPosterURL = showPosterURL + self.isMultiSelectMode = isMultiSelectMode + self.isSelected = isSelected + self.onSelectionChanged = onSelectionChanged + self.onTap = onTap + self.onMarkAllPrevious = onMarkAllPrevious + self.tmdbID = tmdbID + self.seasonNumber = seasonNumber + + + let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || + ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && + UITraitCollection.current.userInterfaceStyle == .light) + + let defaultLightBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" + let defaultDarkBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + + self.defaultBannerImage = defaultBannerImage.isEmpty ? + (isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage + } + + var body: some View { + ZStack { + actionButtonsBackground + + episodeCellContent + } + .onAppear { setupOnAppear() } + .onDisappear { activeDownloadTask = nil } + .onChange(of: progress) { _ in updateProgress() } + .onChange(of: itemID) { _ in handleItemIDChange() } + .onChange(of: tmdbID) { _ in + isLoading = true + retryAttempts = 0 + fetchEpisodeDetails() + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + updateDownloadStatus() + updateProgress() + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadStatusChanged"))) { _ in + updateDownloadStatus() + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in + updateDownloadStatus() + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("episodeProgressChanged"))) { _ in + updateProgress() + } + } +} + +private extension EpisodeCell { + + var actionButtonsBackground: some View { + HStack { + Spacer() + actionButtons + } + .zIndex(0) + } + + var episodeCellContent: some View { + HStack { + episodeThumbnail + episodeInfo + Spacer() + CircularProgressBar(progress: currentProgress) + .frame(width: 40, height: 40) + .padding(.trailing, 4) + } + .contentShape(Rectangle()) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(cellBackground) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .offset(x: swipeOffset + dragState.translation.width) + .zIndex(1) + .scaleEffect(dragState.isActive ? 0.98 : 1.0) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: swipeOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: dragState.isActive) + .contextMenu { contextMenuContent } + .simultaneousGesture( + DragGesture(coordinateSpace: .local) + .onChanged { value in + handleDragChanged(value) + } + .onEnded { value in + handleDragEnded(value) + } + ) + .onTapGesture { handleTap() } + } + + var cellBackground: some View { + RoundedRectangle(cornerRadius: 15) + .fill(Color(UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 15) + .fill(Color.gray.opacity(0.2)) + ) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.25), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + } + + var episodeThumbnail: some View { + ZStack { + AsyncImageView( + url: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl, + width: 100, + height: 56 + ) + + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + } + + var episodeInfo: some View { + VStack(alignment: .leading) { + Text("Episode \(episodeID + 1)") + .font(.system(size: 15)) + + if !episodeTitle.isEmpty { + Text(episodeTitle) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + } + } + + var contextMenuContent: some View { + Group { + if progress <= 0.9 { + Button(action: markAsWatched) { + Label("Mark Episode as Watched", systemImage: "checkmark.circle") + } + } + + if progress != 0 { + Button(action: resetProgress) { + Label("Reset Episode Progress", systemImage: "arrow.counterclockwise") + } + } + + if episodeIndex > 0 { + Button(action: onMarkAllPrevious) { + Label("Mark Previous Episodes as Watched", systemImage: "checkmark.circle.fill") + } + } + + Button(action: downloadEpisode) { + Label("Download This Episode", systemImage: "arrow.down.circle") + } + } + } + + var actionButtons: some View { + HStack(spacing: 8) { + ActionButton( + icon: "arrow.down.circle", + label: "Download", + color: .blue, + width: actionButtonWidth + ) { + closeActionsAndPerform { downloadEpisode() } + } + + if progress <= 0.9 { + ActionButton( + icon: "checkmark.circle", + label: "Watched", + color: .green, + width: actionButtonWidth + ) { + closeActionsAndPerform { markAsWatched() } + } + } + + if progress != 0 { + ActionButton( + icon: "arrow.counterclockwise", + label: "Reset", + color: .orange, + width: actionButtonWidth + ) { + closeActionsAndPerform { resetProgress() } + } + } + + if episodeIndex > 0 { + ActionButton( + icon: "checkmark.circle.fill", + label: "All Prev", + color: .purple, + width: actionButtonWidth + ) { + closeActionsAndPerform { onMarkAllPrevious() } + } + } + } + .padding(.horizontal, 8) + } +} + +private extension EpisodeCell { + enum DragState { case inactive case pressing @@ -92,883 +337,7 @@ struct EpisodeCell: View { } } - private var downloadStatusString: String { - switch downloadStatus { - case .notDownloaded: - return "notDownloaded" - case .downloading(let download): - return "downloading_\(download.id)" - case .downloaded(let asset): - return "downloaded_\(asset.id)" - } - } - - let tmdbID: Int? - let seasonNumber: Int? - - init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, - itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "", - module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil, - isMultiSelectMode: Bool = false, isSelected: Bool = false, - onSelectionChanged: ((Bool) -> Void)? = nil, - onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, - tmdbID: Int? = nil, - seasonNumber: Int? = nil - ) { - self.episodeIndex = episodeIndex - self.episode = episode - self.episodeID = episodeID - self.progress = progress - self.itemID = itemID - self.totalEpisodes = totalEpisodes - - let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || - ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && - UITraitCollection.current.userInterfaceStyle == .light) - let defaultLightBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - let defaultDarkBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" - - self.defaultBannerImage = defaultBannerImage.isEmpty ? - (isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage - - self.module = module - self.parentTitle = parentTitle - self.showPosterURL = showPosterURL - self.isMultiSelectMode = isMultiSelectMode - self.isSelected = isSelected - self.onSelectionChanged = onSelectionChanged - self.onTap = onTap - self.onMarkAllPrevious = onMarkAllPrevious - self.tmdbID = tmdbID - self.seasonNumber = seasonNumber - } - - var body: some View { - ZStack { - HStack { - Spacer() - actionButtons - } - .zIndex(0) - - HStack { - episodeThumbnail - episodeInfo - Spacer() - CircularProgressBar(progress: currentProgress) - .frame(width: 40, height: 40) - .padding(.trailing, 4) - } - .contentShape(Rectangle()) - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 15) - .fill(Color(UIColor.systemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 15) - .fill(Color.gray.opacity(0.2)) - ) - .overlay( - RoundedRectangle(cornerRadius: 15) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 15)) - .offset(x: swipeOffset + dragState.translation.width) - .zIndex(1) - .scaleEffect(dragState.isActive ? 0.98 : 1.0) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: swipeOffset) - .animation(.spring(response: 0.3, dampingFraction: 0.6), value: dragState.isActive) - .contextMenu { - contextMenuContent - } - .simultaneousGesture( - DragGesture(coordinateSpace: .local) - .onChanged { value in - handleDragChanged(value) - } - .onEnded { value in - handleDragEnded(value) - } - ) - } - .onTapGesture { - handleTap() - } - .onAppear { - updateProgress() - updateDownloadStatus() - if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - fetchTMDBEpisodeImage() - } - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - fetchAnimeEpisodeDetails() - } - } - - if let totalEpisodes = totalEpisodes, episodeID + 1 < totalEpisodes { - let nextEpisodeStart = episodeID + 1 - let count = min(5, totalEpisodes - episodeID - 1) - } - } - .onDisappear { - activeDownloadTask = nil - } - .onChange(of: progress) { _ in - updateProgress() - } - .onChange(of: itemID) { newID in - loadedFromCache = false - isLoading = true - retryAttempts = maxRetryAttempts - fetchEpisodeDetails() - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - updateDownloadStatus() - updateProgress() - } - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadStatusChanged"))) { _ in - updateDownloadStatus() - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadCompleted"))) { _ in - updateDownloadStatus() - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("episodeProgressChanged"))) { _ in - updateProgress() - } - } - - private var episodeThumbnail: some View { - ZStack { - if let url = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) { - LazyImage(url: url) { state in - if let image = state.imageContainer?.image { - Image(uiImage: image) - .resizable() - .aspectRatio(16/9, contentMode: .fill) - .frame(width: 100, height: 56) - .cornerRadius(8) - } else if state.error != nil { - Rectangle() - .fill(.tertiary) - .frame(width: 100, height: 56) - .cornerRadius(8) - .onAppear { - Logger.shared.log("Failed to load episode image: \(state.error?.localizedDescription ?? "Unknown error")", type: "Error") - } - } else { - Rectangle() - .fill(.tertiary) - .frame(width: 100, height: 56) - .cornerRadius(8) - } - } - } else { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 100, height: 56) - .cornerRadius(8) - } - - if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - } - } - } - - private var episodeInfo: some View { - VStack(alignment: .leading) { - Text("Episode \(episodeID + 1)") - .font(.system(size: 15)) - if !episodeTitle.isEmpty { - Text(episodeTitle) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - } - } - - private var downloadStatusView: some View { - Group { - switch downloadStatus { - case .notDownloaded: - downloadButton - case .downloading(let activeDownload): - if activeDownload.queueStatus == .queued { - queuedIndicator - } else { - downloadProgressView - } - case .downloaded: - downloadedIndicator - } - } - } - - private var downloadButton: some View { - Button(action: { - showDownloadConfirmation = true - }) { - Image(systemName: "arrow.down.circle") - .foregroundColor(.blue) - .font(.title3) - } - .padding(.horizontal, 8) - } - - private var downloadProgressView: some View { - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle.fill") - .foregroundColor(.blue) - .font(.title3) - .scaleEffect(downloadAnimationScale) - .onAppear { - withAnimation( - Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true) - ) { - downloadAnimationScale = 1.2 - } - } - .onDisappear { - downloadAnimationScale = 1.0 - } - } - .padding(.horizontal, 8) - } - - private var downloadedIndicator: some View { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.title3) - .padding(.horizontal, 8) - .scaleEffect(1.1) - .animation(.default, value: downloadStatusString) - } - - private var queuedIndicator: some View { - HStack(spacing: 4) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.8) - .accentColor(.orange) - - Text("Queued") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 8) - } - - private var contextMenuContent: some View { - Group { - if progress <= 0.9 { - Button(action: markAsWatched) { - Label("Mark as Watched", systemImage: "checkmark.circle") - } - } - - if progress != 0 { - Button(action: resetProgress) { - Label("Reset Progress", systemImage: "arrow.counterclockwise") - } - } - - if episodeIndex > 0 { - Button(action: onMarkAllPrevious) { - Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill") - } - } - - Button(action: downloadEpisode) { - Label("Download Episode", systemImage: "arrow.down.circle") - } - } - } - - private func updateDownloadStatus() { - let newStatus = jsController.isEpisodeDownloadedOrInProgress( - showTitle: parentTitle, - episodeNumber: episodeID + 1 - ) - - if downloadStatus != newStatus { - downloadStatus = newStatus - } - } - - private func downloadEpisode() { - updateDownloadStatus() - - if case .notDownloaded = downloadStatus, !isDownloading { - isDownloading = true - let downloadID = UUID() - - DropManager.shared.downloadStarted(episodeNumber: episodeID + 1) - - Task { - do { - let jsContent = try moduleManager.getModuleContent(module) - jsController.loadScript(jsContent) - tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true) - } catch { - DropManager.shared.error("Failed to start download: \(error.localizedDescription)") - isDownloading = false - } - } - } else { - if case .downloaded = downloadStatus { - DropManager.shared.info("Episode \(episodeID + 1) is already downloaded") - } else if case .downloading = downloadStatus { - DropManager.shared.info("Episode \(episodeID + 1) is already being downloaded") - } - } - } - - private func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) { - if !isDownloading { - return - } - - print("[Download] Trying download method #\(methodIndex+1) for Episode \(episodeID + 1)") - - switch methodIndex { - case 0: - if module.metadata.asyncJS == true { - jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: softsub, module: module) { result in - self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) - } - } else { - tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) - } - - case 1: - if module.metadata.streamAsyncJS == true { - jsController.fetchStreamUrlJSSecond(episodeUrl: episode, softsub: softsub, module: module) { result in - self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) - } - } else { - tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) - } - - case 2: - jsController.fetchStreamUrl(episodeUrl: episode, softsub: softsub, module: module) { result in - self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) - } - - default: - DropManager.shared.error("Failed to find a valid stream for download after trying all methods") - isDownloading = false - } - } - - private func handleSequentialDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), downloadID: UUID, methodIndex: Int, softsub: Bool) { - if !isDownloading { - return - } - - if let sources = result.sources, !sources.isEmpty { - if sources.count > 1 { - showDownloadStreamSelectionAlert(streams: sources, downloadID: downloadID, subtitleURL: result.subtitles?.first) - return - } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { - - let subtitleURLString = sources[0]["subtitle"] as? String - let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - - startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL) - return - } - } - - if let streams = result.streams, !streams.isEmpty { - if streams[0] == "[object Promise]" { - tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) - return - } - - if streams.count > 1 { - showDownloadStreamSelectionAlert(streams: streams, downloadID: downloadID, subtitleURL: result.subtitles?.first) - return - } else if let url = URL(string: streams[0]) { - let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - - startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL) - return - } - } - - tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) - } - - private func showDownloadStreamSelectionAlert(streams: [Any], downloadID: UUID, subtitleURL: String? = nil) { - DispatchQueue.main.async { - let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download from", preferredStyle: .actionSheet) - - var index = 0 - var streamIndex = 1 - - while index < streams.count { - var title: String = "" - var streamUrl: String = "" - - if let streams = streams as? [String] { - if index + 1 < streams.count { - if !streams[index].lowercased().contains("http") { - title = streams[index] - streamUrl = streams[index + 1] - index += 2 - } else { - title = "Server \(streamIndex)" - streamUrl = streams[index] - index += 1 - } - } else { - title = "Server \(streamIndex)" - streamUrl = streams[index] - index += 1 - } - } else if let streams = streams as? [[String: Any]] { - if let currTitle = streams[index]["title"] as? String { - title = currTitle - } else { - title = "Server \(streamIndex)" - } - streamUrl = (streams[index]["streamUrl"] as? String) ?? "" - index += 1 - } - - alert.addAction(UIAlertAction(title: title, style: .default) { _ in - guard let url = URL(string: streamUrl) else { - DropManager.shared.error("Invalid stream URL selected") - self.isDownloading = false - return - } - - let subtitleURLObj = subtitleURL.flatMap { URL(string: $0) } - self.startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURLObj) - }) - - streamIndex += 1 - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - self.isDownloading = false - }) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - self.findTopViewController(rootVC).present(alert, animated: true) - } - } - } - - private func findTopViewController(_ controller: UIViewController) -> UIViewController { - if let navigationController = controller as? UINavigationController { - return findTopViewController(navigationController.visibleViewController!) - } - if let tabController = controller as? UITabBarController { - if let selected = tabController.selectedViewController { - return findTopViewController(selected) - } - } - if let presented = controller.presentedViewController { - return findTopViewController(presented) - } - return controller - } - - private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) { - var headers: [String: String] = [:] - - if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { - print("Using module baseUrl: \(module.metadata.baseUrl)") - - headers = [ - "Origin": module.metadata.baseUrl, - "Referer": module.metadata.baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - if let scheme = url.scheme, let host = url.host { - let baseUrl = scheme + "://" + host - - headers = [ - "Origin": baseUrl, - "Referer": baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - DropManager.shared.error("Invalid stream URL - missing scheme or host") - isDownloading = false - return - } - } - - print("Download headers: \(headers)") - - let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) - let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage) - - let baseTitle = "Episode \(episodeID + 1)" - let fullEpisodeTitle = episodeTitle.isEmpty - ? baseTitle - : "\(baseTitle): \(episodeTitle)" - - let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle - - jsController.downloadWithStreamTypeSupport( - url: url, - headers: headers, - title: fullEpisodeTitle, - imageURL: episodeThumbnailURL, - module: module, - isEpisode: true, - showTitle: animeTitle, - season: 1, - episode: episodeID + 1, - subtitleURL: subtitleURL, - showPosterURL: showPosterImageURL, - completionHandler: { success, message in - if success { - Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download") - AnalyticsManager.shared.sendEvent( - event: "download", - additionalData: ["episode": self.episodeID + 1, "url": streamUrl] - ) - } else { - DropManager.shared.error(message) - } - self.isDownloading = false - } - ) - } - - private func markAsWatched() { - let userDefaults = UserDefaults.standard - let totalTime = 1000.0 - let watchedTime = totalTime - userDefaults.set(watchedTime, forKey: "lastPlayedTime_\(episode)") - userDefaults.set(totalTime, forKey: "totalTime_\(episode)") - DispatchQueue.main.async { - self.updateProgress() - } - } - - private func resetProgress() { - let userDefaults = UserDefaults.standard - userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") - userDefaults.set(0.0, forKey: "totalTime_\(episode)") - DispatchQueue.main.async { - self.updateProgress() - } - } - - private func updateProgress() { - let userDefaults = UserDefaults.standard - let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") - let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") - currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0 - } - - private func fetchEpisodeDetails() { - fetchAnimeEpisodeDetails() - } - - private func fetchAnimeEpisodeDetails() { - guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else { - isLoading = false - Logger.shared.log("Invalid URL for itemID: \(itemID)", type: "Error") - return - } - - if retryAttempts > 0 { - Logger.shared.log("Retrying episode details fetch (attempt \(retryAttempts)/\(maxRetryAttempts))", type: "Debug") - } - - URLSession.custom.dataTask(with: url) { data, response, error in - if let error = error { - Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error") - self.handleFetchFailure(error: error) - return - } - - guard let data = data else { - self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 1, userInfo: [NSLocalizedDescriptionKey: "No data received"])) - return - } - - do { - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let json = jsonObject as? [String: Any] else { - self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])) - return - } - - guard let episodes = json["episodes"] as? [String: Any] else { - Logger.shared.log("Missing 'episodes' object in response", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - } - return - } - - let episodeKey = "\(episodeID + 1)" - guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { - Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - } - return - } - - var title: [String: String] = [:] - var image: String = "" - var missingFields: [String] = [] - - if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty { - title = titleData - - if title.values.allSatisfy({ $0.isEmpty }) { - missingFields.append("title (all values empty)") - } - } else { - missingFields.append("title") - } - - if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty { - image = imageUrl - } else { - missingFields.append("image") - } - - if !missingFields.isEmpty { - Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning") - } - - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - - if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil - || UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { - self.episodeTitle = title["en"] ?? title.values.first ?? "" - - if !image.isEmpty { - self.episodeImageUrl = image - } - } - } - } catch { - Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - } - } - }.resume() - } - - private func handleFetchFailure(error: Error) { - Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error") - - DispatchQueue.main.async { - if self.retryAttempts < self.maxRetryAttempts { - self.retryAttempts += 1 - - let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1)) - - Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug") - - DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { - self.fetchAnimeEpisodeDetails() - } - } else { - Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error") - self.isLoading = false - self.retryAttempts = 0 - } - } - } - - private func fetchTMDBEpisodeImage() { - guard let tmdbID = tmdbID, let season = seasonNumber else { return } - let episodeNum = episodeID + 1 - let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNum)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" - guard let url = URL(string: urlString) else { return } - - let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" - - URLSession.custom.dataTask(with: url) { data, _, error in - guard let data = data, error == nil else { return } - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - let name = json["name"] as? String ?? "" - let stillPath = json["still_path"] as? String - let imageUrl: String - if let stillPath = stillPath { - if tmdbImageWidth == "original" { - imageUrl = "https://image.tmdb.org/t/p/original\(stillPath)" - } else { - imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(stillPath)" - } - } else { - imageUrl = "" - } - DispatchQueue.main.async { - self.episodeTitle = name - self.episodeImageUrl = imageUrl - self.isLoading = false - } - } - } catch { - Logger.shared.log("Failed to parse TMDB episode details: \(error.localizedDescription)", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - } - } - }.resume() - } - - private func calculateMaxSwipeDistance() -> CGFloat { - var buttonCount = 1 - - if progress <= 0.9 { buttonCount += 1 } - if progress != 0 { buttonCount += 1 } - if episodeIndex > 0 { buttonCount += 1 } - - var swipeDistance = CGFloat(buttonCount) * actionButtonWidth + 16 - - if buttonCount == 3 { - swipeDistance += 12 - } else if buttonCount == 4 { - swipeDistance += 24 - } - - return swipeDistance - } - - private var actionButtons: some View { - HStack(spacing: 8) { - Button(action: { - closeActionsAndPerform { - downloadEpisode() - } - }) { - VStack(spacing: 2) { - Image(systemName: "arrow.down.circle") - .font(.title3) - Text("Download") - .font(.caption2) - } - } - .foregroundColor(.blue) - .frame(width: actionButtonWidth) - - if progress <= 0.9 { - Button(action: { - closeActionsAndPerform { - markAsWatched() - } - }) { - VStack(spacing: 2) { - Image(systemName: "checkmark.circle") - .font(.title3) - Text("Watched") - .font(.caption2) - } - } - .foregroundColor(.green) - .frame(width: actionButtonWidth) - } - - if progress != 0 { - Button(action: { - closeActionsAndPerform { - resetProgress() - } - }) { - VStack(spacing: 2) { - Image(systemName: "arrow.counterclockwise") - .font(.title3) - Text("Reset") - .font(.caption2) - } - } - .foregroundColor(.orange) - .frame(width: actionButtonWidth) - } - - if episodeIndex > 0 { - Button(action: { - closeActionsAndPerform { - onMarkAllPrevious() - } - }) { - VStack(spacing: 2) { - Image(systemName: "checkmark.circle.fill") - .font(.title3) - Text("All Prev") - .font(.caption2) - } - } - .foregroundColor(.purple) - .frame(width: actionButtonWidth) - } - } - .padding(.horizontal, 8) - } - - private func handleDragChanged(_ value: DragGesture.Value) { + func handleDragChanged(_ value: DragGesture.Value) { let translation = value.translation let velocity = value.velocity @@ -997,7 +366,7 @@ struct EpisodeCell: View { } } - private func handleDragEnded(_ value: DragGesture.Value) { + func handleDragEnded(_ value: DragGesture.Value) { let translation = value.translation let velocity = value.velocity @@ -1029,7 +398,7 @@ struct EpisodeCell: View { } } - private func handleTap() { + func handleTap() { if isShowingActions { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { swipeOffset = 0 @@ -1043,23 +412,593 @@ struct EpisodeCell: View { } } - private func closeActionsIfNeeded() { - if isShowingActions { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - swipeOffset = 0 - isShowingActions = false - } - } + func calculateMaxSwipeDistance() -> CGFloat { + var buttonCount = 1 + + if progress <= 0.9 { buttonCount += 1 } + if progress != 0 { buttonCount += 1 } + if episodeIndex > 0 { buttonCount += 1 } + + var swipeDistance = CGFloat(buttonCount) * actionButtonWidth + 16 + + if buttonCount == 3 { swipeDistance += 12 } + else if buttonCount == 4 { swipeDistance += 24 } + + return swipeDistance } +} + +private extension EpisodeCell { - private func closeActionsAndPerform(action: @escaping () -> Void) { + func closeActionsAndPerform(action: @escaping () -> Void) { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { - swipeOffset = 0 isShowingActions = false + swipeOffset = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { action() } } + + func markAsWatched() { + let defaults = UserDefaults.standard + let totalTime = 1000.0 + defaults.set(totalTime, forKey: "lastPlayedTime_\(episode)") + defaults.set(totalTime, forKey: "totalTime_\(episode)") + updateProgress() + + if itemID > 0 { + let epNum = episodeID + 1 + let newStatus = (epNum == totalEpisodes) ? "COMPLETED" : "CURRENT" + AniListMutation().updateAnimeProgress( + animeId: itemID, + episodeNumber: epNum, + status: newStatus + ) { result in + switch result { + case .success: + Logger.shared.log("AniList sync: marked ep \(epNum) as \(newStatus)", type: "General") + case .failure(let err): + Logger.shared.log("AniList sync failed: \(err.localizedDescription)", type: "Error") + } + } + } + } + + + func resetProgress() { + let userDefaults = UserDefaults.standard + userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") + userDefaults.set(0.0, forKey: "totalTime_\(episode)") + updateProgress() + } + + func updateProgress() { + let userDefaults = UserDefaults.standard + let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") + let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") + currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0 + } + + func updateDownloadStatus() { + let newStatus = jsController.isEpisodeDownloadedOrInProgress( + showTitle: parentTitle, + episodeNumber: episodeID + 1 + ) + + if downloadStatus != newStatus { + downloadStatus = newStatus + } + } } + +private extension EpisodeCell { + func setupOnAppear() { + updateProgress() + updateDownloadStatus() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" { + fetchTMDBEpisodeImage() + } else { + fetchAnimeEpisodeDetails() + } + } + } + + func handleItemIDChange() { + isLoading = true + retryAttempts = 0 + fetchEpisodeDetails() + } + + func fetchEpisodeDetails() { + fetchAnimeEpisodeDetails() + } +} + +private extension EpisodeCell { + + func downloadEpisode() { + updateDownloadStatus() + + guard case .notDownloaded = downloadStatus, !isDownloading else { + handleAlreadyDownloadedOrInProgress() + return + } + + isDownloading = true + let downloadID = UUID() + + DropManager.shared.downloadStarted(episodeNumber: episodeID + 1) + + Task { + do { + let jsContent = try moduleManager.getModuleContent(module) + jsController.loadScript(jsContent) + tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true) + } catch { + DropManager.shared.error("Failed to start download: \(error.localizedDescription)") + isDownloading = false + } + } + } + + func handleAlreadyDownloadedOrInProgress() { + switch downloadStatus { + case .downloaded: + DropManager.shared.info("Episode \(episodeID + 1) is already downloaded") + case .downloading: + DropManager.shared.info("Episode \(episodeID + 1) is already being downloaded") + case .notDownloaded: + break + } + } + + func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) { + guard isDownloading else { return } + + switch methodIndex { + case 0: + if module.metadata.asyncJS == true { + jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: softsub, module: module) { result in + self.handleDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) + } + } else { + tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) + } + + case 1: + if module.metadata.streamAsyncJS == true { + jsController.fetchStreamUrlJSSecond(episodeUrl: episode, softsub: softsub, module: module) { result in + self.handleDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) + } + } else { + tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) + } + + case 2: + jsController.fetchStreamUrl(episodeUrl: episode, softsub: softsub, module: module) { result in + self.handleDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) + } + + default: + DropManager.shared.error("Failed to find a valid stream for download after trying all methods") + isDownloading = false + } + } + + func handleDownloadResult( + _ result: (streams: [String]?, subtitles: [String]?, sources: [[String: Any]]?), + downloadID: UUID, + methodIndex: Int, + softsub: Bool + ) { + guard isDownloading else { return } + + if let sources = result.sources, !sources.isEmpty { + if sources.count > 1 { + showDownloadStreamSelectionAlert(streams: sources, downloadID: downloadID, subtitleURL: result.subtitles?.first) + return + } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { + let subtitleURLString = sources[0]["subtitle"] as? String + let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } + startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL) + return + } + } + + if let streams = result.streams, !streams.isEmpty { + if streams[0] == "[object Promise]" { + tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) + return + } + + if streams.count > 1 { + showDownloadStreamSelectionAlert(streams: streams, downloadID: downloadID, subtitleURL: result.subtitles?.first) + return + } else if let url = URL(string: streams[0]) { + let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } + startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL) + return + } + } + + tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) + } + + func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) { + let headers = createDownloadHeaders(for: url) + let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) + let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage) + + let baseTitle = "Episode \(episodeID + 1)" + let fullEpisodeTitle = episodeTitle.isEmpty ? baseTitle : "\(baseTitle): \(episodeTitle)" + let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle + + jsController.downloadWithStreamTypeSupport( + url: url, + headers: headers, + title: fullEpisodeTitle, + imageURL: episodeThumbnailURL, + module: module, + isEpisode: true, + showTitle: animeTitle, + season: 1, + episode: episodeID + 1, + subtitleURL: subtitleURL, + showPosterURL: showPosterImageURL + ) { success, message in + if success { + Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download") + AnalyticsManager.shared.sendEvent( + event: "download", + additionalData: ["episode": self.episodeID + 1, "url": streamUrl] + ) + } else { + DropManager.shared.error(message) + } + self.isDownloading = false + } + } + + func createDownloadHeaders(for url: URL) -> [String: String] { + if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { + return [ + "Origin": module.metadata.baseUrl, + "Referer": module.metadata.baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else if let scheme = url.scheme, let host = url.host { + let baseUrl = "\(scheme)://\(host)" + return [ + "Origin": baseUrl, + "Referer": baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else { + DropManager.shared.error("Invalid stream URL - missing scheme or host") + isDownloading = false + return [:] + } + } +} + +private extension EpisodeCell { + + func showDownloadStreamSelectionAlert(streams: [Any], downloadID: UUID, subtitleURL: String? = nil) { + DispatchQueue.main.async { + let alert = UIAlertController( + title: "Select Download Server", + message: "Choose a server to download from", + preferredStyle: .actionSheet + ) + + addStreamActions(to: alert, streams: streams, downloadID: downloadID, subtitleURL: subtitleURL) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + self.isDownloading = false + }) + + presentAlert(alert) + } + } + + func addStreamActions(to alert: UIAlertController, streams: [Any], downloadID: UUID, subtitleURL: String?) { + var index = 0 + var streamIndex = 1 + + while index < streams.count { + let (title, streamUrl, newIndex) = parseStreamInfo(streams: streams, index: index, streamIndex: streamIndex) + index = newIndex + + alert.addAction(UIAlertAction(title: title, style: .default) { _ in + guard let url = URL(string: streamUrl) else { + DropManager.shared.error("Invalid stream URL selected") + self.isDownloading = false + return + } + + let subtitleURLObj = subtitleURL.flatMap { URL(string: $0) } + self.startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURLObj) + }) + + streamIndex += 1 + } + } + + func parseStreamInfo(streams: [Any], index: Int, streamIndex: Int) -> (title: String, streamUrl: String, newIndex: Int) { + if let streams = streams as? [String] { + if index + 1 < streams.count && !streams[index].lowercased().contains("http") { + return (streams[index], streams[index + 1], index + 2) + } else { + return ("Server \(streamIndex)", streams[index], index + 1) + } + } else if let streams = streams as? [[String: Any]] { + let title = streams[index]["title"] as? String ?? "Server \(streamIndex)" + let streamUrl = streams[index]["streamUrl"] as? String ?? "" + return (title, streamUrl, index + 1) + } + + return ("Server \(streamIndex)", "", index + 1) + } + + func presentAlert(_ alert: UIAlertController) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController else { return } + + if UIDevice.current.userInterfaceIdiom == .pad { + if let popover = alert.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height / 2, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + } + + findTopViewController(rootVC).present(alert, animated: true) + } + + func findTopViewController(_ controller: UIViewController) -> UIViewController { + if let navigationController = controller as? UINavigationController { + return findTopViewController(navigationController.visibleViewController!) + } + if let tabController = controller as? UITabBarController { + if let selected = tabController.selectedViewController { + return findTopViewController(selected) + } + } + if let presented = controller.presentedViewController { + return findTopViewController(presented) + } + return controller + } +} + +private extension EpisodeCell { + + func fetchAnimeEpisodeDetails() { + guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else { + isLoading = false + Logger.shared.log("Invalid URL for itemID: \(itemID)", type: "Error") + return + } + + if retryAttempts > 0 { + Logger.shared.log("Retrying episode details fetch (attempt \(retryAttempts)/\(maxRetryAttempts))", type: "Debug") + } + + URLSession.custom.dataTask(with: url) { data, response, error in + if let error = error { + Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error") + self.handleFetchFailure(error: error) + return + } + + guard let data = data else { + self.handleFetchFailure(error: NetworkError.noData) + return + } + + self.processAnimeEpisodeData(data) + }.resume() + } + + func processAnimeEpisodeData(_ data: Data) { + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + guard let json = jsonObject as? [String: Any], + let episodes = json["episodes"] as? [String: Any] else { + handleFetchFailure(error: NetworkError.invalidJSON) + return + } + + let episodeKey = "\(episodeID + 1)" + guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { + Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error") + DispatchQueue.main.async { + self.isLoading = false + self.retryAttempts = 0 + } + return + } + + updateEpisodeMetadata(from: episodeDetails) + + } catch { + Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error") + DispatchQueue.main.async { + self.isLoading = false + self.retryAttempts = 0 + } + } + } + + func updateEpisodeMetadata(from episodeDetails: [String: Any]) { + let title = episodeDetails["title"] as? [String: String] ?? [:] + let image = episodeDetails["image"] as? String ?? "" + + DispatchQueue.main.async { + self.isLoading = false + self.retryAttempts = 0 + + if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil || + UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { + self.episodeTitle = title["en"] ?? title.values.first ?? "" + + if !image.isEmpty { + self.episodeImageUrl = image + } + } + } + } + + func fetchTMDBEpisodeImage() { + guard let tmdbID = tmdbID, let season = seasonNumber else { return } + + let episodeNum = episodeID + 1 + let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNum)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" + + guard let url = URL(string: urlString) else { return } + + let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" + + URLSession.custom.dataTask(with: url) { data, _, error in + guard let data = data, error == nil else { return } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + let name = json["name"] as? String ?? "" + let stillPath = json["still_path"] as? String + + let imageUrl = stillPath.map { path in + tmdbImageWidth == "original" + ? "https://image.tmdb.org/t/p/original\(path)" + : "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(path)" + } ?? "" + + DispatchQueue.main.async { + self.episodeTitle = name + self.episodeImageUrl = imageUrl + self.isLoading = false + } + } + } catch { + Logger.shared.log("Failed to parse TMDB episode details: \(error.localizedDescription)", type: "Error") + DispatchQueue.main.async { + self.isLoading = false + } + } + }.resume() + } + + func handleFetchFailure(error: Error) { + Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error") + + DispatchQueue.main.async { + if self.retryAttempts < self.maxRetryAttempts { + self.retryAttempts += 1 + let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1)) + + Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug") + + DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { + self.fetchAnimeEpisodeDetails() + } + } else { + Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error") + self.isLoading = false + self.retryAttempts = 0 + } + } + } +} + +private enum NetworkError: Error { + case noData + case invalidJSON + + var localizedDescription: String { + switch self { + case .noData: + return "No data received" + case .invalidJSON: + return "Invalid JSON format" + } + } +} + +private struct ActionButton: View { + let icon: String + let label: String + let color: Color + let width: CGFloat + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 2) { + Image(systemName: icon) + .font(.title3) + Text(label) + .font(.caption2) + } + } + .foregroundColor(color) + .frame(width: width) + } +} + +private struct AsyncImageView: View { + let url: String + let width: CGFloat + let height: CGFloat + + var body: some View { + if let url = URL(string: url) { + LazyImage(url: url) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(16/9, contentMode: .fill) + .frame(width: width, height: height) + .cornerRadius(8) + } else if state.error != nil { + placeholderView + .onAppear { + Logger.shared.log("Failed to load episode image: \(state.error?.localizedDescription ?? "Unknown error")", type: "Error") + } + } else { + placeholderView + } + } + } else { + placeholderView + } + } + + private var placeholderView: some View { + Rectangle() + .fill(.tertiary) + .frame(width: width, height: height) + .cornerRadius(8) + } +} + + diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 883788d..66155ba 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -24,67 +24,77 @@ struct MediaInfoView: View { let href: String let module: ScrapingModule - @State var aliases: String = "" - @State var synopsis: String = "" - @State var airdate: String = "" - @State var episodeLinks: [EpisodeLink] = [] - @State var itemID: Int? - @State var tmdbID: Int? + @State private var aliases: String = "" + @State private var synopsis: String = "" + @State private var airdate: String = "" + @State private var episodeLinks: [EpisodeLink] = [] + @State private var itemID: Int? + @State private var tmdbID: Int? + @State private var tmdbType: TMDBFetcher.MediaType? = nil + @State private var currentFetchTask: Task? = nil - @State var isLoading: Bool = true - @State var showFullSynopsis: Bool = false - @State var hasFetched: Bool = false - @State var isRefetching: Bool = true - @State var isFetchingEpisode: Bool = false - - @State private var refreshTrigger: Bool = false - @State private var buttonRefreshTrigger: Bool = false + @State private var isLoading: Bool = true + @State private var showFullSynopsis: Bool = false + @State private var hasFetched: Bool = false + @State private var isRefetching: Bool = true + @State private var isFetchingEpisode: Bool = false + @State private var isError = false + @State private var showLoadingAlert: Bool = false @State private var selectedEpisodeNumber: Int = 0 @State private var selectedEpisodeImage: String = "" @State private var selectedSeason: Int = 0 - - @AppStorage("externalPlayer") private var externalPlayer: String = "Default" - @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 - - private var selectedRangeKey: String { "selectedRangeStart_\(href)" } - private var selectedSeasonKey: String { "selectedSeason_\(href)" } @State private var selectedRange: Range = { let size = UserDefaults.standard.integer(forKey: "episodeChunkSize") let chunk = size == 0 ? 100 : size return 0.. = [] + @State private var showRangeInput: Bool = false + @State private var isBulkDownloading: Bool = false + @State private var bulkDownloadProgress: String = "" + @State private var isSingleEpisodeDownloading: Bool = false + @State private var isModuleSelectorPresented = false - @State private var isError = false @State private var isMatchingPresented = false @State private var matchedTitle: String? = nil + @State private var showSettingsMenu = false + @State private var customAniListID: Int? + @State private var showStreamLoadingView: Bool = false + @State private var currentStreamTitle: String = "" + @State private var activeFetchID: UUID? = nil + @State private var activeProvider: String? + @State private var isTMDBMatchingPresented = false + + @State private var refreshTrigger: Bool = false + @State private var buttonRefreshTrigger: Bool = false + + private var selectedRangeKey: String { "selectedRangeStart_\(href)" } + private var selectedSeasonKey: String { "selectedSeason_\(href)" } + + @AppStorage("externalPlayer") private var externalPlayer: String = "Default" + @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 + @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system @ObservedObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject var tabBarController: TabBarController - @State private var showSettingsMenu = false - @State private var customAniListID: Int? - @State private var showStreamLoadingView: Bool = false - @State private var currentStreamTitle: String = "" - - @State private var activeFetchID: UUID? = nil @Environment(\.dismiss) private var dismiss - - @State private var showLoadingAlert: Bool = false - @Environment(\.colorScheme) private var colorScheme @Environment(\.verticalSizeClass) private var verticalSizeClass - @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system - @State private var isMultiSelectMode: Bool = false - @State private var selectedEpisodes: Set = [] - @State private var showRangeInput: Bool = false - @State private var isBulkDownloading: Bool = false - @State private var bulkDownloadProgress: String = "" - @State private var tmdbType: TMDBFetcher.MediaType? = nil + @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { + try! JSONEncoder().encode(["AniList","TMDB"]) + }() + + private var metadataProvidersOrder: [String] { + get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } + set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } + } private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 @@ -109,6 +119,41 @@ struct MediaInfoView: View { return isCompactLayout ? 20 : 16 } + private var startWatchingText: String { + let indices = finishedAndUnfinishedIndices() + let finished = indices.finished + let unfinished = indices.unfinished + + if episodeLinks.count == 1 { + if let _ = unfinished { + return NSLocalizedString("Continue Watching", comment: "") + } + return NSLocalizedString("Start Watching", comment: "") + } + + if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { + let nextEp = episodeLinks[finishedIndex + 1] + return String(format: NSLocalizedString("Start Watching Episode %d", comment: ""), nextEp.number) + } + + if let unfinishedIndex = unfinished { + let currentEp = episodeLinks[unfinishedIndex] + return String(format: NSLocalizedString("Continue Watching Episode %d", comment: ""), currentEp.number) + } + + return NSLocalizedString("Start Watching", comment: "") + } + + private var singleEpisodeWatchText: String { + if let ep = episodeLinks.first { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + return progress <= 0.9 ? NSLocalizedString("Mark watched", comment: "") : NSLocalizedString("Reset progress", comment: "") + } + return NSLocalizedString("Mark watched", comment: "") + } + var body: some View { ZStack { Group { @@ -122,8 +167,7 @@ struct MediaInfoView: View { .navigationBarHidden(true) .ignoresSafeArea(.container, edges: .top) .onAppear { - buttonRefreshTrigger.toggle() - tabBarController.hideTabBar() + setupViewOnAppear() } .onChange(of: selectedRange) { newValue in UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey) @@ -131,38 +175,17 @@ struct MediaInfoView: View { .onChange(of: selectedSeason) { newValue in UserDefaults.standard.set(newValue, forKey: selectedSeasonKey) } - .onDisappear(){ + .onDisappear { tabBarController.showTabBar() + currentFetchTask?.cancel() + activeFetchID = nil } .task { - guard !hasFetched else { return } - - let savedCustomID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)") - if savedCustomID != 0 { customAniListID = savedCustomID } - if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { - imageUrl = savedPoster - } - - DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) - fetchDetails() - - if savedCustomID != 0 { - itemID = savedCustomID - } else { - fetchMetadataIDIfNeeded() - } - - hasFetched = true - AnalyticsManager.shared.sendEvent( - event: "MediaInfoView", - additionalData: ["title": title] - ) + await setupInitialData() } .alert("Loading Stream", isPresented: $showLoadingAlert) { Button("Cancel", role: .cancel) { - activeFetchID = nil - isFetchingEpisode = false - showStreamLoadingView = false + cancelCurrentFetch() } } message: { HStack { @@ -171,32 +194,30 @@ struct MediaInfoView: View { .padding(.top, 8) } } - .onDisappear { - activeFetchID = nil - isFetchingEpisode = false - showStreamLoadingView = false - } - VStack { - HStack { - Button(action: { - dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 24)) - .foregroundColor(.primary) - .padding(12) - .background(Color.gray.opacity(0.2)) - .clipShape(Circle()) - .circularGradientOutline() - } - .padding(.top, 8) - .padding(.leading, 16) - - Spacer() + navigationOverlay + } + } + + @ViewBuilder + private var navigationOverlay: some View { + VStack { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24)) + .foregroundColor(.primary) + .padding(12) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + .circularGradientOutline() } + .padding(.top, 8) + .padding(.leading, 16) + Spacer() } + Spacer() } } @@ -204,50 +225,8 @@ struct MediaInfoView: View { private var mainScrollView: some View { ScrollView { ZStack(alignment: .top) { - LazyImage(url: URL(string: imageUrl)) { state in - if let uiImage = state.imageContainer?.image { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: UIScreen.main.bounds.width, height: 700) - .clipped() - } else { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .shimmering() - .frame(width: UIScreen.main.bounds.width, height: 700) - .clipped() - } - } - - VStack(spacing: 0) { - Rectangle() - .fill(Color.clear) - .frame(height: 400) - VStack(alignment: .leading, spacing: 16) { - headerSection - if !episodeLinks.isEmpty { - episodesSection - } else { - noEpisodesSection - } - } - .padding() - .background( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2), - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5), - .init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0) - ]), - startPoint: .top, - endPoint: .bottom - ) - .clipShape(RoundedRectangle(cornerRadius: 0)) - .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) - ) - } + heroImageSection + contentContainer } } .onAppear { @@ -256,299 +235,124 @@ struct MediaInfoView: View { } @ViewBuilder - private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { - Spacer() - HStack(spacing: 16) { + private var heroImageSection: some View { + LazyImage(url: URL(string: imageUrl)) { state in + if let uiImage = state.imageContainer?.image { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: UIScreen.main.bounds.width, height: 700) + .clipped() + } else { + Rectangle() + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.gray.opacity(0.2), + Color.gray.opacity(0.3), + Color.gray.opacity(0.2) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: UIScreen.main.bounds.width, height: 700) + .clipped() + } + } + } + + @ViewBuilder + private var contentContainer: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.clear) + .frame(height: 400) + + ZStack(alignment: .top) { + gradientOverlay - if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { - HStack(spacing: 4) { - Image(systemName: "calendar") - .foregroundColor(.accentColor) - - Text(airdate) - .font(.system(size: 14)) - .foregroundColor(.accentColor) + VStack(alignment: .leading, spacing: 16) { + headerSection + if !episodeLinks.isEmpty { + episodesSection + } else { + noEpisodesSection } } - - Spacer() - + .padding() } + } + } + + @ViewBuilder + private var gradientOverlay: some View { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2), + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5), + .init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 0)) + .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) + } + + @ViewBuilder + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { + HStack(spacing: 4) { + Image(systemName: "calendar") + .foregroundColor(.accentColor) + Text(airdate) + .font(.system(size: 14)) + .foregroundColor(.accentColor) + Spacer() + } + } + Text(title) .font(.system(size: 28, weight: .bold)) .foregroundColor(.primary) .lineLimit(3) .onLongPressGesture { - UIPasteboard.general.string = title - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + copyTitleToClipboard() } if !synopsis.isEmpty { - HStack(alignment: .bottom) { - Text(synopsis) - .font(.system(size: 16)) - .foregroundColor(.secondary) - .lineLimit(showFullSynopsis ? nil : 3) - .animation(nil, value: showFullSynopsis) - - Text(showFullSynopsis ? "LESS" : "MORE") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(.accentColor) - .animation(.easeInOut(duration: 0.3), value: showFullSynopsis) - } - .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - showFullSynopsis.toggle() - } - } + synopsisSection } playAndBookmarkSection if episodeLinks.count == 1 { - VStack(spacing: 12) { - HStack(spacing: 12) { - Button(action: { - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - - if progress <= 0.9 { - UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)") - UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)") - DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill")) - } else { - UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(ep.href)") - UserDefaults.standard.set(0.0, forKey: "totalTime_\(ep.href)") - DropManager.shared.showDrop(title: "Progress Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.counterclockwise")) - } - } - }) { - HStack(spacing: 4) { - Image(systemName: { - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - return progress <= 0.9 ? "checkmark.circle" : "arrow.counterclockwise" - } - return "checkmark.circle" - }()) - .foregroundColor(.primary) - Text({ - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - return progress <= 0.9 ? "Mark watched" : "Reset progress" - } - return "Mark watched" - }()) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - - Button(action: { - if let ep = episodeLinks.first { - let downloadStatus = jsController.isEpisodeDownloadedOrInProgress( - showTitle: title, - episodeNumber: ep.number, - season: 1 - ) - - if downloadStatus == .notDownloaded { - selectedEpisodeNumber = ep.number - startBulkDownload() - DropManager.shared.showDrop(title: "Starting Download", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.down.circle")) - } else { - DropManager.shared.showDrop(title: "Already Downloaded", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle")) - } - } - }) { - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle") - .foregroundColor(.primary) - Text("Download") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - - menuButton - } - Text("Why am I not seeing any episodes?") - .font(.caption) - .bold() - .foregroundColor(.gray) - .multilineTextAlignment(.leading) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.") - .font(.caption) - .foregroundColor(.gray) - .multilineTextAlignment(.leading) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - } + singleEpisodeSection } } } @ViewBuilder - private var contentSection: some View { - VStack(alignment: .leading, spacing: 20) { - playAndBookmarkSection + private var synopsisSection: some View { + HStack(alignment: .bottom) { + Text(synopsis) + .font(.system(size: 16)) + .foregroundColor(.secondary) + .lineLimit(showFullSynopsis ? nil : 3) + .animation(nil, value: showFullSynopsis) - if !episodeLinks.isEmpty { - episodesSection - } else { - noEpisodesSection - } + Text(showFullSynopsis ? NSLocalizedString("LESS", comment: "") : NSLocalizedString("MORE", comment: "")) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.accentColor) + .animation(.easeInOut(duration: 0.3), value: showFullSynopsis) } - .padding(.horizontal, 20) - .padding(.vertical, 20) - .background( - Rectangle() - .fill(colorScheme == .dark ? Color.black : Color.white) - ) - } - - @ViewBuilder - private var sourceButton: some View { - Button(action: { - openSafariViewController(with: href) - }) { - Image(systemName: "safari") - .resizable() - .frame(width: 16, height: 16) - .foregroundColor(.primary) - .padding(6) - .background(Color.gray.opacity(0.2)) - .clipShape(Circle()) - .circularGradientOutline() - } - } - - @ViewBuilder - private var menuButton: some View { - Menu { - if let id = itemID ?? customAniListID { - let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)") - Text("Matched with: \(labelText)") - .font(.caption) - .foregroundColor(.gray) - .padding(.vertical, 4) - } - - Divider() - - if let _ = customAniListID { - Button(action: { - customAniListID = nil - itemID = nil - matchedTitle = nil - fetchItemID(byTitle: cleanTitle(title)) { result in - switch result { - case .success(let id): - itemID = id - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)") - } - } - }) { - Label("Reset AniList ID", systemImage: "arrow.clockwise") - } - } - - if let id = itemID ?? customAniListID { - Button(action: { - if let url = URL(string: "https://anilist.co/anime/\(id)") { - openSafariViewController(with: url.absoluteString) - } - }) { - Label("Open in AniList", systemImage: "link") - } - } - - if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" { - Button(action: { - isMatchingPresented = true - }) { - Label("Match with AniList", systemImage: "magnifyingglass") - } - } - - if UserDefaults.standard.string(forKey: "originalPoster_\(href)") != nil { - Button(action: { - if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") { - imageUrl = originalPoster - UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)") - } - }) { - Label("Revert Module Poster", systemImage: "photo.badge.arrow.down") - } - } else { - Button(action: { - fetchTMDBPosterImageAndSet() - }) { - Label("Use TMDB Poster Image", systemImage: "photo") - } - } - - Divider() - - Button(action: { - Logger.shared.log(""" - Debug Info: - Title: \(title) - Href: \(href) - Module: \(module.metadata.sourceName) - AniList ID: \(itemID ?? -1) - Custom ID: \(customAniListID ?? -1) - Matched Title: \(matchedTitle ?? "—") - """, type: "Debug") - DropManager.shared.showDrop( - title: "Debug Info Logged", - subtitle: "", - duration: 1.0, - icon: UIImage(systemName: "terminal") - ) - }) { - Label("Log Debug Info", systemImage: "terminal") - } - } label: { - Image(systemName: "ellipsis") - .resizable() - .frame(width: 16, height: 4) - .foregroundColor(.primary) - .padding(12) - .background(Color.gray.opacity(0.2)) - .clipShape(Circle()) - .circularGradientOutline() - } - .sheet(isPresented: $isMatchingPresented) { - AnilistMatchPopupView(seriesTitle: title) { selectedID in - self.customAniListID = selectedID - self.itemID = selectedID - UserDefaults.standard.set(selectedID, forKey: "custom_anilist_id_\(href)") - self.fetchDetails() - isMatchingPresented = false + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + showFullSynopsis.toggle() } } } @@ -556,9 +360,7 @@ struct MediaInfoView: View { @ViewBuilder private var playAndBookmarkSection: some View { HStack(spacing: 12) { - Button(action: { - playFirstUnwatchedEpisode() - }) { + Button(action: { playFirstUnwatchedEpisode() }) { HStack(spacing: 8) { Image(systemName: "play.fill") .foregroundColor(colorScheme == .dark ? .black : .white) @@ -576,16 +378,8 @@ struct MediaInfoView: View { } .disabled(isFetchingEpisode) - Button(action: { - libraryManager.toggleBookmark( - title: title, - imageUrl: imageUrl, - href: href, - moduleId: module.id.uuidString, - moduleName: module.metadata.sourceName - ) - }) { - Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark") + Button(action: { toggleBookmark() }) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") .resizable() .frame(width: 16, height: 22) .foregroundColor(.primary) @@ -598,69 +392,144 @@ struct MediaInfoView: View { } @ViewBuilder - private var episodesSection: some View { - if episodeLinks.count == 1 { - EmptyView() - } else { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Episodes") - .font(.system(size: 22, weight: .bold)) - .foregroundColor(.primary) - - Spacer() - - Group { - if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize { - Text("") - .font(.system(size: 14)) - .foregroundColor(.secondary) - } else { - episodeNavigationSection - } - } - + private var singleEpisodeSection: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + Button(action: { toggleSingleEpisodeWatchStatus() }) { HStack(spacing: 4) { - sourceButton - menuButton + Image(systemName: singleEpisodeWatchIcon) + .foregroundColor(.primary) + Text(singleEpisodeWatchText) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() } + Button(action: { downloadSingleEpisode() }) { + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle") + .foregroundColor(.primary) + Text(NSLocalizedString("Download", comment: "")) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() + } + + menuButton + } + + VStack(spacing: 4) { + Text(NSLocalizedString("Why am I not seeing any episodes?", comment: "")) + .font(.caption) + .bold() + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(NSLocalizedString("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.", comment: "")) + .font(.caption) + .foregroundColor(.gray) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 4) + } + } + + private var isBookmarked: Bool { + libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) + } + + private var singleEpisodeWatchIcon: String { + if let ep = episodeLinks.first { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + return progress <= 0.9 ? "checkmark.circle" : "arrow.counterclockwise" + } + return "checkmark.circle" + } + + @ViewBuilder + private var episodesSection: some View { + if episodeLinks.count != 1 { + VStack(alignment: .leading, spacing: 16) { + episodesSectionHeader episodeListSection } } } + @ViewBuilder + private var episodesSectionHeader: some View { + HStack { + Text(NSLocalizedString("Episodes", comment: "")) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.primary) + + Spacer() + + episodeNavigationSection + + HStack(spacing: 4) { + sourceButton + menuButton + } + } + } + @ViewBuilder private var episodeNavigationSection: some View { Group { - if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize { - Menu { - ForEach(generateRanges(), id: \.self) { range in - Button(action: { selectedRange = range }) { - Text("\(range.lowerBound + 1)-\(range.upperBound)") - } - } - } label: { - Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)") - .font(.system(size: 14)) - .foregroundColor(.accentColor) - } + if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize { + EmptyView() + } else if !isGroupedBySeasons && episodeLinks.count > episodeChunkSize { + rangeSelectionMenu } else if isGroupedBySeasons { - let seasons = groupedEpisodes() - if seasons.count > 1 { - Menu { - ForEach(0.. 1 { + Menu { + ForEach(0.. 0 ? lastPlayedTime / totalTime : 0 - - let defaultBannerImageValue = getBannerImageBasedOnAppearance() - - EpisodeCell( - episodeIndex: i, - episode: ep.href, - episodeID: ep.number - 1, - progress: progress, - itemID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - defaultBannerImage: defaultBannerImageValue, - module: module, - parentTitle: title, - showPosterURL: imageUrl, - isMultiSelectMode: isMultiSelectMode, - isSelected: selectedEpisodes.contains(ep.number), - onSelectionChanged: { isSelected in - if isSelected { - selectedEpisodes.insert(ep.number) - } else { - selectedEpisodes.remove(ep.number) - } - }, - onTap: { imageUrl in - episodeTapAction(ep: ep, imageUrl: imageUrl) - }, - onMarkAllPrevious: { - markAllPreviousEpisodesInFlatList(ep: ep, index: i) - }, - tmdbID: tmdbID, - seasonNumber: 1 - ) - .disabled(isFetchingEpisode) + createEpisodeCell(episode: ep, index: i, season: 1) } } } @@ -727,42 +561,7 @@ struct MediaInfoView: View { if !seasons.isEmpty, selectedSeason < seasons.count { VStack(spacing: 15) { ForEach(seasons[selectedSeason]) { ep in - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - - let defaultBannerImageValue = getBannerImageBasedOnAppearance() - - EpisodeCell( - episodeIndex: selectedSeason, - episode: ep.href, - episodeID: ep.number - 1, - progress: progress, - itemID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - defaultBannerImage: defaultBannerImageValue, - module: module, - parentTitle: title, - showPosterURL: imageUrl, - isMultiSelectMode: isMultiSelectMode, - isSelected: selectedEpisodes.contains(ep.number), - onSelectionChanged: { isSelected in - if isSelected { - selectedEpisodes.insert(ep.number) - } else { - selectedEpisodes.remove(ep.number) - } - }, - onTap: { imageUrl in - episodeTapAction(ep: ep, imageUrl: imageUrl) - }, - onMarkAllPrevious: { - markAllPreviousEpisodesAsWatched(ep: ep, inSeason: true) - }, - tmdbID: tmdbID, - seasonNumber: selectedSeason + 1 - ) - .disabled(isFetchingEpisode) + createEpisodeCell(episode: ep, index: selectedSeason, season: selectedSeason + 1) } } } else { @@ -770,25 +569,301 @@ struct MediaInfoView: View { } } - private func restoreSelectionState() { - if let savedStart = UserDefaults.standard.object(forKey: selectedRangeKey) as? Int, - let savedRange = generateRanges().first(where: { $0.lowerBound == savedStart }) { - selectedRange = savedRange - } else { - selectedRange = generateRanges().first ?? 0.. some View { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + let defaultBannerImageValue = getBannerImageBasedOnAppearance() + + EpisodeCell( + episodeIndex: index, + episode: episode.href, + episodeID: episode.number - 1, + progress: progress, + itemID: itemID ?? 0, + totalEpisodes: episodeLinks.count, + defaultBannerImage: defaultBannerImageValue, + module: module, + parentTitle: title, + showPosterURL: imageUrl, + isMultiSelectMode: isMultiSelectMode, + isSelected: selectedEpisodes.contains(episode.number), + onSelectionChanged: { isSelected in + handleEpisodeSelection(episode: episode, isSelected: isSelected) + }, + onTap: { imageUrl in + episodeTapAction(ep: episode, imageUrl: imageUrl) + }, + onMarkAllPrevious: { + markAllPreviousEpisodes(episode: episode, index: index, inSeason: isGroupedBySeasons) + }, + tmdbID: tmdbID, + seasonNumber: season + ) + .disabled(isFetchingEpisode) + } + + @ViewBuilder + private var noEpisodesSection: some View { + VStack(spacing: 8) { + Image(systemName: "tv.slash") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(NSLocalizedString("No Episodes Available", comment: "")) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text(NSLocalizedString("Episodes might not be available yet or there could be an issue with the source.", comment: "")) + .font(.body) + .lineLimit(0) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } - - if let savedSeason = UserDefaults.standard.object(forKey: selectedSeasonKey) as? Int { - let maxIndex = max(0, groupedEpisodes().count - 1) - selectedSeason = min(savedSeason, maxIndex) + .padding(.vertical, 50) + } + + @ViewBuilder + private var sourceButton: some View { + Button(action: { openSafariViewController(with: href) }) { + Image(systemName: "safari") + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(.primary) + .padding(6) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + .circularGradientOutline() } } - private func getBannerImageBasedOnAppearance() -> String { - let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) - return isLightMode - ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + @ViewBuilder + private var menuButton: some View { + Menu { + menuContent + } label: { + Image(systemName: "ellipsis") + .resizable() + .frame(width: 16, height: 4) + .foregroundColor(.primary) + .padding(12) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + .circularGradientOutline() + } + .sheet(isPresented: $isMatchingPresented) { + AnilistMatchPopupView(seriesTitle: title) { id, matched in + handleAniListMatch(selectedID: id) + matchedTitle = matched // ← now in scope + fetchMetadataIDIfNeeded() + } + } + .sheet(isPresented: $isTMDBMatchingPresented) { + TMDBMatchPopupView(seriesTitle: title) { id, type, matched in + tmdbID = id + tmdbType = type + matchedTitle = matched // ← now in scope + fetchMetadataIDIfNeeded() + } + } + } + + @ViewBuilder + private var menuContent: some View { + Group { + if let provider = activeProvider { + Text("Matched \(provider): \(matchedTitle ?? title)") + .font(.caption2) + .foregroundColor(.secondary) + } + + if activeProvider == "AniList" { + Button("Match with AniList") { + isMatchingPresented = true + } + Button(action: { resetAniListID() }) { + Label("Reset AniList ID", systemImage: "arrow.clockwise") + } + + Button(action: { openAniListPage(id: itemID ?? 0) }) { + Label("Open in AniList", systemImage: "link") + } + } + else if activeProvider == "TMDB" { + Button("Match with TMDB") { + isTMDBMatchingPresented = true + } + } + + posterMenuOptions + + Divider() + + Button(action: { logDebugInfo() }) { + Label("Log Debug Info", systemImage: "terminal") + } + } + } + + @ViewBuilder + private var posterMenuOptions: some View { + Group { + if UserDefaults.standard.string(forKey: "originalPoster_\(href)") != nil { + Button(action: { restoreOriginalPoster() }) { + Label("Original Poster", systemImage: "photo.badge.arrow.down") + } + } else { + Button(action: { fetchTMDBPosterImageAndSet() }) { + Label("Use TMDB Poster Image", systemImage: "photo") + } + } + } + } + + private func setupViewOnAppear() { + buttonRefreshTrigger.toggle() + tabBarController.hideTabBar() + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let navigationController = window.rootViewController?.children.first as? UINavigationController { + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = nil + } + } + + private func setupInitialData() async { + guard !hasFetched else { return } + + let savedCustomID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)") + if savedCustomID != 0 { customAniListID = savedCustomID } + + if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { + imageUrl = savedPoster + } + + DropManager.shared.showDrop( + title: "Fetching Data", + subtitle: "Please wait while fetching.", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) + + fetchDetails() + + if savedCustomID != 0 { + itemID = savedCustomID + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } else { + fetchMetadataIDIfNeeded() + } + + hasFetched = true + AnalyticsManager.shared.sendEvent( + event: "MediaInfoView", + additionalData: ["title": title] + ) + } + + private func cancelCurrentFetch() { + activeFetchID = nil + isFetchingEpisode = false + showStreamLoadingView = false + showLoadingAlert = false + } + + private func copyTitleToClipboard() { + UIPasteboard.general.string = title + DropManager.shared.showDrop( + title: "Copied to Clipboard", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "doc.on.clipboard.fill") + ) + } + + private func toggleBookmark() { + libraryManager.toggleBookmark( + title: title, + imageUrl: imageUrl, + href: href, + moduleId: module.id.uuidString, + moduleName: module.metadata.sourceName + ) + } + + private func toggleSingleEpisodeWatchStatus() { + guard let ep = episodeLinks.first else { return } + let lastPlayedKey = "lastPlayedTime_\(ep.href)" + let totalTimeKey = "totalTime_\(ep.href)" + let last = UserDefaults.standard.double(forKey: lastPlayedKey) + let total = UserDefaults.standard.double(forKey: totalTimeKey) + let progress = total > 0 ? last/total : 0 + let watchedEp = ep.number + + if progress <= 0.9 { + UserDefaults.standard.set(99999999.0, forKey: lastPlayedKey) + UserDefaults.standard.set(99999999.0, forKey: totalTimeKey) + DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill")) + + if let listID = itemID, listID > 0 { + AniListMutation().updateAnimeProgress(animeId: listID, episodeNumber: watchedEp, status: "CURRENT") { result in + switch result { + case .success: + Logger.shared.log("AniList sync: marked ep \(watchedEp) as CURRENT", type: "General") + case .failure(let err): + Logger.shared.log("AniList sync failed: \(err.localizedDescription)", type: "Error") + } + } + } + } else { + UserDefaults.standard.set(0.0, forKey: lastPlayedKey) + UserDefaults.standard.set(0.0, forKey: totalTimeKey) + DropManager.shared.showDrop(title: "Progress Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.counterclockwise")) + + if let listID = itemID, listID > 0 { + AniListMutation().updateAnimeProgress(animeId: listID, episodeNumber: 0, status: "CURRENT") { _ in } + } + } + } + + private func downloadSingleEpisode() { + if let ep = episodeLinks.first { + let downloadStatus = jsController.isEpisodeDownloadedOrInProgress( + showTitle: title, + episodeNumber: ep.number, + season: 1 + ) + + if downloadStatus == .notDownloaded { + downloadSingleEpisodeDirectly(episode: ep) + DropManager.shared.showDrop( + title: "Starting Download", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "arrow.down.circle") + ) + } else { + DropManager.shared.showDrop( + title: "Already Downloaded", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "checkmark.circle") + ) + } + } + } + + private func handleEpisodeSelection(episode: EpisodeLink, isSelected: Bool) { + if isSelected { + selectedEpisodes.insert(episode.number) + } else { + selectedEpisodes.remove(episode.number) + } } private func episodeTapAction(ep: EpisodeLink, imageUrl: String) { @@ -803,66 +878,197 @@ struct MediaInfoView: View { } } - private func fetchMetadataIDIfNeeded() { - let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" - let cleaned = cleanTitle(title) - - if provider == "TMDB" { - tmdbID = nil - tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in - DispatchQueue.main.async { - self.tmdbID = id - self.tmdbType = type - Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") - } - } - } else if provider == "Anilist" { - itemID = nil - fetchItemID(byTitle: cleaned) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { - self.itemID = id - Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") - } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") - } + private func markAllPreviousEpisodes(episode: EpisodeLink, index: Int, inSeason: Bool) { + if inSeason { + markAllPreviousEpisodesAsWatched(ep: episode, inSeason: true) + } else { + markAllPreviousEpisodesInFlatList(ep: episode, index: index) + } + } + + private func handleAniListMatch(selectedID: Int) { + self.customAniListID = selectedID + self.itemID = selectedID + self.activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + UserDefaults.standard.set(selectedID, forKey: "custom_anilist_id_\(href)") + self.fetchDetails() + isMatchingPresented = false + } + + private func resetAniListID() { + customAniListID = nil + itemID = nil + matchedTitle = nil + fetchItemID(byTitle: cleanTitle(title)) { result in + switch result { + case .success(let id): + itemID = id + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)") } } } - private func fetchTMDBPosterImageAndSet() { - guard let tmdbID = tmdbID, let tmdbType = tmdbType else { return } - let apiType = tmdbType.rawValue - let urlString = "https://api.themoviedb.org/3/\(apiType)/\(tmdbID)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" - guard let url = URL(string: urlString) else { return } + private func openAniListPage(id: Int) { + if let url = URL(string: "https://anilist.co/anime/\(id)") { + openSafariViewController(with: url.absoluteString) + } + } + + private func restoreOriginalPoster() { + if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") { + imageUrl = originalPoster + UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)") + UserDefaults.standard.removeObject(forKey: "originalPoster_\(href)") + } + } + + private func logDebugInfo() { + Logger.shared.log(""" + Debug Info: + Title: \(title) + Href: \(href) + Module: \(module.metadata.sourceName) + AniList ID: \(itemID ?? -1) + Custom ID: \(customAniListID ?? -1) + Matched Title: \(matchedTitle ?? "—") + """, type: "Debug") + DropManager.shared.showDrop( + title: "Debug Info Logged", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "terminal") + ) + } + + private func getBannerImageBasedOnAppearance() -> String { + let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) + return isLightMode + ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" + : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + } + + private func restoreSelectionState() { + if let savedStart = UserDefaults.standard.object(forKey: selectedRangeKey) as? Int, + let savedRange = generateRanges().first(where: { $0.lowerBound == savedStart }) { + selectedRange = savedRange + } else { + selectedRange = generateRanges().first ?? 0.. [Range] { + let chunkSize = episodeChunkSize + let totalEpisodes = episodeLinks.count + var ranges: [Range] = [] - URLSession.custom.dataTask(with: url) { data, _, error in - guard let data = data, error == nil else { return } - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let posterPath = json["poster_path"] as? String { - let imageUrl: String - if tmdbImageWidth == "original" { - imageUrl = "https://image.tmdb.org/t/p/original\(posterPath)" - } else { - imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)" - } - DispatchQueue.main.async { - let currentPosterKey = "originalPoster_\(self.href)" - let currentPoster = self.imageUrl - UserDefaults.standard.set(currentPoster, forKey: currentPosterKey) - self.imageUrl = imageUrl - UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)") - } - } - } catch { - Logger.shared.log("Failed to parse TMDB poster: \(error.localizedDescription)", type: "Error") + for i in stride(from: 0, to: totalEpisodes, by: chunkSize) { + let end = min(i + chunkSize, totalEpisodes) + ranges.append(i.. [[EpisodeLink]] { + guard !episodeLinks.isEmpty else { return [] } + var groups: [[EpisodeLink]] = [] + var currentGroup: [EpisodeLink] = [episodeLinks[0]] + + for ep in episodeLinks.dropFirst() { + if let last = currentGroup.last, ep.number < last.number { + groups.append(currentGroup) + currentGroup = [ep] + } else { + currentGroup.append(ep) } - }.resume() + } + + groups.append(currentGroup) + return groups + } + + private func cleanTitle(_ title: String?) -> String { + guard let title = title else { return "Unknown" } + + let cleaned = title.replacingOccurrences( + of: "\\s*\\([^\\)]*\\)", + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + + return cleaned.isEmpty ? "Unknown" : cleaned + } + + private func playFirstUnwatchedEpisode() { + let indices = finishedAndUnfinishedIndices() + let finished = indices.finished + let unfinished = indices.unfinished + + if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { + let nextEp = episodeLinks[finishedIndex + 1] + selectedEpisodeNumber = nextEp.number + fetchStream(href: nextEp.href) + return + } + + if let unfinishedIndex = unfinished { + let ep = episodeLinks[unfinishedIndex] + selectedEpisodeNumber = ep.number + fetchStream(href: ep.href) + return + } + + if let firstEpisode = episodeLinks.first { + selectedEpisodeNumber = firstEpisode.number + fetchStream(href: firstEpisode.href) + } + } + + private func finishedAndUnfinishedIndices() -> (finished: Int?, unfinished: Int?) { + var finishedIndex: Int? = nil + var firstUnfinishedIndex: Int? = nil + + for (index, ep) in episodeLinks.enumerated() { + let keyLast = "lastPlayedTime_\(ep.href)" + let keyTotal = "totalTime_\(ep.href)" + let lastPlayedTime = UserDefaults.standard.double(forKey: keyLast) + let totalTime = UserDefaults.standard.double(forKey: keyTotal) + + guard totalTime > 0 else { continue } + + let remainingFraction = (totalTime - lastPlayedTime) / totalTime + if remainingFraction <= 0.1 { + finishedIndex = index + } else if firstUnfinishedIndex == nil { + firstUnfinishedIndex = index + } + } + return (finishedIndex, firstUnfinishedIndex) + } + + private func selectNextEpisode() { + guard let currentIndex = episodeLinks.firstIndex(where: { $0.number == selectedEpisodeNumber }), + currentIndex + 1 < episodeLinks.count else { + Logger.shared.log("No more episodes to play", type: "Info") + return + } + + let nextEpisode = episodeLinks[currentIndex + 1] + selectedEpisodeNumber = nextEpisode.number + fetchStream(href: nextEpisode.href) + DropManager.shared.showDrop( + title: "Fetching Next Episode", + subtitle: "", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) } private func markAllPreviousEpisodesAsWatched(ep: EpisodeLink, inSeason: Bool) { @@ -929,130 +1135,19 @@ struct MediaInfoView: View { } } - @ViewBuilder - private var noEpisodesSection: some View { - VStack(spacing: 8) { - Image(systemName: "tv.slash") - .font(.system(size: 48)) - .foregroundColor(.secondary) - - Text("No Episodes Available") - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - - Text("Episodes might not be available yet or there could be an issue with the source.") - .font(.body) - .lineLimit(0) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding(.vertical, 50) - } - private var startWatchingText: String { - let indices = finishedAndUnfinishedIndices() - let finished = indices.finished - let unfinished = indices.unfinished - - if episodeLinks.count == 1 { - if let unfinishedIndex = unfinished { - return "Continue Watching" - } - return "Start Watching" - } - - if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { - let nextEp = episodeLinks[finishedIndex + 1] - return "Start Watching Episode \(nextEp.number)" - } - - if let unfinishedIndex = unfinished { - let currentEp = episodeLinks[unfinishedIndex] - return "Continue Watching Episode \(currentEp.number)" - } - - return "Start Watching" - } - - private func playFirstUnwatchedEpisode() { - let indices = finishedAndUnfinishedIndices() - let finished = indices.finished - let unfinished = indices.unfinished - - if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { - let nextEp = episodeLinks[finishedIndex + 1] - selectedEpisodeNumber = nextEp.number - fetchStream(href: nextEp.href) + private func openSafariViewController(with urlString: String) { + guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else { + Logger.shared.log("Unable to open the webpage", type: "Error") return } - - if let unfinishedIndex = unfinished { - let ep = episodeLinks[unfinishedIndex] - selectedEpisodeNumber = ep.number - fetchStream(href: ep.href) - return - } - - if let firstEpisode = episodeLinks.first { - selectedEpisodeNumber = firstEpisode.number - fetchStream(href: firstEpisode.href) + let safariViewController = SFSafariViewController(url: url) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + rootVC.present(safariViewController, animated: true, completion: nil) } } - private func finishedAndUnfinishedIndices() -> (finished: Int?, unfinished: Int?) { - var finishedIndex: Int? = nil - var firstUnfinishedIndex: Int? = nil - - for (index, ep) in episodeLinks.enumerated() { - let keyLast = "lastPlayedTime_\(ep.href)" - let keyTotal = "totalTime_\(ep.href)" - let lastPlayedTime = UserDefaults.standard.double(forKey: keyLast) - let totalTime = UserDefaults.standard.double(forKey: keyTotal) - - guard totalTime > 0 else { continue } - - let remainingFraction = (totalTime - lastPlayedTime) / totalTime - if remainingFraction <= 0.1 { - finishedIndex = index - } else if firstUnfinishedIndex == nil { - firstUnfinishedIndex = index - } - } - return (finishedIndex, firstUnfinishedIndex) - } - - private func generateRanges() -> [Range] { - let chunkSize = episodeChunkSize - let totalEpisodes = episodeLinks.count - var ranges: [Range] = [] - - for i in stride(from: 0, to: totalEpisodes, by: chunkSize) { - let end = min(i + chunkSize, totalEpisodes) - ranges.append(i.. [[EpisodeLink]] { - guard !episodeLinks.isEmpty else { return [] } - var groups: [[EpisodeLink]] = [] - var currentGroup: [EpisodeLink] = [episodeLinks[0]] - - for ep in episodeLinks.dropFirst() { - if let last = currentGroup.last, ep.number < last.number { - groups.append(currentGroup) - currentGroup = [ep] - } else { - currentGroup.append(ep) - } - } - - groups.append(currentGroup) - return groups - } func fetchDetails() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1094,6 +1189,193 @@ struct MediaInfoView: View { } } + private func fetchAniListPosterImageAndSet() { + guard let listID = itemID, listID > 0 else { return } + AniListMutation().fetchCoverImage(animeId: listID) { result in + switch result { + case .success(let urlString): + DispatchQueue.main.async { + let originalKey = "originalPoster_\(self.href)" + UserDefaults.standard.set(self.imageUrl, forKey: originalKey) + self.imageUrl = urlString + } + case .failure(let err): + Logger.shared.log("AniList poster fetch failed: \(err.localizedDescription)", type: "Error") + } + } + } + + private func fetchAniListIDForSync() { + let cleaned = cleanTitle(title) + fetchItemID(byTitle: cleaned) { result in + switch result { + case .success(let id): + DispatchQueue.main.async { + if customAniListID == nil { + self.itemID = id + } + } + case .failure(let err): + Logger.shared.log("AniList sync‐ID fetch failed: \(err.localizedDescription)", type: "Error") + } + } + } + + func fetchMetadataIDIfNeeded() { + let order = metadataProvidersOrder + let cleanedTitle = cleanTitle(title) + + itemID = nil + tmdbID = nil + activeProvider = nil + isError = false + + var aniListCompleted = false + var tmdbCompleted = false + var aniListSuccess = false + var tmdbSuccess = false + + func checkCompletion() { + guard aniListCompleted && tmdbCompleted else { return } + + let primaryProvider = order.first ?? "AniList" + + if primaryProvider == "AniList" && aniListSuccess { + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } else if primaryProvider == "TMDB" && tmdbSuccess { + activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + } else if aniListSuccess { + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } else if tmdbSuccess { + activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + } else { + isError = true + } + } + + fetchItemID(byTitle: cleanedTitle) { result in + DispatchQueue.main.async { + aniListCompleted = true + switch result { + case .success(let id): + self.itemID = id + aniListSuccess = true + Logger.shared.log("Successfully fetched AniList ID: \(id)", type: "Debug") + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Debug") + } + checkCompletion() + } + } + + tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in + DispatchQueue.main.async { + tmdbCompleted = true + if let id = id, let type = type { + self.tmdbID = id + self.tmdbType = type + tmdbSuccess = true + Logger.shared.log("Successfully fetched TMDB ID: \(id) (type: \(type.rawValue))", type: "Debug") + + if self.activeProvider != "TMDB" { + self.fetchTMDBPosterImageAndSet() + } + } else { + Logger.shared.log("Failed to fetch TMDB ID", type: "Debug") + } + checkCompletion() + } + } + + fetchAniListIDForSync() + } + + private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { + let query = """ + query { + Media(search: "\(title)", type: ANIME) { + id + } + } + """ + + guard let url = URL(string: "https://graphql.anilist.co") else { + completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let parameters: [String: Any] = ["query": query] + request.httpBody = try? JSONSerialization.data(withJSONObject: parameters) + + URLSession.custom.dataTask(with: request) { data, _, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data else { + completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let data = json["data"] as? [String: Any], + let media = data["Media"] as? [String: Any], + let id = media["id"] as? Int { + completion(.success(id)) + } else { + let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } + }.resume() + } + + private func fetchTMDBPosterImageAndSet() { + guard let tmdbID = tmdbID, let tmdbType = tmdbType else { return } + let apiType = tmdbType.rawValue + let urlString = "https://api.themoviedb.org/3/\(apiType)/\(tmdbID)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" + guard let url = URL(string: urlString) else { return } + + let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" + + URLSession.custom.dataTask(with: url) { data, _, error in + guard let data = data, error == nil else { return } + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let posterPath = json["poster_path"] as? String { + let imageUrl: String + if tmdbImageWidth == "original" { + imageUrl = "https://image.tmdb.org/t/p/original\(posterPath)" + } else { + imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)" + } + DispatchQueue.main.async { + let currentPosterKey = "originalPoster_\(self.href)" + let currentPoster = self.imageUrl + UserDefaults.standard.set(currentPoster, forKey: currentPosterKey) + self.imageUrl = imageUrl + UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)") + } + } + } catch { + Logger.shared.log("Failed to parse TMDB poster: \(error.localizedDescription)", type: "Error") + } + }.resume() + } + + func fetchStream(href: String) { let fetchID = UUID() activeFetchID = fetchID @@ -1102,16 +1384,12 @@ struct MediaInfoView: View { isFetchingEpisode = true let completion: ((streams: [String]?, subtitles: [String]?, sources: [[String: Any]]?)) -> Void = { result in - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.showLoadingAlert = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } if let sources = result.sources, !sources.isEmpty { if sources.count > 1 { @@ -1174,17 +1452,13 @@ struct MediaInfoView: View { } func showStreamSelectionAlert(sources: [Any], fullURL: String, subtitles: String? = nil, fetchID: UUID) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.isFetchingEpisode = false self.showLoadingAlert = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet) @@ -1195,6 +1469,7 @@ struct MediaInfoView: View { var title: String = "" var streamUrl: String = "" var headers: [String:String]? = nil + if let sources = sources as? [String] { if index + 1 < sources.count { if !sources[index].lowercased().contains("http") { @@ -1211,13 +1486,11 @@ struct MediaInfoView: View { streamUrl = sources[index] index += 1 } - } - else if let sources = sources as? [[String: Any]] { + } else if let sources = sources as? [[String: Any]] { if let currTitle = sources[index]["title"] as? String { title = currTitle streamUrl = (sources[index]["streamUrl"] as? String) ?? "" - } else - { + } else { title = "Stream \(streamIndex)" streamUrl = (sources[index]["streamUrl"] as? String)! } @@ -1225,11 +1498,8 @@ struct MediaInfoView: View { index += 1 } - alert.addAction(UIAlertAction(title: title, style: .default) { _ in - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.playStream(url: streamUrl, fullURL: href, subtitles: subtitles, headers: headers, fetchID: fetchID) }) @@ -1238,44 +1508,18 @@ struct MediaInfoView: View { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } - - DispatchQueue.main.async { - self.isFetchingEpisode = false - } + self.presentAlert(alert) } } func playStream(url: String, fullURL: String, subtitles: String? = nil, headers: [String:String]? = nil, fetchID: UUID) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.isFetchingEpisode = false self.showLoadingAlert = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora" var scheme: String? @@ -1296,25 +1540,7 @@ struct MediaInfoView: View { case "TracyPlayer": scheme = "tracy://open?url=\(url)" case "Default": - let videoPlayerViewController = VideoPlayerViewController(module: module) - videoPlayerViewController.headers = headers - videoPlayerViewController.streamUrl = url - videoPlayerViewController.fullUrl = fullURL - videoPlayerViewController.episodeNumber = selectedEpisodeNumber - videoPlayerViewController.episodeImageUrl = selectedEpisodeImage - videoPlayerViewController.mediaTitle = title - videoPlayerViewController.subtitles = subtitles ?? "" - videoPlayerViewController.aniListID = itemID ?? 0 - videoPlayerViewController.modalPresentationStyle = .fullScreen - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) - } else { - Logger.shared.log("Failed to find root view controller", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) - } - + self.presentDefaultPlayer(url: url, fullURL: fullURL, subtitles: subtitles, headers: headers) return default: break @@ -1324,433 +1550,261 @@ struct MediaInfoView: View { UIApplication.shared.open(url, options: [:], completionHandler: nil) Logger.shared.log("Opening external app with scheme: \(url)", type: "General") } else { - guard let url = URL(string: url) else { - Logger.shared.log("Invalid stream URL: \(url)", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) - return - } - - guard self.activeFetchID == fetchID else { - return - } - - let customMediaPlayer = CustomMediaPlayerViewController( - module: module, - urlString: url.absoluteString, - fullUrl: fullURL, - title: title, - episodeNumber: selectedEpisodeNumber, - onWatchNext: { - selectNextEpisode() - }, - subtitlesURL: subtitles, - aniListID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - episodeImageUrl: selectedEpisodeImage, - headers: headers ?? nil - ) - customMediaPlayer.modalPresentationStyle = .fullScreen - Logger.shared.log("Opening custom media player with url: \(url)") - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) - } else { - Logger.shared.log("Failed to find root view controller", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) - } + self.presentCustomPlayer(url: url, fullURL: fullURL, subtitles: subtitles, headers: headers, fetchID: fetchID) } } } - private func selectNextEpisode() { - guard let currentIndex = episodeLinks.firstIndex(where: { $0.number == selectedEpisodeNumber }), - currentIndex + 1 < episodeLinks.count else { - Logger.shared.log("No more episodes to play", type: "Info") - return - } + private func presentDefaultPlayer(url: String, fullURL: String, subtitles: String?, headers: [String:String]?) { + let isMovie = tmdbType == .movie + + let videoPlayerViewController = VideoPlayerViewController(module: module) + videoPlayerViewController.headers = headers + videoPlayerViewController.streamUrl = url + videoPlayerViewController.fullUrl = fullURL + videoPlayerViewController.episodeNumber = selectedEpisodeNumber + videoPlayerViewController.seasonNumber = selectedSeason + 1 + videoPlayerViewController.episodeImageUrl = selectedEpisodeImage + videoPlayerViewController.mediaTitle = title + videoPlayerViewController.subtitles = subtitles ?? "" + videoPlayerViewController.aniListID = itemID ?? 0 + videoPlayerViewController.tmdbID = tmdbID + videoPlayerViewController.isMovie = isMovie + videoPlayerViewController.seasonNumber = selectedSeason + 1 + videoPlayerViewController.modalPresentationStyle = .fullScreen - let nextEpisode = episodeLinks[currentIndex + 1] - selectedEpisodeNumber = nextEpisode.number - fetchStream(href: nextEpisode.href) - DropManager.shared.showDrop(title: "Fetching Next Episode", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) - } - - private func openSafariViewController(with urlString: String) { - guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else { - Logger.shared.log("Unable to open the webpage", type: "Error") - return - } - let safariViewController = SFSafariViewController(url: url) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { - rootVC.present(safariViewController, animated: true, completion: nil) + findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) + } else { + Logger.shared.log("Failed to find root view controller", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) } } - private func cleanTitle(_ title: String?) -> String { - guard let title = title else { return "Unknown" } - - let cleaned = title.replacingOccurrences( - of: "\\s*\\([^\\)]*\\)", - with: "", - options: .regularExpression - ).trimmingCharacters(in: .whitespaces) - - return cleaned.isEmpty ? "Unknown" : cleaned - } - - private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { - let query = """ - query { - Media(search: "\(title)", type: ANIME) { - id - } - } - """ - - guard let url = URL(string: "https://graphql.anilist.co") else { - completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + private func presentCustomPlayer(url: String, fullURL: String, subtitles: String?, headers: [String:String]?, fetchID: UUID) { + guard let url = URL(string: url) else { + Logger.shared.log("Invalid stream URL: \(url)", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard self.activeFetchID == fetchID else { return } + let isMovie = tmdbType == .movie - let parameters: [String: Any] = ["query": query] - request.httpBody = try? JSONSerialization.data(withJSONObject: parameters) - - URLSession.custom.dataTask(with: request) { data, _, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) - return - } - - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let data = json["data"] as? [String: Any], - let media = data["Media"] as? [String: Any], - let id = media["id"] as? Int { - completion(.success(id)) - } else { - let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) - completion(.failure(error)) - } - } catch { - completion(.failure(error)) - } - }.resume() - } - - private func showCustomIDAlert() { - let alert = UIAlertController(title: "Set Custom AniList ID", message: "Enter the AniList ID for this media", preferredStyle: .alert) - - alert.addTextField { textField in - textField.placeholder = "AniList ID" - textField.keyboardType = .numberPad - if let customID = customAniListID { - textField.text = "\(customID)" - } - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in - if let text = alert.textFields?.first?.text, - let id = Int(text) { - customAniListID = id - itemID = id - UserDefaults.standard.set(id, forKey: "custom_anilist_id_\(href)") - Logger.shared.log("Set custom AniList ID: \(id)", type: "General") - self.fetchDetails() - } - }) + let customMediaPlayer = CustomMediaPlayerViewController( + module: module, + urlString: url.absoluteString, + fullUrl: fullURL, + title: title, + episodeNumber: selectedEpisodeNumber, + onWatchNext: { selectNextEpisode() }, + subtitlesURL: subtitles, + aniListID: itemID ?? 0, + totalEpisodes: episodeLinks.count, + episodeImageUrl: selectedEpisodeImage, + headers: headers ?? nil + ) + customMediaPlayer.seasonNumber = selectedSeason + 1 + customMediaPlayer.tmdbID = tmdbID + customMediaPlayer.isMovie = isMovie + customMediaPlayer.modalPresentationStyle = .fullScreen + Logger.shared.log("Opening custom media player with url: \(url)") if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } - } - - private func selectEpisodeRange(start: Int, end: Int) { - selectedEpisodes.removeAll() - for episodeNumber in start...end { - selectedEpisodes.insert(episodeNumber) - } - showRangeInput = false - } - - private func selectAllVisibleEpisodes() { - if isGroupedBySeasons { - let seasons = groupedEpisodes() - if !seasons.isEmpty, selectedSeason < seasons.count { - for episode in seasons[selectedSeason] { - selectedEpisodes.insert(episode.number) - } - } + let rootVC = windowScene.windows.first?.rootViewController { + findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) } else { - for i in episodeLinks.indices.filter({ selectedRange.contains($0) }) { - selectedEpisodes.insert(episodeLinks[i].number) - } + Logger.shared.log("Failed to find root view controller", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) } } - private func startBulkDownload() { - guard !selectedEpisodes.isEmpty else { return } + + private func downloadSingleEpisodeDirectly(episode: EpisodeLink) { + if isSingleEpisodeDownloading { return } - isBulkDownloading = true - bulkDownloadProgress = "Starting downloads..." - let episodesToDownload = episodeLinks.filter { selectedEpisodes.contains($0.number) } + isSingleEpisodeDownloading = true + DropManager.shared.downloadStarted(episodeNumber: episode.number) Task { - await processBulkDownload(episodes: episodesToDownload) - } - } - - @MainActor - private func processBulkDownload(episodes: [EpisodeLink]) async { - let totalCount = episodes.count - var completedCount = 0 - var successCount = 0 - - for (index, episode) in episodes.enumerated() { - bulkDownloadProgress = "Downloading episode \(episode.number) (\(index + 1)/\(totalCount))" - - let downloadStatus = jsController.isEpisodeDownloadedOrInProgress( - showTitle: title, - episodeNumber: episode.number, - season: 1 - ) - - switch downloadStatus { - case .downloaded: - Logger.shared.log("Episode \(episode.number) already downloaded, skipping", type: "Info") - case .downloading: - Logger.shared.log("Episode \(episode.number) already downloading, skipping", type: "Info") - case .notDownloaded: - let downloadSuccess = await downloadSingleEpisode(episode: episode) - if downloadSuccess { - successCount += 1 - } - } - - completedCount += 1 - - try? await Task.sleep(nanoseconds: 500_000_000) - } - - isBulkDownloading = false - bulkDownloadProgress = "" - isMultiSelectMode = false - selectedEpisodes.removeAll() - - DropManager.shared.showDrop( - title: "Bulk Download Complete", - subtitle: "\(successCount)/\(totalCount) episodes queued for download", - duration: 2.0, - icon: UIImage(systemName: successCount == totalCount ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - ) - } - - private func downloadSingleEpisode(episode: EpisodeLink) async -> Bool { - return await withCheckedContinuation { continuation in - Task { - do { - let jsContent = try moduleManager.getModuleContent(module) - jsController.loadScript(jsContent) - - self.tryNextDownloadMethodForBulk( - episode: episode, - methodIndex: 0, - softsub: module.metadata.softsub == true, - continuation: continuation - ) - } catch { - Logger.shared.log("Error downloading episode \(episode.number): \(error)", type: "Error") - continuation.resume(returning: false) - } + do { + let jsContent = try moduleManager.getModuleContent(module) + jsController.loadScript(jsContent) + tryNextSingleDownloadMethod(episode: episode, methodIndex: 0, softsub: module.metadata.softsub == true) + } catch { + DropManager.shared.error("Failed to start download: \(error.localizedDescription)") + isSingleEpisodeDownloading = false } } } - private func tryNextDownloadMethodForBulk( - episode: EpisodeLink, - methodIndex: Int, - softsub: Bool, - continuation: CheckedContinuation - ) { - print("[Bulk Download] Trying download method #\(methodIndex+1) for Episode \(episode.number)") + private func tryNextSingleDownloadMethod(episode: EpisodeLink, methodIndex: Int, softsub: Bool) { + if !isSingleEpisodeDownloading { return } switch methodIndex { case 0: if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: episode.href, softsub: softsub, module: module) { result in - self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation) + self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) } } else { - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) } - case 1: if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: episode.href, softsub: softsub, module: module) { result in - self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation) + self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) } } else { - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) } - case 2: jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in - self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation) + self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) } - default: - Logger.shared.log("Failed to find a valid stream for bulk download after trying all methods", type: "Error") - continuation.resume(returning: false) + DropManager.shared.error("Failed to find a valid stream for download after trying all methods") + isSingleEpisodeDownloading = false } } - private func handleBulkDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool, continuation: CheckedContinuation) { + private func handleSingleDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool) { + if !isSingleEpisodeDownloading { return } if let sources = result.sources, !sources.isEmpty { if sources.count > 1 { - showBulkDownloadStreamSelectionAlert(sources: sources, episode: episode, continuation: continuation) + showSingleDownloadStreamSelectionAlert(streams: sources, episode: episode, subtitleURL: result.subtitles?.first) return } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { - let subtitleURLString = sources[0]["subtitle"] as? String let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - - startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) - continuation.resume(returning: true) + startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) return } } if let streams = result.streams, !streams.isEmpty { if streams[0] == "[object Promise]" { - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) return } if streams.count > 1 { - showBulkDownloadStreamSelectionAlert(sources: streams, episode: episode, continuation: continuation) + showSingleDownloadStreamSelectionAlert(streams: streams, episode: episode, subtitleURL: result.subtitles?.first) return } else if let url = URL(string: streams[0]) { let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - - startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL) - continuation.resume(returning: true) + startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL) return } } - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) } - private func showBulkDownloadStreamSelectionAlert(sources: [Any], episode: EpisodeLink, continuation: CheckedContinuation) { + private func showSingleDownloadStreamSelectionAlert(streams: [Any], episode: EpisodeLink, subtitleURL: String? = nil) { DispatchQueue.main.async { let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download Episode \(episode.number) from", preferredStyle: .actionSheet) var index = 0 var streamIndex = 1 - while index < sources.count { + while index < streams.count { var title: String = "" var streamUrl: String = "" - var headers: [String:String]? = nil - if let sources = sources as? [String] { - if index + 1 < sources.count { - if !sources[index].lowercased().contains("http") { - title = sources[index] - streamUrl = sources[index + 1] - index += 2 - } else { - title = "Server \(streamIndex)" - streamUrl = sources[index] - index += 1 - } + if let streams = streams as? [String] { + if index + 1 < streams.count && !streams[index].lowercased().contains("http") { + title = streams[index] + streamUrl = streams[index + 1] + index += 2 } else { title = "Server \(streamIndex)" - streamUrl = sources[index] + streamUrl = streams[index] index += 1 } - } else if let sources = sources as? [[String: Any]] { - if let currTitle = sources[index]["title"] as? String { - title = currTitle - } else { - title = "Server \(streamIndex)" - } - streamUrl = (sources[index]["streamUrl"] as? String) ?? "" + } else if let streams = streams as? [[String: Any]] { + title = (streams[index]["title"] as? String) ?? "Server \(streamIndex)" + streamUrl = (streams[index]["streamUrl"] as? String) ?? "" index += 1 } alert.addAction(UIAlertAction(title: title, style: .default) { _ in guard let url = URL(string: streamUrl) else { DropManager.shared.error("Invalid stream URL selected") - continuation.resume(returning: false) + self.isSingleEpisodeDownloading = false return } var subtitleURL: URL? = nil - if let sources = sources as? [[String: Any]], - let subtitleURLString = sources[index-1]["subtitle"] as? String { + if let streams = streams as? [[String: Any]], + let subtitleURLString = streams[index-1]["subtitle"] as? String { subtitleURL = URL(string: subtitleURLString) } - self.startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) - continuation.resume(returning: true) + self.startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) }) streamIndex += 1 } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - continuation.resume(returning: false) + self.isSingleEpisodeDownloading = false }) - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } + self.presentAlert(alert) } } - private func startEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) { + private func startSingleEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) { + let headers = generateDownloadHeaders(for: url) + + fetchEpisodeMetadataForDownload(episode: episode) { metadata in + let episodeTitle = metadata?.title["en"] ?? "Episode \(episode.number)" + let episodeImageUrl = metadata?.imageUrl ?? "" + + let episodeThumbnailURL: URL? + if !episodeImageUrl.isEmpty { + episodeThumbnailURL = URL(string: episodeImageUrl) + } else { + episodeThumbnailURL = URL(string: self.getBannerImageBasedOnAppearance()) + } + + let showPosterImageURL = URL(string: self.imageUrl) + + self.jsController.downloadWithStreamTypeSupport( + url: url, + headers: headers, + title: episodeTitle, + imageURL: episodeThumbnailURL, + module: self.module, + isEpisode: true, + showTitle: self.title, + season: 1, + episode: episode.number, + subtitleURL: subtitleURL, + showPosterURL: showPosterImageURL, + completionHandler: { success, message in + if success { + Logger.shared.log("Started download for Episode \(episode.number): \(episode.href)", type: "Download") + AnalyticsManager.shared.sendEvent( + event: "download", + additionalData: ["episode": episode.number, "url": streamUrl] + ) + } else { + DropManager.shared.error(message) + } + self.isSingleEpisodeDownloading = false + } + ) + } + } + + private func generateDownloadHeaders(for url: URL) -> [String: String] { var headers: [String: String] = [:] if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { - print("Using module baseUrl: \(module.metadata.baseUrl)") - headers = [ "Origin": module.metadata.baseUrl, "Referer": module.metadata.baseUrl, @@ -1764,7 +1818,6 @@ struct MediaInfoView: View { } else { if let scheme = url.scheme, let host = url.host { let baseUrl = scheme + "://" + host - headers = [ "Origin": baseUrl, "Referer": baseUrl, @@ -1776,59 +1829,18 @@ struct MediaInfoView: View { "Sec-Fetch-Site": "same-origin" ] } else { - headers = [ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" - ] - Logger.shared.log("Warning: Missing URL scheme/host for episode \(episode.number), using minimal headers", type: "Warning") + headers = ["User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"] + Logger.shared.log("Warning: Missing URL scheme/host for episode, using minimal headers", type: "Warning") } } - print("Bulk download headers: \(headers)") - fetchEpisodeMetadataForDownload(episode: episode) { metadata in - let episodeTitle = metadata?.title["en"] ?? metadata?.title.values.first ?? "" - let episodeImageUrl = metadata?.imageUrl ?? "" - - let episodeName = metadata?.title["en"] ?? "Episode \(episode.number)" - let fullEpisodeTitle = episodeName - - let episodeThumbnailURL: URL? - if !episodeImageUrl.isEmpty { - episodeThumbnailURL = URL(string: episodeImageUrl) - } else { - episodeThumbnailURL = URL(string: self.getBannerImageBasedOnAppearance()) - } - - let showPosterImageURL = URL(string: self.imageUrl) - - print("[Bulk Download] Using episode metadata - Title: '\(fullEpisodeTitle)', Image: '\(episodeImageUrl.isEmpty ? "default banner" : episodeImageUrl)'") - - self.jsController.downloadWithStreamTypeSupport( - url: url, - headers: headers, - title: fullEpisodeTitle, - imageURL: episodeThumbnailURL, - module: self.module, - isEpisode: true, - showTitle: self.title, - season: 1, - episode: episode.number, - subtitleURL: subtitleURL, - showPosterURL: showPosterImageURL, - completionHandler: { success, message in - if success { - Logger.shared.log("Queued download for Episode \(episode.number) with metadata", type: "Download") - } else { - Logger.shared.log("Failed to queue download for Episode \(episode.number): \(message)", type: "Error") - } - } - ) - } + return headers } private func fetchEpisodeMetadataForDownload(episode: EpisodeLink, completion: @escaping (EpisodeMetadataInfo?) -> Void) { guard let anilistId = itemID else { Logger.shared.log("No AniList ID available for episode metadata", type: "Warning") - completion(nil) + completion(nil as EpisodeMetadataInfo?) return } @@ -1842,39 +1854,26 @@ struct MediaInfoView: View { return } - print("[Bulk Download] Fetching metadata for episode \(episodeNumber) from network") - URLSession.custom.dataTask(with: url) { data, response, error in if let error = error { Logger.shared.log("Failed to fetch episode metadata: \(error)", type: "Error") - completion(nil) + completion(nil as EpisodeMetadataInfo?) return } guard let data = data else { Logger.shared.log("No data received for episode metadata", type: "Error") - completion(nil) + completion(nil as EpisodeMetadataInfo?) return } do { let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let json = jsonObject as? [String: Any] else { - Logger.shared.log("Invalid JSON format for episode metadata", type: "Error") - completion(nil) - return - } - - guard let episodes = json["episodes"] as? [String: Any] else { - Logger.shared.log("Missing 'episodes' object in metadata response", type: "Error") - completion(nil) - return - } - - let episodeKey = "\(episodeNumber)" - guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { - Logger.shared.log("Episode \(episodeKey) not found in metadata response", type: "Warning") - completion(nil) + guard let json = jsonObject as? [String: Any], + let episodes = json["episodes"] as? [String: Any], + let episodeDetails = episodes["\(episodeNumber)"] as? [String: Any] else { + Logger.shared.log("Episode \(episodeNumber) not found in metadata response", type: "Warning") + completion(nil as EpisodeMetadataInfo?) return } @@ -1898,13 +1897,35 @@ struct MediaInfoView: View { episodeNumber: episodeNumber ) - print("[Bulk Download] Fetched metadata for episode \(episodeNumber): title='\(title["en"] ?? "N/A")', hasImage=\(!image.isEmpty)") completion(metadataInfo) } catch { Logger.shared.log("JSON parsing error for episode metadata: \(error.localizedDescription)", type: "Error") - completion(nil) + completion(nil as EpisodeMetadataInfo?) } }.resume() } + + + private func presentAlert(_ alert: UIAlertController) { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + + if UIDevice.current.userInterfaceIdiom == .pad { + if let popover = alert.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height / 2, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + } + + findTopViewController.findViewController(rootVC).present(alert, animated: true) + } + } } diff --git a/Sora/Views/SearchView/SearchStateView.swift b/Sora/Views/SearchView/SearchStateView.swift index f174398..618a273 100644 --- a/Sora/Views/SearchView/SearchStateView.swift +++ b/Sora/Views/SearchView/SearchStateView.swift @@ -27,9 +27,9 @@ struct SearchStateView: View { Image(systemName: "magnifyingglass") .font(.largeTitle) .foregroundColor(.secondary) - Text("No Results Found") + Text("No Search Results Found") .font(.headline) - Text("Try different keywords") + Text("Try different search terms") .font(.caption) .foregroundColor(.secondary) } diff --git a/Sora/Views/SearchView/SearchViewComponents.swift b/Sora/Views/SearchView/SearchViewComponents.swift index 49c1201..95ebab5 100644 --- a/Sora/Views/SearchView/SearchViewComponents.swift +++ b/Sora/Views/SearchView/SearchViewComponents.swift @@ -117,6 +117,10 @@ struct ModuleSelectorMenu: View { ) } } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6).opacity(0)) + .cornerRadius(12) } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index f6e3039..9a909ae 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -59,14 +59,12 @@ fileprivate struct SettingsSection: View { } struct SettingsViewAbout: View { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA" - var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ADs!") { + SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ads!") { HStack(alignment: .center, spacing: 16) { - LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcons/AppIcon_Default.appiconset/darkmode.png")) { state in + LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png")) { state in if let uiImage = state.imageContainer?.image { Image(uiImage: uiImage) .resizable() @@ -83,7 +81,7 @@ struct SettingsViewAbout: View { Text("Sora") .font(.title) .bold() - Text("AKA Sulfur") + Text("Also known as Sulfur") .font(.caption) .foregroundColor(.secondary) } @@ -174,15 +172,34 @@ struct ContributorsView: View { } private var filteredContributors: [Contributor] { - contributors.filter { contributor in + let realContributors = contributors.filter { contributor in !["cranci1", "code-factor"].contains(contributor.login.lowercased()) } + + let artificialUsers = createArtificialUsers() + + return realContributors + artificialUsers + } + + private func createArtificialUsers() -> [Contributor] { + return [ + Contributor( + id: 71751652, + login: "qooode", + avatarUrl: "https://avatars.githubusercontent.com/u/71751652?v=4" + ), + Contributor( + id: 8116188, + login: "undeaDD", + avatarUrl: "https://avatars.githubusercontent.com/u/8116188?v=4" + ) + ] } private func loadContributors() { let url = URL(string: "https://api.github.com/repos/cranci1/Sora/contributors")! - URLSession.shared.dataTask(with: url) { data, response, error in + URLSession.custom.dataTask(with: url) { data, response, error in DispatchQueue.main.async { isLoading = false diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index 99bd701..15e3b6e 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -153,41 +153,25 @@ struct SettingsViewData: View { return ScrollView { VStack(spacing: 24) { SettingsSection( - title: "App Storage", - footer: "The app cache allow the app to sho immages faster.\n\nClearing the documents folder will remove all the modules.\n\nThe App Data should never be erased if you don't know what that will cause." + title: NSLocalizedString("App Storage", comment: ""), + footer: NSLocalizedString("The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction.", comment: "") ) { VStack(spacing: 0) { - HStack { - Button(action: { + SettingsButtonRow( + icon: "trash", + title: NSLocalizedString("Remove All Cache", comment: ""), + subtitle: cacheSizeText, + action: { activeAlert = .clearCache showAlert = true - }) { - HStack { - Image(systemName: "trash") - .frame(width: 24, height: 24) - .foregroundStyle(.red) - - Text("Remove All Caches") - .foregroundStyle(.red) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) } - .buttonStyle(PlainButtonStyle()) - - Text(cacheSizeText) - .foregroundStyle(.gray) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) + ) Divider().padding(.horizontal, 16) SettingsButtonRow( icon: "film", - title: "Remove Downloads", + title: NSLocalizedString("Remove Downloads", comment: ""), subtitle: formatSize(downloadsSize), action: { activeAlert = .removeDownloads @@ -199,7 +183,7 @@ struct SettingsViewData: View { SettingsButtonRow( icon: "doc.text", - title: "Remove All Documents", + title: NSLocalizedString("Remove All Documents", comment: ""), subtitle: formatSize(documentsSize), action: { activeAlert = .removeDocs @@ -211,7 +195,7 @@ struct SettingsViewData: View { SettingsButtonRow( icon: "exclamationmark.triangle", - title: "Erase all App Data", + title: NSLocalizedString("Erase all App Data", comment: ""), action: { activeAlert = .eraseData showAlert = true @@ -221,7 +205,7 @@ struct SettingsViewData: View { } } .scrollViewBottomPadding() - .navigationTitle("App Data") + .navigationTitle(NSLocalizedString("App Data", comment: "")) .onAppear { calculateCacheSize() updateSizes() @@ -231,36 +215,36 @@ struct SettingsViewData: View { switch activeAlert { case .eraseData: return Alert( - title: Text("Erase App Data"), - message: Text("Are you sure you want to erase all app data? This action cannot be undone."), - primaryButton: .destructive(Text("Erase")) { + title: Text(NSLocalizedString("Erase App Data", comment: "")), + message: Text(NSLocalizedString("Are you sure you want to erase all app data? This action cannot be undone.", comment: "")), + primaryButton: .destructive(Text(NSLocalizedString("Erase", comment: ""))) { eraseAppData() }, secondaryButton: .cancel() ) case .removeDocs: return Alert( - title: Text("Remove Documents"), - message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."), - primaryButton: .destructive(Text("Remove")) { + title: Text(NSLocalizedString("Remove Documents", comment: "")), + message: Text(NSLocalizedString("Are you sure you want to remove all files in the Documents folder? This will remove all modules.", comment: "")), + primaryButton: .destructive(Text(NSLocalizedString("Remove", comment: ""))) { removeAllFilesInDocuments() }, secondaryButton: .cancel() ) case .removeDownloads: return Alert( - title: Text("Remove Downloaded Media"), - message: Text("Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone."), - primaryButton: .destructive(Text("Remove")) { + title: Text(NSLocalizedString("Remove Downloaded Media", comment: "")), + message: Text(NSLocalizedString("Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone.", comment: "")), + primaryButton: .destructive(Text(NSLocalizedString("Remove", comment: ""))) { removeDownloadedMedia() }, secondaryButton: .cancel() ) case .clearCache: return Alert( - title: Text("Clear Cache"), - message: Text("Are you sure you want to clear all cached data? This will help free up storage space."), - primaryButton: .destructive(Text("Clear")) { + title: Text(NSLocalizedString("Clear Cache", comment: "")), + message: Text(NSLocalizedString("Are you sure you want to clear all cached data? This will help free up storage space.", comment: "")), + primaryButton: .destructive(Text(NSLocalizedString("Clear", comment: ""))) { clearAllCaches() }, secondaryButton: .cancel() @@ -268,182 +252,183 @@ struct SettingsViewData: View { } } } + } + + func calculateCacheSize() { + isCalculatingSize = true + cacheSizeText = "..." - func calculateCacheSize() { - isCalculatingSize = true - cacheSizeText = "..." - - DispatchQueue.global(qos: .background).async { - if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { - let size = calculateDirectorySize(for: cacheURL) - DispatchQueue.main.async { - self.cacheSize = size - self.cacheSizeText = formatSize(size) - self.isCalculatingSize = false - } + DispatchQueue.global(qos: .background).async { + if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { + let size = calculateDirectorySize(for: cacheURL) + DispatchQueue.main.async { + self.cacheSize = size + self.cacheSizeText = formatSize(size) + self.isCalculatingSize = false + } + } else { + DispatchQueue.main.async { + self.cacheSizeText = "N/A" + self.isCalculatingSize = false + } + } + } + } + + func updateSizes() { + DispatchQueue.global(qos: .background).async { + if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let size = calculateDirectorySize(for: documentsURL) + DispatchQueue.main.async { + self.documentsSize = size + } + } + } + } + + func calculateDownloadsSize() { + DispatchQueue.global(qos: .background).async { + if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let size = calculateMediaFilesSize(in: documentsURL) + DispatchQueue.main.async { + self.downloadsSize = size + } + } + } + } + + func calculateMediaFilesSize(in directory: URL) -> Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + let mediaExtensions = [".mov", ".mp4", ".pkg"] + + do { + let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey]) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) + if resourceValues.isDirectory == true { + totalSize += calculateMediaFilesSize(in: url) } else { - DispatchQueue.main.async { - self.cacheSizeText = "N/A" - self.isCalculatingSize = false - } - } - } - } - - func updateSizes() { - DispatchQueue.global(qos: .background).async { - if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - let size = calculateDirectorySize(for: documentsURL) - DispatchQueue.main.async { - self.documentsSize = size - } - } - } - } - - func calculateDownloadsSize() { - DispatchQueue.global(qos: .background).async { - if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - let size = calculateMediaFilesSize(in: documentsURL) - DispatchQueue.main.async { - self.downloadsSize = size - } - } - } - } - - func calculateMediaFilesSize(in directory: URL) -> Int64 { - let fileManager = FileManager.default - var totalSize: Int64 = 0 - let mediaExtensions = [".mov", ".mp4", ".pkg"] - - do { - let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey]) - for url in contents { - let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) - if resourceValues.isDirectory == true { - totalSize += calculateMediaFilesSize(in: url) - } else { - let fileExtension = url.pathExtension.lowercased() - if mediaExtensions.contains(".\(fileExtension)") { - totalSize += Int64(resourceValues.fileSize ?? 0) - } - } - } - } catch { - Logger.shared.log("Error calculating media files size: \(error)", type: "Error") - } - - return totalSize - } - - func clearAllCaches() { - clearCache() - } - - func clearCache() { - let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - do { - if let cacheURL = cacheURL { - let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: []) - for filePath in filePaths { - try FileManager.default.removeItem(at: filePath) - } - Logger.shared.log("Cache cleared successfully!", type: "General") - calculateCacheSize() - updateSizes() - calculateDownloadsSize() - } - } catch { - Logger.shared.log("Failed to clear cache.", type: "Error") - } - } - - func removeDownloadedMedia() { - let fileManager = FileManager.default - let mediaExtensions = [".mov", ".mp4", ".pkg"] - - if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { - removeMediaFiles(in: documentsURL, extensions: mediaExtensions) - Logger.shared.log("Downloaded media files removed", type: "General") - updateSizes() - calculateDownloadsSize() - } - } - - func removeMediaFiles(in directory: URL, extensions: [String]) { - let fileManager = FileManager.default - - do { - let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) - for url in contents { - let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey]) - if resourceValues.isDirectory == true { - removeMediaFiles(in: url, extensions: extensions) - } else { - let fileExtension = ".\(url.pathExtension.lowercased())" - if extensions.contains(fileExtension) { - try fileManager.removeItem(at: url) - Logger.shared.log("Removed media file: \(url.lastPathComponent)", type: "General") - } - } - } - } catch { - Logger.shared.log("Error removing media files in \(directory.path): \(error)", type: "Error") - } - } - - func removeAllFilesInDocuments() { - let fileManager = FileManager.default - if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { - do { - let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) - for fileURL in fileURLs { - try fileManager.removeItem(at: fileURL) - } - Logger.shared.log("All files in documents folder removed", type: "General") - exit(0) - } catch { - Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error") - } - } - } - - func eraseAppData() { - if let domain = Bundle.main.bundleIdentifier { - UserDefaults.standard.removePersistentDomain(forName: domain) - UserDefaults.standard.synchronize() - Logger.shared.log("Cleared app data!", type: "General") - exit(0) - } - } - - func calculateDirectorySize(for url: URL) -> Int64 { - let fileManager = FileManager.default - var totalSize: Int64 = 0 - - do { - let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) - for url in contents { - let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) - if resourceValues.isDirectory == true { - totalSize += calculateDirectorySize(for: url) - } else { + let fileExtension = url.pathExtension.lowercased() + if mediaExtensions.contains(".\(fileExtension)") { totalSize += Int64(resourceValues.fileSize ?? 0) } } - } catch { - Logger.shared.log("Error calculating directory size: \(error)", type: "Error") } - - return totalSize + } catch { + Logger.shared.log("Error calculating media files size: \(error)", type: "Error") } - func formatSize(_ bytes: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] - formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) + return totalSize + } + + func clearAllCaches() { + clearCache() + } + + func clearCache() { + let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + do { + if let cacheURL = cacheURL { + let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: []) + for filePath in filePaths { + try FileManager.default.removeItem(at: filePath) + } + Logger.shared.log("Cache cleared successfully!", type: "General") + calculateCacheSize() + updateSizes() + calculateDownloadsSize() + } + } catch { + Logger.shared.log("Failed to clear cache.", type: "Error") } } + + func removeDownloadedMedia() { + let fileManager = FileManager.default + let mediaExtensions = [".mov", ".mp4", ".pkg"] + + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + removeMediaFiles(in: documentsURL, extensions: mediaExtensions) + Logger.shared.log("Downloaded media files removed", type: "General") + updateSizes() + calculateDownloadsSize() + } + } + + func removeMediaFiles(in directory: URL, extensions: [String]) { + let fileManager = FileManager.default + + do { + let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true { + removeMediaFiles(in: url, extensions: extensions) + } else { + let fileExtension = ".\(url.pathExtension.lowercased())" + if extensions.contains(fileExtension) { + try fileManager.removeItem(at: url) + Logger.shared.log("Removed media file: \(url.lastPathComponent)", type: "General") + } + } + } + } catch { + Logger.shared.log("Error removing media files in \(directory.path): \(error)", type: "Error") + } + } + + func removeAllFilesInDocuments() { + let fileManager = FileManager.default + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + do { + let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) + for fileURL in fileURLs { + try fileManager.removeItem(at: fileURL) + } + Logger.shared.log("All files in documents folder removed", type: "General") + exit(0) + } catch { + Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error") + } + } + } + + func eraseAppData() { + if let domain = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: domain) + UserDefaults.standard.synchronize() + Logger.shared.log("Cleared app data!", type: "General") + exit(0) + } + } + + func calculateDirectorySize(for url: URL) -> Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + + do { + let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) + if resourceValues.isDirectory == true { + totalSize += calculateDirectorySize(for: url) + } else { + totalSize += Int64(resourceValues.fileSize ?? 0) + } + } + } catch { + Logger.shared.log("Error calculating directory size: \(error)", type: "Error") + } + + return totalSize + } + + func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } } + diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift index 587d55c..ab9be27 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift @@ -164,12 +164,12 @@ struct SettingsViewDownloads: View { ScrollView { VStack(spacing: 24) { SettingsSection( - title: "Download Settings", - footer: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." + title: String(localized: "Download Settings"), + footer: String(localized: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources.") ) { SettingsPickerRow( icon: "4k.tv", - title: "Quality", + title: String(localized: "Quality"), options: DownloadQualityPreference.allCases.map { $0.rawValue }, optionToString: { $0 }, selection: $downloadQuality @@ -181,7 +181,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Max Concurrent Downloads") + Text(String(localized: "Max Concurrent Downloads")) .foregroundStyle(.primary) Spacer() @@ -200,14 +200,14 @@ struct SettingsViewDownloads: View { SettingsToggleRow( icon: "antenna.radiowaves.left.and.right", - title: "Allow Cellular Downloads", + title: String(localized: "Allow Cellular Downloads"), isOn: $allowCellularDownloads, showDivider: false ) } SettingsSection( - title: "Quality Information" + title: String(localized: "Quality Information") ) { if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description { HStack { @@ -222,7 +222,7 @@ struct SettingsViewDownloads: View { } SettingsSection( - title: "Storage Management" + title: String(localized: "Storage Management") ) { VStack(spacing: 0) { HStack { @@ -230,7 +230,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Storage Used") + Text(String(localized: "Storage Used")) .foregroundStyle(.primary) Spacer() @@ -255,7 +255,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Files Downloaded") + Text(String(localized: "Files Downloaded")) .foregroundStyle(.primary) Spacer() @@ -277,7 +277,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Refresh Storage Info") + Text(String(localized: "Refresh Storage Info")) .foregroundStyle(.primary) Spacer() @@ -297,7 +297,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.red) - Text("Clear All Downloads") + Text(String(localized: "Clear All Downloads")) .foregroundStyle(.red) Spacer() @@ -310,18 +310,18 @@ struct SettingsViewDownloads: View { } .padding(.vertical, 20) } - .navigationTitle("Downloads") + .navigationTitle(String(localized: "Downloads")) .scrollViewBottomPadding() - .alert("Delete All Downloads", isPresented: $showClearConfirmation) { - Button("Cancel", role: .cancel) { } - Button("Delete All", role: .destructive) { + .alert(String(localized: "Delete All Downloads"), isPresented: $showClearConfirmation) { + Button(String(localized: "Cancel"), role: .cancel) { } + Button(String(localized: "Delete All"), role: .destructive) { clearAllDownloads(preservePersistentDownloads: false) } - Button("Clear Library Only", role: .destructive) { + Button(String(localized: "Clear Library Only"), role: .destructive) { clearAllDownloads(preservePersistentDownloads: true) } } message: { - Text("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.") + Text(String(localized: "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.")) } .onAppear { calculateTotalStorage() @@ -370,9 +370,9 @@ struct SettingsViewDownloads: View { DispatchQueue.main.async { if preservePersistentDownloads { - DropManager.shared.success("Library cleared successfully") + DropManager.shared.success(String(localized: "Library cleared successfully")) } else { - DropManager.shared.success("All downloads deleted successfully") + DropManager.shared.success(String(localized: "All downloads deleted successfully")) } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 331f3da..1c48ac8 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -153,42 +153,71 @@ struct SettingsViewGeneral: View { @AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true @AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false - @AppStorage("metadataProviders") private var metadataProviders: String = "TMDB" + @AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false + @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { + try! JSONEncoder().encode(["TMDB","AniList"]) + }() @AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + @AppStorage("metadataProviders") private var metadataProviders: String = "TMDB" - private let metadataProvidersList = ["AniList", "TMDB"] + private var metadataProvidersOrder: [String] { + get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } + set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } + } private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"] private let sortOrderOptions = ["Ascending", "Descending"] + private let metadataProvidersList = ["TMDB", "AniList"] @EnvironmentObject var settings: Settings + @State private var showRestartAlert = false var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "Interface") { + SettingsSection(title: NSLocalizedString("Interface", comment: "")) { SettingsPickerRow( icon: "paintbrush", - title: "Appearance", + title: NSLocalizedString("Appearance", comment: ""), options: [Appearance.system, .light, .dark], optionToString: { appearance in switch appearance { - case .system: return "System" - case .light: return "Light" - case .dark: return "Dark" + case .system: return NSLocalizedString("System", comment: "") + case .light: return NSLocalizedString("Light", comment: "") + case .dark: return NSLocalizedString("Dark", comment: "") } }, selection: $settings.selectedAppearance ) + + SettingsToggleRow( + icon: "wand.and.rays.inverse", + title: NSLocalizedString("Hide Splash Screen", comment: ""), + isOn: $hideSplashScreenEnable, + showDivider: false + ) + } + + SettingsSection(title: NSLocalizedString("Language", comment: "")) { + SettingsPickerRow( + icon: "globe", + title: NSLocalizedString("App Language", comment: ""), + options: ["English", "Dutch"], + optionToString: { $0 }, + selection: $settings.selectedLanguage + ) + .onChange(of: settings.selectedLanguage) { _ in + showRestartAlert = true + } } SettingsSection( - title: "Media View", - footer: "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.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." + title: NSLocalizedString("Media View", comment: ""), + footer: NSLocalizedString("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.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers.", comment: "") ) { SettingsPickerRow( icon: "list.number", - title: "Episodes Range", + title: NSLocalizedString("Episodes Range", comment: ""), options: [25, 50, 75, 100], optionToString: { "\($0)" }, selection: $episodeChunkSize @@ -196,47 +225,67 @@ struct SettingsViewGeneral: View { SettingsToggleRow( icon: "info.circle", - title: "Fetch Episode metadata", + title: NSLocalizedString("Fetch Episode metadata", comment: ""), isOn: $fetchEpisodeMetadata ) - if metadataProviders == "TMDB" { - SettingsPickerRow( - icon: "server.rack", - title: "Metadata Provider", - options: metadataProvidersList, - optionToString: { $0 }, - selection: $metadataProviders, - showDivider: true - ) + VStack(spacing: 0) { + HStack { + Image(systemName: "arrow.up.arrow.down") + .frame(width: 24, height: 24) + .foregroundStyle(.primary) + + Text(NSLocalizedString("Metadata Providers Order", comment: "")) + .foregroundStyle(.primary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) - SettingsPickerRow( - icon: "square.stack.3d.down.right", - title: "Thumbnails Width", - options: TMDBimageWidhtList, - optionToString: { $0 }, - selection: $TMDBimageWidht, - showDivider: false - ) - } else { - SettingsPickerRow( - icon: "server.rack", - title: "Metadata Provider", - options: metadataProvidersList, - optionToString: { $0 }, - selection: $metadataProviders, - showDivider: false - ) + Divider() + .padding(.horizontal, 16) + + List { + ForEach(Array(metadataProvidersOrder.enumerated()), id: \.element) { index, provider in + HStack { + Text("\(index + 1)") + .frame(width: 24, height: 24) + .foregroundStyle(.gray) + + Text(provider) + .foregroundStyle(.primary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .listRowBackground(Color.clear) + .listRowSeparator(.visible) + .listRowSeparatorTint(.gray.opacity(0.3)) + .listRowInsets(EdgeInsets()) + } + .onMove { from, to in + var arr = metadataProvidersOrder + arr.move(fromOffsets: from, toOffset: to) + metadataProvidersOrderData = try! JSONEncoder().encode(arr) + } + } + .listStyle(.plain) + .frame(height: CGFloat(metadataProvidersOrder.count * 48)) + .background(Color.clear) + .padding(.bottom, 8) } + .environment(\.editMode, .constant(.active)) } SettingsSection( - title: "Media Grid Layout", - footer: "Adjust the number of media items per row in portrait and landscape modes." + title: NSLocalizedString("Media Grid Layout", comment: ""), + footer: NSLocalizedString("Adjust the number of media items per row in portrait and landscape modes.", comment: "") ) { SettingsPickerRow( icon: "rectangle.portrait", - title: "Portrait Columns", + title: NSLocalizedString("Portrait Columns", comment: ""), options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4), optionToString: { "\($0)" }, selection: $mediaColumnsPortrait @@ -244,7 +293,7 @@ struct SettingsViewGeneral: View { SettingsPickerRow( icon: "rectangle", - title: "Landscape Columns", + title: NSLocalizedString("Landscape Columns", comment: ""), options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5), optionToString: { "\($0)" }, selection: $mediaColumnsLandscape, @@ -253,32 +302,40 @@ struct SettingsViewGeneral: View { } SettingsSection( - title: "Modules", - footer: "Note that the modules will be replaced only if there is a different version string inside the JSON file." + 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: "Refresh Modules on Launch", + title: NSLocalizedString("Refresh Modules on Launch", comment: ""), isOn: $refreshModulesOnLaunch, showDivider: false ) } SettingsSection( - title: "Advanced", - footer: "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." + title: NSLocalizedString("Advanced", comment: ""), + footer: NSLocalizedString("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.", comment: "") ) { SettingsToggleRow( icon: "chart.bar", - title: "Enable Analytics", + title: NSLocalizedString("Enable Analytics", comment: ""), isOn: $analyticsEnabled, showDivider: false ) } } - .padding(.vertical, 20) + .navigationTitle("General") + .scrollViewBottomPadding() } - .navigationTitle("General") + .navigationTitle(NSLocalizedString("General", comment: "")) .scrollViewBottomPadding() + .alert(isPresented: $showRestartAlert) { + Alert( + title: Text(NSLocalizedString("Restart Required", comment: "")), + message: Text(NSLocalizedString("Please restart the app to apply the language change.", comment: "")), + dismissButton: .default(Text("OK")) + ) + } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift index c62d5ff..ddb935b 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift @@ -76,12 +76,12 @@ struct SettingsViewLogger: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "Logs") { + SettingsSection(title: NSLocalizedString("Logs", comment: "")) { if isLoading { HStack { ProgressView() .scaleEffect(0.8) - Text("Loading logs...") + Text(NSLocalizedString("Loading logs...", comment: "")) .font(.footnote) .foregroundColor(.secondary) } @@ -99,7 +99,7 @@ struct SettingsViewLogger: View { Button(action: { showFullLogs = true }) { - Text("Show More (\(logs.count - displayCharacterLimit) more characters)") + Text(NSLocalizedString("Show More (%lld more characters)", comment: "").replacingOccurrences(of: "%lld", with: "\(logs.count - displayCharacterLimit)")) .font(.footnote) .foregroundColor(.accentColor) } @@ -113,7 +113,7 @@ struct SettingsViewLogger: View { } .padding(.vertical, 20) } - .navigationTitle("Logs") + .navigationTitle(NSLocalizedString("Logs", comment: "")) .onAppear { loadLogsAsync() } @@ -123,14 +123,14 @@ struct SettingsViewLogger: View { Menu { Button(action: { UIPasteboard.general.string = logs - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + DropManager.shared.showDrop(title: NSLocalizedString("Copied to Clipboard", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) }) { - Label("Copy to Clipboard", systemImage: "doc.on.doc") + Label(NSLocalizedString("Copy to Clipboard", comment: ""), systemImage: "doc.on.doc") } Button(role: .destructive, action: { clearLogsAsync() }) { - Label("Clear Logs", systemImage: "trash") + Label(NSLocalizedString("Clear Logs", comment: ""), systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle") diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift index 6588afc..3b6e3c7 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift @@ -115,11 +115,11 @@ class LogFilterViewModel: ObservableObject { private let userDefaultsKey = "LogFilterStates" private let hardcodedFilters: [(type: String, description: String, defaultState: Bool)] = [ - ("General", "General events and activities.", true), - ("Stream", "Streaming and video playback.", true), - ("Error", "Errors and critical issues.", true), - ("Debug", "Debugging and troubleshooting.", false), - ("Download", "HLS video downloading.", true), + (NSLocalizedString("General", comment: ""), NSLocalizedString("General events and activities.", comment: ""), true), + (NSLocalizedString("Stream", comment: ""), NSLocalizedString("Streaming and video playback.", comment: ""), true), + (NSLocalizedString("Error", comment: ""), NSLocalizedString("Errors and critical issues.", comment: ""), true), + (NSLocalizedString("Debug", comment: ""), NSLocalizedString("Debugging and troubleshooting.", comment: ""), false), + (NSLocalizedString("Download", comment: ""), NSLocalizedString("HLS video downloading.", comment: ""), true), ("HTMLStrings", "", false) ] @@ -179,7 +179,7 @@ struct SettingsViewLoggerFilter: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "Log Types") { + SettingsSection(title: NSLocalizedString("Log Types", comment: "")) { ForEach($viewModel.filters) { $filter in SettingsToggleRow( icon: iconForFilter(filter.type), @@ -192,6 +192,6 @@ struct SettingsViewLoggerFilter: View { } .padding(.vertical, 20) } - .navigationTitle("Log Filters") + .navigationTitle(NSLocalizedString("Log Filters", comment: "")) } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index a5611d9..9e03433 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -119,16 +119,16 @@ fileprivate struct ModuleListItemView: View { .contextMenu { Button(action: { UIPasteboard.general.string = module.metadataUrl - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + DropManager.shared.showDrop(title: NSLocalizedString("Copied to Clipboard", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) }) { - Label("Copy URL", systemImage: "doc.on.doc") + Label(NSLocalizedString("Copy URL", comment: ""), systemImage: "doc.on.doc") } Button(role: .destructive) { if selectedModuleId != module.id.uuidString { onDelete() } } label: { - Label("Delete", systemImage: "trash") + Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash") } .disabled(selectedModuleId == module.id.uuidString) } @@ -137,7 +137,7 @@ fileprivate struct ModuleListItemView: View { Button(role: .destructive) { onDelete() } label: { - Label("Delete", systemImage: "trash") + Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash") } } } @@ -163,25 +163,25 @@ struct SettingsViewModule: View { ScrollView { VStack(spacing: 24) { if moduleManager.modules.isEmpty { - SettingsSection(title: "Modules") { + SettingsSection(title: NSLocalizedString("Modules", comment: "")) { VStack(spacing: 16) { Image(systemName: "plus.app") .font(.largeTitle) .foregroundColor(.secondary) - Text("No Modules") + Text(NSLocalizedString("No Modules", comment: "")) .font(.headline) if didReceiveDefaultPageLink { NavigationLink(destination: CommunityLibraryView() .environmentObject(moduleManager)) { - Text("Check out some community modules here!") + Text(NSLocalizedString("Check out some community modules here!", comment: "")) .font(.caption) .foregroundColor(.accentColor) .frame(maxWidth: .infinity) } .buttonStyle(PlainButtonStyle()) } else { - Text("Click the plus button to add a module!") + Text(NSLocalizedString("Click the plus button to add a module!", comment: "")) .font(.caption) .foregroundColor(.secondary) .frame(maxWidth: .infinity) @@ -191,14 +191,14 @@ struct SettingsViewModule: View { .frame(maxWidth: .infinity) } } else { - SettingsSection(title: "Installed Modules") { + SettingsSection(title: NSLocalizedString("Installed Modules", comment: "")) { ForEach(moduleManager.modules) { module in ModuleListItemView( module: module, selectedModuleId: selectedModuleId, onDelete: { moduleManager.deleteModule(module) - DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash")) + DropManager.shared.showDrop(title: NSLocalizedString("Module Removed", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash")) }, onSelect: { selectedModuleId = module.id.uuidString @@ -216,7 +216,7 @@ struct SettingsViewModule: View { .padding(.vertical, 20) } .scrollViewBottomPadding() - .navigationTitle("Modules") + .navigationTitle(NSLocalizedString("Modules", comment: "")) .navigationBarItems(trailing: HStack(spacing: 16) { if didReceiveDefaultPageLink { @@ -228,7 +228,7 @@ struct SettingsViewModule: View { .frame(width: 20, height: 20) .padding(5) } - .accessibilityLabel("Open Community Library") + .accessibilityLabel(NSLocalizedString("Open Community Library", comment: "")) } Button(action: { @@ -239,7 +239,7 @@ struct SettingsViewModule: View { .frame(width: 20, height: 20) .padding(5) } - .accessibilityLabel("Add Module") + .accessibilityLabel(NSLocalizedString("Add Module", comment: "")) } ) .background( diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 9ce67eb..e5f182e 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -205,18 +205,22 @@ struct SettingsViewPlayer: View { @AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true @AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true + @AppStorage("videoQualityWiFi") private var wifiQuality: String = VideoQualityPreference.defaultWiFiPreference.rawValue + @AppStorage("videoQualityCellular") private var cellularQuality: String = VideoQualityPreference.defaultCellularPreference.rawValue + private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA", "TracyPlayer"] + private let qualityOptions = VideoQualityPreference.allCases.map { $0.rawValue } var body: some View { ScrollView { VStack(spacing: 24) { SettingsSection( - title: "Media Player", - footer: "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." + title: NSLocalizedString("Media Player", comment: ""), + footer: NSLocalizedString("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.", comment: "") ) { SettingsPickerRow( icon: "play.circle", - title: "Media Player", + title: NSLocalizedString("Media Player", comment: ""), options: mediaPlayers, optionToString: { $0 }, selection: $externalPlayer @@ -224,35 +228,35 @@ struct SettingsViewPlayer: View { SettingsToggleRow( icon: "rotate.right", - title: "Force Landscape", + title: NSLocalizedString("Force Landscape", comment: ""), isOn: $isAlwaysLandscape ) SettingsToggleRow( icon: "hand.tap", - title: "Two Finger Hold for Pause", + title: NSLocalizedString("Two Finger Hold for Pause", comment: ""), isOn: $holdForPauseEnabled, showDivider: true ) SettingsToggleRow( icon: "pip", - title: "Show PiP Button", + title: NSLocalizedString("Show PiP Button", comment: ""), isOn: $pipButtonVisible, showDivider: false ) } - SettingsSection(title: "Speed Settings") { + SettingsSection(title: NSLocalizedString("Speed Settings", comment: "")) { SettingsToggleRow( icon: "speedometer", - title: "Remember Playback speed", + title: NSLocalizedString("Remember Playback speed", comment: ""), isOn: $isRememberPlaySpeed ) SettingsStepperRow( icon: "forward.fill", - title: "Hold Speed", + title: NSLocalizedString("Hold Speed", comment: ""), value: $holdSpeedPlayer, range: 0.25...2.5, step: 0.25, @@ -260,9 +264,30 @@ struct SettingsViewPlayer: View { showDivider: false ) } + SettingsSection( + title: String(localized: "Video Quality Preferences"), + footer: String(localized: "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.") + ) { + SettingsPickerRow( + icon: "wifi", + title: String(localized: "WiFi Quality"), + options: qualityOptions, + optionToString: { $0 }, + selection: $wifiQuality + ) + + SettingsPickerRow( + icon: "antenna.radiowaves.left.and.right", + title: String(localized: "Cellular Quality"), + options: qualityOptions, + optionToString: { $0 }, + selection: $cellularQuality, + showDivider: false + ) + } - SettingsSection(title: "Progress bar Marker Color") { - ColorPicker("Segments Color", selection: Binding( + SettingsSection(title: NSLocalizedString("Progress bar Marker Color", comment: "")) { + ColorPicker(NSLocalizedString("Segments Color", comment: ""), selection: Binding( get: { if let data = UserDefaults.standard.data(forKey: "segmentsColorData"), let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor { @@ -285,12 +310,12 @@ struct SettingsViewPlayer: View { } SettingsSection( - title: "Skip Settings", - footer: "Double tapping the screen on it's sides will skip with the short tap setting." + title: NSLocalizedString("Skip Settings", comment: ""), + footer: NSLocalizedString("Double tapping the screen on it's sides will skip with the short tap setting.", comment: "") ) { SettingsStepperRow( icon: "goforward", - title: "Tap Skip", + title: NSLocalizedString("Tap Skip", comment: ""), value: $skipIncrement, range: 5...300, step: 5, @@ -299,7 +324,7 @@ struct SettingsViewPlayer: View { SettingsStepperRow( icon: "goforward.plus", - title: "Long press Skip", + title: NSLocalizedString("Long press Skip", comment: ""), value: $skipIncrementHold, range: 5...300, step: 5, @@ -308,19 +333,19 @@ struct SettingsViewPlayer: View { SettingsToggleRow( icon: "hand.tap.fill", - title: "Double Tap to Seek", + title: NSLocalizedString("Double Tap to Seek", comment: ""), isOn: $doubleTapSeekEnabled ) SettingsToggleRow( icon: "forward.end", - title: "Show Skip 85s Button", + title: NSLocalizedString("Show Skip 85s Button", comment: ""), isOn: $skip85Visible ) SettingsToggleRow( icon: "forward.frame", - title: "Show Skip Intro / Outro Buttons", + title: NSLocalizedString("Show Skip Intro / Outro Buttons", comment: ""), isOn: $skipIntroOutroVisible, showDivider: false ) @@ -331,7 +356,7 @@ struct SettingsViewPlayer: View { .padding(.vertical, 20) } .scrollViewBottomPadding() - .navigationTitle("Player") + .navigationTitle(NSLocalizedString("Player", comment: "")) } } @@ -348,10 +373,10 @@ struct SubtitleSettingsSection: View { private let shadowOptions = [0, 1, 3, 6] var body: some View { - SettingsSection(title: "Subtitle Settings") { + SettingsSection(title: NSLocalizedString("Subtitle Settings", comment: "")) { SettingsToggleRow( icon: "captions.bubble", - title: "Enable Subtitles", + title: NSLocalizedString("Enable Subtitles", comment: ""), isOn: $subtitlesEnabled, showDivider: false ) @@ -363,7 +388,7 @@ struct SubtitleSettingsSection: View { SettingsPickerRow( icon: "paintbrush", - title: "Subtitle Color", + title: NSLocalizedString("Subtitle Color", comment: ""), options: colors, optionToString: { $0.capitalized }, selection: $foregroundColor @@ -376,7 +401,7 @@ struct SubtitleSettingsSection: View { SettingsPickerRow( icon: "shadow", - title: "Shadow", + title: NSLocalizedString("Shadow", comment: ""), options: shadowOptions, optionToString: { "\($0)" }, selection: Binding( @@ -392,7 +417,7 @@ struct SubtitleSettingsSection: View { SettingsToggleRow( icon: "rectangle.fill", - title: "Background Enabled", + title: NSLocalizedString("Background Enabled", comment: ""), isOn: $backgroundEnabled ) .onChange(of: backgroundEnabled) { newValue in @@ -403,7 +428,7 @@ struct SubtitleSettingsSection: View { SettingsStepperRow( icon: "textformat.size", - title: "Font Size", + title: NSLocalizedString("Font Size", comment: ""), value: $fontSize, range: 12...36, step: 1 @@ -416,7 +441,7 @@ struct SubtitleSettingsSection: View { SettingsStepperRow( icon: "arrow.up.and.down", - title: "Bottom Padding", + title: NSLocalizedString("Bottom Padding", comment: ""), value: $bottomPadding, range: 0...50, step: 1, diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 8dff56f..2d4ac59 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -117,7 +117,7 @@ struct SettingsViewTrackers: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "AniList") { + SettingsSection(title: NSLocalizedString("AniList", comment: "")) { VStack(spacing: 0) { HStack(alignment: .center, spacing: 10) { LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) { state in @@ -137,32 +137,30 @@ struct SettingsViewTrackers: View { } VStack(alignment: .leading, spacing: 4) { - Text("AniList.co") + Text(NSLocalizedString("AniList.co", comment: "")) .font(.title3) .fontWeight(.semibold) - Group { - if isAnilistLoading { - ProgressView() - .scaleEffect(0.8) - .frame(height: 18) - } else if isAnilistLoggedIn { - HStack(spacing: 0) { - Text("Logged in as ") - .font(.footnote) - .foregroundStyle(.gray) - Text(anilistUsername) - .font(.footnote) - .fontWeight(.medium) - .foregroundStyle(profileColor) - } + if isAnilistLoading { + ProgressView() + .scaleEffect(0.8) .frame(height: 18) - } else { - Text(anilistStatus) + } else if isAnilistLoggedIn { + HStack(spacing: 0) { + Text(NSLocalizedString("Logged in as", comment: "")) .font(.footnote) .foregroundStyle(.gray) - .frame(height: 18) + Text(anilistUsername) + .font(.footnote) + .fontWeight(.medium) + .foregroundStyle(profileColor) } + .frame(height: 18) + } else { + Text(NSLocalizedString("You are not logged in", comment: "")) + .font(.footnote) + .foregroundStyle(.gray) + .frame(height: 18) } } .frame(height: 60, alignment: .center) @@ -179,7 +177,7 @@ struct SettingsViewTrackers: View { SettingsToggleRow( icon: "arrow.triangle.2.circlepath", - title: "Sync anime progress", + title: NSLocalizedString("Sync anime progress", comment: ""), isOn: $isSendPushUpdates, showDivider: false ) @@ -200,7 +198,7 @@ struct SettingsViewTrackers: View { .frame(width: 24, height: 24) .foregroundStyle(isAnilistLoggedIn ? .red : .accentColor) - Text(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") + Text(isAnilistLoggedIn ? NSLocalizedString("Log Out from AniList", comment: "") : NSLocalizedString("Log In with AniList", comment: "")) .foregroundStyle(isAnilistLoggedIn ? .red : .accentColor) Spacer() @@ -212,7 +210,7 @@ struct SettingsViewTrackers: View { } } - SettingsSection(title: "Trakt") { + SettingsSection(title: NSLocalizedString("Trakt", comment: "")) { VStack(spacing: 0) { HStack(alignment: .center, spacing: 10) { LazyImage(url: URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) { state in @@ -232,32 +230,30 @@ struct SettingsViewTrackers: View { } VStack(alignment: .leading, spacing: 4) { - Text("Trakt.tv") + Text(NSLocalizedString("Trakt.tv", comment: "")) .font(.title3) .fontWeight(.semibold) - Group { - if isTraktLoading { - ProgressView() - .scaleEffect(0.8) - .frame(height: 18) - } else if isTraktLoggedIn { - HStack(spacing: 0) { - Text("Logged in as ") - .font(.footnote) - .foregroundStyle(.gray) - Text(traktUsername) - .font(.footnote) - .fontWeight(.medium) - .foregroundStyle(.primary) - } + if isTraktLoading { + ProgressView() + .scaleEffect(0.8) .frame(height: 18) - } else { - Text(traktStatus) + } else if isTraktLoggedIn { + HStack(spacing: 0) { + Text(NSLocalizedString("Logged in as", comment: "")) .font(.footnote) .foregroundStyle(.gray) - .frame(height: 18) + Text(traktUsername) + .font(.footnote) + .fontWeight(.medium) + .foregroundStyle(Color.accentColor) } + .frame(height: 18) + } else { + Text(NSLocalizedString("You are not logged in", comment: "")) + .font(.footnote) + .foregroundStyle(.gray) + .frame(height: 18) } } .frame(height: 60, alignment: .center) @@ -268,6 +264,18 @@ struct SettingsViewTrackers: View { .padding(.vertical, 12) .frame(height: 84) + if isTraktLoggedIn { + Divider() + .padding(.horizontal, 16) + + SettingsToggleRow( + icon: "arrow.triangle.2.circlepath", + title: NSLocalizedString("Sync TV shows progress", comment: ""), + isOn: $isSendTraktUpdates, + showDivider: false + ) + } + Divider() .padding(.horizontal, 16) @@ -283,7 +291,7 @@ struct SettingsViewTrackers: View { .frame(width: 24, height: 24) .foregroundStyle(isTraktLoggedIn ? .red : .accentColor) - Text(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") + Text(isTraktLoggedIn ? NSLocalizedString("Log Out from Trakt", comment: "") : NSLocalizedString("Log In with Trakt", comment: "")) .foregroundStyle(isTraktLoggedIn ? .red : .accentColor) Spacer() @@ -296,14 +304,14 @@ struct SettingsViewTrackers: View { } SettingsSection( - title: "Info", - footer: "Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate." + title: NSLocalizedString("Info", comment: ""), + footer: NSLocalizedString("Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate.", comment: "") ) {} } .padding(.vertical, 20) } .scrollViewBottomPadding() - .navigationTitle("Trackers") + .navigationTitle(NSLocalizedString("Trackers", comment: "")) .onAppear { updateAniListStatus() updateTraktStatus() diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index bd40bd8..51f191c 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -6,16 +6,17 @@ // import SwiftUI +import NukeUI fileprivate struct SettingsNavigationRow: View { let icon: String - let title: String + let titleKey: String let isExternal: Bool let textColor: Color - init(icon: String, title: String, isExternal: Bool = false, textColor: Color = .primary) { + init(icon: String, titleKey: String, isExternal: Bool = false, textColor: Color = .primary) { self.icon = icon - self.title = title + self.titleKey = titleKey self.isExternal = isExternal self.textColor = textColor } @@ -26,7 +27,7 @@ fileprivate struct SettingsNavigationRow: View { .frame(width: 24, height: 24) .foregroundStyle(textColor) - Text(title) + Text(NSLocalizedString(titleKey, comment: "")) .foregroundStyle(textColor) Spacer() @@ -43,10 +44,93 @@ fileprivate struct SettingsNavigationRow: View { .padding(.vertical, 12) } } + +fileprivate struct ModulePreviewRow: View { + @EnvironmentObject var moduleManager: ModuleManager + @AppStorage("selectedModuleId") private var selectedModuleId: String? + + private var selectedModule: ScrapingModule? { + guard let id = selectedModuleId else { return nil } + return moduleManager.modules.first { $0.id.uuidString == id } + } + + var body: some View { + HStack(spacing: 16) { + if let module = selectedModule { + LazyImage(url: URL(string: module.metadata.iconUrl)) { state in + if let uiImage = state.imageContainer?.image { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + Image(systemName: "cube") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(module.metadata.sourceName) + .font(.headline) + .foregroundStyle(.primary) + + Text("Tap to manage your modules") + .font(.subheadline) + .foregroundStyle(.gray) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Image(systemName: "cube") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 4) { + Text("No Module Selected") + .font(.headline) + .foregroundStyle(.primary) + + Text("Tap to select a module") + .font(.subheadline) + .foregroundStyle(.gray) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Image(systemName: "chevron.right") + .foregroundStyle(.gray) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.3), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + } +} + struct SettingsView: View { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA" @Environment(\.colorScheme) var colorScheme @StateObject var settings = Settings() + @EnvironmentObject var moduleManager: ModuleManager var body: some View { NavigationView { @@ -59,35 +143,43 @@ struct SettingsView: View { .padding(.horizontal, 20) .padding(.top, 16) + // Modules Section at the top VStack(alignment: .leading, spacing: 4) { - Text("MAIN") + Text("MODULES") + .font(.footnote) + .foregroundStyle(.gray) + .padding(.horizontal, 20) + + NavigationLink(destination: SettingsViewModule()) { + ModulePreviewRow() + } + .padding(.horizontal, 20) + } + + VStack(alignment: .leading, spacing: 4) { + Text("MAIN SETTINGS") .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) VStack(spacing: 0) { NavigationLink(destination: SettingsViewGeneral()) { - SettingsNavigationRow(icon: "gearshape", title: "General Preferences") + SettingsNavigationRow(icon: "gearshape", titleKey: "General Preferences") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewPlayer()) { - SettingsNavigationRow(icon: "play.circle", title: "Video Player") + SettingsNavigationRow(icon: "play.circle", titleKey: "Video Player") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewDownloads()) { - SettingsNavigationRow(icon: "arrow.down.circle", title: "Download") - } - Divider().padding(.horizontal, 16) - - NavigationLink(destination: SettingsViewModule()) { - SettingsNavigationRow(icon: "cube", title: "Modules") + SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Download") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewTrackers()) { - SettingsNavigationRow(icon: "square.stack.3d.up", title: "Trackers") + SettingsNavigationRow(icon: "square.stack.3d.up", titleKey: "Trackers") } } .background(.ultraThinMaterial) @@ -110,19 +202,19 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 4) { - Text("DATA/LOGS") + Text("DATA & LOGS") .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) VStack(spacing: 0) { NavigationLink(destination: SettingsViewData()) { - SettingsNavigationRow(icon: "folder", title: "Data") + SettingsNavigationRow(icon: "folder", titleKey: "Data") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewLogger()) { - SettingsNavigationRow(icon: "doc.text", title: "Logs") + SettingsNavigationRow(icon: "doc.text", titleKey: "Logs") } } .background(.ultraThinMaterial) @@ -145,21 +237,21 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 4) { - Text("INFOS") + Text(NSLocalizedString("INFOS", comment: "")) .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) VStack(spacing: 0) { NavigationLink(destination: SettingsViewAbout()) { - SettingsNavigationRow(icon: "info.circle", title: "About Sora") + SettingsNavigationRow(icon: "info.circle", titleKey: "About Sora") } Divider().padding(.horizontal, 16) Link(destination: URL(string: "https://github.com/cranci1/Sora")!) { SettingsNavigationRow( icon: "chevron.left.forwardslash.chevron.right", - title: "Sora GitHub Repository", + titleKey: "Sora GitHub Repository", isExternal: true, textColor: .gray ) @@ -169,7 +261,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) { SettingsNavigationRow( icon: "bubble.left.and.bubble.right", - title: "Join the Discord", + titleKey: "Join the Discord", isExternal: true, textColor: .gray ) @@ -179,7 +271,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) { SettingsNavigationRow( icon: "exclamationmark.circle", - title: "Report an Issue", + titleKey: "Report an Issue", isExternal: true, textColor: .gray ) @@ -189,7 +281,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) { SettingsNavigationRow( icon: "doc.text", - title: "License (GPLv3.0)", + titleKey: "License (GPLv3.0)", isExternal: true, textColor: .gray ) @@ -214,7 +306,7 @@ struct SettingsView: View { .padding(.horizontal, 20) } - Text("Running Sora \(version) - cranci1") + Text("Sora \(version) by cranci1") .font(.footnote) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) @@ -258,6 +350,12 @@ class Settings: ObservableObject { updateAppearance() } } + @Published var selectedLanguage: String { + didSet { + UserDefaults.standard.set(selectedLanguage, forKey: "selectedLanguage") + updateLanguage() + } + } init() { self.accentColor = .primary @@ -267,7 +365,9 @@ class Settings: ObservableObject { } else { self.selectedAppearance = .system } + self.selectedLanguage = UserDefaults.standard.string(forKey: "selectedLanguage") ?? "English" updateAppearance() + updateLanguage() } func updateAccentColor(currentColorScheme: ColorScheme? = nil) { @@ -298,4 +398,10 @@ class Settings: ObservableObject { windowScene.windows.first?.overrideUserInterfaceStyle = .dark } } + + func updateLanguage() { + let languageCode = selectedLanguage == "Dutch" ? "nl" : "en" + UserDefaults.standard.set([languageCode], forKey: "AppleLanguages") + UserDefaults.standard.synchronize() + } } diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift new file mode 100644 index 0000000..af37f9f --- /dev/null +++ b/Sora/Views/SplashScreenView.swift @@ -0,0 +1,42 @@ +// +// SplashScreenView.swift +// Sora +// +// Created by paul on 11/06/25. +// + +import SwiftUI + +struct SplashScreenView: View { + @State private var isAnimating = false + @State private var showMainApp = false + + var body: some View { + ZStack { + if showMainApp { + ContentView() + } else { + VStack { + Image("SplashScreenIcon") + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .cornerRadius(24) + .scaleEffect(isAnimating ? 1.2 : 1.0) + .opacity(isAnimating ? 1.0 : 0.0) + } + .onAppear { + withAnimation(.easeIn(duration: 0.5)) { + isAnimating = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.easeOut(duration: 0.5)) { + showMainApp = true + } + } + } + } + } + } +} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 9437d43..ba5a9f6 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -18,10 +18,12 @@ 0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; }; 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; }; 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; }; + 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */; }; 04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; }; 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; }; 04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; }; 04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; }; + 130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */; }; 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; }; 13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; }; 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; }; @@ -57,6 +59,7 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; + 138FF5642DFB17FF00083087 /* SoraCore in Frameworks */ = {isa = PBXBuildFile; productRef = 138FF5632DFB17FF00083087 /* SoraCore */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; }; @@ -85,16 +88,16 @@ 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; + 1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */; }; 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; }; 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; }; 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; }; 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; }; - 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */; }; 722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; }; 72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; }; 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; }; 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; }; - 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; }; + 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -110,10 +113,12 @@ 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = ""; }; 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = ""; }; 04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = ""; }; + 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = ""; }; 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 = ""; }; 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = ""; }; + 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; 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 = ""; }; @@ -176,16 +181,16 @@ 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; + 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBMatchPopupView.swift; sourceTree = ""; }; 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = ""; }; 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = ""; }; 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = ""; }; 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = ""; }; - 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = ""; }; 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = ""; }; 72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = ""; }; 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; }; - 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = ""; }; + 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -196,6 +201,7 @@ files = ( 13367ECC2DF70698009CB33F /* Nuke in Frameworks */, 13637B902DE0ECD200BDA2FC /* Drops in Frameworks */, + 138FF5642DFB17FF00083087 /* SoraCore in Frameworks */, 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */, 13367ECE2DF70698009CB33F /* NukeUI in Frameworks */, ); @@ -260,6 +266,15 @@ path = Models; sourceTree = ""; }; + 130326B42DF979A300AEF610 /* WebAuthentication */ = { + isa = PBXGroup; + children = ( + 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */, + ); + name = WebAuthentication; + path = Sora/Utils/WebAuthentication; + sourceTree = SOURCE_ROOT; + }; 13103E802D589D6C000F0673 /* Tracking Services */ = { isa = PBXGroup; children = ( @@ -340,6 +355,7 @@ 133D7C7B2D2BE2630075467E /* Views */ = { isa = PBXGroup; children = ( + 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */, 72443C7C2DC8036500A61321 /* DownloadView.swift */, 0402DA122DE7B5EC003BB42C /* SearchView */, 133D7C7F2D2BE2630075467E /* MediaInfoView */, @@ -352,8 +368,8 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( + 1E0435F02DFCB86800FF6808 /* CustomMatching */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, - 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, ); path = MediaInfoView; @@ -378,6 +394,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 130326B42DF979A300AEF610 /* WebAuthentication */, 0457C5962DE7712A000AFBD9 /* ViewModifiers */, 04F08EE02DE10C22006B29D9 /* Models */, 04F08EDD2DE10C05006B29D9 /* TabBar */, @@ -450,11 +467,10 @@ 134A387B2DE4B5B90041B687 /* Downloads */ = { isa = PBXGroup; children = ( + 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */, 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */, - 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */, 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */, 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */, - 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */, ); path = Downloads; sourceTree = ""; @@ -592,6 +608,15 @@ path = Components; sourceTree = ""; }; + 1E0435F02DFCB86800FF6808 /* CustomMatching */ = { + isa = PBXGroup; + children = ( + 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */, + 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, + ); + path = CustomMatching; + sourceTree = ""; + }; 72443C832DC8046500A61321 /* DownloadUtils */ = { isa = PBXGroup; children = ( @@ -623,6 +648,7 @@ 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */, 13367ECB2DF70698009CB33F /* Nuke */, 13367ECD2DF70698009CB33F /* NukeUI */, + 138FF5632DFB17FF00083087 /* SoraCore */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -649,12 +675,14 @@ hasScannedForEncodings = 0; knownRegions = ( en, + Base, ); mainGroup = 133D7C612D2BE2500075467E; packageReferences = ( 13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */, 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */, 13367ECA2DF70698009CB33F /* XCRemoteSwiftPackageReference "Nuke" */, + 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -685,6 +713,7 @@ files = ( 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */, 131270172DC13A010093AA9C /* DownloadManager.swift in Sources */, + 1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */, 1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */, 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */, 1359ED142D76F49900C13034 /* finTopView.swift in Sources */, @@ -699,6 +728,7 @@ 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, + 130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */, 04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */, 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, @@ -717,6 +747,7 @@ 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, 04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */, + 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */, @@ -731,7 +762,6 @@ 13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */, 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */, 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */, - 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */, @@ -750,9 +780,9 @@ 13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */, - 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */, 0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */, 0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */, + 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */, 0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */, @@ -935,7 +965,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -977,7 +1007,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1039,6 +1069,14 @@ kind = branch; }; }; + 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/cranci1/SoraCore"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1062,6 +1100,11 @@ package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; + 138FF5632DFB17FF00083087 /* SoraCore */ = { + isa = XCSwiftPackageProductDependency; + package = 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */; + productName = SoraCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b5a161..4bae672 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "07beed18a1a0b5e52eea618e423e9ca1c37c24c4d3d4ec31d68c1664db0f0596", "pins" : [ { "identity" : "drops", @@ -26,7 +27,16 @@ "branch" : "main", "revision" : "c7ba4833b1b38f09e9708858aeaf91babc69f65c" } + }, + { + "identity" : "soracore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cranci1/SoraCore", + "state" : { + "branch" : "main", + "revision" : "957207dded41b1db9fbfdabde81ffb2e72e71b31" + } } ], - "version" : 2 + "version" : 3 }